工具调用(tool use),从协议层说起。
大多数教程把工具调用(tool use)说成"然后 SDK 调用你的函数"。这不是真正的抽象——那是为了方便而编造的谎言。真实的图景是:模型发出一个结构化请求,你的代码解释它,你的代码返回一个结构化结果,模型继续运行。一旦你真正理解这一点,每一个奇怪的工具调用 bug 都会变得显而易见。本章在 Anthropic 和 OpenAI 两种形式下,逐层解析 SDK 之下的协议,命名每个字段,暴露每个常见 bug。读完之后,你将成为团队中那个能在一分钟内调试清楚"模型调用了我的工具却出错"问题的人。
工具定义的解剖。
一个工具定义是穿着风衣的三件事:一个名称、一个描述和一个输入模式(input schema)。这三者共同构成了你的代码与模型之间的完整契约。没有其他任何渠道能让模型了解如何使用你的工具。没有文档字符串,没有源代码,只有这三个字段,加上你在系统提示词(system prompt)中说的内容。
这是第一件值得内化的令人惊讶的事。模型不会内省你的 Python 代码。它看不到你的函数体。它对 search_docs 的一切了解都来自这里:
{
"name": "search_docs",
"description": "Search the indexed documentation corpus for chunks relevant to a query. Returns the top 5 chunks by relevance. Use this when the user asks about technical topics, API usage, configuration, or anything that might be in the docs. Do NOT use for casual conversation or questions about the user's personal data.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A search query in natural language. Should be specific enough to retrieve relevant chunks. Example: 'how to configure autovacuum naptime' rather than just 'vacuum'."
},
"section": {
"type": "string",
"enum": ["admin", "developer", "reference"],
"description": "Optional. Restrict search to one section."
}
},
"required": ["query"]
}
}
{
"type": "function",
"name": "search_docs",
"description": "Search the indexed documentation corpus for chunks relevant to a query. Returns the top 5 chunks by relevance. Use this when the user asks about technical topics, API usage, configuration, or anything that might be in the docs. Do NOT use for casual conversation or questions about the user's personal data.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A search query in natural language. Should be specific enough to retrieve relevant chunks. Example: 'how to configure autovacuum naptime' rather than just 'vacuum'."
},
"section": {
"type": "string",
"enum": ["admin", "developer", "reference"],
"description": "Optional. Restrict search to one section."
}
},
"required": ["query"],
"additionalProperties": false
},
"strict": true
}
两家提供商,相同的三个概念,形状略有不同。Anthropic 将 JSON Schema 放在 input_schema 下;OpenAI 将其放在 parameters 下,并且在设置 strict: true 时要求 additionalProperties: false。除此之外,契约完全相同。
描述承担了 90% 的工作
人们在工具定义上最常犯的错误,是把描述当作标签来处理。它不是。描述是那个工具的提示词——模型在决定是否调用这个工具、用什么参数以及如何调用时读取的就是它。一个糟糕的描述是工具被错误使用的最常见单一原因。
看看区别。下面是同一个工具,描述写得漫不经心:
{
"name": "search_docs",
"description": "Searches the docs",
"input_schema": { ... "query": {"type": "string"} ... }
}
这个描述做了两件有害的事:它告诉模型何时使用该工具(总是,因为没有任何限制),并告诉模型如何格式化查询(没有指导,所以模型有时会传 "vacuum",有时会传 "PostgreSQL vacuum autovacuum tuning configuration",毫无一致性)。与上面那个详细版本相比,后者明确了何时使用("技术主题、API 用法……")、何时不使用("不用于闲聊")以及如何使用("要足够具体以检索相关内容,示例:……")。
根据 Anthropic 文档:"与 Claude 其他依赖示例来引导的提示词不同,在使用工具时,描述是最重要的信息之一。"这是字面意义——描述吸收了你通常在纯文本生成的系统提示词中投入的提示词工程精力。
如果你能用不到 50 个词写完工具的描述,那你可能还没有认真写。一个好的描述通常包含三个部分:工具做什么(一句话)、何时使用(正面和负面示例)以及如何格式化输入(每个参数,在各自的描述字段中)。三者缺一不可;跳过任何一个,你就会看到糟糕的调用。
参数描述同样重要
同样的原则适用于更深一层。模式(schema)中的每个参数都有自己的 description 字段,模型会读取它。如果你的 query 字段没有描述,模型就只能猜测一个好的查询是什么样的。如果它有一个描述,其中包含一个具体示例("例如:'how to configure autovacuum naptime'"),模型就会以此为锚,生成类似形状的查询。
这是提升工具质量最大的杠杆点。精心编写的参数描述能将格式错误的参数发生率降低一个数量级。
严格模式(strict mode)(两家提供商现在都支持)
Anthropic 和 OpenAI 都支持 strict 模式,该模式保证模型的工具调用参数将与你的 JSON Schema 完全匹配。只要模式(schema)定义明确,就应该开启它。OpenAI 版本要求 additionalProperties: false,并且每个属性都在 required 中;Anthropic 的版本更宽松。开启严格模式后,你无需验证处理程序收到的参数——API 已经替你完成了验证。
支持严格模式的理由很简单:它消除了整整一类 bug(模型将 "limit": "5" 作为字符串返回,而你期望的是整数)。反对意见是:在真正模糊的情况下,它有时会约束模型表现出更差的行为。默认应当开启严格模式;只有在有具体原因时才关闭它。
命名
工具名称会出现在模型的上下文窗口(context window)和你的代码中。以下命名惯例不会在日后带来麻烦:
- snake_case,动词_名词。
search_docs、fetch_user、send_email。不要用DocsSearch、searchDocs或search(太通用)。 - 相关工具使用不同前缀。
user_get、user_create、user_delete。前缀帮助模型在脑海中将它们归类,也帮助你组织处理程序。 - 不用缩写。 用
retrieve_documents而不是ret_docs。模型处理完整词语更好,你未来的自己也会感谢你。 - 与处理程序名称完全匹配。 如果工具叫
search_docs,Python 函数也叫search_docs。避免名称之间的转换层;它们会让调试更难。
值得一提的一个高级特性:input_examples
截至 2025 年底,Anthropic 在工具定义中新增了 input_examples——工具调用的具体示例,形状与参数相同。当模式(schema)无法表达描述单独无法完整传达的使用模式时(例如"这个可选字段与那个字段有关联"),这个特性很有用。
{
"name": "create_ticket",
"input_schema": { ... /* schema */ ... },
"input_examples": [
{
"title": "Login page returns 500 error",
"priority": "critical",
"labels": ["bug", "production"],
"escalation": { "level": 2, "sla_hours": 4 }
},
{
"title": "Add dark mode support",
"labels": ["feature-request", "ui"]
// no priority, no escalation — that's the pattern
}
]
}
示例通常会消耗 50–200 个提示词令牌(token)。对于形状复杂的工具,值得投入;对于描述和模式已经足够清晰的简单工具,则没必要。
模型会弄清楚……有时候。问题是你能接受多高的失败率。描述写得草率,预计有 5-15% 的工具调用会出现格式错误、不相关或应该调用却没有调用的情况。描述写得认真,预计低于 1%。经济账:在一个认真的描述上花 20 分钟,能省去工具整个生命周期内数百次调试会话。这是你在智能体质量上能投入的杠杆率最高的 20 分钟。
1024 字符限制是 Azure 特有的(据我们最后查到的信息)。直接使用 Anthropic 和 OpenAI API 的有效限制要高得多——首先限制你的是上下文窗口(context window)预算,而不是硬性上限。本章中认真写的描述是 200–500 个字符;草率写的不到 50 个。完全没问题。如果你在 Azure 上遇到了 1024 字符上限,你的描述很可能本来就太长了。
生成它。Pydantic 模型给你一个单一的事实来源:处理程序签名、运行时验证和模式(schema)始终保持同步。Anthropic SDK 和 OpenAI SDK 都直接接受 Pydantic 生成的模式(schema)。一次性写好这 20 行样板代码是正确的投资——手写的模式(schema)在一周内就会与处理程序签名产生偏差。
需要手写的唯一情况是:当你希望参数描述和工具描述与 Pydantic 从文档字符串生成的内容不同时(这种情况很常见——Pydantic 字段描述通常对工具调用来说过于简洁)。
在线传输的数据形状。
现在我们深入到模型决定调用工具时,HTTP 上实际传输的内容。理解这一点,是把 SDK 当作魔法来用和能在它出错时修复它之间的区别。你遇到的每一个工具调用 bug,都会在这个层面上表现出来。
这是一次往返。你向模型发送一个包含工具和用户消息的请求。模型返回一个可能包含一个或多个工具调用块的响应。你执行这些调用,打包好结果,作为后续轮次发送回去。然后模型要么继续调用更多工具,要么给出最终答案。工具调用块和工具结果块的结构是两家提供商在外观上有所不同,但在概念上完全一致的地方。
Anthropic:tool_use 和 tool_result 内容块
在 Anthropic 的 Messages API 中,模型的响应有一个 content 数组。每个条目有一个 type。纯文本是 {"type": "text", "text": "..."}。当模型想要调用工具时,你会得到一个或多个 tool_use 块,与文本交错出现:
// Response from messages.create when the model calls a tool
{
"id": "msg_01ABCdef...",
"model": "claude-sonnet-4-5",
"role": "assistant",
"stop_reason": "tool_use", // ← key signal
"content": [
{
"type": "text",
"text": "I'll search the docs for that."
},
{
"type": "tool_use",
"id": "toolu_01XyzAbc...", // ← critical: the call ID
"name": "search_docs",
"input": {
"query": "autovacuum naptime configuration"
}
}
],
"usage": { "input_tokens": 1842, "output_tokens": 47 }
}
tool_use 块上有三个重要字段:id(调用 ID,用于关联结果)、name(哪个工具)和 input(参数,已解析为字典)。要响应,你需要追加一条包含原始内容数组的助手消息,然后追加一条包含每个 tool_use 对应的 tool_result 块的用户消息:
// Your follow-up turn
messages.append({
"role": "assistant",
"content": response.content // ← echo back the original blocks unchanged
})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XyzAbc...", // ← MUST match the tool_use.id
"content": "Found 5 results: [chunk_id=routine-vacuuming::5, text=...]"
}
]
})
结果上的 tool_use_id 必须与原始 tool_use 上的 id 匹配。这种配对是 Anthropic 跨往返关联"你要求我调用 X,这是 X 的结果"的方式。搞错了,API 会返回错误。
OpenAI:Responses API 中的 function_call 条目和 function_call_output 条目
OpenAI 的现代 API 是 Responses(截至 2026 年,Chat Completions 正处于弃用曲线上)。在 Responses 中,内容块的类比是条目(item)。模型的响应有一个 output 条目数组。函数调用是 function_call 类型的条目:
// Response from responses.create when the model calls a tool
{
"id": "resp_5g2a...",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "I'll search the docs for that."}]
},
{
"type": "function_call",
"id": "fc_67d2e3...", // ← item ID (rarely used)
"call_id": "call_Co8dkB8h7N...", // ← CRITICAL: the correlation ID
"name": "search_docs",
"arguments": "{\"query\":\"autovacuum naptime configuration\"}"
// ← STRING. you must JSON.parse it.
}
],
"usage": { "input_tokens": 1842, "output_tokens": 47 }
}
有两件事会让人栽跟头。第一,arguments 是一个JSON 编码的字符串,不是解析好的对象。在使用之前,你需要对其执行 json.loads(call.arguments)。第二,有两个 ID:id(条目 ID,内部使用)和 call_id(关联 ID,你实际需要的那个)。混淆这两个是最常见的 OpenAI 工具调用 bug,会产生神秘的 400 No tool call found for function call output with call_id 错误。
要返回结果,你需要以 call_id 为键,将 function_call_output 条目作为输入发送回去:
// Your follow-up turn in OpenAI Responses
client.responses.create(
model="gpt-5.5",
previous_response_id=response.id, // ← stateful threading
input=[
{
"type": "function_call_output",
"call_id": "call_Co8dkB8h7N...", // ← MUST match function_call.call_id
"output": "Found 5 results: [chunk_id=routine-vacuuming::5, text=...]"
}
],
tools=TOOLS,
)
Responses API 通过 previous_response_id 在服务端维护对话状态,所以你不需要重放完整的消息历史。只需发送新的函数输出,模型就会从中断处继续。
并排对比:字段对应关系
{"type": "tool_use", ...}{"type": "function_call", ...}id(位于 tool_use 上)call_id(不是 id)input — 已解析的字典arguments — JSON 字符串{"type": "tool_result", ...}{"type": "function_call_output", ...}tool_use_idcall_idcontentoutputmessages[]previous_response_idstop_reason: "tool_use"这个层面上的三个 bug
几乎每一个工具调用 bug 都是以下三种之一。
Bug 1:孤立的工具结果。你返回了一个 tool_result(Anthropic)或 function_call_output(OpenAI),其 ID 与前一轮的任何工具调用都不匹配。API 以 400 状态码拒绝该请求。原因通常是以下之一:你在重新发送之前修改了助手消息;你意外地丢弃了一个 tool_use 块;你在 OpenAI 中使用了 id 而不是 call_id。
Bug 2:缺少对某个工具调用的结果。模型在一轮中调用了三个工具,而你只返回了两个结果。Anthropic 和 OpenAI 都要求在下一轮之前回答每一个调用。原因通常是以下之一:你只遍历了第一个 tool_use 块;你过滤掉了一个你不认识的调用。
Bug 3:格式错误的参数。模型产生了不符合你模式(schema)的 JSON——期望数字的地方给了字符串,枚举值拼写错误。在没有严格模式的情况下,这偶尔会发生,你的处理程序需要进行验证。开启严格模式后,这理论上不可能发生——但如果你仍然看到这种情况,几乎总是因为 additionalProperties: false 未设置,或者 required 没有列出所有字段(OpenAI 的严格要求是严格的)。
当你在 OpenAI 上看到 400 No tool call found for function call output 时,99% 的情况是因为你使用了 function_call.id 而不是 function_call.call_id。这两个 ID 看起来相似(fc_abc... 与 call_abc...),而字段名 id 感觉像是显而易见的选择。但它不是。
最小化的正确调度器,两家提供商
供参考。30 行代码,正确处理往返。每个智能体(agent)只需写一次。
# agent/dispatch.py — Anthropic async def handle_turn(messages, response): """Given an assistant response with tool_use blocks, run handlers and return the next user message with tool_results.""" if response.stop_reason != "tool_use": return None # no tools called; agent is done # 1. Echo back the assistant content unchanged messages.append({"role": "assistant", "content": response.content}) # 2. Run EVERY tool_use block and collect results results = [] for block in response.content: if block.type != "tool_use": continue try: result = await HANDLERS[block.name](**block.input) results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result), }) except Exception as e: # Return errors as tool_results, not raises. The model can recover. results.append({ "type": "tool_result", "tool_use_id": block.id, "content": f"Error: {type(e).__name__}: {e}", "is_error": True, }) # 3. Send all results back as one user message messages.append({"role": "user", "content": results}) return messages
# agent/dispatch.py — OpenAI Responses async def handle_turn(previous_response_id, response): """Given a response with function_call items, run handlers and return inputs for the next responses.create() call.""" function_calls = [item for item in response.output if item.type == "function_call"] if not function_calls: return None # no tools called; agent is done # Run EVERY function_call and collect outputs outputs = [] for call in function_calls: args = json.loads(call.arguments) # ← string → dict try: result = await HANDLERS[call.name](**args) outputs.append({ "type": "function_call_output", "call_id": call.call_id, # ← NOT call.id "output": str(result), }) except Exception as e: outputs.append({ "type": "function_call_output", "call_id": call.call_id, "output": f"Error: {type(e).__name__}: {e}", }) # Send outputs as input to the next turn. Server holds state via # previous_response_id, so you don't replay history. return outputs
这是任何代理循环(agent loop)的核心。记住这个形状。智能体(agent)的其余部分只是决定暴露哪些工具以及编写哪些处理程序。
input,而 OpenAI 将 arguments 留作字符串?历史原因。OpenAI 的函数调用在流式传输(streaming)方案不够成熟时以字符串形式发布参数;字符串形式让你可以在流式传输中看到部分参数。Anthropic 后来发布时采用了预解析的输入,因为那时部分 JSON 的流式传输已经是已解决的问题了。OpenAI 的形状现在是历史包袱——更改它会破坏太多集成。记住要调用 json.loads() 就好。
另请注意:对于 Anthropic 上的细粒度工具流式传输(该特性以文本增量形式传输部分参数),你需要自己组装它们。情况相同,只是在选择加入时才会暴露。
严格模式可以防止这种情况——模型只能调用请求中定义的工具。没有严格模式,这仍然很少见,因为模型是在工具列表上进行条件化的,但在边缘情况下可能发生。你的调度器应将未知工具名称视为错误,并返回一个 is_error: true 的 tool_result,并带有清晰的消息,例如 "Error: unknown tool 'sarch_docs'. Available tools: search_docs, fetch_doc, send_email."。模型可以读取此消息并在下一轮中自我纠正。
不行。两个 API 都要求一轮中的每个工具调用都在下一轮中得到结果(或错误)的回答。原因:模型的下一个响应以所有调用都已完成为前提;缺少结果是违反协议的。如果你真的无法运行某个工具,请返回一个错误结果。错误结果正是为此而设计的。
并行调用、错误,以及那些会让人栽跟头的边缘情况。
步骤 1 和 2 给出了顺利路径。生产环境是边缘情况出现的地方。本步骤介绍四种会破坏简单实现的最常见模式:并行工具调用、工具错误、重复调用和格式错误的参数。每种都有特定的表现形式和特定的解决方法。
并行工具调用
模型可以在单轮中调用多个工具。这是好事——当用户询问"旧金山的天气和东京的时间"时,模型可以在一次往返中同时发出两个调用,而不是两次顺序调用,将延迟(latency)减半。
形状正如你所期望的那样。响应内容中有多个 tool_use 块(Anthropic),或输出中有多个 function_call 条目(OpenAI)。你的调度器必须在一轮中处理所有这些块。步骤 2 中的最小化调度器已经正确地做到了这一点(for ... in 循环),但有一个关于如何处理的问题:顺序还是并行?
简单代码的默认做法是顺序——你的 for 循环在启动下一个处理程序之前等待每个处理程序完成。对于独立工具(搜索加另一个搜索;天气加时间),这是浪费的延迟(latency)。解决方法是 asyncio.gather:
# Sequential — naive for block in tool_use_blocks: result = await HANDLERS[block.name](**block.input) results.append(# ...) # 3 tool calls of 400ms each = 1.2s # Parallel — production async def run_one(block): try: result = await HANDLERS[block.name](**block.input) return {"type": "tool_result", "tool_use_id": block.id, "content": str(result)} except Exception as e: return {"type": "tool_result", "tool_use_id": block.id, "content": f"Error: {e}", "is_error": True} results = await asyncio.gather(*[run_one(b) for b in tool_use_blocks]) # 3 tool calls of 400ms each ≈ 450ms (longest one + overhead)
收益大约与并行调用的数量成线性关系。对于一个在研究轮次中触发 5 次检索(retrieval)的工具密集型智能体(agent),这是 5 秒等待和 1 秒等待之间的差别。免费的延迟优化。
一个微妙之处:有副作用的工具(写入、发送、删除)不应该盲目地并行化。如果模型在一轮中触发了两个 delete_record 调用,而你同时运行两者,可能会产生竞争条件。安全模式是将只读工具并行运行,将修改状态的工具顺序运行。很容易编码:维护两个列表。
错误作为数据,而非异常
工具调用智能体(agent)最大的思维转变:工具错误不是失败,而是模型可以据此行动的信息。当你的 search_docs 处理程序无法连接到数据库时,正确的做法不是让智能体崩溃——而是返回一个错误结果,让模型决定该怎么做。
步骤 2 中的调度器已经做到了这一点:它捕获异常,并返回一个带有 is_error: true 和可读消息的 tool_result。模型看到的是:
[turn 3]
[assistant]: I'll fetch that document.
→ fetch_doc(doc_id="invoices-2025-q3")
[tool_result for fetch_doc]:
{"is_error": true, "content": "Error: DatabaseTimeout:
connection to docs-db timed out after 5s. Retry may succeed."}
[turn 4]
[assistant]: That document fetch timed out. Let me try search
instead and see if I can find it that way.
→ search_docs(query="Q3 2025 invoices")
[tool_result for search_docs]:
[5 chunks returned, including invoices-2025-q3 as top result]
[turn 5]
[assistant]: Found it. Your Q3 invoices...
这是智能体(agent)自行恢复的过程。如果没有"错误作为数据",你的代码会抛出异常,运行会中断,用户会看到"发生了错误"。有了"错误作为数据",模型就有机会尝试不同的方法。现代模型在这种恢复上表现很好——给它们这个机会。
什么样的错误消息对模型有用:
- 具体。"Error: DatabaseTimeout: connection to docs-db timed out after 5s"是可操作的。"Error: tool failed"不是。
- 包含建议的替代方案。"Retry may succeed"或"try search_docs instead"或"the document_id 'foo' was not found; valid IDs start with 'INV-'"——这些引导模型走向恢复。
- 不要包含堆栈跟踪。它们消耗令牌(token),对模型没有帮助,还可能泄露实现细节。异常类型和消息就足够了。
- 在 Anthropic 上使用
is_error: true。这是一个结构性提示,表明该结果是错误。OpenAI 没有等效标志——只需在输出字符串的开头加上"Error:"即可。
"模型连续调用同一个工具 5 次"的故障
你在生产中会看到这种情况。模型调用 search_docs,得到一个结果,用略有不同的查询再次调用,得到另一个结果,第三次调用……第四次,第五次。追踪(trace)看起来像是一个失去理智的智能体(agent)。
这几乎总是以下两种原因之一:
原因 1:工具描述不包含停止条件。模型不知道何时停止搜索。你的描述说"搜索文档中的相关内容"——它没有说"你很少需要调用这个工具超过两次;如果两次搜索都没有找到你需要的内容,该文档可能不存在。"将停止条件添加到描述中;行为就会改变。
原因 2:结果实际上没有回答模型的问题,而模型不知道还能做什么。模型陷入了循环,因为它卡住了。解决方法在上游——你的检索(retrieval)很糟糕,你的语料库缺少该文档,查询改写没有帮助。调度循环不是问题所在;循环只是一个症状。
无论哪种情况,你的防御都是步骤预算(第 1.1 章)——你的智能体(agent)循环将工具调用总数限制在某个数字(20 是常见值),超过就退出。预算捕获了症状;描述修复或语料库修复解决了原因。
格式错误的参数
模型可能产生不符合你模式(schema)的 JSON。开启严格模式后,这种情况很少见,几乎可以认为是 bug。没有严格模式,它发生得足够频繁,你的处理程序应该具备防御性。
两种有效的模式:
# Pattern 1: validate explicitly in the handler async def search_docs(query: str, section: str | None = None): if not isinstance(query, str) or not query.strip(): raise ValueError("query must be a non-empty string") if section and section not in {"admin", "developer", "reference"}: raise ValueError(f"section must be one of admin|developer|reference, got {section!r}") # ... real work # Pattern 2: use Pydantic models as the handler signature from pydantic import BaseModel, Field class SearchDocsArgs(BaseModel): query: str = Field(min_length=1) section: Literal["admin", "developer", "reference"] | None = None async def search_docs(**raw): args = SearchDocsArgs(**raw) # raises if bad # ... use args.query, args.section
无论哪种方式,验证错误都会变成异常消息,调度器捕获后作为工具错误返回——模型随后可以读取这些消息并在下一轮中自我纠正。模式与任何工具错误相同:返回信息,让模型恢复。
一个微妙之处:工具参数生成期间的 max_tokens 截断
如果模型正在为工具生成参数,而你设置的 max_tokens 太低,响应可能会在参数中途结束,产生无效的 JSON。两家提供商在细粒度流式传输时都会对此发出警告。症状:stop_reason: "max_tokens" 而不是 "tool_use";arguments 字符串无法解析为 JSON。
解决方法是将 max_tokens 设置得足够高,能够舒适地超过你最大预期工具参数加上周围文本。对于大多数智能体(agent),4096 是充裕的;对于通过工具传递大型结构化载荷的智能体(罕见),则需要更多。
这取决于模型对依赖关系的判断。如果模型认为一个调用的输入依赖于另一个调用的输出(例如,"先查询用户 ID,再获取该用户的发票"),它会顺序执行。如果它认为它们是独立的("查询旧金山的天气和东京的时间"),它会并行执行。你可以通过提示词(prompt)层面的指导来微调这一点——"如果调用是独立的,在一轮中批量处理它们"——但大多数情况下模型能处理好。
让模型感到困惑的一种情况是,调用看起来是独立的,但实际上并不是(因为你没有告诉它的副作用或顺序约束)。解决方法是在工具描述中明确说明约束。
按优先顺序,有三个选项:
- 在返回前总结。工具处理程序完成将 100KB 原始输出压缩为 5KB 最相关摘录的工作。模型很少需要原始的 100KB;它需要的是包含在 100KB 中的答案。
- 返回一个句柄,让模型按 ID 获取。工具返回"已保存 100 个内容块,ID 为 chunk_001..chunk_100";模型对它想要的特定内容块调用
fetch_chunk(id)。额外消耗一次往返,但节省了大量上下文窗口(context window)。 - 程序化工具调用(Anthropic,2025 年底)。模型编写一个小 Python 程序来调用工具、处理结果,并只将最终摘要返回到模型的上下文窗口(context window)中。最适合中间数据量大而最终答案小的情况。
通过阅读追踪来建立直觉。
你可以阅读上述所有内容,但仍然没有形成有效的直觉。直觉来自于阅读真实的追踪(trace)并识别每种模式的含义。本步骤逐步讲解三个复杂度递增的追踪,并附有注释,说明应该看什么以及为什么。读完之后,你应该能够在 30 秒内通过阅读追踪来诊断工具调用 bug。
追踪 A:干净的调用
最简单的可能交互:一个工具调用,一个结果,最终答案。所有其他情况都是这个形状的变体。
══ TURN 1 ══════════════════════════════════════════════════════════
[user]: how do I tune autovacuum naptime?
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: tool_use
content:
[0] text "I'll check the documentation for that."
[1] tool_use
id: toolu_01ABc...
name: search_docs
input: {"query": "autovacuum naptime tuning"}
══ DISPATCH ═════════════════════════════════════════════════════════
→ HANDLERS["search_docs"](query="autovacuum naptime tuning")
← 5 chunks, top result: routine-vacuuming::5 (score 0.87)
══ TURN 2 (sent to model) ═══════════════════════════════════════════
[assistant]: <echo of turn 1 content unchanged>
[user]: tool_result
tool_use_id: toolu_01ABc...
content: "Found 5 chunks: [{...routine-vacuuming::5..."
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: end_turn
content:
[0] text "Autovacuum naptime is controlled by the
autovacuum_naptime config setting. Default is 1 minute..."
在每个干净的追踪中,你要扫描三件事:(1)stop_reason 是否符合你的预期——调用轮次是 tool_use,最终轮次是 end_turn;(2)结果上的 tool_use_id 是否与调用轮次的 id 匹配(这是孤立结果 bug);(3)工具输入形状是否符合预期——query 是非空字符串,没有意外。如果三项都通过,追踪就是干净的。
追踪 B:并行调用,其中一个出错
两个工具同时触发,其中一个出错,模型恢复。这是恢复循环的实际运作。
══ TURN 1 ══════════════════════════════════════════════════════════
[user]: what's the weather in Tokyo and the current time there?
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: tool_use
content:
[0] text "I'll get both."
[1] tool_use id=toolu_W1... name=get_weather input={"location":"Tokyo"}
[2] tool_use id=toolu_T1... name=get_time input={"location":"Tokyo"}
══ DISPATCH (parallel) ══════════════════════════════════════════════
asyncio.gather(
get_weather("Tokyo"), → "62°F, partly cloudy"
get_time("Tokyo"), → ✗ TimeAPIError: rate limit exceeded
)
══ TURN 2 ═══════════════════════════════════════════════════════════
[user]: [
tool_result tool_use_id=toolu_W1... content="62°F, partly cloudy"
tool_result tool_use_id=toolu_T1... content="Error: TimeAPIError:
rate limit exceeded. Retry in 30s recommended."
is_error: true
]
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: tool_use
content:
[0] text "Weather is in; time API is rate-limited. Let me try
an alternative."
[1] tool_use id=toolu_T2... name=get_time
input={"location":"Asia/Tokyo"}
// the model tried a different format
══ DISPATCH ═════════════════════════════════════════════════════════
→ get_time(location="Asia/Tokyo") → "11:42 PM JST"
══ TURN 3 ═══════════════════════════════════════════════════════════
[user]: tool_result tool_use_id=toolu_T2... content="11:42 PM JST"
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: end_turn
"It's 62°F and partly cloudy in Tokyo, currently 11:42 PM local time."
模型利用了错误消息("rate limit exceeded, retry in 30s")来决定下一步——它没有立即重试(聪明,它遵从了 30 秒的建议),而是尝试了不同的输入格式。这种恢复只有在错误作为带有有用上下文的数据返回时才能实现。如果你的处理程序抛出了异常并中断了运行,用户会看到"发生了错误"。相反,他们得到了正确的答案,只有微小的额外延迟(latency)。
教训:你花在编写更好错误消息上的每一分钟,都会在数千次运行中产生复利回报。模型会利用你给它的一切。
追踪 C:你在生产中会看到的格式错误调用
没有严格模式时,这是让团队措手不及的模式。模型产生了一个看起来有效但实际上无效的参数。
══ TURN 1 ══════════════════════════════════════════════════════════
[user]: search for the top 5 admin docs about backups
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: tool_use
content:
[1] tool_use
id: toolu_5_X1...
name: search_docs
input: {
"query": "backups",
"section": "administrator", // schema enum is ["admin","developer","reference"]
"limit": "5" // schema doesn't define this; type is string anyway
}
══ DISPATCH ═════════════════════════════════════════════════════════
search_docs(query="backups", section="administrator", limit="5")
→ ✗ ValueError: section must be one of admin|developer|reference,
got 'administrator'
══ TURN 2 ═══════════════════════════════════════════════════════════
[user]: tool_result
tool_use_id: toolu_5_X1...
content: "Error: ValueError: section must be one of
admin|developer|reference, got 'administrator'.
Did you mean 'admin'?"
is_error: true
══ MODEL RESPONSE ═══════════════════════════════════════════════════
stop_reason: tool_use
content:
[0] text "Right, the enum is 'admin'. Retrying."
[1] tool_use id=toolu_5_X2... name=search_docs
input={"query":"backups","section":"admin"}
══ DISPATCH ═════════════════════════════════════════════════════════
search_docs(query="backups", section="admin") → 5 chunks
══ TURN 3 ═══════════════════════════════════════════════════════════
[end_turn answer with the backup docs]
第一,模型将"admin"扩展成了"administrator"——一种合理的自然语言扩展,但违反了严格的枚举。没有严格模式,API 允许这通过;验证发生在处理程序中。
第二,模型还尝试传递 "limit": "5"——一个模式(schema)中未定义的参数,值也是字符串而非数字。这种情况经常被忽视(处理程序只是忽略未知的 kwargs),但可能导致微妙的 bug。
第三,恢复之所以成功,是因为错误消息写得好。在工具错误中,"Did you mean 'admin'?"是最有用的一句话。只要你有一组小的有效值,就添加这句话;模型会从中受益。
根本解决方案:开启严格模式并在模式(schema)中添加 additionalProperties: false。两个问题都会在协议层面消失。
五秒钟分诊协议
当你收到"智能体做了奇怪的事情"的通知时,以下是扫描追踪的顺序。有经验的智能体(agent)工程师能在一分钟内完成这个流程。
这就是本章的全部内容。协议层面的心智模型——工具定义包含什么、HTTP 上传输什么、并行和错误情况如何工作,以及如何快速阅读追踪——是解锁后续所有章节的钥匙。当第 1.1 章引导你构建"尽可能小的智能体(agent)"时,你现在就能理解循环之所以是那个形状的原因了。当第 2.1 章在 tool_use 块周围包裹跨度(span)时,你会知道每个跨度(span)属性的含义。当第 2.3 章讨论工具结果注入时,你将准确理解攻击面是什么。
内化这些材料最快的方式,是花一个下午阅读你自己智能体(agent)的追踪(trace)。选 10 次成功的运行和 10 次失败的运行。对每一次,执行上面的 3 步分诊流程。第五或第六个追踪就会开始让你豁然开朗。到第十个,你就能在几秒钟内完成分诊了。
日常工作中,不需要——SDK 抽象是好的。调试时,需要。SDK 给你的是带有漂亮属性访问的对象;一旦出现问题,你就会盯着来自 API 响应的原始 JSON,试图理解为什么 SDK 会抛出错误。你花在内化在线格式上的三十分钟,会在你第一次在晚上 11 点盯着 400 错误时得到回报。
另一个原因:SDK 各有差异。Anthropic SDK 和 OpenAI SDK 有不同的人机工程学,而 Vercel AI SDK / LangChain / LiteLLM 等工具又增加了各自的层。了解底层协议意味着你可以调试其中任何一个,而无需学习每个 SDK 的特殊之处。
MCP 在更高一层。我们介绍的在线格式是你的代码和模型 API 之间的通信。MCP 是你的代码(作为 MCP 客户端)和 MCP 服务器(提供工具的一方)之间的通信。MCP 服务器向你的代码暴露工具;你的代码随后以我们介绍的形状,将这些工具的定义转发给模型 API。
它们的关系:MCP 给你一种消费别人维护的工具的方式。模型 API 给你一种让模型调用这些工具的方式。它们是互补的,而不是替代品。我们在第 4.x 章中略有涉及;完整的 MCP 内容足以单独成章。
最终需要,但现在还不用。两者都是近期新增的(2025 年底),针对特定的扩展问题进行了优化:tool_search 适用于拥有数百或数千个工具的智能体(agent),在每次请求中都发送所有定义会耗尽你的上下文窗口(context window)预算;程序化工具调用适用于工具 I/O 量很大,且你希望中间数据完全不出现在模型上下文窗口中的情况。
如果你的智能体(agent)少于 50 个工具,且不通过它们传递兆字节级别的数据,本章的基本模式就是你所需要的。等遇到这些高级特性所解决的特定扩展问题时再学,不要预防性地学习。
Deliverable
对协议层面工具调用(tool use)的有效心智模型——这是后续所有章节所假定的基础。你可以仅凭追踪(trace)来调试"工具被错误调用"的问题。你编写的工具定义中,描述和参数文档承担了大部分工作,并且默认开启严格模式。你了解 Anthropic 的 tool_use_id 和 OpenAI 的 call_id 之间的区别,不会在凌晨两点搞混这两者。你将工具错误视为模型可以从中恢复的数据,而不是中断运行的异常。SDK 之下的协议现在对你来说是可见的。
- 工具定义包含认真编写的描述(不少于 3 句话)以及带示例的逐参数文档
- Pydantic 模型或手写模式(schema);默认开启严格模式
- 调度器运行一轮中的所有工具调用,并将错误作为 is_error tool_result 返回
- 通过 asyncio.gather 并行执行独立工具;对修改状态的工具顺序执行
- 处理程序签名中的验证(Pydantic)生成可操作的错误消息
- 了解三种常见 bug:孤立结果、缺失结果、格式错误的参数
- 在你自己的智能体(agent)的 10 个以上真实追踪上练习过分诊协议
- 对 Anthropic 与 OpenAI 形状有心理层面的并排对比;能在 30 分钟内切换提供商