# 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: {}
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: ... };
}
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();
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
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 → 流式输出 → 记忆)已经跑通,接下来开始搭真正的能力:工具系统。