拆.
工具——AI 怎么'做事' · 第 01

Tool Calling 是什么

LLM 不能直接动手的根本原因

4500读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:从"文本续写"到"调用函数"的语义跨越

第 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(去掉 $refadditionalProperties、把 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 命名要直观

readfile_reader 好、bashexecute_shell_command 好、grepsearch_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 调用流程是什么样。