流式输出管线深度分析

从 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()

两层超时:

  1. STREAM_IDLE_WARNING_MS → 日志警告
  2. 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 批处理后,displayedMessagesdeferredMessages 切换到 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

useDeferredValuemessages 的更新以过渡优先级渲染(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_deltaonStreamingText → 直接更新文本 state(低成本)
  • AssistantMessage 完成时 → onStreamingText(null) + onMessage(一次性追加)

3.3 为什么 streamingText 要截取到最后一个 \n?

tsx 复制代码
streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null

如果没有换行符,lastIndexOf 返回 -1substring(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 转换
相关推荐
qcx232 小时前
【系统学AI】21 AI产品定位:April Dunford方法在AI红海中的应用
人工智能·claude·cursor·定价·ai native
jerrywus3 小时前
别只换模型!Claude Opus 4.8 努力控制 + Fast模式,真实能省钱3倍
前端·agent·claude
DO_Community4 小时前
AI推理成本砍半:DigitalOcean 批量推理服务正式上线
云原生·serverless·aigc·claude·deepseek
win4r17 小时前
MiniMax M3 深度体验:这可能是国产模型里最接近“全能工程师”的一次
aigc·ai编程·claude
序列未来1 天前
Claude Prompt 六大进阶技巧全实战:Effort 控制 / Few-Shot / CoT / Cache / 双层护栏
claude
序列未来1 天前
MCP 企业级集成全指南:从协议原理到 OAuth 2.1 安全配置四层体系
claude
序列未来1 天前
RAG vs 长上下文:企业场景完整决策框架,混合检索 +17% 召回率实战
claude
Ztopcloud极拓云视角1 天前
Claude Opus 4.8 实战接入指南:动态工作流 + 思考投入控制深度使用
大数据·人工智能·gpt·claude·deepseek