Appearance
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 流程
- 用户发起问题
- 模型判断当前需要工具
- 模型生成工具名和参数
- 宿主系统执行工具
- 工具返回结构化结果
- 系统把结果回传给模型
- 模型基于结果生成最终回复
这里一个关键点是:工具负责拿数据,模型负责解释数据。
Tool Schema 其实是在定义模型的行动空间
很多人会把工具 Schema 只看成"参数格式说明"。这还不够。
对模型来说,工具列表本身就是它在这一轮里可采取的行动集合。你给它什么工具、每个工具叫什么、描述怎么写、参数长什么样,都会直接影响它怎么理解"我下一步可以做什么"。
换句话说,Schema 不只是给程序校验的,它同时也在给模型塑造一个行动空间。
这会带来两个直接后果:
- 工具太少,模型会被迫把很多事情都用自然语言硬答
- 工具太杂、太重叠,模型会频繁选错,或者反复在相似工具之间摇摆
所以工具设计要让每个工具的职责边界在模型视角下足够清晰——多了不好,万能了更不好。
一个具体例子:查询今天北京的天气
用户发送:"北京今天天气怎么样?"
第一轮请求(系统 → 模型):
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 约束
很多新手系统会在提示词里写一句:"除非用户明确要求,否则不要发送邮件或删除数据。" 这当然有帮助,但远远不够。
因为只要动作真的会影响外部世界,安全边界就不能只建立在模型"最好听话"这件事上。你必须在宿主层和工具层再加硬约束:
- 权限校验
- 人工确认
- 参数白名单或范围检查
- 执行前后的日志与审计
否则一旦遇到提示注入、上下文污染、工具描述歧义,模型就可能在看似合理的语境里做出真实危险动作。
你现在最适合练的工具
- 时间查询
- 天气查询
- 网页搜索
- 只读数据库查询
建议先不要从“自动发消息”或“自动改库”起步。
和后续章节的关系
- Structured Output 解决的是“工具参数和结果怎样稳定表示”
- Agent 基础原理 解决的是“多步、多工具怎么串起来”
- Prompt Injection 与 AI 安全 解决的是“工具为什么不能被模型随便乱调”
从 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
greporrgas a Bash command. The Grep tool has been optimized for correct permissions and access.
注意这不是用户手册,这是写给模型看的。它告诉模型:哪些情况必须用 Grep,哪些情况禁止用 Bash 去替代。
换句话说,工具的 prompt() 字段,本质上就是在做 Prompt 工程:通过语言约束模型的行为,引导它在什么时候选择这个工具,什么时候不选。写得模糊,模型就会乱选;写得精准,模型就会稳定地选对。
这是 Tool Calling 和 Prompt 工程的交叉点。你设计的不只是参数 Schema,而是整个调用判断的上下文。
安全校验内嵌于工具本身
章节前面讲到"高风险操作必须有安全边界"。BashTool 的实现就是一个完整的工程答案。
它的权限检查分三层递进:
- 语义分析:用 tree-sitter 把命令解析成 AST(抽象语法树),识别命令结构,找出命令替换、进程替换等危险模式
- 规则分类:每条 Bash 命令经过 allow / ask / deny 三种规则的匹配——允许直接执行、需要用户确认、直接拒绝
- 路径约束:检查命令是否试图访问项目目录之外的路径,或者通过输出重定向写入不应修改的文件
当命令太复杂、AST 解析无法静态分析时,系统默认进入"ask"——要求用户确认,而不是猜测。失败倒向安全,是这套设计的核心原则。
安全验证被编译进了工具定义本身,不是每次调用前临时加一个检查。任何时候模型调用 BashTool,这套检查一定会跑,开发者无法绕过。
结果回传驱动下一步决策
工具执行完成后,结果以 tool_result 的形式追加进对话历史,模型在下一轮请求时拿到完整上下文再做判断。
这就是前面说的"为什么工具结果还要喂回模型"的完整实现:系统执行工具、拿到结果、再把结果发回给模型,让模型决定下一步做什么——继续调工具、合并结果、还是生成最终回答。
在 Claude Code 里,一次完整的代码修改任务可能包括:GlobTool 找到目标文件 → FileReadTool 读取内容 → FileEditTool 修改 → BashTool 运行测试——每一步的输出都是下一步的输入,整个链条靠结果回传来维持状态。
落到你自己的工具系统
单一职责、描述具体、结果结构化、副作用最小、安全边界——前面反复出现的原则,在 Claude Code 里变成了类型接口、源码逻辑和运行时检查,没有一条只是口号。
自己设计工具系统时可以直接抄这个结构:先定统一的工具接口,每个工具聚焦一件事,description 按给模型写 Prompt 的标准来写,安全检查嵌进工具定义里,返回值设计成下一步决策能直接用的结构化数据。
踩过才知道的事
Tool Calling 跑通 demo 很快,但从 demo 到生产之间有一段距离,中间的坑大多跟"边界"有关。
Schema 看着像参数说明,其实它在定义模型这一轮能怎么行动。工具一旦职责重叠,模型选错的概率比缺工具还高。高风险动作写一句提示词远远不够,权限和确认必须做进宿主逻辑。
成熟系统的 Tool Calling 往往看起来更保守——执行错了比回答差了严重得多,所以该拦的地方宁可多拦一步。
补充自测
试着回答这两个问题:
- 如果你要为一个产品设计"数据库操作工具",你会设计成一个工具还是多个?判断依据是什么?
- 假设你在设计一个"发送通知"工具,它的 description 字段应该告诉模型什么,才能让模型知道什么时候用、什么时候不该用?
对应项目
可以从 AI 聊天助手 的带工具版本开始练手。
下一章
继续读 Embedding 与向量检索,进入第二阶段:把模型和外部知识连接起来。