【pi-mono 源码解析 4/4】toolCallId: string 的债从哪里来
从 pi-mono 的一行 `toolCallId: string` 出发,追踪一条跨越框架层和 LLM Provider 协议层的技术债。任何包装 Anthropic 或 OpenAI API 的框架,在接手协议的那一刻就已经继承了这笔债。
第 4 篇 / 共 5 篇
toolCallId: string 的债从哪里来
pi-mono 源码解析系列后记。前三篇分别分析了 Agent Loop、Context Compaction 和 Session Fork 的关键实现。这篇从 pi-mono 里的一个具体细节出发,追踪一条跨越框架层和协议层的技术债的根源。
一、三行代码,类型系统全程沉默
agent-loop.ts:736 有这样一行赋值:
toolCallId: finalized.toolCall.id,
这行代码把一个工具调用的 ID 赋给 ToolResultMessage,告诉 LLM "这个结果对应哪次工具调用"。类型是 string。
这个 string 从哪里来?
// anthropic-messages.ts:575-578
} else if (event.content_block.type === "tool_use") {
const block: Block = {
type: "toolCall",
id: event.content_block.id, // ← tool_use.id 进入框架内部
name: event.content_block.name,
arguments: {},
};
它从 Anthropic 的 tool_use.id 来——解析 Provider 响应时,event.content_block.id 被直接映射成框架内部的 Block.id。
这个 string 到哪里去?
// anthropic-messages.ts:1131
tool_use_id: msg.toolCallId, // ← string 原样传回 Provider
传回给 Anthropic,作为 tool_result 的 tool_use_id,告诉 Provider 这次结果对应哪次 tool_use。
三个节点,id 从 Provider → 框架内部 → Provider 来回一趟。类型全程是 string,整条链路类型系统看不见任何约束:
toolCallId和toolCalls[].id之间没有类型关联- 一个
ToolResultMessage是否对应合法的 tool call,编译器不知道 - 如果构造一个
toolCallId指向不存在的 tool call,TypeScript 编译通过,运行时才出错
这不是 pi-mono 写得粗糙。这是一个在所有层面都被正确实现的系统,但仍然有一个约束无法在类型层表达。
要理解为什么,要去看这个 string 的出处。
二、债在协议层,不在框架层
Anthropic 的 tool use 协议是这样定义的:
// assistant 回复:带 id 的 tool_use block
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": { "city": "Beijing" }
}
// 下一轮 user 消息:tool_result 引用那个 id
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "晴,25°C"
}
tool_use_id 必须等于某个有效的 tool_use.id——这是协议的核心约束。违反这条约束,API 会报错。
但这条约束在哪里表达?在文档里。
不在 JSON Schema 里,不在类型定义里,不在任何编译时可检查的地方。Anthropic 的 API schema 描述 tool_use_id 时,类型是 string。你可以传任何字符串,schema 层不会拦截。
OpenAI 的 tool use 协议结构完全相同:
// assistant 回复:tool_calls[] 里每个 call 有 id
{ "role": "assistant", "tool_calls": [{"id": "call_xyz", "function": {...}}] }
// tool 消息:tool_call_id 指回去
{ "role": "tool", "tool_call_id": "call_xyz", "content": "..." }
同样的结构,同样的约束,同样只在文档里表达。
这个约束无法在 JSON Schema 里表达。
JSON Schema 能描述一个字段是 string,但无法描述"这个 string 必须等于同一对话里某条历史消息中某个字段的值"。这需要 Dependent Types——根据值来约束其他值的类型系统特性。Haskell 的 Liquid Haskell、Idris、Agda 提供了这种能力,但没有主流工程框架使用它,代价是类型检查变成定理证明。
所以这条约束从第一天起就只能活在文档里,不能活在类型系统里。
任何忠实包装 Anthropic 或 OpenAI API 的框架,在接手 Provider 协议的那一刻,就已经继承了这笔债。
pi-mono 如此,LangChain 如此,任何类似框架都如此。这不是框架的选择,是 JSON Schema 表达力的行业性上限。
三、框架层能做什么,上限在哪里
既然协议层不能解决,框架层有补偿空间,但有上限。
现状:toolCallId: string。框架把配对关系的验证责任丢给调用方。调用方必须自己保证"每个 ToolResultMessage 的 toolCallId 对应某个实际发出的 tool call"。编译器帮不上忙。
框架层能做的最好补偿:把配对关系提升为类型结构——也就是 DDD 里的 Aggregate 模式。
以 pi-mono 分析过程中出现的 ToolCallGroupEntry 设想为例:
// 现状:两条独立消息靠 string 关联
interface AssistantMessage {
toolCalls: ToolCall[]
}
interface ToolResultMessage {
toolCallId: string // 没有类型约束指向哪里
}
// Aggregate 方向:把配对关系内化为结构
interface ToolCallGroupEntry {
toolCalls: ToolCall[]
results: Array<{
toolCallId: string
content: ToolResult
complete: boolean // 配对完整性成为结构的一部分
}>
}
这不能消灭底层的 string——发给 Provider 前,ToolCallGroupEntry 还是要展开成两条消息,toolCallId 还是 string。但它把"两条消息必须配对"这个不变量的验证点,从"调用方随时可能出错"集中到了"写入 ToolCallGroupEntry 时由框架强制检查"。
这和 LangChain 0.2 的 Runnable 在做同一件事:Provider 协议不提供类型安全的组合原语,框架层在 Provider 之上加一层结构,把不变量的验证点前移。
这是框架层能到达的上限:不能消灭债,能把债的验证点集中在一个可控位置。
在 Aggregate 模式之外,还有两个代价更高的路径:
Typestate 模拟:用类型参数携带"哪些 tool calls 等待结果"的信息,在 session 关闭前强制所有 tool calls 都有对应 result。技术上可行,但框架 API 复杂度指数级增长,工程代价极高。
运行时守卫:在 appendEntry() 或消息发送前做完整性检查,发现缺失配对时抛出错误。代价最低,但把编译时问题推到了运行时,不是类型安全,是退而求其次。
对大多数工程团队来说,Aggregate 封装是性价比最高的路径:把验证点前移,不追求完全的编译时保证,接受运行时兜底。
前三篇分析的 bug 都有立刻可执行的修复:增加 token type、修复 findCutPoint 截断、补齐 forkFrom 的迁移逻辑。这篇的结论不同:债在协议层,框架层的补偿有上限,修复路径的代价随着类型安全要求的提高而快速增长。
知道上限在哪里,是做工程决策时不让自己过于乐观也不让自己过于悲观的前提。