跳到正文
DEV Community· Zhengxin·· 2 小時前AI 評分49

Claude Code 智能體循環深入剖析(6):從 SSE 事件到逐字渲染

Claude Code Agent Loop Deep Dive (6): From SSE Events to Character-by-Character Rendering

AI 導讀

Claude Code 透過 Anthropic API 的 SSE 串流逐段接收回應,並在文字片段抵達時逐字更新介面。串流包含六種事件,Claude Code 按區塊累積文字及工具輸入 JSON,並在區塊完成後解析完整輸入。它亦可檢視未完成的 JSON,在必要參數可推斷後啟動工具執行,讓工具工作與模型串流重疊。

正文

In the earlier articles, calling an LLM looked like one atomic operation:

response = call_llm(messages)

In reality, it is not atomic at all. The Anthropic API streams its response through Server-Sent Events (SSE). While the model generates, it sends fragments to the client. The text that appears character by character in Claude Code is not displayed after a complete paragraph is ready—it is rendered as those fragments arrive.

This article follows that stream from the API to the UI: the six event types, how fragments become a complete assistant message, how partial tool JSON is handled, and why the interface can keep accepting user intent while a query is running.

One call, six event types

A simplified SSE response looks like this:

event: message_start           ← response begins
event: content_block_start     ← a content block begins (text or tool_use)
event: content_block_delta     ← incremental block content
event: content_block_delta     ← possibly many more fragments
event: content_block_stop      ← the block is complete
event: content_block_start     ← the next block begins
...
event: message_delta           ← message-level metadata, such as stop_reason and usage
event: message_stop            ← response ends

The six event types are:

  • message_start — response metadata such as ID, model, and role; content is still empty.
  • content_block_start — declares the start and type of a content block, such as text, tool_use, or thinking.
  • content_block_delta — one small incremental fragment of that block.
  • content_block_stop — marks the end of that block.
  • message_delta — updates message-level metadata such as stop_reason and token usage.
  • message_stop — marks the end of the complete response.

A single response can contain several content blocks. For example, explanatory text followed by a tool_use is two independently streamed blocks, each with its own start, deltas, and stop.

Reassembling fragments into one message

Claude Code maintains a contentBlocks array and accumulates events in order:

message_start
    → create an assistant-message shell
      { id: '...', role: 'assistant', content: [] }

content_block_start (index=0, type=text)
    → contentBlocks[0] = { type: 'text', text: '' }

content_block_delta (index=0, text_delta: 'Let me ')
    → contentBlocks[0].text += 'Let me '

content_block_delta (index=0, text_delta: 'read auth.py')
    → contentBlocks[0].text += 'read auth.py'

content_block_start (index=1, type=tool_use, name='Read')
    → contentBlocks[1] = { type: 'tool_use', name: 'Read', input: '' }

content_block_delta (index=1, input_json_delta: '{"file')
    → contentBlocks[1].input += '{"file'

content_block_delta (index=1, partial_json: '_path":"auth.py"}')
    → contentBlocks[1].input += '_path":"auth.py"}'

content_block_stop (index=1)
    → parse the completed input JSON; tool use is ready

message_delta
    → update stop_reason and usage

message_stop
    → append the completed message to conversation history

The essential operation is an append to the indexed block. Text uses text += delta.text; tool calls use input += delta.partial_json. The API sends incremental deltas rather than replaying the entire current value, so the client accumulates them until the block stops.

Tool input JSON arrives in pieces

A tool_use input is a JSON object, but it streams as string fragments:

fragment 1: '{"file'
fragment 2: '_path":"'
fragment 3: 'auth.py"}'

Only after concatenation does it become valid JSON:

{"file_path":"auth.py"}

The simplest client waits for content_block_stop, then parses the full object once. Claude Code goes further: it can inspect partial JSON as it arrives and start later work as soon as essential arguments can be inferred. This is the StreamingToolExecutor optimization discussed in Part 3: From Reading Files to Parallel Scheduling. It allows tool work to overlap with the model continuing to stream.

The price is a parser that tolerates incomplete input—for example, an object with no closing brace yet.

An SDK quirk: initial text can be sent twice

One counterintuitive behavior of the Anthropic SDK is that a text content_block_start can contain initial text, and the next delta can repeat that same text:

content_block_start: { type: 'text', text: 'Hello' }
content_block_delta: { text_delta: 'Hello, I am...' }

If a client initializes the block with Hello and then blindly appends the delta, it renders HelloHello, I am....

Claude Code intentionally ignores the text field on content_block_start and accumulates only delta text. The source comments call out this SDK quirk directly. It is a small but familiar integration lesson: a clean API model often comes with edge behaviors that only become visible in a real streaming client.

Why the message is mutated rather than replaced

An assistant message may be appended to the messages array before the final message_delta arrives. That late delta contains stop_reason and usage, so the already-stored message still needs updating.

A common immutable approach would replace the last object:

messages[last] = { ...messages[last], stop_reason: 'tool_use' }

Claude Code instead mutates the existing object in place.

Why? The same message object can already be referenced elsewhere. For example, a transcript-persistence queue may retain that object and asynchronously serialize it to JSONL. Replacing the array entry leaves that queue holding the old object, whose stop_reason remains null. Mutating the shared object ensures every holder of the reference sees the updated data.

This trades the usual functional-programming preference for immutability for reliable reference consistency across the in-memory conversation, UI, and persistence queue.

How the UI consumes the stream

The UI gets its live state from a handwritten store of only 34 lines. It exposes the minimal surface area:

setState / getState / subscribe

For each new delta, Claude Code updates the current streaming-message fields in that store. UI components subscribe to those fields and re-render when they change. The UI therefore displays text exactly as far as it has accumulated.

That is the whole “character-by-character” effect.

The deliberately small implementation fits a CLI interface. It does not need time travel, a devtools ecosystem, or middleware. A local store has fewer dependencies, starts quickly, and makes its behavior easy to control.

QueryGuard: three states for concurrent input

The UI also has a specialized concurrency guard with three states:

idle         → no query is active; accept a new user message
dispatching  → a message was submitted and is being dispatched
running      → the LLM is streaming a response

If the user presses Enter while the guard is running, the new message is queued in commandQueue rather than executed immediately. When the loop returns to idle, queued commands are drained in order.

This guard matters because only one loop should run at a time: a second input must not overwrite the state of the active query. At the same time, the keyboard should not be locked while the model works.

The three-state model is more expressive than a boolean. dispatching lasts only a few milliseconds, but Ctrl-C at that moment should cancel dispatching; Ctrl-C while running should interrupt the actual loop. A single “busy” flag cannot represent those distinct semantics.

Takeaways

  • One model call is an SSE stream of six events: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, and message_stop.
  • The client accumulates text and tool input separately in contentBlocks.
  • Tool-input JSON can be parsed incrementally, enabling work to begin before streaming completes.
  • Two practical edge cases matter: ignore duplicated initial text from content_block_start, and mutate referenced message objects when late metadata arrives.
  • A 34-line local store is enough to turn incoming deltas into incremental UI rendering.
  • QueryGuard uses idle, dispatching, and running to protect one active loop while preserving queued user input.

The next article examines what happens when that stream or the surrounding loop fails: exponential retry, layered prompt_too_long recovery, fallback model swaps, and special handling for 529 overloads.


References

Primary source locations (v2.1.220):

  • src/services/api/claude.ts: queryModelWithStreaming and its six-event switch.
  • src/QueryEngine.ts: SDK-layer consumption of stream_event.
  • src/state/store.ts: the 34-line handwritten store.
  • src/state/AppState.tsx: UI components consuming the store.
  • src/utils/QueryGuard.ts: the idle / dispatching / running state guard.

Related reading:

來源:DEV Community · dev.to