agentsope / agentsop-streaming-output
Enhancement-overlay decision protocol for STREAMING the output of long-running LLM / agent runs from the *backend*, not just wiring a typing animation in the UI. Activates when a coder agent must stream final tokens to a chat client, surface intermediate agent steps (which tool, which node, partial reasoning), emit custom tool-progress events, choose a transport (SSE vs WebSocket), or decide what to do when the client disconnects mid-stream. The langchain / langgraph skills mention stream modes but stop at "you can stream"; this skill encodes *what to stream, over what transport, and how to fail safely*.
Streaming Tool/Agent Output · SOP (Enhancement Overlay)
Source posture: every non-trivial claim is cited inline. Short tags like
[lg/stream],[lc/astream-events],[oai/stream],[anthropic/stream],[mdn/sse]resolve againstreferences/R1-source-evidence.md.This is an ENHANCE overlay: it sits on top of
[[agentsop-langgraph]](which
names the four stream modes but treats streaming as one of ten operations) and[[langchain]]. Read those for the orchestration; read this for the
streaming SOP. Cross-link:[[agentsop-langgraph]]OP-8.
何时激活 (Activation Rules)
Activate when any of these fire:
The run is long (multi-second to multi-minute agent loop, RAG over many
docs, multi-tool chain) and the user is waiting — perceived latency, not
total latency, is the product metric.The user asks to "stream the response", "show a typing effect", "show progress",
"show which tool the agent is running", or "show the chain of thought".You are building a chat surface (stream final tokens) OR an agent surface
(stream intermediate steps: node entered, tool called, partial state) OR a
long task surface (stream custom progress like "embedded 40/200 docs").You must pick a transport: Server-Sent Events (SSE) vs WebSocket vs plain
chunked HTTP, and handle client disconnect / cancellation cleanly.You're wiring
graph.stream(...)/astream_events/ OpenAIstream=True/
Anthropicclient.messages.streamand need to know which mode and *what to
forward to the client*.
Do not activate for: a single fast (<1s) completion, a batch/offline job with
no waiting human, or a pure front-end animation question (that's CSS, not a backend
SOP). Streaming a 300ms call adds protocol overhead for zero UX gain — see
反模式.
核心心智模型 (Core Mental Model)
Stream what the user needs to see, not everything the engine emits. A
backend stream is a curated projection of the run's internal event firehose onto
exactly three audiences:
Chat audience → final tokens. A human reading prose wants character-by-
character output of the final assistant message. In LangGraph this isstream_mode="messages"(LLM tokens + metadata); in raw SDKs it'sstream=True/.messages.stream[lg/stream][oai/stream][anthropic/stream]. They do not want to see tool JSON or scratch nodes.Agent audience → intermediate updates. A developer (or a power-user UI)
watching an agent work wants "entered nodeplanner", "calling toolsearch", "got 5 results" — the state diffs between steps. LangGraph:stream_mode="updates"(per-node diffs)[lg/stream]. LangChain LCEL:astream_events(a typed event stream:on_chat_model_stream,on_tool_start,on_tool_end)[lc/astream-events].Progress audience → custom events. Work happening inside one tool/node
(a loop, a long embed, a download) is invisible to the framework's automatic
events. You must emit progress yourself: LangGraphstream_mode="custom"
viaget_stream_writer()[lg/stream]; LCEL via custom callback / dispatched
events[lc/astream-events].
The load-bearing insight from the LangGraph docs: **stream modes are composable —
pass a list** (stream_mode=["messages","updates","custom"]) and demultiplex on
the client by the tuple tag [lg/stream]. So the real design question is never
"can I stream" but **"which projection(s) does this surface need, and how do I tag
them on one wire?"**
Second axiom: a stream is a contract with a client that can vanish. Networks
drop, users close tabs, browsers cap connections. The backend must decide, *up
front, whether a disconnect should cancel* the run (stop burning tokens) or
detach and let it finish (so a reconnect can replay). That decision is part of
the design, not an afterthought — see 困境 Case 2.
SOP 工作流 (Agentic Protocol)
Walk top-down. Each step has a gate.
Step 1 · Confirm streaming is warranted
Gate: is a human waiting on a run that takes >~1–2s? If no (batch job, sub-
second call), don't stream — return the whole payload. Streaming a fast call
adds SSE/WebSocket framing, reconnect logic, and partial-parse bugs for no UX win[mdn/sse]. Exit here for fast paths.
Step 2 · Classify the surface → pick the projection
Map the surface to one (or more) of the three audiences:
| Surface | Primary projection | LangGraph mode | LangChain |
|---|---|---|---|
| Chat / prose | final tokens | messages | astream_events → on_chat_model_stream |
| Agent inspector / dev UI | step updates | updates | astream_events (on_tool_*, on_chain_*) |
| Full-state replay / resume | snapshots | values | n/a (rebuild from events) |
| Long in-tool work | custom progress | custom | dispatched custom events |
| Debug everything | raw firehose | debug | astream_events (all) |
values emits the full state after each step (heavy, good for resume);updates emits only the diff (light, good for live UI) [lg/stream]. Default
a chat agent to ["messages","updates"] and add "custom" only when a tool has
internal progress worth surfacing [lg/stream] (= [[agentsop-langgraph]] OP-8).
Step 3 · Pick the transport
Gate questions: does the client only receive (server→client), or also need to
send mid-stream (interrupt, steer)?
Receive-only → SSE. Simplest correct default: one long-lived HTTP response,
text/event-stream, auto-reconnect +Last-Event-IDbuilt into the browserEventSource[mdn/sse]. This is what most "stream the agent" use cases need.Bidirectional → WebSocket. Only when the client must push during the stream
(live cancel, mid-run user input, collaborative). Costs you reconnect logic you
get free with SSE.Server-internal / non-browser → async generator / gRPC stream. If both ends
are yours, skip HTTP framing and yield the tuples directly.
Step 4 · Mix token + step streams on one wire
Use the multi-mode form so one connection carries everything; tag each chunk
so the client routes it:
async for mode, chunk in graph.astream(
inp, stream_mode=["messages", "updates", "custom"], config=cfg):
if mode == "messages":
token, meta = chunk
yield sse("token", token.content) # → append to bubble
elif mode == "updates":
yield sse("step", chunk) # → "running tool X"
elif mode == "custom":
yield sse("progress", chunk) # → progress bar[lg/stream]. SSE event: field is exactly the demux key; the browser'sEventSource.addEventListener("token"|"step"|"progress", …) splits it client-side[mdn/sse]. Never interleave two semantic streams on one untagged channel — the
client can't tell a token from a tool name.
Step 5 · Decide disconnect policy before shipping
For each surface answer: on client disconnect, cancel or detach?
Cancel (stop the run) when: every step costs money/tokens, output is useless
without the client, no resume planned. Wire it to the request's cancellation
signal so the generator is closed and the LLM call aborted[oai/stream].Detach + persist when: the run has side effects that must complete, OR the
user may reconnect and wants the result. Pair with a checkpointer
([[agentsop-langgraph]]Step 6) and a resumable event log so reconnect replays viaLast-Event-ID[mdn/sse].
Default for a chat agent: cancel (cheap, stateless). Default for a long
side-effecting pipeline: detach + persist.
Step 6 · Add backpressure + heartbeat before production
Heartbeat: SSE connections die silently behind proxies; emit a comment ping
(: keep-alive\n\n) every ~15s during long quiet stretches[mdn/sse].Backpressure: if the client reads slower than the model emits, your buffer
grows. Bound the queue; on overflow either drop intermediateupdates(keepmessages) or apply flow control. Tokens are the audience-critical stream;
progress events are droppable.Flush: disable response buffering (
X-Accel-Buffering: nofor nginx) or the
proxy batches your tokens and kills the "streaming" feel.
操作模型 (Operation Models)
Format: Trigger → Action → Output → Evidence.
OP-1 · Stream final tokens to a chat client (the 80% case)
- Trigger: User-facing chat; want typing effect on the final answer.
Action: LangGraph
graph.astream(inp, stream_mode="messages")→ yield each(token, metadata)'stoken.content; filter bymetadataso you only stream
the final node's LLM, not sub-agent chatter. Raw: OpenAIstream=Trueiteratechunk.choices[0].delta.content; Anthropic `with client.messages.stream(...) as
s: for t in s.text_stream`.- Output: Character-by-character final answer; no tool JSON leaks.
- Evidence:
[lg/stream]messages mode;[oai/stream];[anthropic/stream].
OP-2 · Stream intermediate agent steps
- Trigger: Dev/inspector UI; show "which node / which tool, with inputs".
Action: LangGraph
stream_mode="updates"→ each chunk is `{node_name:
statediff}; render as a step log. LCEL:astreamevents(version="v2")` and
switch onevent["event"](on_tool_start/on_tool_end/on_chain_*).- Output: Live step trace without the full state weight of
values. - Evidence:
[lg/stream]updates mode;[lc/astream-events].
OP-3 · Emit custom in-tool progress
Trigger: A tool/node does long internal work (embed 200 docs, paginate an
API) the framework can't see.Action: LangGraph — inside the node, `w = getstreamwriter(); w({"progress":
i/n}); consume onstream_mode="custom"`. LCEL — dispatch a custom event /
callback thatastream_eventssurfaces.- Output: A real progress signal instead of a frozen spinner.
- Evidence:
[lg/stream]custom mode + stream writer.
OP-4 · Multiplex modes on one SSE connection
- Trigger: One surface needs tokens and steps and progress.
Action:
stream_mode=["messages","updates","custom"]; map each `(mode,
chunk)tuple to a distinct SSEevent:name; clientaddEventListener` per
name (Step 4 snippet).- Output: Single connection, cleanly demuxed; no extra round-trips.
Evidence:
[lg/stream](list form returns(mode, chunk)tuples);[mdn/sse](named events).
OP-5 · Choose SSE vs WebSocket
- Trigger: Deciding the transport.
Action: Receive-only (browser just displays) → SSE (free reconnect +
Last-Event-ID). Client must push mid-stream (cancel, steer, collaborate) →
WebSocket. Both ends yours / non-HTTP → async generator.- Output: Right transport; no hand-rolled reconnect for the common case.
- Evidence:
[mdn/sse](EventSource auto-reconnect, server-push only).
OP-6 · Handle client disconnect (cancel vs detach)
- Trigger: Stream may outlive the client's interest.
Action: Hook the request cancellation token. Cancel: close the async
generator → upstream LLM/agent call aborts; release resources[oai/stream].
Detach: keep running under a checkpointer, log events with monotonic IDs so a
reconnect replays fromLast-Event-ID.- Output: No zombie runs burning tokens; or a resumable run, by design.
Evidence:
[oai/stream]cancellation;[mdn/sse]Last-Event-ID;[[agentsop-langgraph]]Step 6 (checkpointer for durability).
OP-7 · Keep the connection alive (heartbeat + flush)
Trigger: Long quiet gaps (a slow tool) cause proxies to drop the stream, or
tokens arrive in clumps not smoothly.Action: Emit
: ping\n\ncomments every ~15s; setX-Accel-Buffering: no/
disable proxy buffering; flush after each event.- Output: Connection survives idle periods; tokens render smoothly.
- Evidence:
[mdn/sse](comment lines ignored by client, keep socket warm).
OP-8 · Filter the firehose to the final answer only
Trigger: A multi-agent graph streams every LLM's tokens; the chat bubble
fills with sub-agent noise.Action: On
messagesmode, inspectmetadata(langgraph_node, tags) and
forward only tokens whose node is the user-facing responder; route the rest toupdates(dev view) or drop.- Output: Clean final answer; sub-agent reasoning stays in the inspector.
- Evidence:
[lg/stream](messages chunks carry node metadata for filtering).
困境决策案例 (Dilemma Cases)
Case 1 · "Stream the tokens, or stream the steps?" — an agent that thinks then answers
困境: A research agent runs 4 tools over ~40s, then writes a 2-paragraph
answer. If you streammessagesonly, the user stares at a frozen spinner for
40s, then sees text. If you streamupdatesonly, they see "calling tool X" but
the final answer dumps all at once, losing the typing feel.约束: One SSE connection (mobile client). The 40s of tool work is the scary
part for the user; the final prose is the payoff.决策步骤:
Reject "pick one mode" — the surface has two audiences in one timeline
(progress during work, prose at the end)[lg/stream].Use
stream_mode=["updates","messages"]. During tool work,updateschunks
drive a live step list ("Searching… Reading 5 docs… Synthesizing"). When the
final responder node starts emitting,messagestokens stream into the
bubble (OP-4 demux).Filter
messagesto the final node only (OP-8) so the tool-call LLMs don't
leak into the answer.If a tool itself is slow (>5s), add
customprogress from inside it (OP-3)
so the step list isn't itself frozen.
结果: Continuous feedback for the whole 40s, then a smooth typed answer — on
one connection, no extra round-trips.可提取的操作: OP-4 + OP-8. **The answer to "tokens or steps" is almost always
"both, tagged, on one wire" — the question is which is primary when.**
Case 2 · "Client disconnects mid-stream — cancel the run or let it finish?"
困境: A user kicks off a 90s agent that books a flight (real side effect),
then closes the tab at second 30. The stream's consumer is gone. Do you kill the
run (and maybe leave a half-booking) or let it complete (burning tokens for a
client that may never return)?约束: The booking step is irreversible; tokens cost money; the user might
reopen the tab.决策步骤:
Recognize this is the cancel-vs-detach decision (Step 5), and it differs
by where in the run the disconnect happens.Because there's an irreversible side effect, do not hard-cancel mid-action
— that's the half-booking risk. Detach: let the current durable step finish
under a checkpointer ([[agentsop-langgraph]]Step 6 / HITL ordering — side
effects in their own committed step).Persist the event log with monotonic IDs. On reconnect, replay from
Last-Event-IDso the user sees the outcome[mdn/sse].If, instead, this were a read-only chat with no side effects, do the
opposite: cancel immediately on disconnect to stop burning tokens[oai/stream]— that's the cheaper, correct default for chat.
结果: Side-effecting runs detach + persist + replay; stateless chat runs
cancel. The policy is chosen by reversibility and cost, not by reflex.可提取的操作: OP-6. **Disconnect policy is a function of side-effect
reversibility and per-step cost — decide it per surface, before shipping, never
let it default to "whatever the framework does on socket close".**
反模式与边界 (Anti-patterns & Boundaries)
Don't stream everything. Forwarding the raw
debug/valuesfirehose to a
chat UI floods the client with full-state snapshots and sub-agent tokens. Project
to the audience (Step 2);valuesis heavy by design[lg/stream].Don't stream a sub-second call. SSE/WebSocket framing + reconnect + partial-
parse bugs for zero perceived-latency gain. Return the whole payload[mdn/sse].Don't skip disconnect handling. A stream with no cancel/detach policy leaks
zombie runs that burn tokens after the client is gone, or half-completes side
effects. Decide in Step 5[oai/stream].Don't interleave semantic streams on one untagged channel. Tokens and tool
names on the same unnamed wire are unparseable client-side. Tag with SSEevent:
/ the(mode, chunk)tuple (OP-4)[lg/stream][mdn/sse].Don't leak sub-agent tokens into the final answer. Filter
messagesby node
metadata (OP-8)[lg/stream].Don't forget the heartbeat. Long quiet gaps behind a proxy silently kill the
connection; the user sees a hang, not an error. Ping every ~15s[mdn/sse].Don't assume buffering is off. A buffering proxy batches your tokens and
destroys the streaming feel; disable it explicitly (OP-7).Don't reach for WebSocket by default. If the client only receives, SSE is
simpler and gives reconnect for free[mdn/sse]. Reserve WS for true
bidirectional needs.
Hard boundaries (streaming is the wrong tool when):
Output must be validated/transformed as a whole before the user sees any of
it (structured JSON you parse server-side, content that needs a safety pass) —
stream nothing until validated, or stream into a parser, never raw to the user.The consumer is a machine that wants one atomic JSON object — give it the
whole response; partial JSON tokens are a parsing hazard, not a feature.- No human is waiting (offline batch) — streaming adds cost for no audience.
跨框架对照 (Cross-framework Context)
| Concern | LangGraph | LangChain (LCEL) | OpenAI SDK | Anthropic SDK |
|---|---|---|---|---|
| Final tokens | stream_mode="messages" → (token, meta) [lg/stream] | astream_events → on_chat_model_stream [lc/astream-events] | stream=True, iterate delta.content [oai/stream] | client.messages.stream(...), text_stream [anthropic/stream] |
| Intermediate steps | stream_mode="updates" (per-node diff) [lg/stream] | astream_events (on_tool_*, on_chain_*) [lc/astream-events] | manual: detect tool_calls deltas [oai/stream] | manual: handle content_block_* / tool-use events [anthropic/stream] |
| Full state | stream_mode="values" (snapshot) [lg/stream] | rebuild from events | n/a | n/a |
| Custom progress | stream_mode="custom" + get_stream_writer() [lg/stream] | dispatch custom event / callback [lc/astream-events] | hand-rolled out-of-band | hand-rolled out-of-band |
| Mix modes | list form → tagged (mode, chunk) tuples [lg/stream] | one typed event stream, switch on event [lc/astream-events] | one delta stream, branch on field | one event stream, branch on type |
| Granularity | node-level + token-level + custom | event-level (richest typed taxonomy) | token + tool-call deltas | event + token (typed blocks) |
Heuristics:
LangGraph — best when you already have a graph and want token+step+custom on
one demuxable wire;[lg/stream]modes are the cleanest projection model. See[[agentsop-langgraph]]OP-8 for the orchestration side.LangChain LCEL —
astream_eventsgives the richest typed event taxonomy
(everyon_*lifecycle hook); reach for it when you need fine-grained event
routing without a full graph[lc/astream-events].Raw OpenAI / Anthropic — you get a single token/delta stream and must derive
"steps" yourself from tool-call deltas / content-block events. Choose when you
have no orchestration layer and want zero framework weight[oai/stream][anthropic/stream].
Transport is orthogonal to all four: SSE (default, receive-only), WebSocket
(bidirectional), or async generator (internal) wraps any of them. Pick the SDK for
what to stream, the transport for how the client consumes it [mdn/sse].
附录: 引用速查 (Citation Index)
Short tags → full sources in references/R1-source-evidence.md:
[lg/stream]= LangGraph streaming concept (values / updates / messages / custom
/ debug; multi-mode list;get_stream_writer) — distilled in[[agentsop-langgraph]]OP-8 +references/R1.[lc/astream-events]= LangChain LCELastream_eventstyped event stream.[oai/stream]= OpenAI streaming (stream=True, deltas, cancellation).[anthropic/stream]= Anthropic Messages streaming (client.messages.stream,text_stream, content-block events).[mdn/sse]= MDN Server-Sent Events (EventSource, named events, auto-reconnect,Last-Event-ID, comment heartbeats).