Skip to content

Tool Calling

上一章解决了“模型输出如何稳定”——用 Schema 约束格式,用程序校验结果。你已经知道模型能被约束成按结构化格式返回数据。

现在要解决的问题更根本:模型的知识是静态的,它不知道实时天气、不能查你的数据库、更不能替你发一封邮件。Tool Calling 就是给模型接上真实世界的通道。

Tool Calling 是你从“聊天机器人”走向“能做事的系统”的第一道分界线。

本章目标

  • 能描述一轮完整的工具调用流程
  • 能设计参数清晰、职责单一的工具
  • 能识别高风险工具并加上确认与权限边界

Tool Calling 是什么

Tool Calling 的本质是:模型在需要时不直接瞎答,而是先决定调用某个外部工具,再根据工具返回结果继续生成回答。

你可以把角色分成三层:

  • 模型:理解用户意图,决定调哪个工具
  • 工具:访问外部世界,拿真实数据或执行动作
  • 业务系统:负责权限、日志、超时、重试和确认

往深一层看,Tool Calling 改变的不只是"模型多了一个插件功能"——系统开始把一部分能力从模型内部转移到模型外部。

模型擅长的是理解意图、补全语义、组织表达;工具擅长的是访问真实世界、执行确定动作、返回机器可验证的结果。Tool Calling 把这两类能力分开,让模型别去假装自己会查数据库、会看实时天气、会改线上记录。

说白了,Tool Calling 是一种能力委托:模型不再什么都自己答,而是在该交给外部系统时,把决定权交出去。

为什么需要工具

模型本身并不天然拥有这些能力:

  • 获取实时天气
  • 查询数据库
  • 搜索网页
  • 获取当前时间
  • 发送消息
  • 修改业务记录

所以只要需求涉及实时信息、私有数据或真实动作,通常就需要工具。

Tool Calling 不是 RPC 封装,而是决策边界

初学时很容易把 Tool Calling 理解成"模型调用一个函数"。从接口表面看,这么说没错;但从系统设计看,它远不止是 RPC 封装。

背后要解决的是一个边界问题:哪些事情可以由模型自由生成,哪些事情必须经过外部世界确认。

比如"帮我解释这段文案"可以直接生成,"帮我查当前库存"就必须经过数据库;"帮我写封邮件草稿"可以直接生成,"帮我发出去"就必须经过权限和确认。这里的关键不是有没有函数,而是系统有没有明确划出生成边界和执行边界。

一旦边界没划清,问题就会出现:

  • 模型会把本该查询的事实直接编出来
  • 模型会把本该确认的动作直接发起
  • 工具会被设计成"什么都能做",最后无法约束

所以真正成熟的 Tool Calling 设计,第一步是先决定:哪些能力必须外包给工具,哪些仍然留在模型这一边。

一轮完整的 Tool Calling 流程

  1. 用户发起问题
  2. 模型判断当前需要工具
  3. 模型生成工具名和参数
  4. 宿主系统执行工具
  5. 工具返回结构化结果
  6. 系统把结果回传给模型
  7. 模型基于结果生成最终回复

这里一个关键点是:工具负责拿数据,模型负责解释数据。

Tool Schema 其实是在定义模型的行动空间

很多人会把工具 Schema 只看成"参数格式说明"。这还不够。

对模型来说,工具列表本身就是它在这一轮里可采取的行动集合。你给它什么工具、每个工具叫什么、描述怎么写、参数长什么样,都会直接影响它怎么理解"我下一步可以做什么"。

换句话说,Schema 不只是给程序校验的,它同时也在给模型塑造一个行动空间。

这会带来两个直接后果:

  1. 工具太少,模型会被迫把很多事情都用自然语言硬答
  2. 工具太杂、太重叠,模型会频繁选错,或者反复在相似工具之间摇摆

所以工具设计要让每个工具的职责边界在模型视角下足够清晰——多了不好,万能了更不好。

一个具体例子:查询今天北京的天气

用户发送:"北京今天天气怎么样?"

第一轮请求(系统 → 模型):

json
{
  "messages": [
    { "role": "user", "content": "北京今天天气怎么样?" }
  ],
  "tools": [
    {
      "name": "get_weather",
      "description": "查询指定城市的当前天气",
      "parameters": {
        "city": { "type": "string", "description": "城市名称" }
      }
    }
  ]
}

模型判断需要工具,返回工具调用指令(而不是直接回答):

json
{
  "tool_call": {
    "name": "get_weather",
    "arguments": { "city": "北京" }
  }
}

宿主系统执行工具,拿到结果后回传给模型:

json
{
  "role": "tool",
  "name": "get_weather",
  "content": "{ "temperature": 18, "condition": "", "humidity": 40 }"
}

第二轮模型基于工具结果生成最终回复:

北京今天天气晴,气温 18°C,湿度 40%,适合外出。

注意:工具结果是结构化的原始数据,最终转成用户能读的语言是模型做的事,而不是工具做的事。

一个最小可运行示例

上面的流程描述比较抽象,看一个用 OpenAI SDK 实现的最小版本,有助于把流程跟代码对上:

python
import json
from openai import OpenAI

client = OpenAI()

# 定义工具
tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询指定城市的当前天气,返回温度、天气状况和湿度",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "城市名称,例如:北京、上海"
                }
            },
            "required": ["city"]
        }
    }
}]

def get_weather(city: str) -> dict:
    """实际的工具实现(这里用模拟数据)"""
    return {"temperature": 18, "condition": "晴", "humidity": 40}

messages = [{"role": "user", "content": "北京今天天气怎么样?"}]

# 第一轮:让模型判断是否需要工具
response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools
)

message = response.choices[0].message

# 如果模型决定调工具
if message.tool_calls:
    tool_call = message.tool_calls[0]
    args = json.loads(tool_call.function.arguments)

    # 执行工具
    result = get_weather(**args)

    # 把工具结果加回消息历史
    messages.append(message)
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,
        "content": json.dumps(result, ensure_ascii=False)
    })

    # 第二轮:让模型基于工具结果生成最终回复
    final_response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=tools
    )
    print(final_response.choices[0].message.content)
    # 北京今天天气晴,气温 18°C,湿度 40%,适合外出。
else:
    print(message.content)

注意代码里几个关键点:工具定义里的 description 是写给模型的,不是给程序员看的;工具结果要带着 tool_call_id 回传,模型才知道这是哪次调用的结果;整个流程需要两次 API 调用。

为什么工具结果还要喂回模型

工具返回的通常是原始结构化结果,模型接回去之后才能:

  • 组织成用户能理解的语言
  • 合并多个工具结果
  • 根据界面要求输出不同格式
  • 在必要时继续做下一步判断

这里有个关键机制很值得理解:模型不是执行者,它是解释者和下一步决策者

工具把结果回传给模型,不只是为了把 JSON 翻译成自然语言,更是为了让模型把这次执行结果纳入当前任务状态。只有这样,它才能判断:

  • 现在是否已经拿到足够信息
  • 还要不要继续调另一个工具
  • 结果里哪些字段值得展示给用户
  • 这次调用失败后应该重试、换工具还是直接说明失败

如果工具执行完就直接把原始结果显示给用户,系统就会失去模型这一层的语义整合能力;但如果完全不把工具结果交回模型,模型又无法把这次执行纳入后续推理。Tool Calling 的核心闭环,就建立在这次"结果回流"上。

Tool Calling 和 Structured Output 不是一回事

这两个概念经常一起出现,所以特别容易被混淆。

  • Structured Output 解决的是:模型输出怎样稳定落成某种结构
  • Tool Calling 解决的是:模型什么时候应该把任务交给外部能力处理

前者重点是"表示",后者重点是"行动"。

当然它们经常会一起用。模型调用工具时,需要按结构化参数传参;工具返回结果后,模型也可能再按结构化格式整理最终输出。但你还是要分清:一个在约束输出形状,一个在打开外部能力通道。

好工具的设计原则

单一职责:一个工具只做一件事,get_weather(city) 比"万能查询工具"更可控。参数名和类型必须明确,不能让模型猜。工具描述要告诉模型什么时候该用、什么时候不该用——这是写给模型看的 Prompt,不是写给程序员看的注释。返回结果尽量结构化,方便系统和模型消费。

副作用这条单独说一下:优先从只读工具起步,别一开始就接"删除"和"发送"。危险动作带来的麻烦比写不好一段描述大得多。

为什么“单一职责”对模型尤其重要

对传统 API 设计来说,单一职责主要是为了可维护性;对 Tool Calling 来说,它还有另一层意义:让模型更容易选对。

人类程序员看到一个万能接口,可能还能读文档、慢慢理解;模型不会真正"读懂"一个含糊的万能工具,它只能根据名字、描述和参数,快速判断这个工具是不是适合当前任务。

所以工具一旦承担多个意图,模型就更容易:

  • 传错参数
  • 用错场景
  • 用一个带副作用的工具去做本来只需要读取的事情

很多成熟系统都把"读"和"写"分开,把"搜索"和"抓取"分开,把"局部编辑"和"覆盖写入"分开。目的就是降低模型的决策歧义。

什么问题适合直接回答,什么问题适合调工具

适合直接回答

  • 文本总结
  • 文案改写
  • 简单解释
  • 一般性概念说明

适合调工具

  • 实时天气
  • 当前时间
  • 网页搜索
  • 数据库查询
  • 业务动作执行

常见失败点

  • 工具选错
  • 参数缺失或格式不对
  • 外部 API 超时
  • 工具结果为空
  • 结果回传后模型又乱发挥

所以你必须在宿主层补上:

  • 参数校验
  • 超时与错误提示
  • 重试或降级
  • 日志记录

失败为什么大多不在模型“不会调”,而在宿主控制不完整

很多团队做 Tool Calling,第一次跑通就会很兴奋,然后很快遇到一堆看似随机的问题。再往下排,通常会发现真正的脆弱点不在"模型知不知道这个工具",而在宿主层没有把控制逻辑补齐。

因为 Tool Calling 一旦接上真实世界,就会立刻引入传统分布式系统那一套问题:

  • 外部接口超时
  • 返回结构不稳定
  • 权限不足
  • 参数合法但业务上无效
  • 同一个动作被重复执行

模型只能决定"想调哪个工具",但它不能替你处理幂等性、重试策略、熔断、权限审批、审计日志。这些都属于宿主系统职责。Tool Calling 成熟与否,往往就看这一层是不是被认真设计了。

安全边界为什么重要

只要工具能“做真实动作”,风险就不再是回答错了,而是系统可能真的做错事。

高风险动作示例:

  • 发邮件
  • 删除数据
  • 修改订单
  • 转账
  • 提交审批

这类动作必须满足:

  • 最小权限
  • 用户确认
  • 全量日志
  • 不可直接由不可信外部内容触发

为什么高风险工具不能只靠 Prompt 约束

很多新手系统会在提示词里写一句:"除非用户明确要求,否则不要发送邮件或删除数据。" 这当然有帮助,但远远不够。

因为只要动作真的会影响外部世界,安全边界就不能只建立在模型"最好听话"这件事上。你必须在宿主层和工具层再加硬约束:

  • 权限校验
  • 人工确认
  • 参数白名单或范围检查
  • 执行前后的日志与审计

否则一旦遇到提示注入、上下文污染、工具描述歧义,模型就可能在看似合理的语境里做出真实危险动作。

你现在最适合练的工具

  • 时间查询
  • 天气查询
  • 网页搜索
  • 只读数据库查询

建议先不要从“自动发消息”或“自动改库”起步。

和后续章节的关系

从 40+ 工具系统看设计原则

读完前面的内容,你已经知道工具的设计原则是什么。但"原则"这个词有时显得空洞,因为你没有看到它们在真实产品里怎么运作。这一节用 Claude Code 的工具系统来做一次对照——看看你刚学的那些原则,被一个实际产品严格执行到了什么程度。

Claude Code 是 Anthropic 推出的 AI 编程工具,本质上就是一个能反复调用工具的 Agent。它的核心能力全部通过工具系统来实现,总工具数超过 40 个(来自 Claude Code v2.1.88 源码)。

工具的统一约束接口

所有工具都通过同一个 Tool 接口定义,强制约束了以下几个核心字段:

字段作用
name工具名称(模型通过名称选择工具)
prompt()工具描述,写给模型看,决定模型何时选择它
inputSchema参数 Schema(用 Zod 定义,强类型约束)
call()工具执行函数,返回结构化结果
checkPermissions()权限检查,决定是否允许、需要确认还是拒绝
isReadOnly()标记是否只读,影响权限决策
isDestructive()标记是否不可逆(删除、覆盖、发送)

这就是"好工具的设计原则"在工程里的形态:不靠约定,而是靠类型接口强制约束。每个新工具要加进去,必须实现这套接口,否则编译不通过。

这里特别值得注意的是:真实系统会把"给模型看的描述"、"给程序校验的 Schema"、"给安全系统判断的权限元信息"放进同一个工具定义里。也就是说,工具是一个同时服务于模型决策、宿主执行和安全控制的复合对象,远超单一函数的范畴。

工具分类与拆分决策

40 多个工具大致分成五类:

类别工具举例特点
文件操作FileReadTool、FileWriteTool、FileEditTool读和写分开,Edit 单独一个
代码搜索GrepTool(按内容)、GlobTool(按文件名)两种搜索意图不同,拆开
命令执行BashTool、PowerShellTool、REPLTool按运行环境区分
网络访问WebSearchTool、WebFetchTool搜索和抓取是两种操作
Agent 协调AgentTool、TaskCreateTool、SendMessageTool多 Agent 编排

重点看文件操作这一组:为什么不做成一个"文件工具",而是拆成 Read、Write、Edit 三个?

因为模型选工具靠的是工具描述的语义,而不是靠猜。如果只有一个"文件工具",模型必须先猜你是要读还是要写,再从参数里推断意图,这两步都是出错的机会。拆开之后,模型在看到"我需要修改文件里的某段内容"时,直接对应 FileEditTool;在看到"我需要查看文件内容"时,直接对应 FileReadTool——不需要二次推断。

搜索工具也是一样的逻辑:GrepTool 是按内容搜,GlobTool 是按文件名模式匹配,两种操作的输入形态和适用场景完全不同,混在一起只会让模型选错。

这就是"单一职责"原则在真实产品里的决策成本:工具拆得越细,每个工具的描述就越精准,模型选择工具时的判断就越准确,最终的执行错误就越少。

工具描述本身就是 Prompt 工程

GrepTool 的描述里有一句话值得仔细读:

ALWAYS use Grep for search tasks. NEVER invoke grep or rg as a Bash command. The Grep tool has been optimized for correct permissions and access.

注意这不是用户手册,这是写给模型看的。它告诉模型:哪些情况必须用 Grep,哪些情况禁止用 Bash 去替代。

换句话说,工具的 prompt() 字段,本质上就是在做 Prompt 工程:通过语言约束模型的行为,引导它在什么时候选择这个工具,什么时候不选。写得模糊,模型就会乱选;写得精准,模型就会稳定地选对。

这是 Tool Calling 和 Prompt 工程的交叉点。你设计的不只是参数 Schema,而是整个调用判断的上下文。

安全校验内嵌于工具本身

章节前面讲到"高风险操作必须有安全边界"。BashTool 的实现就是一个完整的工程答案。

它的权限检查分三层递进:

  1. 语义分析:用 tree-sitter 把命令解析成 AST(抽象语法树),识别命令结构,找出命令替换、进程替换等危险模式
  2. 规则分类:每条 Bash 命令经过 allow / ask / deny 三种规则的匹配——允许直接执行、需要用户确认、直接拒绝
  3. 路径约束:检查命令是否试图访问项目目录之外的路径,或者通过输出重定向写入不应修改的文件

当命令太复杂、AST 解析无法静态分析时,系统默认进入"ask"——要求用户确认,而不是猜测。失败倒向安全,是这套设计的核心原则。

安全验证被编译进了工具定义本身,不是每次调用前临时加一个检查。任何时候模型调用 BashTool,这套检查一定会跑,开发者无法绕过。

结果回传驱动下一步决策

工具执行完成后,结果以 tool_result 的形式追加进对话历史,模型在下一轮请求时拿到完整上下文再做判断。

这就是前面说的"为什么工具结果还要喂回模型"的完整实现:系统执行工具、拿到结果、再把结果发回给模型,让模型决定下一步做什么——继续调工具、合并结果、还是生成最终回答。

在 Claude Code 里,一次完整的代码修改任务可能包括:GlobTool 找到目标文件 → FileReadTool 读取内容 → FileEditTool 修改 → BashTool 运行测试——每一步的输出都是下一步的输入,整个链条靠结果回传来维持状态。

落到你自己的工具系统

单一职责、描述具体、结果结构化、副作用最小、安全边界——前面反复出现的原则,在 Claude Code 里变成了类型接口、源码逻辑和运行时检查,没有一条只是口号。

自己设计工具系统时可以直接抄这个结构:先定统一的工具接口,每个工具聚焦一件事,description 按给模型写 Prompt 的标准来写,安全检查嵌进工具定义里,返回值设计成下一步决策能直接用的结构化数据。

踩过才知道的事

Tool Calling 跑通 demo 很快,但从 demo 到生产之间有一段距离,中间的坑大多跟"边界"有关。

Schema 看着像参数说明,其实它在定义模型这一轮能怎么行动。工具一旦职责重叠,模型选错的概率比缺工具还高。高风险动作写一句提示词远远不够,权限和确认必须做进宿主逻辑。

成熟系统的 Tool Calling 往往看起来更保守——执行错了比回答差了严重得多,所以该拦的地方宁可多拦一步。

补充自测

试着回答这两个问题:

  1. 如果你要为一个产品设计"数据库操作工具",你会设计成一个工具还是多个?判断依据是什么?
  2. 假设你在设计一个"发送通知"工具,它的 description 字段应该告诉模型什么,才能让模型知道什么时候用、什么时候不该用?

对应项目

可以从 AI 聊天助手 的带工具版本开始练手。

下一章

继续读 Embedding 与向量检索,进入第二阶段:把模型和外部知识连接起来。

面向开发者的 AI 实战路线——Vibe Coding 与 AI 应用开发