Tool Calling 是什么
LLM 不能直接动手的根本原因
一、原理:从"文本续写"到"调用函数"的语义跨越
第 0.5 章我们简要讲过 tool calling 的工作流程——LLM 生成 tool 请求、agent 执行、结果回流。这一章我们走深一层——讲 tool calling 作为协议的演化、技术细节、各家 provider 的差异。
理解 tool calling 的核心是理解一个根本事实——LLM 自己只会生成文本。
ChatGPT 之前的 LLM(GPT-3、早期的 Claude)就是文本续写器。你给它一段开头,它续写最可能的接续。它没有"做事"的概念,没有"我要打开文件"或者"我要查数据库"这种意图——它只有"接下来这段文字应该是什么"的概率分布。
这种纯文本能力在很多场景下够用——翻译、改写、摘要、问答。但在 Agent 场景里完全不够——Agent 要"动手做事",而 LLM 只能"说"。
最早期的解决方案是 prompt 里教 LLM 输出特定格式。比如:
如果你需要查数据库,请按以下格式输出:
<query>SELECT * FROM users</query>我会执行查询并把结果返回给你。
这是 prompt engineering 时代的"伪 tool calling"。问题是——LLM 的输出格式不稳定,经常忘了用 <query> 标签或者格式错乱。Agent 需要写正则提取,错误率高。
2023 年 6 月,OpenAI 推出 Function Calling。这是第一个原生支持 tool calling 的 LLM API。模型在训练时见过大量"应该调用 function 的样本"——SFT 数据里教模型"看到这种场景就用结构化 JSON 调用工具"。
模型不再"凭记忆"生成奇怪格式——API 层面给了 LLM 一个结构化输出通道。LLM 可以选择"输出文本"或者"输出 tool call",两者格式严格分开。
这是 tool calling 协议的诞生。之后 Anthropic、Google、Meta 都跟进了。但每家的实现细节都不一样。这就是第 6 篇要展开的"10 个 provider 的真实差距"。
我们先看 tool calling 协议的核心结构。
Request 里告诉 LLM 有哪些工具可用:
{
"messages": [...],
"tools": [
{
"name": "read",
"description": "Read a file from the filesystem.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string"}
}
}
}
]
}
tools 是一个数组,每个 tool 有 name、description、参数 schema。LLM 在生成响应时会看到这个清单。
Response 里 LLM 决定是说话还是调用工具:
{
"content": [
{
"type": "text",
"text": "Let me check that file."
},
{
"type": "tool_use",
"id": "tool_call_123",
"name": "read",
"input": {"file_path": "src/main.py"}
}
]
}
response 是 content blocks 的数组。每个 block 是文本或 tool_use。LLM 可以混合输出——先说一句"让我看看"再调 tool。
Agent 执行 tool 后把结果塞回去:
{
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}, // 上面的响应
{
"role": "user", // 注意是 user role,不是 tool role
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_123",
"content": "(文件内容...)"
}
]
}
]
}
注意 tool_result 放在 user role 里——这是个 hack。理论上应该有专门的 tool role,但 OpenAI 早期就这么设计了,后来 Anthropic 也跟着——成为约定俗成。
LLM 收到 tool_result 后继续生成——可能再调工具,可能给最终回复。循环就这样持续。
这就是 tool calling 协议的核心。看似简单,但它给了 LLM 一种间接的"动手"能力。LLM 还是只生成文字,但 Agent 把这些文字翻译成实际操作——LLM 通过 Agent 跟世界交互。
二、案例:opencode 的工具暴露与协议适配
opencode 给 LLM 暴露了 15 个工具。这个清单在 packages/opencode/src/tool/ 目录下,每个工具有 <name>.ts(实现)和 <name>.txt(描述)。
完整清单:
| 工具 | 干什么 |
|---|---|
| read | 读文件 |
| write | 写文件(覆盖) |
| edit | 改文件(字符串替换) |
| multiedit | 一次改多处 |
| glob | 按文件名 pattern 找 |
| grep | 按内容找 |
| list | 列目录 |
| bash | 跑 shell |
| webfetch | 抓网页 |
| websearch | 搜索网络 |
| task | 派生子 agent |
| todowrite | 写待办清单 |
| skill | 加载 skill |
| question | 问用户 |
| lsp | 用 LSP 查代码 |
每个工具的 .txt 文件被打包成 description 塞给 LLM。但塞之前要做协议适配——不同 provider 的 tool calling 格式不一样。
packages/llm/src/protocols/ 目录下有 10 个 adapter,每个处理一个 provider 的协议差异。我们看几个核心差异:
差异 1:tool schema 怎么写
- Anthropic:
input_schema: { type: "object", properties: {...} }——直接放 JSON Schema - OpenAI:
parameters: { type: "object", properties: {...} }——叫法不一样 - Gemini:
parameters: {...}——而且 schema 必须严格 sanitize(去掉$ref、additionalProperties、把 integer enum 转 string) - Bedrock:
inputSchema: { json: {...} }——再包一层json字段
opencode 内部用统一的 schema 表达,每个 adapter 把它翻译成 provider 期望的格式。这是适配的第一层。
差异 2:tool call ID 怎么生成
LLM 调一个 tool 时,要给这次调用一个唯一 ID(这样 agent 知道 tool result 对应哪次调用)。但不同 provider 处理不一样:
- Anthropic:在 content block 里有
id字段,模型自己生成 - OpenAI Chat:模型生成
call_xxx字符串 - Gemini:模型不生成 ID——adapter 自己生成
- Bedrock:用
toolUseId
opencode 的 adapter 要处理这些差异——为 Gemini 自动生成 ID、把不同 provider 的 ID 字段统一映射。
差异 3:tool_choice 怎么写
tool_choice 让 agent 强制 LLM 调用某个工具(或者不调用任何工具):
- Anthropic:
{ type: "tool", name: "read" }或{ type: "auto" }或{ type: "any" } - OpenAI:
"auto"/"none"/"required"/{ type: "function", function: { name: "read" } } - Gemini:
{ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["read"] } }
opencode 内部统一表达,adapter 翻译。Anthropic 用枚举对象、OpenAI 混着字符串和对象、Gemini 完全不同的嵌套结构。
差异 4:tool_result 的回流格式
- Anthropic:
{ type: "tool_result", tool_use_id: "...", content: "..." }放在 user message 里 - OpenAI:
{ role: "tool", tool_call_id: "...", content: "..." }独立 role - Gemini:
{ functionResponse: { name: "...", response: ... } } - Bedrock:
{ toolResult: { toolUseId: "...", content: [...], status: "success" } }
opencode 的 adapter 把内部的 tool result 翻译成每家 provider 期望的格式。
这 4 个差异叠加,让"看起来简单"的 tool calling 实际复杂——opencode 的 protocol adapter 层加起来几千行代码处理这些差异。
但这种适配是值得的——它让 opencode 的 15 个工具能在 10 个 provider 上跑。协议层是 opencode 的核心资产之一——第 6 篇专门讲。
三、设计启示:tool 是 AI Agent 的"接口表面"
这一章的核心论点:tool calling 不是技术细节,是 AI Agent 跟世界交互的"接口表面"。tool 集决定 Agent 能做什么。
设计 AI 产品时关于 tool 你要做的几件事:
1. 明确"基础能力 vs 高级能力"
read、write、bash 这些是基础能力——大部分 Agent 都需要。task、skill、todowrite 这些是高级能力——根据产品定位选择。
不要为了"能力多"而加大量 tool——LLM 看到太多 tool 会决策困难、容易选错。15-20 个 tool 是个合理范围,超过这个数要考虑是不是该分级(核心 tool + 进阶 tool)。
2. 每个 tool 的边界要清晰
read 干什么、bash 干什么、task 干什么——边界要清楚,不能重叠。如果 read 和 bash 都能读文件,LLM 会困惑选哪个。
opencode 的 read 只读文件、bash 跑命令(可以 cat 但不推荐)、task 派生子 agent——清楚不重叠。
3. tool 命名要直观
read 比 file_reader 好、bash 比 execute_shell_command 好、grep 比 search_in_files 好。LLM 见过的训练数据里这些短名字最常见,最熟悉。
4. tool 描述写得像教材
不是 API 文档——是给 LLM 看的"什么时候用我"教材。3.3 章会展开。
5. 抽象统一表达 + 适配层翻译
如果你的产品要跨 provider,内部用统一 schema 表达 tool,写 adapter 翻译成每家 provider 期望的格式。不要让 tool 实现直接绑死某个 provider——以后想换 provider 会改死。
6. tool 的实现要 fail-safe
LLM 给的参数可能错(参数缺、类型错、路径不对)。tool 实现要校验、要给清晰错误、不要 panic(panic 会让整个 session 崩)。详见 3.5 章 Edit 9 层 fallback 的例子。
7. tool 的实现要可观测
每次 tool 调用要发事件(开始、结束、失败)。让 UI 能显示"现在在调用 X 工具"——用户不会觉得卡死。
8. tool 的失败要带"修复建议"
不是"操作失败"——是"操作失败,建议下次试 X"。3.5 章会展开。
最后一个观察。Tool calling 协议这件事两年前还没标准化——每家 LLM 厂商自己探索。现在虽然各家细节不同,但核心结构已经趋同——request 里 tools 数组、response 里 tool_use block、result 塞回去循环。
未来可能会有官方标准(W3C 之类)把这件事 RFC 化。在那之前,每个 AI 产品都要写自己的 protocol adapter——这是当前 AI 工程的现实成本。
理解 tool calling 协议你才能理解 AI Agent 的"接口规范"。下一章 3.2 我们把这个规范放在 opencode 里跑一次——看一个完整的 tool 调用流程是什么样。