# Step 07: 流式输出

一句话导读:把 step06 的「等几秒、砰一整段」改成「逐字冒出来」——用 messages.stream 订阅 text 事件,每收到一个 delta 就写屏。核心不只是体验升级,更是一个把「怎么处理数据」外包给调用方的回调模式。


# 一、这一步做了什么(What)

step07 把 step06 的 callAI(非流式)换成 streamAI(input, history, onChunk)。内部用 getClient().messages.stream(...) 拿到事件流,监听 stream.on('text', delta => ...):每来一块文本就累加进 fullText 并回调 onChunk 写屏,最后 await stream.finalMessage() 拿完整消息和 token。display.ts 配套加了 startStreamDisplay()(打「AI: 」前缀、返回写入器)和 endStreamDisplay()(收尾换行)。


# 二、面试官视角:为什么值得为「逐字显示」单开一步?(Why)

面试题:反正总时长一样,等 3 秒看完整段 和 逐字冒 3 秒,有本质区别吗?

有,而且是产品体验的分水岭。区别不在总时长,在首字延迟(TTFB, time-to-first-token):

  • step06(非流式):发请求 → 服务端生成完整回复 → 一次性返回。用户面对 3 秒纯黑屏,不知道是在思考还是卡死了。
  • step07(流式):发请求 → 服务端边生成边推第一个 token 就立刻显示。用户 200ms 内就看到字开始动,心理上「它在工作」的确定感完全不同。
维度 非流式 流式
首字延迟 = 整段生成完 ≈ 第一个 token 时间
感知等待 焦虑的黑屏 「像真人在打字」
可中断性 要么全有要么全无 可中途 Ctrl+C 停掉

对话式 AI 的体验竞争力有一大半在这。这不是锦上添花,是及格线——现代 AI 应用不上流式,用户会觉得「卡」。


# 三、原理:SSE + 回调模式(How)

# 底层:Server-Sent Events

Anthropic API 的流式基于 SSE(一种基于 HTTP 的单向流协议,不需要 WebSocket)。响应体不是完整 JSON,而是一串 text/event-stream 事件:

event: content_block_start
data: {"type":"text","text":""}

event: content_block_delta
data: {"type":"text_delta","text":"你好"}

event: content_block_delta
data: {"type":"text_delta","text":"世界"}

event: message_stop
data: {}
1
2
3
4
5
6
7
8
9
10
11

服务端每生成一小段就推一个 delta 事件。SDK 把这坨原始 SSE 解析、封装成友好的 Node 事件接口,我们只需 stream.on('text', ...)。

# 封装:streamAI 的真实实现

export async function streamAI(userInput, history, onChunk: (text: string) => void) {
  const messages = [...(history || []), { role: 'user', content: userInput }];
  const stream = getClient().messages.stream({ model, max_tokens: 1024, messages });

  let fullText = '';
  stream.on('text', (delta: string) => {
    fullText += delta;   // 累加:为了最后返回完整文本存进 history
    onChunk(delta);      // 回调:把这块交给调用方决定怎么显示
  });

  const finalMsg = await stream.finalMessage();  // 等流结束,拿 token 统计
  return { text: fullText, inputTokens: finalMsg.usage?.input_tokens ?? 0, outputTokens: ... };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

两条线并行:fullText 累加是给函数返回值用的(要把完整回复存进 history),onChunk(delta) 是给实时显示用的。一份数据流,两个消费者。

# 题眼:为什么用回调而不是直接 return?

streamAI 不自己 console.log,而是收一个 onChunk 回调,把「收到数据后做什么」的决定权交给调用方:

// index.ts
const writeChunk = startStreamDisplay();          // 拿到写入器
const result = await streamAI(input, history, writeChunk);  // 把它作为 onChunk 传进去
endStreamDisplay();
1
2
3
4

这就是控制反转(IoC)。好处是同一份 streamAI 封装能服务多种场景:交互模式传「写屏」回调 → 逐字显示;静默收集模式传「空操作」回调 → 只要最后的 fullText;测试时传「记录到数组」回调 → 断言收到的 delta 序列。API 层只管「产生数据」,不绑死「如何消费」。这个模式在 Claude Code 源码里到处都是。

# 数据流

streamAI 发起 stream 请求
   ↓
SSE 事件持续到达 ── 每个 text_delta ──▶ stream.on('text')
                                            ├─ fullText += delta   (存)
                                            └─ onChunk(delta)      (显示)
                                                   ↓
                                            writeChunk 写终端(逐字)
   ↓ message_stop
finalMessage() → 拿到完整 usage
   ↓
返回 { text: fullText, tokens }  →  push 进 history
1
2
3
4
5
6
7
8
9
10
11

# 四、深入追问(面试常见 follow-up)

Q:既然逐字回调显示了,为什么还要 fullText 累加一份完整文本? A:因为显示和存储是两个目的。onChunk 负责「当下让用户看到」,delta 显示完就没了;但下一轮对话要把这次的完整 assistant 回复 push 进 history(step06 那套记忆机制),所以必须有人把碎片重新拼成整段。fullText += delta 就是这个拼接器。少了它,流式显示是好看了,但对话历史会丢掉 AI 说过的话。

Q:流式为什么不用 WebSocket,而用 SSE? A:因为需求是单向的——服务端往客户端推 token,客户端不需要在流中途反向发数据。SSE 正好是「服务端单向推送」的轻量方案,跑在普通 HTTP 上,天然穿透代理和防火墙,自带断线重连语义,实现比 WebSocket 简单得多。WebSocket 的双向能力在这里是浪费。选 SSE 是「用最小够用的协议」。

Q:流到一半网络断了,fullText 里是半截回复,会怎样? A:await stream.finalMessage() 会 reject 抛异常,被 index.ts 的 try/catch 接住(走 401/429/其它分支)。关键是:因为异常发生在 history.push 之前,那半截回复不会被写进历史——下一轮不会带着一段残缺的 assistant 消息。这是把「push 历史」放在 streamAI 成功返回之后的用意:要么完整地记住,要么当作没发生。

Q:逐字 write 会不会把终端格式搞乱(比如代码块、颜色)? A:会,这正是真实 Claude Code 输入/输出层复杂的原因。逐段到达的文本可能把一个 Markdown 代码围栏、一个 ANSI 颜色序列切成两半,直接写屏会花屏。真实版要做流式渲染缓冲:攒够一个完整语法单元再渲染、处理跨 chunk 的高亮状态。我们这一步只做裸 write,把这个难题留给了后面的 Ink UI 阶段。


# 五、设计权衡

流式的代价是复杂度上移:非流式一个 await 拿完整结果,简单直白;流式要管事件订阅、碎片累加、结束时机、异常时的历史一致性。step07 用回调模式把这份复杂度封装在 streamAI 内部,对 index.ts 只暴露「传个 onChunk」的简单接口——复杂度没消失,但被关进了盒子。另外裸 write 不处理格式,是刻意欠债,留到 UI 阶段一次性还。


# 六、与真实源码的对照

我们的实现 Claude Code 源码
messages.stream + on('text') src/services/api/ 流式封装
onChunk 回调模式 引擎层普遍的回调/事件流
startStreamDisplay 裸 write ink/ 组件流式渲染 + 缓冲
fullText 拼接进 history src/types/ Message + QueryEngine
finalMessage().usage 流式下的 token/成本统计

# 七、一句话总结

Step 07 = SSE 逐字流 + 回调控制反转:流式的真正价值是砍掉首字延迟、把「黑屏焦虑」换成「打字般的确定感」;而 onChunk 回调把「如何消费数据」的权力交给调用方,让同一份 API 封装既能实时显示又能静默收集。fullText 悄悄拼回整段,守住了对话记忆。

# 下一步

cd step08 && npm start —— 基础闭环(输入 → API → 流式输出 → 记忆)已经跑通,接下来开始搭真正的能力:工具系统。