Every model call runs as a workflow, and a dead worker costs one retry.
A client submits a prompt and lets go at 202 Accepted, a worker runs the model call inside a Temporal workflow and writes the answer into a Redis stream, Temporal runs the call again on another worker if the first one dies, and the Relay tells the client when the answer is ready to fetch.
Advanced inference Durable inference, benchmarked to the millisecondUPDATED 2026-09-26 · VERSION 0.2
Async and durable
A model call is the slowest step in an AI system, and the least reliable: it runs for seconds or minutes, on hardware that can fail halfway through. Continuum never makes a client wait on one. The request is handed off at once, the call runs on a worker inside a Temporal workflow, and a worker that dies mid-answer costs one retry.
Milestone 0 took it from a Redis stream benchmark on 18 February to a single
POST /infer answering 202 Accepted a month later, and everything
since runs on it.
Hold the line
A request that holds its connection loses its answer when the line drops.
Watch the top half first. The client calls an API, the API calls the model provider, and the connection stays open while the answer builds up. Four fifths of the way through, the model fails. The error travels back to the client, everything generated so far is thrown away, and the retry starts again from nothing.
The bottom half is Continuum, and the same failure hits it. The client handed the request to the Gateway and moved on. The prompt waits in a Redis stream, Temporal gives the work to a worker, and the answer builds up in a second stream. When the model fails, the worker clears what it had written, Temporal runs the call again, and the answer arrives whole.
Everything the request needs lives somewhere that outlives the process running it: the prompt and the output in Redis, the messages in Postgres, and the run's history in Temporal. A worker is only ever holding a copy.
Pass the parcel
The client lets go at 202 Accepted.
The Console sends POST /v1/sessions/{sessionId}/infer with the prompt
as the request body, up to 128 KB. The Gateway checks that the session belongs to the
caller and is still active, and reads the body once, 8 KB at a time, into a pooled
buffer. One read feeds two writes, issued together. The prompt goes into a Redis
stream under {userId}:input:{messageId}, and into Postgres as the user's
message, beside an empty assistant reply marked as streaming.
Then it starts the workflow inference-{messageId} on the
inference task queue. The workflow carries ids and nothing else: the
prompt never enters Temporal's history. The Gateway answers, about 12 ms after
the request arrived, and the client is free before the model has seen a token.
What a stream holds
Input and output streams share one format. The prompt goes in as entries of up to 8 KB, written from a buffer rented out of the shared pool and handed back, never a fresh array per chunk. The answer goes in as the model produces it: the first token in an entry of its own, then one entry for every 50 ms of tokens. A closing entry marks the end and records the total, and only then does the stream get its 24-hour expiry.
| Field | In each entry | In the closing entry |
|---|---|---|
chunk | Up to 8 KB of the prompt, or 8,192 characters of the answer | Empty |
offset | Where the chunk starts, in bytes | The total size |
hash | The chunk's XxHash3, in hex | – |
end_of_stream | 0 | 1 |
total_size | – | The total size, in bytes |
A reader knows the stream is whole when it sees the closing entry, and every entry carries what it needs to check itself: its hash, and its offset against the bytes read so far. Stream ids keep the entries in the order they were written.
POST /v1/sessions/{sessionId}/infer
(the prompt, streamed as the request body - up to 128 KB)
HTTP/1.1 202 Accepted
{ "threadId": "…", "messageId": "…" }
# later, pushed by the Relay over SignalR or a raw WebSocket
RelayEvent ServerInferenceCompleted (203)
outputUrl: /v1/inference/{messageId}/output
GET /v1/inference/{messageId}/output
HTTP/1.1 200 OK
{ "role": "assistant", "content": [ { "type": "text", "value": "…" } ] }Clean slate
Every attempt starts from an empty output stream.
A swarm worker takes the activity from the task queue. Before it writes anything, it
deletes whatever output an earlier attempt left under
{userId}:output:{messageId}, and reads the prompt back out of the input
stream. Then it makes one streaming call to the model. The loop reading the model only
queues each token, and one writer drains the queue into the output stream: the first
token at once, then whatever has arrived every 50 ms, with the model's reasoning in a
stream of its own, {userId}:collating:{messageId}.
If the worker dies, Temporal still holds the workflow's history, and hands the activity to whichever worker is free. A crashed worker is noticed when its five-minute attempt runs out; an attempt that throws is retried five seconds later. The backoff doubles up to two minutes, and the retries keep coming for thirty minutes with no cap on the count. A provider error that can never succeed - a 4xx other than 408, 409 or 429 - stops them at once.
The next attempt uses the same key, so the answer's address never changes: anything that knows the message id knows where to read. The prompt stays in Redis until an attempt succeeds, so every retry reads exactly what the first one read.
A duplicate can't run twice either. Every submission mints its own message id, the workflow's id is that message id, and starting a workflow that already exists does nothing, so a start that is retried lands on the run already going.
You've got mail
The Relay says the answer is ready, and the client goes to get it.
When the answer is written, the worker finalizes the stream and posts a
RelayEvent to the Relay: ServerInferenceCompleted, with the
output's URL. The Relay pushes it to the session over a raw WebSocket and to the
session's SignalR group at the same time, and the workflow marks the message complete
in Postgres.
The client fetches GET /v1/inference/{messageId}/output. The first read
comes from the Redis stream and clears it; after that, the completed message in Postgres
answers. The Relay never carries the answer itself. It only says where to look, so a
notice that goes astray loses nothing: the output is still where it was.
The events a client hears
| Event | Code | Tells the client |
|---|---|---|
ServerInferenceQueued | 200 | The workflow has started and the work is on the queue |
ServerInferenceRunning | 201 | A worker has taken it |
ServerInferenceStreamingAvailable | 202 | The first tokens are in the output stream |
ServerInferenceCompleted | 203 | The answer is ready to fetch |
ServerInferenceFailed | 210 | The retries are spent; the message is marked failed |
ServerInferenceCancelled | 211 | The run was cancelled |
A chat reply streams straight to the Dashboard over SignalR.
Chat is the second way back, built for the Dashboard, where a person reads the reply
as it is written. The turn is handed off the same way: the Dashboard posts it to Chat,
which saves the message beside an empty reply and signals the thread's long-running
workflow, chat-{threadId}, and a swarm worker runs the model. The worker
publishes each token to the thread's Redis channel, chat:{threadId}:channel,
and the Chat host holding the Dashboard's connection subscribes to it and streams the
tokens over its own SignalR hub, /v1/chat/stream. When the last one
arrives, the Dashboard reads the finished message back from Postgres.
Chat doesn't use the Relay, because a reply is hundreds of fragments a second for one thread, and the Relay's path costs an HTTP POST and a group broadcast for every event. That suits the few notices a run sends. A streaming hub method answers only the connection that called it, and the host serving that connection subscribes to Redis itself, so Chat scales out with no SignalR backplane. A client that doesn't speak SignalR takes the first way back: the Relay's notice, then the Gateway's output.
Small change
Durability adds 16 to 21 ms to a model call, and its own time at p99 is 25 to 32 ms.
The startup consensus is that a workflow engine is too complicated and too slow for a team that has to move fast. Measured apples to apples against the model call it wraps, durability costs 19 ms of a 1.9-second answer on LM Studio, and 18 ms of 3.1 seconds on vLLM, over 200 runs each.
- Client → model. The call made directly, timed from the request to the last token.
- Client → Temporal → model. The same call inside a workflow of one activity, with the answer returned as the workflow's result. Nothing of Continuum's is in the path: no Gateway, Postgres, Redis, Relay or fetch.
| The long prompt, 200 runs per arm | LM Studio | vLLM NVFP4 |
|---|---|---|
| Client → model, p50 | 1,911 ms | 3,072 ms |
| Temporal adds, p50 | 19 ms (1.0%) | 18 ms (0.6%) |
| Its own time at p99, outside the call | 30 ms (1.5%) | 26 ms (0.8%) |
Table view
| Runtime | p50 | p95 | p99 | Runs |
|---|---|---|---|---|
| LM Studio, 658-token answer | 14 ms | 26 ms | 30 ms | 200 |
| vLLM NVFP4, 700-token answer | 14 ms | 24 ms | 26 ms | 200 |
Outside the model call, durability is three hand-offs through Temporal: 13 to 17 ms at p50, whatever the model writes, on LM Studio as on vLLM. Starting the activity and returning its result add 2 to 4 ms more. On LM Studio the total falls from 5.9% of an 88-token answer to 1.0% of a 658-token one.
All the trimmings
Everything the platform does around a model call costs another 18 to 27 ms.
The bare test measures durability. The real-world run measures a request through the whole platform, which does a good deal more than durable inference. Between the submit and the answer in the client's hands:
- the Gateway checks the caller's auth and session, from the Redis cache;
- it writes the thread and both messages to Postgres, and the prompt to a Redis stream alongside them;
- it starts the workflow and answers
202; - Temporal hands the activity to a worker, which checks the prompt is there, clears whatever a failed attempt left, and reads the prompt;
- the worker streams the answer into Redis as the model writes it, so a client can read it as it arrives;
- the workflow posts the Relay's notice as a local activity, which Temporal retries if it fails, and the Relay pushes it to the client over SignalR;
- the client fetches the output through the Gateway.
| Added at p50, LM Studio, 200 runs | Short prompt | Long prompt | 4K document |
|---|---|---|---|
| Durability alone | 17 ms | 19 ms | 19 ms |
| The whole platform | 35 ms (11.5%) | 46 ms (1.9%) | 37 ms (3.6%) |
| The platform's own work | about 18 ms | about 27 ms | about 18 ms |
On the short prompt, the model call inside the worker took 301.4 ms, against 301 for the direct call. The durable inference benchmark draws that request to scale, lists the changes that took the platform from 168 ms to 38, and gives the setup to repeat the runs.
Why it's built this way
Temporal is the retry logic, and the stream is the buffer.
The workflow carries ids, so the answer can be read while it's written.
The obvious design hands the prompt to Temporal and lets the answer come back as the activity's result. It works: the bare benchmark above does exactly that, and a 700-token answer is about 3 KB, where Temporal warns at 256 KB per payload. But an activity's result only exists once the activity finishes, so Temporal can't be the pipe for tokens a client should read as they arrive.
It would also grow the history. Temporal writes every input and result into the workflow's event history, keeps it, and replays it whenever a worker picks the run back up, so a 128 KB prompt would be written into it on every run. Continuum learned that lesson in Milestone 1: carrying a chat's history in workflow state bloated the event log, and the fix was the same one. The workflow holds ids, and loads what it needs from the stores each turn.
The stream lives in Redis, because Postgres pays for every write.
Postgres is where the answer ends up, as the completed message, and it is the durable truth. It is the wrong place for the stream itself. A token arriving every few milliseconds would be a transactional write each time, with a new row version and a WAL record behind it, and a reader would have to poll to find out whether anything new had arrived.
A Redis stream is an append-only log ordered by entry id: cheap to add to, readable from any point, and it expires on its own a day after it's complete. Milestone 0 set the split at the start: Redis is the hot landing layer for streaming input and output, and Postgres takes one write when the answer is done. Continuum had already moved a single, frequently written timestamp out of the sessions table for the same reason.
Redis, because it was already carrying everything fast.
Redis was in the stack from the first day, as the cache, and it went on to carry the SignalR backplane and the Pub/Sub channel that live tokens travel on. A stream per message is one more key on a server the platform already runs, with the expiry and the ordering built in, and nothing new to deploy.
It was measured before anything depended on it. The first BenchmarkDotNet project, in the first week, timed the stream writer and reader, and set the chunk size. The first version used 64 KB, as the plan said, and the next 2 KB. The benchmarks showed write time tracking the number of stream appends rather than the bytes, so the writer settled on 8 KB.
- Temporal over a queue. A queue delivers a message; it can't resume a half-finished run after the process holding it dies. A retry loop around a model call needs durable state of its own, a dead-letter queue, and something to reconcile the two. The workflow definition is the retry logic. Temporal is heavier to run than RabbitMQ and costs a model call 16 to 21 ms, and for a platform whose promise is that work isn't lost, that is the cost worth paying from day one.
- The stream is the buffer. The alternative was piping tokens from the worker straight to the client. That works until the client isn't there, and then it needs a buffer, retries and delivery guarantees. A Redis stream already is one: the output waits until someone reads it.
- Two transports, one event. SignalR gives .NET clients reconnection
and hub groups; a raw WebSocket serves anything that speaks the protocol, from Go to
a Python agent. Both carry the same
RelayEvent, and SignalR's Redis backplane lets Relay instances scale out. - Completion is compare-and-swap. A message moves to completed only from streaming. However many attempts race, a finished answer is never overwritten.
- Postgres holds the truth. It is the most boring correct choice: sessions, threads and messages are relational, and one transactional store beats three that have to agree.
Room to grow
Every buffer is borrowed, and every limit is written down.
- Borrowed buffers. The Gateway reads a prompt into a pooled buffer, and the stream writer rents its 8 KB buffer from the shared pool and hands it back, so a busy Gateway doesn't turn every request into garbage for the collector. The field names every stream entry carries are allocated once, when the process starts, never per entry.
- Bounded reads. A reader asks Redis for eight entries at a time, so a single read never holds more than 64 KB, however long the stream.
- Tokens, coalesced. The token path is the hottest in the system. A pass in April cut its allocations, and until September the worker still awaited a stream entry and a Pub/Sub message for every token. In the inference activity, one writer now drains them every 50 ms, sending each chunk's stream entry and message together. At the model's pace a 1,000-token reply is 50 entries instead of 1,000 and allocates 206 KB instead of 2.1 MB, and fetching a 730-token answer fell from 46 ms to 5. In a burst, 1,000 tokens are written in a median 2.8 ms, against 1,025 ms with a write and a publish per token.
- Redis memory gives itself back. The input stream is deleted when an attempt succeeds, the output stream is purged after the client's first read, and anything left expires 24 hours after it closed. Postgres keeps the answer.
- Temporal's history stays small. A workflow carries ids, never payloads, and a chat's long-running workflow continues as new every 100 turns, carrying any prompts still queued, so no history grows without end.
- More workers, more capacity. Workers poll their task queues, so another worker is more capacity with no configuration; the only setting a fleet has to change is the dev server's single task-queue partition. Relay instances scale out behind SignalR's Redis backplane, and each one caps itself: 10,000 connections, 10 per session, a keepalive every 45 seconds, and a load factor it reports. Chat hosts need no backplane, since each subscribes to the Redis channels of the threads its own connections are streaming.
- Nothing on the request path waits for a model. Submitting is three
Postgres statements, the prompt into Redis alongside them, and a workflow start,
with the body capped at 128 KB: 12 ms to the
202, whatever the model is doing.
Day one
Durable inference was the first thing Continuum built.
Milestone 0 built it in a month, in dependency order, starting with the part everything else would sit on: the Redis stream reader and writer, measured with BenchmarkDotNet before anything used them.
| When | What landed |
|---|---|
| 18–24 Feb | The Redis stream writer and reader, and the first BenchmarkDotNet project to measure them |
| 25 Feb | The Gateway's first inference routes: a PUT to upload, 409 Conflict on a duplicate, and a chunked GET for a faster first byte |
| 26 Feb | Continuum's own Aspire integration for Temporal, with a health check |
| 27 Feb | The first run end to end, inference-{id}, with a copy activity standing in for the model: 207 ms |
| 3 Mar | The Relay, and the decision that Temporal owns durability while the Relay only delivers |
| 12 Mar | SignalR beside the raw WebSocket, both carrying one event model |
| 13 Mar | A real model, served by LM Studio |
| 18 Mar | Delete-on-retry: every attempt reuses its key and starts from an empty stream |
| 19 Mar | One POST /infer, answering 202 Accepted, with eager workflow start |
| 28 Mar | Output written to Redis token by token; retries run until the thirty-minute limit |
The milestone's own record is Milestone 0: Durable inference →