2026年6月23日2约 1244 字5 分钟阅读

【pi-mono 源码解析 4/4】toolCallId: string 的债从哪里来

从 pi-mono 的一行 `toolCallId: string` 出发,追踪一条跨越框架层和 LLM Provider 协议层的技术债。任何包装 Anthropic 或 OpenAI API 的框架,在接手协议的那一刻就已经继承了这笔债。

GitHub 源码解析

第 4 篇 / 共 5 篇

toolCallId: string 的债从哪里来

pi-mono 源码解析系列后记。前三篇分别分析了 Agent Loop、Context Compaction 和 Session Fork 的关键实现。这篇从 pi-mono 里的一个具体细节出发,追踪一条跨越框架层和协议层的技术债的根源。

一、三行代码,类型系统全程沉默

agent-loop.ts:736 有这样一行赋值:

typescript
toolCallId: finalized.toolCall.id,

这行代码把一个工具调用的 ID 赋给 ToolResultMessage,告诉 LLM "这个结果对应哪次工具调用"。类型是 string

这个 string 从哪里来?

typescript
// 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 到哪里去?

typescript
// anthropic-messages.ts:1131
tool_use_id: msg.toolCallId,  // ← string 原样传回 Provider

传回给 Anthropic,作为 tool_resulttool_use_id,告诉 Provider 这次结果对应哪次 tool_use。

三个节点,id 从 Provider → 框架内部 → Provider 来回一趟。类型全程是 string,整条链路类型系统看不见任何约束:

  • toolCallIdtoolCalls[].id 之间没有类型关联
  • 一个 ToolResultMessage 是否对应合法的 tool call,编译器不知道
  • 如果构造一个 toolCallId 指向不存在的 tool call,TypeScript 编译通过,运行时才出错

这不是 pi-mono 写得粗糙。这是一个在所有层面都被正确实现的系统,但仍然有一个约束无法在类型层表达。

要理解为什么,要去看这个 string 的出处。

二、债在协议层,不在框架层

Anthropic 的 tool use 协议是这样定义的:

json
// assistant 回复:带 id 的 tool_use block
{
  "type": "tool_use",
  "id": "toolu_abc123",
  "name": "get_weather",
  "input": { "city": "Beijing" }
}
json
// 下一轮 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 协议结构完全相同:

json
// assistant 回复:tool_calls[] 里每个 call 有 id
{ "role": "assistant", "tool_calls": [{"id": "call_xyz", "function": {...}}] }
json
// 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。框架把配对关系的验证责任丢给调用方。调用方必须自己保证"每个 ToolResultMessagetoolCallId 对应某个实际发出的 tool call"。编译器帮不上忙。

框架层能做的最好补偿:把配对关系提升为类型结构——也就是 DDD 里的 Aggregate 模式。

以 pi-mono 分析过程中出现的 ToolCallGroupEntry 设想为例:

typescript
// 现状:两条独立消息靠 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 的迁移逻辑。这篇的结论不同:债在协议层,框架层的补偿有上限,修复路径的代价随着类型安全要求的提高而快速增长。

知道上限在哪里,是做工程决策时不让自己过于乐观也不让自己过于悲观的前提。