Streaming graph events: progress bars for agents
Our due-diligence runs take between three and eight minutes end to end. In May I finally read the usage logs properly and found the pattern: an analyst kicks off a run, watches a spinner, decides it's hung, kills it, resubmits. Roughly one run in six died that way, and every resubmit paid the token bill twice for nothing.
The runs were fine; the silence was the bug.
the stream modes, and the one I misused
LangGraph streams at several granularities, and you can subscribe to more than one at once:
async for mode, chunk in graph.astream(inp, cfg, stream_mode=["updates", "messages"]):
render(mode, chunk)
updates emits each node's state delta as it finishes. That's the backbone of any progress display. messages emits LLM tokens with metadata naming the node they came from. values emits the full state after every step, and that's the one I misused: my first version shipped it straight to the browser, re-sending the entire accumulated state on every step (tens of megabytes of JSON per run for what amounted to a step counter). updates carries the same signal at a fraction of the weight. Below all of this sits astream_events for sub-node granularity; one mid-size run emitted about 2,300 events, which is why it feeds my tracing and never my UI. Token-level streaming is its own topic with its own plumbing.
the progress bar itself
Raw node names mean nothing to users, so a small map turns check_source into "Checking sources," and counting branch updates turns the fan-out into "Checking 9 sources, 6 done." Tokens stream for exactly one node, the final synthesis. Watching the report write itself is what finally convinced analysts the thing wasn't hung. Nobody needs to watch the critique node think.
After shipping this, manual cancels nearly vanished. Two in the last 140-odd runs, both deliberate.
Nothing got faster.
Users don't cancel slow runs. They cancel silent ones.
It's the streaming latency architecture lesson in miniature: perceived latency is a property of the interface, and you can fix it without touching the model or the graph.
what I log versus what I show
Log everything: the full updates stream plus events, one JSONL file per run, replayable a week later when something looks weird. That's the observability feed. Show almost nothing: six labeled steps, the branch counter, one token stream at the end. Every time I surfaced more detail, it produced support questions instead of trust ("what does retrying enrich mean, is that bad") from the very screen that was supposed to reduce questions.
Two honest limits. updates only ticks when a node finishes, so inside one slow node you're back to a spinner, and any per-node progress is on you to invent. And the consumer isn't free: my SSE endpoint with reconnect handling has grown to about 200 lines that I now maintain forever.
Still. The model didn't change, the graph didn't change, and it was the highest-value afternoon of the quarter.