| name | agentsop-streaming-output |
| description | 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*.
|
| version | 0.1.0 |
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 against references/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 / OpenAI stream=True /
Anthropic client.messages.stream and 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 is
stream_mode="messages" (LLM tokens + metadata); in raw SDKs it's
stream=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 node planner", "calling tool
search", "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: LangGraph stream_mode="custom"
via get_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-ID built into the browser
EventSource [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)
elif mode == "updates":
yield sse("step", chunk)
elif mode == "custom":
yield sse("progress", chunk)
[lg/stream]. SSE event: field is exactly the demux key; the browser's
EventSource.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 via
Last-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 intermediate
updates (keep
messages) or apply flow control. Tokens are the audience-critical stream;
progress events are droppable.
- Flush: disable response buffering (
X-Accel-Buffering: no for 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)'s token.content; filter by metadata so you only stream
the final node's LLM, not sub-agent chatter. Raw: OpenAI stream=True iterate
chunk.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: state_diff}; render as a step log. LCEL: astream_events(version="v2") and
switch on event["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 = get_stream_writer(); w({"progress": i/n}); consume on stream_mode="custom". LCEL — dispatch a custom event /
callback that astream_events surfaces.
- 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 SSE event: name; client addEventListener 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 from Last-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\n comments every ~15s; set X-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
messages mode, inspect metadata (langgraph_node, tags) and
forward only tokens whose node is the user-facing responder; route the rest to
updates (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 stream
messages only, the user stares at a frozen spinner for
40s, then sees text. If you stream updates only, 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, updates chunks
drive a live step list ("Searching… Reading 5 docs… Synthesizing"). When the
final responder node starts emitting, messages tokens stream into the
bubble (OP-4 demux).
- Filter
messages to 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
custom progress 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-ID so 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/values firehose to a
chat UI floods the client with full-state snapshots and sub-agent tokens. Project
to the audience (Step 2); values is 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 SSE
event:
/ the (mode, chunk) tuple (OP-4) [lg/stream] [mdn/sse].
- Don't leak sub-agent tokens into the final answer. Filter
messages by 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_events gives the richest typed event taxonomy
(every on_* 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 LCEL astream_events typed 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).