# Step 33: Extended Thinking

一句话导读:让模型在正式回答前先输出一段「思考过程」(thinking 块),并把它当作新的事件流式渲染出来。这一步直接站在 step32 的事件流 + step31 的 ContentBlock 两块地基上——加思考模式只是「多几种事件 + 多一种块」,扩展成本极低。


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

四件事:

  1. 请求里开启思考:thinking: { type: "enabled", budget_tokens: 2000 }。
  2. 解析 thinking 块:streamTurn 按「内容块边界」把 SDK 流翻译成事件——thinking 块 → thinking_start / thinking / thinking_end;text 块 → stream_start / text / stream_end。
  3. 流式渲染:终端用灰色 💭 思考: 区别于青色 AI回复:。
  4. /think 开关:默认关(部分代理不支持),随时切换,状态写入 config。

events.ts 加三种事件、api.ts 的 streamTurn 加 thinking 参数并改成按块边界 yield、QueryEngine.ts 去掉自己发的 stream_start/end 改由 streamTurn 发。


# 二、面试官视角:为什么要做?(Why)

面试题:加一个 thinking 思考模式,需要改动哪些层?如果你的事件系统设计得好,这个改动应该有多大?

这是一道检验前面架构质量的题。thinking 是一条新的内容流(和文本流并列),要让它端到端跑通,理论上牵涉:请求参数、SDK 流解析、事件协议、历史存储、终端渲染、开关配置——六层。

但因为 step31(ContentBlock)和 step32(事件流)两块地基已经铺好,实际改动被摊得极薄:

  • 事件协议(step32 建的):只往判别联合里加三种事件(thinking_start/thinking/thinking_end),消费方 switch 里多三个 case——这就是事件流相比回调的复利:加一种流 = 加几个 case,不用动引擎主干;
  • 历史存储(step31 建的):ThinkingBlock 这个类型 step31 就预先定义好了,现在 assistantMsg(result.contentBlocks) 直接把含 thinking 的完整块入历史,一行不改;
  • 压缩(step29/31 建的):blockToText 的 default 分支早就把 thinking 丢弃了,无需改动。

所以答案是:地基好,这个功能就廉价。如果 step31/32 没做,这里就得同时动「消息结构 + 事件机制 + 渲染」三处,改动量翻几倍还容易出 bug。这道题真正考的是「你之前的抽象有没有为未来的扩展留出零成本的扩展点」。


# 三、原理:它是怎么工作的(How)

# 机制一:按「内容块边界」把 SDK 流翻译成事件

Anthropic 的流式响应是一串带边界标记的事件:content_block_start → 若干 content_block_delta → content_block_stop,一个块一组。streamTurn 贴着这些边界,把「思考流」和「文本流」分别翻译成我们的事件:

if (thinking) {
  (req as any).thinking = { type: "enabled", budget_tokens: 2000 };  // 必须 < max_tokens
}
const stream = getClient().messages.stream(req);

let currentType: string | null = null;
for await (const ev of stream) {
  if (ev.type === "content_block_start") {
    currentType = ev.content_block?.type ?? null;
    if (currentType === "thinking") yield { type: "thinking_start" };
    else if (currentType === "text") yield { type: "stream_start" };
  } else if (ev.type === "content_block_delta") {
    const d = ev.delta as any;
    if (d?.type === "thinking_delta") yield { type: "thinking", text: d.thinking };
    else if (d?.type === "text_delta")  { fullText += d.text; yield { type: "text", text: d.text }; }
  } else if (ev.type === "content_block_stop") {
    if (currentType === "thinking") yield { type: "thinking_end" };
    else if (currentType === "text") yield { type: "stream_end" };
    currentType = null;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

用 currentType 记住「当前在流哪种块」,start/stop 时据此发对应的首尾事件,delta 时据 delta 子类型(thinking_delta / text_delta)分流。

# 机制二:边界标记归「产生者」——为什么把 stream_start/end 从引擎挪进 streamTurn

step32 时只有「文本」一种流,引擎在 yield* 前后各发一次 stream_start/stream_end 就够了。step33 多了「思考」这种流,两种块要分别加首尾标记——而「现在到底在流哪种块」只有 streamTurn(贴着 SDK 的 content_block_start/stop)才知道,引擎在外层根本看不见块边界。

所以标记必须下沉到 streamTurn:引擎不再自己发流首尾事件,改由真正产生块的 streamTurn 按块边界发。原则是——谁产生块,谁负责打块的边界。职责归位后,加任意多种块类型都不用再动引擎。

# 机制三:thinking 块完整入历史,但压缩时丢弃

// QueryEngine 里:把 AI 的完整回复(含 thinking 块)入历史
this.messages.push(assistantMsg(result.contentBlocks));   // contentBlocks 含 ThinkingBlock
1
2

开启思考后 assistant 回复里含 thinking 块,用 assistantMsg(完整内容块) 把它完整入历史——这正是 step31 特意在 ContentBlock 里保留 ThinkingBlock 的原因(模型续推时可能需要看到自己上一轮的思考)。而压缩时 blockToText 的 default 分支返回空串,丢弃 thinking:它冗长、是非事实的推理痕迹,不值得占摘要篇幅。入历史与入摘要,区别对待。

# 数据流:一次回答的事件时序

用户问 "3+5=?"(开了 thinking)
  thinking_start        → 打印 "💭 思考:"
  thinking × N (流式)   → 灰色逐字:"我们被问到…3+5=8…"
  thinking_end
  stream_start          → 打印 "AI回复:"
  text × N (流式)       → "3+5等于8。"
  stream_end
  round                 → 本轮统计
1
2
3
4
5
6
7
8

(实测 DeepSeek 代理:thinking_start:1, thinking:35, thinking_end:1, stream_start:1, text:6, stream_end:1。)


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

Q:budget_tokens 是什么?为什么必须小于 max_tokens? A:budget_tokens 是分配给「思考」这段的 token 预算。思考和最终回答共享同一个 max_tokens 输出上限,思考先花、回答后花——所以思考预算必须严格小于总上限,否则思考就把额度用光、模型没 token 留给正式回答了。这里设 2000,max_tokens 是 4096。

Q:为什么 thinking 默认关,还要做成 /think 开关? A:两个现实原因。一是兼容性——不是所有 API 代理都支持 extended thinking,默认关避免在不支持的代理上直接报错;二是成本/延迟——思考会多烧 token、多花时间,简单问题不值得。所以做成用户可切换的开关,让人按问题难度自己决定。(真实版更进一步,用 adaptive 档位让模型自适应,见 step34。)

Q:thinking 块既然完整入历史,压缩时又丢弃,会不会导致模型「续推时看得到、压缩后看不到」的不一致? A:这是刻意的取舍,且是对的。thinking 的价值是局部的、当轮的——它帮模型在紧接着的回答里保持连贯;一旦对话走远、进入压缩阶段,几轮前的推理痕迹早已失去参考价值,只剩「占摘要篇幅」的坏处。所以近期完整保留(供续推)、远期压缩丢弃(省空间),恰好匹配 thinking 的时效性。

Q:如果 Anthropic 又新增一种块(比如 image 块的流),按现在的设计要改哪里? A:只改 streamTurn 里那个 if (currentType === ...) 分流,加一个分支发新事件;events.ts 加对应事件类型;消费方 switch 加 case。引擎主干、历史存储、压缩全都不动——因为「块边界解析」已经收口在 streamTurn 一处,扩展点唯一。这正是 step32「边界标记归产生者」这个决定换来的红利。


# 五、踩坑 / 设计权衡

  • 块边界状态要正确复位:content_block_stop 后必须 currentType = null。漏掉会导致下一个块的 delta 误判成上一个块的类型——这类「流式状态机忘了复位」的 bug 在逐块解析里很典型。
  • 不是所有代理都支持:默认关 + /think 显式开,本质是「能力探测的兜底」。在不确定后端能力时,把高级特性设为 opt-in 是稳妥做法。
  • 入历史 vs 入摘要的分野:这一步隐含一个通用原则——同一份数据,在「短期上下文」和「长期记忆」里可以有不同的去留策略。thinking 短期留、长期删;工具结果短期长期都留(step29)。区分「事实」与「过程」是关键。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
thinking: {type:"enabled", budget_tokens} 同样的 extended thinking 开关
按 content_block_start/delta/stop 边界 yield 思考/文本事件 真实版逐 content_block 解析流
灰色 💭 思考: 渲染 真实版思考折叠/灰显 UI
/think 布尔开关 真实版的思考档位(adaptive / disabled / 固定预算)
thinking 完整入历史、压缩丢弃 同样保留思考块供续推、压缩时不带

# 七、一句话总结

加思考模式 = 多三种事件 + 用好一种早就定义的块:streamTurn 贴着 SDK 的块边界把思考流和文本流分别翻译成事件(边界标记归产生者),thinking 块靠 step31 预留的 ThinkingBlock 完整入历史、靠 blockToText 在压缩时丢弃。这个功能之所以廉价,全靠 step31 的 ContentBlock 和 step32 的事件流两块地基——好抽象让新特性几乎零成本。

# 下一节预告

Phase 3.5 收尾——step34 把我们做出来的引擎和真实 QueryEngine.ts(1295 行)摆一起精读,看清「教学最小内核」与「生产实现」的差距。