首页/Agent 核心/Tool Calling:协议与封装
Agent 核心 · 第 07 章

Tool Calling:协议与封装

第三章「怎么让它干活」从这里开始。前面两章里,模型再好用也只有一张嘴:它能告诉你"今天适合跑步",却查不了实时天气、读不了你的文件、调不了你的接口——嘴上功夫再好,也伸不出一只手。给模型接上工具,就是补上这只手。这是 A…

约 24 分钟9,715 字13 个代码示例

第三章「怎么让它干活」从这里开始。前面两章里,模型再好用也只有一张嘴:它能告诉你"今天适合跑步",却查不了实时天气、读不了你的文件、调不了你的接口——嘴上功夫再好,也伸不出一只手。给模型接上工具,就是补上这只手。这是 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 没有任何新魔法,三样老朋友拼装而成:

  1. 注入:SDK 把 tools 定义序列化成文本(类似一段"可用函数列表"的说明),拼进输入——本质上和你在 system 里手写"你有以下工具可用"没有区别
  2. 生成:模型被后训练(第 4 章)专门训练过"见到工具列表 + 合适的用户请求时,输出调用格式的文本"
  3. 解析:推理服务把这段特殊文本解析成结构化的 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 天以外的预报。"

三个写法要点:

  1. 写触发条件("当用户问……时使用")比写名词解释有效——模型是在做"用户请求 vs 工具描述"的匹配
  2. 写边界和失败情形,管理模型的期望,减少误调用
  3. 参数描述同理: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 越笨。原因有三层:

  1. 选择困难:几十个相似工具的 description 挤占输入,模型匹配精度下降(见过"用 read_file 读天气 API"的迷惑行为吗)
  2. 窗口消耗:每个工具 schema 平均 100~200 token,30 个工具就是半个系统提示词(第 4 章的预算思维)
  3. 组合爆炸:工具间可组合的"邪道用法"随数量指数增长(第 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 个为宜,多了用路由/子代理/技能分层

零件齐了:模型会表达调用意向(本章前半),工具有了统一封装(本章后半)。但还缺一样东西把零件转起来——那个"不停循环直到任务完成"的引擎,下一章见。

下一篇:08 · ReAct 模式与主循环