从 API SSE 流到终端 UI 的完整数据流
一、整体架构
scss
┌──────────────────────────────────────────────────────────────┐
│ queryModel() (claude.ts) │
│ Anthropic SDK stream: true → for await raw events │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 每收到一个 SSE 事件: │ │
│ │ - 累加到 contentBlocks[] 内存数组 │ │
│ │ - content_block_stop → yield AssistantMessage │ │
│ │ - 每个事件 → yield { type: 'stream_event', event } │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────────────┘
│ yield Message | StreamEvent
▼
┌──────────────────────────────────────────────────────────────┐
│ queryLoop() (query.ts) │
│ for await...of deps.callModel() │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ - 收集 assistant messages(用于工具执行) │ │
│ │ - 过滤 withheld 错误(可恢复的先不 yield) │ │
│ │ - 将 tool_use 块加入 StreamingToolExecutor │ │
│ │ - 获取已完成的工具结果 → yield │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────────────┘
│ yield 给 REPL
▼
┌──────────────────────────────────────────────────────────────┐
│ onQueryEvent (REPL.tsx) │
│ handleMessageFromStream() (messages.ts) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 路由分发: │ │
│ │ text_delta → setStreamingText(累加) │ │
│ │ input_json_delta → setStreamingToolUses(增量) │ │
│ │ thinking_delta → update response length │ │
│ │ content_block_start → setStreamMode/StreamingToolUse │ │
│ │ message_stop → 清空 streamingToolUses │ │
│ │ assistant 消息 → 清空 streamingText → setMessages │ │
│ └──────────────────────────────────────────────────────┘ │
└──────┬───────────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ setStreamingText │ │ setMessages (useDeferred) │
│ (state) │ │ (deferredMessages) │
│ visibleStreamingText│ │ │
│ (只保留完整行) │ │ Messages 组件渲染 │
└─────────┬─────────┘ └──────────────────────────┘
│
▼
┌─────────────────┐
│ Messages 组件 │
│ streamingText │
│ streamingTool │
│ Uses 增量显示 │
└─────────────────┘
二、各层详解
2.1 原始层:queryModel() --- src/services/api/claude.ts:1017
这是最底层的流式引擎。它通过 Anthropic SDK 发起 stream: true 请求,然后 for await 遍历 SDK 的 Stream<BetaRawMessageStreamEvent>。
关键数据结构:contentBlocks[]
一个以 index 为下标的数组,只累积不替换:
ts
// content_block_start → 在对应 index 处创建占位块
case 'tool_use':
contentBlocks[part.index] = { ...part.content_block, input: '' } // input 初始化为空字符串
case 'text':
contentBlocks[part.index] = { ...part.content_block, text: '' } // text 初始化为空字符串
// content_block_delta → 累加
case 'text_delta':
contentBlock.text += delta.text // 追加文本
case 'input_json_delta':
contentBlock.input += delta.partial_json // 追加 JSON 片段
case 'thinking_delta':
contentBlock.thinking += delta.thinking // 追加思考内容
Yield 策略:双重 yield
每收到一个原始 SSE 事件,queryModel 同时 yield 两样东西:
ts
// 1. content_block_stop → yield 组装好的 AssistantMessage(一次)
case 'content_block_stop':
const m: AssistantMessage = {
message: { ...partialMessage, content: normalizeContentFromAPI([contentBlock], ...) },
type: 'assistant', ...
}
newMessages.push(m)
yield m
// 2. 每个原始事件 → yield StreamEvent(全部)
yield {
type: 'stream_event',
event: part, // 原始 SDK 事件
...(part.type === 'message_start' ? { ttftMs } : undefined),
}
这意味着 queryModel 是一个 AsyncGenerator,产出两种类型:
AssistantMessage--- content block 完整时的成品消息StreamEvent--- 每个原始 SSE 事件的包装,用于下游实时渲染
流空闲看门狗(第 1880-1929 行)
ts
// 每隔 STREAM_IDLE_TIMEOUT_MS 没有收到数据 → 主动 abort
function resetStreamIdleTimer(): void {
streamIdleTimer = setTimeout(() => {
streamIdleAborted = true
releaseStreamResources() // 断开 SDK 连接
}, STREAM_IDLE_TIMEOUT_MS)
}
// 每收到一个 chunk 调 resetStreamIdleTimer()
两层超时:
STREAM_IDLE_WARNING_MS→ 日志警告STREAM_IDLE_TIMEOUT_MS→ 触发非流式重试(第 2310 行streamIdleAborted检查)
2.2 编排层:queryLoop() --- src/query.ts:241
queryLoop 消费 queryModel 产出的流,做三件事:
收集 AssistantMessage
ts
for await (const message of deps.callModel({...})) {
// 只 yield 没有被 withheld 的消息
if (!withheld) yield message
// 收集 assistant 消息,用于后续工具执行
if (message.type === 'assistant') {
assistantMessages.push(message)
// tool_use 块加入 StreamingToolExecutor
streamingToolExecutor.addTool(toolBlock, message)
}
}
并发工具执行
ts
// 在消息流式进入的同时,已完成合并的工具结果直接被 yield
for (const result of streamingToolExecutor.getCompletedResults()) {
if (result.message) yield result.message
}
StreamingToolExecutor 支持:
- 并发安全工具(如读文件)--- 可与其他工具并行执行
- 排他工具(如 bash)--- 同一时间只能执行一个
- 结果按工具被发现的顺序 yield(FIFO)
错误恢复
ts
catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel // 切换到备用模型重试
attemptWithFallback = true
// 清除旧流产生的 orphan 消息
for (const msg of assistantMessages) {
yield { type: 'tombstone', message: msg } // 通知 UI 删除这些消息
}
}
}
2.3 分发层:handleMessageFromStream() --- src/utils/messages.ts:2930
这是流事件 → React state 更新的中央路由器 。接收 StreamEvent / Message 两种类型的对象,通过回调函数分发给不同的状态更新函数。
对 StreamEvent 的分发逻辑
ini
stream_request_start → onSetStreamMode('requesting')
message_start → onApiMetrics({ ttftMs }) ← 记录首 token 时间
content_block_start:
type=thinking → onSetStreamMode('thinking')
type=text → onStreamingText(null) + onSetStreamMode('responding')
type=tool_use → onStreamingToolUses(append) + onSetStreamMode('tool-input')
content_block_delta:
type=text_delta → onStreamingText(text => text + delta)
type=input_json_delta → onStreamingToolUses(更新 index 的 unparsedToolInput)
type=thinking_delta → onUpdateLength(delta)
type=signature_delta → 忽略(不计入 OTPS)
content_block_stop → (无操作,等待 message_stop)
message_delta → onSetStreamMode('responding')
message_stop → onSetStreamMode('tool-use') + onStreamingToolUses([])
对 Message 的分发逻辑
ts
// 任何非 stream_event 消息:
// 1. 先清除 streamingText(关键!保证原子切换)
onStreamingText?.(() => null)
// 2. 追加到消息列表
onMessage(message)
原子切换 是 onStreamingText?.(() => null) + onMessage(message) 在同一帧内调用。React 批处理后,displayedMessages 从 deferredMessages 切换到 messages 的瞬间,streamingText 已经被清空了------不会出现文本闪烁。
2.4 展现层:REPL.tsx + Messages.tsx
双重缓冲策略
tsx
// REPL.tsx:1318 --- 低优先级的消息列表
const deferredMessages = useDeferredValue(messages)
// REPL.tsx:1461 --- 高优先级的流式文本
const [streamingText, setStreamingText] = useState<string | null>(null)
// REPL.tsx:1473 --- 只显示完整行
const visibleStreamingText = streamingText && showStreamingText
? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null
: null
useDeferredValue 让 messages 的更新以过渡优先级渲染(React 18 并发特性),确保输入框保持响应。同时 streamingText 是普通 state,每收到 text_delta 就更新,通过 Ink 的 16ms 渲染节流批量更新到屏幕。
visibleStreamingText 截取到最后一个 \n,实现逐行输出而非逐字符跳跃。
选择显示源
tsx
// REPL.tsx:4506-4509
const usesSyncMessages = showStreamingText || !isLoading
const displayedMessages = viewedAgentTask
? viewedAgentTask.messages ?? []
: usesSyncMessages ? messages : deferredMessages
- 流式文本开启时 :直接使用
messages(同步),因为streamingText才是实时反馈的主力 - 流式文本关闭时 (reducedMotion):使用
deferredMessages(低优先级),保持 UI 流畅
Messages 组件内部
tsx
// Messages.tsx:447 --- 构造"正在流式输入的 tool 调用"的假消息
const syntheticStreamingToolUseMessages = useMemo(() =>
streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => {
// 为每个正在流式输入中的 tool_use 构造一个 AssistantMessage
// 让用户能看到 Claude 正在构建什么工具调用
})
, [streamingToolUsesWithoutInProgress])
// Messages.tsx:703 --- 流式文本直接渲染
{streamingText && !isBriefOnly &&
<StreamingMarkdown>{streamingText}</StreamingMarkdown>
}
三、核心设计权衡
3.1 为什么 content_block_stop 才 yield 完整 Message?
因为一个 content block 可能包含两个工具调用共享同一块文本,也可能一个工具调用的 input_json_delta 横跨多个 SSE 事件。只有 content_block_stop 才能保证消息的 content block 是完整的。
同时 content_block_stop 之前的每个原始事件都以 StreamEvent 形式单独 yield,保证 UI 层能实时更新。
3.2 为什么 text_delta 不走 handleMessageFromStream 的 onMessage?
onMessage (即 setMessages)会触发整个消息列表的重渲染。如果每个 text_delta (可能只有几个字符)都调 setMessages,React 需要协调整个列表的 Virtual DOM,代价太高。
解决方案:
text_delta→onStreamingText→ 直接更新文本 state(低成本)AssistantMessage完成时 →onStreamingText(null)+onMessage(一次性追加)
3.3 为什么 streamingText 要截取到最后一个 \n?
tsx
streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null
如果没有换行符,
lastIndexOf返回-1,substring(0, 0)得到'',|| null转为null→ 不显示。
效果:只有当一个完整的行结束时才显示。正在输入的行不显示,避免终端上逐字符跳跃的闪烁感。
3.4 Ink 的渲染节流
Ink(React 终端渲染器)本身有 16ms 的渲染节流。这意味着即使每个 text_delta 都调了 setStreamingText,实际写入终端的频率也不会超过 60fps 。这与浏览器的 requestAnimationFrame 效果类似。
3.5 StreamingToolExecutor 的 yield 时机
arduino
时间 →
───┬──────────┬──────────┬──────────┬──────────┬──────────┬───
│ │ │ │ │ │
text_delta text_delta tool_use input_ input_ text_delta
start json_ json_
delta delta
│
tool 执行开始(并发)
│
tool 执行结束 → yield 结果
工具在流式输入的同时 就开始执行了。StreamingToolExecutor 检测到某个 tool_use 的 input 已完整(content_block_stop)就立即启动执行。当 queryLoop 在下一个 for await 迭代中调用 getCompletedResults() 时,已完成的结果被 yield 回 UI。
3.6 完整消息流时序图
scss
时间 →
───┬───────────────────────────────────────────────────────────
│
用户提交输入
│
query() → queryModel() → SSE 连接建立
│
├─ message_start → onApiMetrics(ttftMs=1.2s)
│
├─ content_block_start → onSetStreamMode('thinking')
│ (thinking)
├─ content_block_delta → onUpdateLength(thinking-text)
│ (thinking_delta) ← thinking 内容只在后台累加,不显示
├─ content_block_stop
│
├─ content_block_start → onSetStreamMode('responding')
│ (text) + onStreamingText(null)
├─ content_block_delta → onStreamingText("Hello")
│ (text_delta: "H")
├─ content_block_delta → onStreamingText("Hello ")
│ (text_delta: "ello ")
├─ content_block_delta → onStreamingText("Hello world")
│ (text_delta: "world")
├─ content_block_stop → yield AssistantMessage
│ → onStreamingText(null) + setMessages
│ └─ streamingText 消失,final message 出现
│ 两端之间无闪烁 gap
│
├─ content_block_start → onSetStreamMode('tool-input')
│ (tool_use) + onStreamingToolUses([{index:2, ...}])
├─ content_block_delta → onStreamingToolUses(更新 index=2)
│ (input_json_delta)
├─ content_block_stop → yield AssistantMessage
│ → StreamingToolExecutor.addTool() → 开始执行
│
├─ (工具执行中 → yield 工具结果)
│
├─ message_delta → onSetStreamMode('responding')
├─ message_stop → onSetStreamMode('tool-use')
│ + onStreamingToolUses([])
│
└─ (工具结果触发下一轮) → while(true) 回到顶部
四、相关文件清单
| 文件 | 角色 |
|---|---|
src/services/api/claude.ts |
底层流式引擎,queryModel() yield 原始事件和消息 |
src/query.ts |
编排层,queryLoop() 消费流事件、管理工具执行、错误恢复 |
src/utils/messages.ts |
handleMessageFromStream() 流事件→React state 路由分发 |
src/services/tools/StreamingToolExecutor.ts |
并发工具执行器,边流式边执行工具 |
src/screens/REPL.tsx |
消费流式状态,管理 streamingText/ToolUses/Thinking |
src/components/Messages.tsx |
渲染流式文本和增量 tool_use UI |
src/hooks/useLogMessages.ts |
增量写入 transcript,避免全量扫描 |
src/cli/transports/ccrClient.ts |
远程模式 SSE 累积(100ms buffering) |
src/cli/transports/HybridTransport.ts |
WebSocket+HTTP 混合传输,合并流事件 |
src/remote/sdkMessageAdapter.ts |
远程 SDK 消息→内部 StreamEvent 转换 |