Tool Calling:协议与封装
第三章「怎么让它干活」从这里开始。前面两章里,模型再好用也只有一张嘴:它能告诉你"今天适合跑步",却查不了实时天气、读不了你的文件、调不了你的接口——嘴上功夫再好,也伸不出一只手。给模型接上工具,就是补上这只手。这是 A…
第三章「怎么让它干活」从这里开始。前面两章里,模型再好用也只有一张嘴:它能告诉你"今天适合跑步",却查不了实时天气、读不了你的文件、调不了你的接口——嘴上功夫再好,也伸不出一只手。给模型接上工具,就是补上这只手。这是 Agent 与聊天机器人的分水岭:在此之前,模型只能"说";在此之后,模型能"做"——查数据、读文件、调接口、执行命令。
本章前后两半:前半拆解 Tool Calling 的完整协议——工具怎么声明、模型怎么表达调用意向、你的代码怎么执行并把结果回传,理解它,Agent 就不再神秘;后半讲工具的设计与封装——Agent 的智商上限由模型决定,但下限由工具决定,同一个模型,配上设计良好的工具是得力助手,配上粗糙的工具只会帮倒忙。我们会用 Pydantic 搭一套
Tool + Registry封装,第 8 章的 Agent 主循环直接来这里取零件。
1. 为什么需要工具:模型只有嘴,没有手
模型有三个先天残缺(前几章都埋过伏笔):
| 残缺 | 根源 | 章节 |
|---|---|---|
| 知识有截止日期 | 预训练数据是快照 | 第 4 章 |
| 不能访问你的私有系统 | 训练时没见过你的数据库 | 第 4 章 |
| 算不准数、数不清字符 | token 粒度的感知局限 | 第 1 章 |
Tool Calling(也叫 Function Calling)的思路:模型负责"决定做什么",你的代码负责"真正去做"。模型像一个大脑,工具是它的手脚。
先建立一个最重要的心智模型(第 15 章安全的基石):
模型永远不执行任何工具。它只是输出一段结构化的"调用意向"(工具名 + 参数),执行发生在你的代码里。
2. 协议全貌:四次交互
Tool Calling 是一个多步协议,完整走一遍。这次我们给模型一个天气工具:
第一步:声明工具。把工具的名字、功能、参数 schema 随请求发给模型:
import json
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市当前的实时天气。当用户询问天气、温度、"
"降雨、穿衣建议等问题时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名,如「北京」",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认 celsius",
},
},
"required": ["city"],
},
},
},
]
注意 parameters 就是第 6 章的 JSON Schema——tool calling 的参数约束,正是结构化输出技术的复用。
第二步:模型返回"调用意向":
resp = client.chat.completions.create(
model="qwen3:4b",
messages=[{"role": "user", "content": "北京今天多少度?适合跑步吗"}],
tools=tools,
)
msg = resp.choices[0].message
print(msg.content) # 通常为空,或只有简短的思考文字
print(msg.tool_calls) # 真正的货在这里
输出(整理后):
[ChoiceMessageToolCall(
id='call_abc123',
function=Function(
name='get_weather',
arguments='{"city": "北京"}' # ← 一段 JSON 字符串
)
)]
两个细节:arguments 是 JSON 字符串不是 dict,使用前要 json.loads;finish_reason 此时是 tool_calls 而不是 stop——又是一个第 5 章说的"必须检查 finish_reason"的理由。
第三步:你的代码执行工具:
def get_weather(city: str, unit: str = "celsius") -> str:
"""演示用假实现;真实场景换成任意天气 API"""
data = {"北京": 12, "上海": 18}
t = data.get(city, 20)
if unit == "fahrenheit":
t = t * 9 / 5 + 32
return f"{city}当前 {t}°{'C' if unit == 'celsius' else 'F'},晴,空气质量优"
tc = msg.tool_calls[0]
args = json.loads(tc.function.arguments) # JSON 字符串 → dict
result = get_weather(**args) # 真正的执行发生在这里
print(result) # 北京当前 12°C,晴,空气质量优
第四步:把结果作为 tool 消息回传,让模型收尾:
messages = [
{"role": "user", "content": "北京今天多少度?适合跑步吗"},
msg, # 调用意向(assistant)
{"role": "tool", "tool_call_id": tc.id, "content": result}, # 执行结果
]
final = client.chat.completions.create(
model="qwen3:4b", messages=messages, tools=tools,
)
print(final.choices[0].message.content)
# 模型现在能看到工具结果,产出最终回答:
# "北京当前 12°C,晴,空气质量优,很适合跑步。不过温度略低,建议穿件薄外套。"
注意第 5 章的角色表在这里补全了最后一个角色:tool 消息携带 tool_call_id,必须和它回应的那条调用一一配对。这就是第 6 章警告过的"成对边界"——裁剪历史时把 assistant 的 tool_calls 和它对应的 tool 结果切开,下次请求直接 400 报错。
3. 协议背后的原理
剥开工业包装,tool calling 没有任何新魔法,三样老朋友拼装而成:
- 注入:SDK 把
tools定义序列化成文本(类似一段"可用函数列表"的说明),拼进输入——本质上和你在 system 里手写"你有以下工具可用"没有区别 - 生成:模型被后训练(第 4 章)专门训练过"见到工具列表 + 合适的用户请求时,输出调用格式的文本"
- 解析:推理服务把这段特殊文本解析成结构化的
tool_calls对象返回给你
所以它可靠性不错,但仍是概率行为——模型可能不调用该调的工具、编造不存在的参数值、或给出格式错误的参数。第 6 章的"校验 + 兜底"思想在这里同样适用(本章第 8 节落地)。
tool_choice 参数控制模型的调用倾向:
| 值 | 行为 |
|---|---|
"auto"(默认) |
模型自行决定调不调、调哪个 |
"none" |
禁止调用,纯对话 |
"required" |
强制至少调用一个工具 |
{"type": "function", "function": {"name": "..."}} |
强制调用指定工具(可用来做"参数提取器") |
最后那个用法很实用:强制模型把自然语言填进某个 schema,等于一个"自然语言→结构化数据"的转换器(第 6 章的另一种实现路径)。
4. 并行调用与多轮工具
现代模型(含 qwen3)支持一次返回多个调用——用户问"北京和上海哪个更适合今天跑步"时:
print(len(msg.tool_calls)) # 可能是 2:get_weather(北京) + get_weather(上海)
协议要求:每个 tool_call 都要有对应的一条 tool 消息回传。执行侧可以并发:
import concurrent.futures as cf
messages.append(msg) # 先把意向入列
with cf.ThreadPoolExecutor() as pool:
futures = {tc.id: pool.submit(
get_weather, **json.loads(tc.function.arguments)
) for tc in msg.tool_calls}
for tc in msg.tool_calls:
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": str(futures[tc.id].result()),
})
多轮同理:模型收到工具结果后,可能不直接回答,而是发起下一轮调用(先查天气、再查空气质量)。这个"循环直到模型不再调用工具"的模式,就是 Agent 主循环的雏形——第 8 章的主角。
5. 一个完整的可运行示例
把本章前半的协议收拢成一个函数(单工具、支持多轮、带防御):
def chat_with_tools(user_input: str, tools: list, executor: dict,
max_rounds: int = 5) -> str:
"""executor: {工具名: 可调用函数} 的映射"""
messages = [{"role": "user", "content": user_input}]
for _ in range(max_rounds):
resp = client.chat.completions.create(
model="qwen3:4b", messages=messages, tools=tools,
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls: # 不再调用 → 最终回答
return msg.content
for tc in msg.tool_calls:
func = executor.get(tc.function.name)
if func is None: # 防御:模型幻觉出工具名
result = f"错误:不存在名为 {tc.function.name} 的工具"
else:
try:
result = str(func(**json.loads(tc.function.arguments)))
except Exception as e: # 防御:执行失败不崩溃
result = f"工具执行出错:{e}"
messages.append({
"role": "tool", "tool_call_id": tc.id, "content": result,
})
return "(达到最大轮次限制)"
注意两个防御点:模型幻觉出不存在的工具名、参数解析失败——生产代码里这两个都会真实发生。处理哲学统一为"把错误信息作为 tool 结果回传,让模型自己纠偏",本章第 8 节会把这套防御工程化。
现在模型能"做一件事"了。但真实 Agent 需要一抽屉工具,而且工具的质量直接决定 Agent 的智商——协议解决了"怎么接上",接下来解决"怎么接好":本章后半,讲工具的设计与封装。
6. description:写给模型的产品说明书
先建立一个容易搞反的认知:模型看不到你的代码,只看得到 name、description 和参数 schema。get_weather 函数内部写得多优雅,模型一无所知;它判断"什么时候用这个工具",完全依赖 description 这几十个字。
对比两种写法:
# ❌ 只说"是什么"
"description": "获取天气"
# ✅ 说清"什么时候用、能干什么、边界在哪"
"description": "查询指定城市当前的实时天气(温度、天气现象、空气质量)。
当用户询问天气、气温、是否下雨下雪、该穿什么衣服等问题时使用。
仅支持中国主要城市。不提供未来 3 天以外的预报。"
三个写法要点:
- 写触发条件("当用户问……时使用")比写名词解释有效——模型是在做"用户请求 vs 工具描述"的匹配
- 写边界和失败情形,管理模型的期望,减少误调用
- 参数描述同理:
city不写描述时,模型不知道该传"北京"还是"北京市朝阳区"——写清楚
7. 好工具的六个特征
| 特征 | 说明 | 反例 |
|---|---|---|
| 名称动词化、自解释 | read_file、query_orders |
handle_data |
| 参数面最小 | 每个参数都是模型的一次发挥机会 | 8 个参数里有 5 个可选 |
| 枚举锁死取值 | Literal / enum |
自由字符串的 sort_by |
| 返回可读字符串 | 模型要"读"结果,不是反序列化 | 返回嵌套 5 层的 JSON |
| 结果大小受控 | 截断 + 提示被截断 | 返回整个 2MB 文件 |
| 错误信息可行动 | "城市不存在,支持的城市:…" | 抛异常或返回 null |
最后两条展开讲——它们是新手最容易踩的坑:
结果大小受控。工具结果会全文进入上下文(第 4 章的窗口预算)。read_file 必须截断:
def read_file(path: str) -> str:
content = Path(path).read_text(encoding="utf-8")
if len(content) > 4000:
return content[:4000] + f"\n...(已截断,全文共 {len(content)} 字符)"
return content
错误可行动。错误信息是给模型的"下一步指令":
# ❌ 错误示例
"error" # 模型:??然后开始编
# ✅ 可行动的错误
"城市 '北jing' 不存在。请检查拼写,或改用支持的城市列表中的名称"
8. 封装:Tool + Registry
工具多了之后,手写 schema、手写 dispatch(本章第 5 节结尾那个 if-else)会失控。用 Pydantic(第 6 章)把"参数校验、schema 生成、执行分发"收进一个注册器:
import json
from pydantic import BaseModel, ValidationError
class Tool:
"""把一个 Python 函数封装成模型可用的工具"""
def __init__(self, func, args_model: type[BaseModel], description: str):
self.func = func
self.args_model = args_model
self.name = func.__name__
self.description = description
def schema(self) -> dict:
"""生成 OpenAI 格式的工具声明(本章第 2 节的 tools 列表元素)"""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.args_model.model_json_schema(),
},
}
def run(self, arguments_json: str) -> str:
"""执行工具。所有失败都以可读字符串返回,绝不抛异常"""
try:
args = self.args_model.model_validate_json(arguments_json)
return str(self.func(**args.model_dump()))
except ValidationError as e:
return f"参数校验失败:{e}"
except Exception as e:
return f"工具执行出错:{type(e).__name__}: {e}"
class Registry:
"""工具注册器:schema 生成 + 名称分发"""
def __init__(self):
self.tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
self.tools[tool.name] = tool
def schemas(self) -> list[dict]:
return [t.schema() for t in self.tools.values()]
def dispatch(self, name: str, arguments_json: str) -> str:
tool = self.tools.get(name)
if tool is None:
return f"错误:不存在名为 {name} 的工具。可用工具:{list(self.tools)}"
return tool.run(arguments_json)
设计要点:
run永不抛异常——所有错误转为字符串返回。这是本章第 5 节防御哲学的工程化:错误是模型的信息,不是调用方的灾难- Pydantic 在入口统一校验——本章第 3 节"幻觉参数"问题在这里被拦下(
ValidationError的报文本身就是可行动的错误信息) - 未知工具名返回可用工具列表,模型下一轮通常能自己纠正
9. 注册一组真实工具
用这套封装注册三个常用工具(第 8、9 章的 Agent 直接用它们):
import re
from pathlib import Path
from pydantic import BaseModel, Field, ValidationError
# ---------- read_file ----------
class ReadFileArgs(BaseModel):
path: str = Field(description="要读取的文件路径(相对当前目录或绝对路径)")
max_chars: int = Field(default=4000, description="最多返回的字符数")
def read_file(path: str, max_chars: int = 4000) -> str:
p = Path(path)
if not p.exists():
return f"文件不存在:{path}"
if not p.is_file():
return f"路径不是文件:{path}"
content = p.read_text(encoding="utf-8")
if len(content) > max_chars:
return content[:max_chars] + f"\n...(已截断,共 {len(content)} 字符)"
return content
# ---------- write_file ----------
class WriteFileArgs(BaseModel):
path: str = Field(description="目标文件路径")
content: str = Field(description="要写入的完整内容(全量覆盖,非追加)")
def write_file(path: str, content: str) -> str:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"已写入 {path}({len(content)} 字符)"
# ---------- calculate ----------
class CalculateArgs(BaseModel):
expression: str = Field(description="Python 算术表达式,如 '(12.5 + 3) * 2'")
def calculate(expression: str) -> str:
# 只放行安全子集,杜绝 eval 直接执行任意代码
if not re.fullmatch(r"[0-9+\-*/().%\s]+", expression):
return "表达式包含不支持的字符(仅支持数字与 + - * / % ( ))"
try:
return str(eval(expression, {"__builtins__": {}}, {}))
except Exception as e:
return f"计算失败:{e}"
# ---------- 注册 ----------
registry = Registry()
registry.register(Tool(read_file, ReadFileArgs,
"读取本地文本文件的内容。文件过大时自动截断。"))
registry.register(Tool(write_file, WriteFileArgs,
"将文本内容写入本地文件(覆盖写入)。需要创建或修改文件时使用。"))
registry.register(Tool(calculate, CalculateArgs,
"精确计算算术表达式。任何数学计算都必须用本工具,不要心算。"))
calculate 的 description 最后一句值得注意——你在用说明书反向抑制模型的坏习惯(第 1 章讲过模型的数学缺陷)。这比在系统提示词里泛泛要求"算数要准确"有效得多,因为它出现在模型决策调用的现场。
10. 工具不是越多越好
一个反直觉的事实:工具越多,Agent 越笨。原因有三层:
- 选择困难:几十个相似工具的 description 挤占输入,模型匹配精度下降(见过"用 read_file 读天气 API"的迷惑行为吗)
- 窗口消耗:每个工具 schema 平均 100~200 token,30 个工具就是半个系统提示词(第 4 章的预算思维)
- 组合爆炸:工具间可组合的"邪道用法"随数量指数增长(第 15 章安全的攻击面)
工程对策按代价排序:
- 控制注册量:一个 Agent 只挂它职责内的工具(5~8 个为宜)
- 按需装配:路由 Agent 先分类,再挂子工具集(第 12 章子代理的动机)
- 文档外置:Skill 机制——平时只给一行索引,用时才加载全文(第 10 章专门讲)
本章小结
- 心智模型:模型只产出"调用意向"(工具名 + JSON 参数),执行永远在你的代码里
- 协议四步:声明工具 → 收到 tool_calls → 执行 → tool 消息回传(id 必须配对)
- 原理 = schema 注入 + 后训练出的调用格式 + 服务端解析;可靠但非绝对
tool_choice可禁用/强制调用;强制指定工具可当"参数提取器"用- 并行调用:一个 assistant 消息可含多个 tool_calls,每个都要有配对的 tool 回传
- 防御两件事:幻觉工具名、执行异常——错误信息回传给模型让它自纠
- 模型只看得见 name/description/schema——description 要写"什么时候用"而非"是什么"
- 好工具六特征:动词化命名、最小参数面、枚举锁值、可读返回、受控大小、可行动的错误
Tool.run永不抛异常:错误是给模型的信息,不是调用方的灾难- Pydantic 统一了参数校验和 schema 生成(第 6 章技术的复用)
- 工具数量是负债:5~8 个为宜,多了用路由/子代理/技能分层
零件齐了:模型会表达调用意向(本章前半),工具有了统一封装(本章后半)。但还缺一样东西把零件转起来——那个"不停循环直到任务完成"的引擎,下一章见。