# Step 31: 统一消息内容模型
一句话导读:step30 之前
messages一直是any[],「块长什么样」「怎么读出文字」「怎么判断是不是工具结果」这些知识散落在compact.ts/QueryEngine.ts/api.ts各处——而这正是前两个 bug 的共同根因。step31 把它收口到一个message.ts:4 种内容块的判别联合 + 统一的构造器 + 统一的读取器。
# 一、这一步做了什么(What)
新增 message.ts,把「消息 / 内容块」从 any[] 升级成有类型、有唯一读写入口的一等公民:
内容块(判别联合,靠 type 字段区分)
├── TextBlock { type:"text", text }
├── ThinkingBlock { type:"thinking", thinking, signature? }
├── ToolUseBlock { type:"tool_use", id, name, input }
└── ToolResultBlock { type:"tool_result", tool_use_id, content, is_error? }
Message { role: "user"|"assistant", content: string | ContentBlock[] }
构造器:userText / assistantMsg / toolResultMsg / toolResultBlock
读取器:blocksOf / textOf / toolUsesOf / isToolResultMessage
isCleanUserStart / blockToText / messageToText / transcriptOf
2
3
4
5
6
7
8
9
10
11
QueryEngine.ts / compact.ts / api.ts / storage.ts 的 any[] 全部换成 Message[]。造消息只能走构造器,读消息只能走读取器——任何人都不再裸写 { type: ... }。这一步不加功能,是纯粹的知识收口。
# 二、面试官视角:为什么要做?(Why)
面试题:
any[]也能跑,为什么要专门抽一个类型模块?「类型收口」到底解决了什么真实问题?
它直接消灭了一类 bug 的根因。回顾前两步:
- step29:压缩切出「孤儿 tool_result」→ API 400;
- step30:摘要把工具块整体丢成
(tool call)→ 丢失上下文。
这两个 bug 表面无关,根因却是同一个:没有一个地方权威地定义「消息块的形状和读写方式」。于是每个文件各自 (m.content || []).filter(b => b.type === ...),各写各的判断——compact.ts 里有一份 isCleanUserStart,QueryEngine.ts 里手拼 { type: "tool_result", ... },api.ts 里又一套读文本的逻辑。同一份知识被抄了 N 遍,就有 N 个地方可能抄错、可能漏改。
any[] 的代价不是「没类型提示」这么表面,而是:编译器帮不上任何忙,所有结构约束只能靠人肉记忆和 code review。把知识收口成单一权威定义后,「孤儿块判断」只有一处、「工具块翻译」只有一处、「造工具结果消息」只有一个入口——结构错误在编译期就被挡住,bug 从源头消失。
# 三、原理:它是怎么工作的(How)
# 机制一:判别联合 + 自动类型收窄
这是这一步的 TS 核心知识点。四种块共享一个 type 判别字段,TS 能据此在每个分支自动收窄类型:
export function blockToText(b: ContentBlock): string {
switch (b.type) {
case "text":
return b.text || ""; // b 自动收窄为 TextBlock,能访问 .text
case "tool_use": {
const args = JSON.stringify(b.input || {}); // 收窄为 ToolUseBlock,能访问 .input/.name
return `[调用 ${b.name}(${args.length > 200 ? args.slice(0, 200) + "…" : args})]`;
}
case "tool_result": {
const raw = typeof b.content === "string" ? b.content : ""; // 收窄为 ToolResultBlock
return `[结果${b.is_error ? "(失败)" : ""}: ${raw.length > 500 ? raw.slice(0, 500) + "…" : raw}]`;
}
default:
return ""; // thinking 等:压缩时丢弃
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
进了 case "text" 分支,TS 就知道 b 一定是 TextBlock,访问 b.text 不用任何手动断言;访问 b.name 反而会编译报错。一个 type 字段,省掉所有 as 断言——这就是判别联合相比 any 的建模威力。
# 机制二:构造器封住「造」,读取器封住「读」
造消息只能走这几个函数,结构不可能写错:
export function userText(s: string): Message { return { role: "user", content: s }; }
export function assistantMsg(blocks: ContentBlock[]): Message { return { role: "assistant", content: blocks }; }
// 工具结果以 user 角色发回给模型(Anthropic 协议如此)
export function toolResultMsg(blocks: ToolResultBlock[]): Message { return { role: "user", content: blocks }; }
export function toolResultBlock(id: string, content: string, isError = false): ToolResultBlock {
return { type: "tool_result", tool_use_id: id, content, is_error: isError };
}
2
3
4
5
6
7
读消息也只走读取器,isCleanUserStart 复用 isToolResultMessage,不再各写各的:
export function isToolResultMessage(m: Message): boolean {
return typeof m.content !== "string" && m.content.some((b) => b.type === "tool_result");
}
export function isCleanUserStart(m: Message): boolean {
return !!m && m.role === "user" && !isToolResultMessage(m); // step29 那个判断,现在只此一处
}
2
3
4
5
6
# 数据流:知识如何收口
step30(散) step31(收口)
compact.ts: 本地 isCleanUserStart ─┐
本地 blockToText ─┤
本地 extractText ─┼→ 全部删掉,import message.ts
QueryEngine: 手拼 {type:"tool_result"} ┤ 造消息走 toolResultMsg/toolResultBlock
api.ts: 自己读 text 的逻辑 ─┤ 读文本走 textOf
storage.ts: any[] ─┘ 类型升级为 Message[]
message.ts = 唯一权威定义
2
3
4
5
6
7
8
# 四、深入追问(面试常见 follow-up)
Q:为什么 Message.content 要设计成 string | ContentBlock[] 两种形态,而不统一成数组?
A:因为两种来源天然不同。用户输入和压缩摘要就是一段纯文本,强行包成 [{type:"text",...}] 是无谓的仪式;而 assistant 回复和工具结果必须是块数组(含 tool_use / tool_result)。保留二态既贴合 Anthropic 协议本身(它也接受这两种),又让读取器用 typeof m.content === "string" 一个分支优雅处理纯文本。真实源码同样是这个二态设计。
Q:ThinkingBlock 这一步还没用到(step33 才做 thinking),为什么现在就定义?
A:因为这一步是建模,要一次把「Anthropic 消息里可能出现的块」定义完整,而不是用到才补。定义齐了,step33 加 thinking 时就是「地基上盖楼」——assistantMsg(result.contentBlocks) 直接就能把含 thinking 的完整块入历史,message.ts 一行不改。这就是「好地基让后续功能变廉价」。
Q:判别联合和「用一个基类 + 继承子类」建模,区别在哪?为什么这里选前者?
A:内容块是数据不是行为——它们没有多态方法,只是形状不同的普通对象(要过 JSON 序列化发给 API)。判别联合天然贴合「同一个位置可能是几种形状之一」的数据建模,配合 switch 收窄零成本;而继承会引入类实例、instanceof、原型链,序列化还得处理,纯属重。数据建模用判别联合,行为建模才用多态。
Q:收口之后,如果 Anthropic 新增一种块类型,改动量有多大?
A:加一个 interface 到联合里,然后 switch 里的 default 分支或编译器(若开了 exhaustiveness 检查)会提示你所有该处理的地方。改动集中在 message.ts 一个文件,别处只要用读取器就自动适配。这正是收口的收益——扩展点唯一。
# 五、踩坑 / 设计权衡
- 收口 vs 过度抽象:收口的正确时机是「同一份知识已经被抄了 ≥2 遍、且已经因此出过 bug」。step29/30 两个 bug 就是信号。过早抽象(还没有重复就先建模)是另一种病,这里是「被 bug 逼出来的、恰到好处的收口」。
any的隐性成本会累积:messages: any[]在早期跑得飞快,代价被推迟到「压缩、摘要、多轮往返」这些复杂路径上集中爆发。类型欠债和技术债一样,越晚还利息越高。- 构造器不是形式主义:
toolResultBlock(id, text, isErr)看着比裸写对象啰嗦,但它保证tool_use_id字段名不会被某处误写成toolUseId——这种字段名手滑正是any时代最难查的 bug。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
ContentBlock 判别联合(4 种块) | Anthropic SDK 的 content block 类型 |
message.ts 统一读写器 | 真实版散布在 utils 里的一堆 message 助手 |
Message.content 二态(string | block[]) | 真实版同样支持两种形态 |
ThinkingBlock 提前定义 | SDK 原生 thinking block 类型 |
# 七、一句话总结
把「消息块的形状和读写方式」从 any[] 收口成单一权威定义:4 种块用判别联合建模(一个 type 字段让 TS 自动收窄、省掉所有断言),造消息只走构造器、读消息只走读取器。重复的 bug 源于同一份知识被抄了多遍,收口后 bug 从源头消失——这也是 step32 事件流、step33 thinking 的共同地基。
# 下一节预告
这套 ContentBlock 是后面几步的地基。step32 紧接着把 step30 的「一组回调」升级成一条 async-generator 事件流——真实源码的做法。