Chapter 02

协议机制

上一章把工具调用拆成「模型只请求、harness 执行」的 5 个角色。这一章把那段「结构化请求」放大到字段级:它在 Anthropic 和 OpenAI 里到底长什么样,以及模型究竟怎么「生成」出它。

本章你将建立的 schema

  • ① tool 定义 / tool_use / tool_result 三者的精确字段,与作为停机信号的 stop_reason。
  • ② Anthropic 与 OpenAI 的术语和数据形状差异:input 对象 vs arguments 字符串、user 角色 vs tool 角色。
  • ③ tool_choice、并行调用、流式三者的语义边界。
  • ④ 深一层:模型靠后训练 + 特殊 token +(可选)约束解码产出调用,JSON 合法性不是天生的。

2.1tool 定义:name / description / input_schema

一个工具定义就是 name + description + 一份 JSON Schema 参数表,提前声明给模型。

工具调用的起点不是代码,而是一段声明。在发起请求时,caller 把可用工具列表放进 tools 字段,每个工具至少包含三件事:name(模型用来指名调用)、description(模型用来判断「这工具是干什么的、什么时候该用」)、参数表(模型用来知道每个参数叫什么、是什么类型、哪些必填)。

底层机制

参数表的格式是 JSON Schema(一套用 JSON 描述「另一段 JSON 该长什么样」的标准)。Anthropic 把它放在 input_schema 字段里,OpenAI 放在 function.parameters 里——同一份东西,两个字段名。

关键在于这段定义的去向:它会被序列化成文本、拼进模型的 context(通常落在系统提示区)。模型据这段文字决定选哪个工具、每个参数填什么值。所以一个工具定义的本质,是写给模型读的 prompt——description 写得含糊,模型就选错、填错(04 章专门展开「为什么 description 是最高杠杆」)。

tool-def.json JSON
{
  "name": "get_weather",
  "description": "查询指定城市的当前天气。当用户询问实时天气时调用。",
  "input_schema": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "城市名,如 San Francisco"
      }
    },
    "required": ["location"]
  }
}

这是 Anthropic 风格的写法。input_schema 内部就是标准 JSON Schema:顶层是一个 object,properties 列出每个参数,required 标出必填项。模型读到它,就知道「调 get_weather 时必须给一个叫 location 的字符串」。

2.2一次往返的报文:tool_use + stop_reason + tool_result

模型回一个 tool_use 块并停机,caller 执行后用一条 user 消息把 tool_result 送回去。

续用 01 章的天气例子,看 Anthropic Messages API 里一次完整往返的报文形状。整个循环是四拍:

  • caller 发请求(带上 tools 定义);
  • 模型回一条 assistant 消息,stop_reason 为 "tool_use",且 content 里含一个 tool_use 块;
  • caller 在自己的程序里执行那个工具;
  • caller 发一条 role:"user" 消息,content 里装一个 tool_result 块——模型据此继续。
anthropic-roundtrip.json JSON
// ② 模型这一轮的回复(assistant)
{
  "role": "assistant",
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_01A",
      "name": "get_weather",
      "input": { "location": "San Francisco" }
    }
  ]
}

// ③ 你的程序执行后,把结果作为一条 user 消息发回
{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A",
      "content": "15°C, 多云",
      "is_error": false
    }
  ]
}
stop_reason=tool_use 是停机信号,模型在此交还控制权 · id ↔ tool_use_id 把结果对回它所属的那次请求 · role:user Anthropic 用 user 角色装结果,没有专门的 tool 角色。
你的程序 (harness) Claude (模型) ① 请求 + tools 定义 ② tool_use + stop_reason ⏸ harness 调真实 API ③ tool_result 回填 ④ 最终文本答案
图 2.1Anthropic 一次工具往返的四条报文。注意:第 ② 条(朱红)带回 stop_reason:"tool_use" 后模型就停了;真正调 API(左侧小框)发生在你的程序里,模型在等第 ③ 条。
陷阱 · 配对必须严格

tool_use 与 tool_result 必须严格 1:1 配对,并通过 tool_use_id 关联。若上下文裁剪 / 压缩时丢了 tool_use 却留下 tool_result(或两者顺序错乱),API 会直接报 400。03 章会讲循环里怎么维护这份配对不被破坏。

2.3Anthropic ↔ OpenAI 术语对照

同一套机制,两家的字段名和数据形状不同。读别人的代码、跨厂商迁移时,这张表是高频参照。

表 2.1 · 同一机制的两套叫法
概念AnthropicOpenAI (Chat Completions)
模型的调用请求tool_use 块tool_calls[]
返回结果tool_result 块(role: user)一条 role:"tool" 消息
关联 idtool_use_idtool_call_id
参数 payloadinput(已是对象)function.arguments(JSON 字符串,要自己 parse)
停止信号stop_reason:"tool_use"finish_reason:"tool_calls"
schema 字段input_schemafunction.parameters
强制调用某工具tool_choice:{"type":"any"}tool_choice:"required"
洞察 · 最易踩的差异

OpenAI 的 arguments 是 JSON 字符串,拿到手要先 JSON.parse 才能当对象用;Anthropic 的 input 直接就是对象,可直接取字段。但两家都不是模型在「执行」——它们都只是把模型生成的请求换了个数据形状交还给你,剩下的活仍归你的程序。

2.4tool_choice:让不让、让不让得了哪个

tool_choice 控制四种姿态:自由决定、必须调某个、钉死某个、完全禁止。

tool_choice 决定模型在这一轮对工具的姿态。四种语义(括号内为两家的命名):

  • auto(默认)——模型自行决定调不调、调哪个。
  • any(Anthropic)/ required(OpenAI)——必须调某个工具,但不指定是哪个,由模型从工具列表里挑。
  • 指定某工具——钉死调用哪个:Anthropic 用 {"type":"tool","name":"x"},OpenAI 用 {"type":"function","function":{"name":"x"}}。
  • none——禁止调用任何工具,模型只能直接回文本。
想一想

设了 tool_choice:"any"(OpenAI 的 required),模型一定会调你心里想的那个工具吗?

展开答案

不一定。any / required 只强制「调某个工具」,并不指定是哪个——模型仍会从工具列表里自己挑。要钉死具体工具,得用指定形式 {"type":"tool","name":...}(OpenAI 对应 {"type":"function","function":{"name":...}})。把「强制调用」和「强制调哪个」分开记。

2.5并行工具调用

一个回合可以请求多件事;两家的结果回填形状不同,但都靠 id 对应。

一个 assistant turn 可以一次带多个 tool_use 块(OpenAI 对应 tool_calls 数组里多个元素)。回填方式两家不同:

  • Anthropic:把多个 tool_result 放进同一条 user 消息的 content 数组里。
  • OpenAI:每个 tool_call_id 对应一条独立的 role:"tool" 消息。

不论哪种形状,结果与请求的对应关系都靠 id(tool_use_id / tool_call_id)维系。机制上,并行只是「一个回合里请求多件事」,执行仍由 harness 负责——彼此独立的调用可以并发跑,从而省墙钟时间(03 章会讲并行带来的隐患,例如互相依赖或写同一资源)。

2.6流式:参数是一点点拼出来的

流式下工具参数逐 chunk 到达,中途是残缺 JSON,block 结束才完整。

开启流式后,工具参数不是一次性给全,而是逐 chunk 到达:Anthropic 通过 input_json_delta 事件吐出 partial_json 片段;OpenAI 通过分块的 delta.tool_calls[].function.arguments 累加。在到达过程中,手里的内容是不完整、甚至不合法的 JSON——只有当这个工具调用 block 结束时,所有片段拼起来才构成一个完整可解析的对象。

想一想

流式输出到一半,拿到的工具参数 JSON 能直接解析吗?

展开答案

不能。中途的 partial_json 是残缺片段(比如只到 {"location": "San Fr),强行 parse 会失败。必须等这个 block 结束、片段拼全后再解析。设计启示:别在流式中途就拿参数去执行工具,否则要么解析报错、要么拿到半截参数误触发。

2.7深一层:模型到底怎么「生成」一个调用

工具调用就是普通生成的 token;JSON 合法性靠事后重试或约束解码,不是天生的。

前六节都在讲「报文长什么样」。这一节下沉一层,讲模型究竟怎么把那个 tool_use 块生成出来——这是读官方文档读不到、却决定了你怎么排错的那层。

一个工具调用就是普通生成的 token

模型并没有一个「调用工具」的特殊能力开关。harness 把每个工具的 {name, description, input_schema} 序列化进 context;模型经过后训练(在成千上万条工具调用轨迹上微调),学会在该调用时输出一段被 tokenizer 认得的、包着特殊 token 的结构化片段;harness 再按模式匹配这些特殊 token,从中解析出 name 与那段 JSON 参数,组装成 API 里你看到的 tool_use 块。整段过程,模型做的事和「写一句话」没有本质区别——都是在预测下一个 token。

JSON 合法性不是天生的

既然只是 token 预测,纯生成完全会吐出非法 JSON(少个引号、括号不配对、字段名拼错)。合法性靠工程手段补,主流是两条路:

  • ① 事后解析重试——拿到输出就尝试 parse,失败了就把错误信息塞回去、重新提示模型再生成一次。简单、不改采样过程,但有失败重试的额外开销与延迟。
  • ② 约束解码(constrained decoding)——把 JSON Schema 提前编译成一套语法 / 状态机,在模型和采样器之间插一个 logit 处理器:每一步只允许「符合语法的下一个 token」,把所有不符合的 token 的 logit 强行设成 −∞。于是采样器只会采到 schema 合法的 token。模型权重一字不改,被改的只是采样时每一步的候选集合。
模型输出 全词表 logits Schema→语法 mask 非法 token logit → −∞ 采样 → 合法 JSON token 只挡语法,不挡语义 逐 token 重复
图 2.2约束解码怎么保证 JSON 合法:在采样前把不符合 schema 的 token 概率压成零。注意:它锁的是「形状」——参数结构一定合法,但值仍然可以是错的。

关键代价与边界

约束解码看着像银弹,但它有明确的边界:只保证语法,不保证语义。schema 说 location 是字符串,约束解码能保证模型确实吐出一个字符串、整体 JSON 也合法;但模型把用户问的 "San Francisco" 填成 "New York",它一点办法没有——参数形状对、值却错(valid but wrong)是这条路绕不开的盲区。代价上还有两笔:每生成一个 token 都要算一次 mask 的运行时开销,以及把 schema 编译成语法 / 状态机的一次性延迟。

一句话收束

「模型选了正确的工具」和「模型填对了参数」是两件事——约束解码能帮第一件的形状(保证输出是合法调用),帮不了第二件的对错(参数值是否真的正确,仍取决于模型的理解与你的 description)。

§本章 self-check

合上页面,先用自己的话答一遍,再展开对照。答得出形状、对得上字段,才算这章的 schema 进了脑子。

  1. 在 Anthropic 协议里,tool_result 是放在哪个 role 的消息里发回去的?这和 OpenAI 有什么不同?
  2. OpenAI 的 tool_calls[].function.arguments 拿到手能不能直接当对象访问?为什么?
  3. (设计层)约束解码能保证模型「填对参数」吗?它到底保证了什么、保证不了什么?
展开参考答案
  1. 放在 role:"user" 的消息里,tool_result 作为该 user 消息 content 数组里的一个块,靠 tool_use_id 关联回请求。OpenAI 不同:它用一条独立的 role:"tool" 消息装结果,靠 tool_call_id 关联——Anthropic 没有专门的 tool 角色。
  2. 不能直接当对象。OpenAI 的 arguments 是一个 JSON 字符串,必须先 JSON.parse(或等价反序列化)才能访问字段。对比之下 Anthropic 的 input 已经是对象,可直接取值。
  3. 不能保证填对参数。约束解码只保证语法 / 形状合法:输出一定是符合 schema 的合法 JSON(类型对、结构对、必填项在)。它保证不了语义正确:参数值是否真的对(valid but wrong 仍会发生),取决于模型的理解和工具 description 的质量。
进阶挑战 · 刚好够不着

把同一个工具写成两家的形状

拿 §2.1 的 get_weather,分别写出 Anthropic 和 OpenAI 的工具定义,再各写一次往返的报文骨架(请求 → 模型的调用请求 → 结果回填)。对照表 2.1,列出你改动的每一处字段名和数据形状。

提示(卡住再展开)

重点盯 4 处:定义里 input_schema vs function.parameters;请求 tool_use vs tool_calls;参数 对象 vs 字符串;回填 role:user vs role:tool。