外观
第 4 章 函数调用
先说明用语:工具调用和函数调用在本书里指同一个机制,第 2 章讲回路时用“工具调用”,这一章沿用 API 的术语“函数调用”。模型自己不执行任何代码,只生成一份写明要调用哪个函数、传入什么参数的结构化请求;真正执行的是工程侧的代码,执行完再把结果作为消息交回给模型。智能体的行动能力全部来自这套机制。机制本身也非常稳定:2023 年 6 月 OpenAI 把函数调用正式加入 Chat API,三年里两条基本结构一直没变,工具定义用 JSON Schema 描述,调用与结果走消息协议传递;变的是外围框架,OpenAI 的 Swarm 停在实验状态,Assistants API 已于 2026 年 8 月 26 日关停,而原生函数调用的写法几乎原样沿用至今,把机制学透,框架怎么更新都不用从头学起。4.1 拆解工具定义的本质,4.2 用 Python 和 OpenAI 官方 SDK 跑通一个完整的工具调用回路,4.3 比较 OpenAI、Anthropic、Gemini 和几家中文模型在接口写法上的差异,4.4 把“执行工具”这一步从一行代码扩展成可以用于生产的执行器,4.5 讲工具本身怎么设计,4.6 讲智能体运行的环境,六节学完就能把第 2 章回路图里的“工具”部分补全。
4.1 工具定义的本质
工程师看一个函数,首先看到的是 def 和类型签名,这些是写给编译器读的。模型不读签名,它只处理文本。工具定义就是把一个函数转换成一段模型能读的 JSON 文本,里面只有三部分:工具的名字、一段用自然语言写的描述和一份参数格式说明。
前两个是普通文本,第三个用 JSON Schema(一种用 JSON 描述数据结构的规范)来写。它通过 type、properties 和 required 这几个关键字,说明一个对象有哪些字段、每个字段是什么类型、哪些字段必填。第 3 章讲结构化输出时已经用过它,这一章用途不同:不再约束模型的输出,而是向模型描述工具的输入。从 Python 函数到 JSON Schema,可以手工转换,也可以写一个十几行的转换器自动生成。
python
import inspect
from typing import get_type_hints
PY_TO_JSON = {
str: "string", int: "integer", float: "number", bool: "boolean",
list: "array", dict: "object", type(None): "null",
}
def signature_to_schema(func) -> dict:
"""把 Python 函数签名转成参数 JSON Schema,不含描述字段。"""
hints = get_type_hints(func)
properties, required = {}, []
for param in inspect.signature(func).parameters.values():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue # *args 和 **kwargs 不暴露给模型
properties[param.name] = {"type": PY_TO_JSON.get(hints.get(param.name), "string")}
if param.default is inspect.Signature.empty:
required.append(param.name)
return {"type": "object", "properties": properties, "required": required}示例仍然用快递查询函数,只是把第 2 章那个只接收订单号的简化版,扩展成更接近真实的样子。查一单快递得知道快递公司和运单号,所以它有两个必填参数,另外还有一个带默认值的可选参数。
python
def query_express(company: str, tracking_no: str, detail: bool = False) -> dict:
"""查询快递物流轨迹,返回最新状态与更新时间。仅支持国内快递公司。"""signature_to_schema(query_express) 的输出如下。参数是否进入 required,取决于它有没有默认值。
json
{
"type": "object",
"properties": {
"company": {"type": "string"},
"tracking_no": {"type": "string"},
"detail": {"type": "boolean"}
},
"required": ["company", "tracking_no"]
}把名字、由 docstring 转成的描述,以及这份 schema 组合起来,就是一条完整的工具定义,会随请求一起发送。下面示意中的 parameters,内容就是上一段的 schema,这里不再重复。工具名的约束各家厂商不一样:OpenAI 规定不超过 64 个字符,只能使用字母、数字、下划线和连字符,起名时不要用中文和点号。
json
{
"type": "function",
"function": {
"name": "query_express",
"description": "查询快递物流轨迹,返回最新状态与更新时间。仅支持国内快递公司。",
"parameters": {"type": "object", "properties": {"...": "同上"}, "required": ["company", "tracking_no"]}
}
}转换器有一个容易出错的地方,我在这一步返工过一次:类型表里查不到的类型,会被默认当作 string,而且不报任何错误。参数一旦写成 list[str]、str | None 这类带参数的注解,get_type_hints 返回的就不再是 list、str 这样的基础类型,查表会失败,schema 里的类型就悄悄写错了。演示用的函数刚好都是基础类型,所以输出是对的。转换器要真正使用,有两种办法:逐个人工核对生成的 schema,或者先用 typing.get_origin 和 get_args 把带参数的注解展开,再查表。
转换器只能完成类型转换,而决定模型能不能用好这个工具的,是它给不了的两部分:工具描述和参数说明。模型选择工具时,名字只能提供很粗略的线索,除名字外能依据的只有 description 这段文字;填写参数时,能依据的只有每个参数的 description。这段文字写得含糊,模型的行为就会含糊。
模型会使用工具,不是天生的,而是训练阶段使用了大量“工具定义加正确调用”的样本,公开的 hermes-function-calling-v1 就是这类数据集。训练教会模型的是读定义、生成参数这个通用动作;至于某个具体工具怎么用,完全取决于这份定义写得好不好。
发出请求时,这份 JSON 会被序列化成一段文本放进输入,与系统提示、用户消息拼在一起,构成模型实际读到的内容。模型读取工具定义的办法,和读取普通文本没有区别。
由此可以得出两个结论。第一,描述里的每个字都会占用模型的注意力,多余的话不是无害的,它会把模型的注意力从真正的任务信息上引开。第二,工具定义属于提示,不属于代码:修改它不需要发版,但也没有编译器检查,写错了不会报错,只会让模型的行为变差,而且不容易发现。因此要把工具定义当作配置中最重要的部分来管理:纳入 git 版本控制,修改要经过评审,这是不少团队吃过亏之后总结出的做法。改了描述之后,怎么判断行为有没有变差?用一组固定的问题做回归对比,方法在第 12 章展开。
写工具描述有两条经验。第一条针对工具本身:描述要交代清楚三件事,什么场景下使用、做什么、边界在哪里。“查询指定门店指定日期的营业额(单位元),仅支持单个门店单天”是合格的描述,“查询数据”等于什么都没说。第二条针对参数:描述里要给出格式和示例,日期参数要写明 YYYY-MM-DD,再配一个 2026-08-24 这样的例子。模型对“昨天”这类相对时间的理解,并没有想象中那么可靠,格式约束能避免大部分格式错误。
以同一句“我的件到哪了”为例。描述只写“查询快递”时,模型能判断该用这个工具,但 company 参数只能靠猜,填圆通还是中通,完全取决于训练数据留下的印象。把描述改成“查询国内快递公司的物流轨迹,company 填快递公司全称如‘顺丰速运’,tracking_no 填运单号”,同一句问话,模型就知道怎么填参数了。两次的差别不在模型,只在这段描述文字。这也是工具定义比普通代码更需要频繁调整的原因之一:业务话术改一句,描述就得跟着改。
还有一点要算清楚:工具定义是要付费的。它随每一次请求一起发送,和消息一起计入输入,按 token 计费。
工具清单怎么放进请求,各家的做法不同。Anthropic 官方给过一组实测数字:启用工具后,系统会自动加入一段隐藏的系统提示,向模型说明工具的用法。以 Claude Opus 5 为例,auto 模式固定增加 286 个输入 token,强制调用模式固定增加 406 个,这还不包括工具清单中各份 schema 本身占用的 token。工具越多,单轮请求越贵,模型的注意力也越分散,这笔开销怎么省,第 7 章讲上下文预算时展开。
工具定义是写给模型的接口文档。编译器只需要函数签名,而模型还需要知道什么时候该用这个工具、参数怎么填。
4.2 一次完整的工具调用回路
把回路拆开,一共五步:定义、发起、执行、回填、续写。消息协议的基本结构在第 2 章 2.3 已经讲过,tool 角色怎么通过编号与调用请求配对也讲过,这里不再重复,只在回填这一步补充生产环境的细节,重点是并行调用。
下面用“查三家店”这次问答,观察消息列表的变化:消息从最初的两条增加到六条。整个过程向模型发送了两次请求:第一次模型要求调用工具获取数据,第二次带着结果回答。
场景是连锁品牌的运营助手。运营人员提出:“查一下 ST001、ST002、ST003 三家店昨天的营业额,谁最高”。三次查询互不依赖,适合并行执行,这一节正好用它完整走一遍并行调用。完整代码如下,安装 openai 库并配置好 OPENAI_API_KEY 就能运行,模型名以 OpenAI 官方文档为准,选择自己账号有权限使用的型号即可。
python
import json
from openai import OpenAI
client = OpenAI() # 从环境变量 OPENAI_API_KEY 读取密钥
# 工具的真实实现。示例用假数据模拟门店营业额,生产中替换为内部系统 API
def get_daily_revenue(store_id: str, date: str) -> dict:
fake = {"ST001": 12850.5, "ST002": 9320.0, "ST003": 15704.2}
if store_id not in fake:
return {"error": "store_not_found", "message": f"门店 {store_id} 不存在"}
return {"store_id": store_id, "date": date, "revenue_yuan": fake[store_id]}
tools = [
{
"type": "function",
"function": {
"name": "get_daily_revenue",
"description": "查询指定门店指定日期的营业额,单位为元。仅支持查询单个门店单天数据。",
"parameters": {
"type": "object",
"properties": {
"store_id": {
"type": "string",
"description": "门店编号,格式为 ST 开头加三位数字,如 ST001",
},
"date": {
"type": "string",
"description": "查询日期,格式 YYYY-MM-DD,如 2026-08-24",
},
},
"required": ["store_id", "date"],
},
},
}
]
TOOLBOX = {"get_daily_revenue": get_daily_revenue}
# model 默认值随官方在售型号迭代,跑不通就查文档换名
def run_agent(user_question: str, model: str = "gpt-5.5", max_rounds: int = 5) -> str:
messages = [
{"role": "system", "content": "你是连锁品牌的运营助手,回答必须基于工具返回的真实数据。"},
{"role": "user", "content": user_question},
]
for _ in range(max_rounds):
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
messages.append(msg) # 带着工具调用请求的 assistant 消息必须先进历史
if not msg.tool_calls:
return msg.content
for tool_call in msg.tool_calls:
fn = TOOLBOX.get(tool_call.function.name)
if fn is None:
result = json.dumps(
{"error": "unknown_tool", "message": f"没有叫 {tool_call.function.name} 的工具"},
ensure_ascii=False)
else:
args = json.loads(tool_call.function.arguments)
result = json.dumps(fn(**args), ensure_ascii=False)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "达到最大轮数,任务未完成。"
if __name__ == "__main__":
print(run_agent("查一下 ST001、ST002、ST003 三家店 2026-08-24 的营业额,谁最高?"))发起这一步,请求里多出来的只有一个 tools 数组;tool_choice="auto" 表示由模型自己决定要不要调用工具,这本来就是默认行为。响应返回后,最先要注意的是 resp.choices[0].finish_reason:普通回答时它是 stop,这次变成了 tool_calls。message.content 是空的,实际信息都在 message.tool_calls 列表里。这个问题,模型会一次发回三条调用请求,结构如下(id 每次都不同)。
json
[
{"id": "call_a1", "type": "function",
"function": {"name": "get_daily_revenue",
"arguments": "{\"store_id\": \"ST001\", \"date\": \"2026-08-24\"}"}},
{"id": "call_a2", "type": "function",
"function": {"name": "get_daily_revenue",
"arguments": "{\"store_id\": \"ST002\", \"date\": \"2026-08-24\"}"}},
{"id": "call_a3", "type": "function",
"function": {"name": "get_daily_revenue",
"arguments": "{\"store_id\": \"ST003\", \"date\": \"2026-08-24\"}"}}
]有两处需要特别注意。第一,arguments 是 JSON 字符串,不是现成的对象,作为参数使用前要再用 json.loads 转换一次;官方 API 参考还写明,这段字符串有可能不是合法的 JSON,遇到这种情况怎么处理,4.4 节专门讲。第二,三条调用请求各带一个 id,回填时靠它把结果对上号,这是调用与结果之间唯一的对应依据。
回填分两步,新手最容易出错的地方也在这两步。第一步:把带有 tool_calls 的那条 assistant 消息原样追加到 messages。漏掉这一步,下一次请求会直接返回 400 错误,因为协议规定了顺序:tool 消息必须紧跟在它对应的 assistant 消息之后。
第二步:逐个执行工具,每执行完一个,就追加一条 role 为 tool 的消息,tool_call_id 填对应请求的 id,content 填执行结果的字符串。执行这一步,fn(**args) 调用的就是一个普通的 Python 函数。工具的底层可能是数据库查询、内部 API,也可能只是一段纯计算。
续写这一步就是再调用一次 create。此时 messages 里已经包含了完整的过程:最初的问题、模型发出的三条调用请求,以及三份结果。模型读完这些内容,汇总成一句回答,输出“ST003 营业额最高,15704.2 元”。第二次请求发送的完整 messages 共六条,如下所示。
json
[
{"role": "system", "content": "你是连锁品牌的运营助手,回答必须基于工具返回的真实数据。"},
{"role": "user", "content": "查一下 ST001、ST002、ST003 三家店 2026-08-24 的营业额,谁最高?"},
{"role": "assistant", "tool_calls": [
{"id": "call_a1", "type": "function", "function": {"name": "get_daily_revenue", "arguments": "{\"store_id\": \"ST001\", \"date\": \"2026-08-24\"}"}},
{"id": "call_a2", "type": "function", "function": {"name": "get_daily_revenue", "arguments": "{\"store_id\": \"ST002\", \"date\": \"2026-08-24\"}"}},
{"id": "call_a3", "type": "function", "function": {"name": "get_daily_revenue", "arguments": "{\"store_id\": \"ST003\", \"date\": \"2026-08-24\"}"}}]},
{"role": "tool", "tool_call_id": "call_a1", "content": "{\"store_id\": \"ST001\", \"date\": \"2026-08-24\", \"revenue_yuan\": 12850.5}"},
{"role": "tool", "tool_call_id": "call_a2", "content": "{\"store_id\": \"ST002\", \"date\": \"2026-08-24\", \"revenue_yuan\": 9320.0}"},
{"role": "tool", "tool_call_id": "call_a3", "content": "{\"store_id\": \"ST003\", \"date\": \"2026-08-24\", \"revenue_yuan\": 15704.2}"}
]这份消息清单里各角色的分工一目了然:user 提出需求,assistant 发出三条调用请求,tool 返回三份结果,第二次请求把这整段记录原样带上,模型基于它比较三个数字并组织回答。调试工具回路时有一个省事的做法:先把这份清单打印出来,再看模型的输出。参数填错,问题出在清单的前半部分;回答与工具结果对不上,问题出在后半部分;tool 消息少了一条或者顺序乱了,就要去检查执行侧拼装消息的代码。
清单里的三条 tool 消息都是成功结果。出错时回填的消息,格式与成功结果几乎相同:查询一个不存在的门店,比如 ST004,get_daily_revenue 会返回 {"error": "store_not_found"};模型生成了一个不存在的工具名,执行器会返回 {"error": "unknown_tool"}。这两份字典同样用 json.dumps 转成字符串后回填,对协议来说和成功结果没有区别。
第二次请求中,模型读到的就是这份带 error 的结果,通常会如实告知用户:“ST004 查无此店,其余两家中 ST003 最高”,必要时再想别的办法,比如回头向用户核对门店编号。并行的三条调用各自独立回填,一条失败不影响另外两条:失败的带 error,成功的带数据,一起随第二次请求发回。错误不需要专门的通道,仍然走同一条回填路径。第 2 章提出的“把错误文本回填给模型”的做法,在工具这一侧就是这样实现的。
这次问答看起来只有一轮,实际要为两次请求付费。第一次请求的输入里,除了 system 和 user,还带着整份 tools 数组的 schema;第二次请求中,三份工具结果又作为输入再计一次费。工具越多、返回的结果越大,单个任务的费用增长越快。这笔开销怎么系统地控制,第 7 章和第 14 章会专门讲。把这两次请求的逻辑放进 run_agent 的 for 循环,就得到了第 2 章手写回路的工具版本,轮数上限仍然保留,防止模型在多轮调用的任务中一直循环下去。
这段代码换到 DeepSeek 上,只需要改三处。
python
from openai import OpenAI
import os
client = OpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="<DeepSeek 兼容端点,见官方文档>",
)
# create 时把 model 换成 DeepSeek 的模型名(以官方 quick start 页为准),
# tools、tool_choice、消息结构、tool 消息回填全部照旧4.3 各家方言
4.2 的回路使用的是 OpenAI 的 Chat Completions。换成其他厂商时,底层机制不变,变化的只是字段名和消息结构,这些差异可以称为各家的“方言”。
函数调用是 OpenAI 在 2023 年 6 月 13 日随 gpt-3.5-turbo-0613 和 gpt-4-0613 一起推出的,当时参数名叫 functions,一次请求只能调用一个函数。同年 11 月 6 日的 DevDay 上,GPT-4 Turbo 增加了并行函数调用,功能更完整的 tools 和 tool_choice 取代了 functions 和 function_call,此后官方文档里只保留 tools 这一种写法。
到 2026 年 8 月,OpenAI 主推的接口已经是 Responses API,工具结果放在 function_call_output 条目里返回,默认还会尝试开启严格模式。Chat Completions 仍然完整可用,但主要作为兼容选项保留。本书示例统一使用 Chat Completions 的写法,原因很实际:中文厂商提供的兼容端点,对应的正是这套写法。
tool_choice 在 OpenAI 的接口中有五种写法。auto 是默认值,由模型自己决定要不要调用;required 要求模型至少调用一个;none 禁用所有工具;{"type": "function", "function": {"name": "..."}} 指定必须调用某个函数;{"type": "allowed_tools", ...} 把可调用的工具限制在一个子集内。
parallel_tool_calls 参数可以单独关闭并行调用。某些模型在并行时偶尔会重复调用,关闭并行是快速解决问题的办法。
开启严格模式后,服务端要求参数严格符合 schema,schema 的写法也会受到限制:每个字段都要列入 required,对象类型还要显式加上 additionalProperties: false。Chat Completions 默认不开启这个模式。
Anthropic 的 Messages API 使用另一套术语,把两家的写法放在一起对照,差别最清楚。
python
import json
import anthropic # pip install anthropic
client = anthropic.Anthropic() # 从环境变量 ANTHROPIC_API_KEY 读取
tools = [{
"name": "get_daily_revenue",
"description": "查询指定门店指定日期的营业额,单位为元。仅支持查询单个门店单天数据。",
"input_schema": {
"type": "object",
"properties": {
"store_id": {"type": "string", "description": "门店编号,如 ST001"},
"date": {"type": "string", "description": "日期,格式 YYYY-MM-DD"},
},
"required": ["store_id", "date"],
},
}]
messages = [{"role": "user", "content": "查一下 ST001 店 2026-08-24 的营业额"}]
resp = client.messages.create(
model="claude-opus-5",
max_tokens=1024, # Messages API 必填
tools=tools,
messages=messages,
)
tool_use = next(b for b in resp.content if b.type == "tool_use")
print(tool_use.name, tool_use.input)
# 输出 get_daily_revenue {'store_id': 'ST001', 'date': '2026-08-24'}
# input 已经是字典,不需要再 json.loads
result = get_daily_revenue(**tool_use.input) # 函数沿用 4.2 节的实现
final = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages + [
{"role": "assistant", "content": resp.content}, # 原始回复整体回填
{"role": "user", "content": [ # 结果装进 user 消息
{"type": "tool_result", "tool_use_id": tool_use.id,
"content": json.dumps(result, ensure_ascii=False)}
]},
],
)
print(next(b for b in final.content if b.type == "text").text)两家的差别从参数名一直延伸到请求头。参数 schema 的字段,Anthropic 叫 input_schema,OpenAI 叫 parameters;响应里没有独立的 tool_calls,调用请求是 content 列表中 type 为 tool_use 的块,input 直接就是对象,不需要再解析一次。
结果回填的做法也不同。Anthropic 没有 role: "tool" 这种角色,要把 tool_result 块放进一条 user 消息,再通过 tool_use_id 与调用请求对应;表示停止原因的字段叫 stop_reason: "tool_use",而不是 finish_reason: "tool_calls"。
tool_choice 在 Messages API 里传的是对象:{"type": "auto"}、{"type": "any"}、{"type": "tool", "name": "..."} 依次表示自动、任意调用一个、指定调用某个,另外还有 none。并行的开关写成 disable_parallel_tool_use: true,与 OpenAI 的 parallel_tool_calls 方向相反;严格模式也有,写法同样是 strict: true。
协议层面还有两处不同。Messages API 把 max_tokens 列为必填,每次请求还要在请求头里带上 anthropic-version。另外,启用工具后那段隐藏系统提示占用多少固定 token(4.1 节提过),官方文档也给出了具体数字。
第三套术语来自 Google 的 Gemini,到 2026 年它的主接口是 Interactions API。工具声明直接放在 tools 数组里,type、name、description 和 parameters 四个字段平级排列,没有 OpenAI 那层 function 包装。
Gemini 返回的回复是一个步骤列表,按发生的先后顺序排列,记录的是模型生成的步骤,包括思考和函数调用两类。其中函数调用步骤的 type 是 function_call,arguments 直接是对象,还额外带一个 id。函数执行结果不在这份返回里,需要开发者自己送回去。回填的做法不是往历史里追加消息,而是发起一次新的请求,在 Gemini 里叫一次交互:input 里放一条 function_result,name 和 call_id 与调用对应,结果包装成一段内容,末尾再带上 previous_interaction_id 指向前一次交互。两次交互通过它连成一次会话,会话状态保存在服务端。下文的对照表把 Gemini 和 OpenAI 的写法并排列出。
也可以自己管理历史。把 Interactions API 的 store 参数设为 false,服务端就不再保存会话状态,之后每次请求都把完整历史原样重新发送,模型生成的步骤一字不改地带上。这与 OpenAI 默认每次重发 messages 的做法是一样的。
要强制调用,需要在 generation_config 里设置 tool_choice,填 any 表示强制调用,另有一档 validated,要求模型按 schema 填写参数。并行调用是原生支持的,一次回复可以包含多个 function_call 步骤;官方还演示过组合式调用,在同一次请求里连续调用了查天气和设温度两个函数。
国内模型方面,主流厂商都提供了 OpenAI 兼容端点,4.2 的回路改三处就能迁移过去。DeepSeek 的 tools 结构和回填方式与 OpenAI 相同,严格模式使用它单独提供的 beta 端点,在参数里加上 strict: true 即可,端点地址以官方文档为准。官方说明还指出,从 DeepSeek-V3.2 开始,思考模式下也可以调用工具。
Kimi 分国内、国际两套端点,两套控制台各自发放密钥,端点地址请查官方文档。kimi-k3 支持工具调用,但工具名必须符合正则 ^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$,总长度为 1 到 128 个字符,且第一个字符不能是数字。比起 OpenAI 的 64 字符上限,限制宽松不少。
智谱的 glm 系列使用标准的 OpenAI SDK 写法,端点见官方文档。注意它的 temperature 只能取大于 0、小于 1 的值,填 0 会被接口拒绝,同时要求 OpenAI SDK 版本在 1.0 以上。通义千问使用阿里云百炼的兼容模式端点,路径为 compatible-mode/v1,域名因业务空间和地域而异,这一点第 1 章 1.4 讲过;qwen-plus 等型号支持 tools 参数。
兼容指的是大部分结构相同,不是每个字段都完全一致。各家厂商在 tool_choice 支持的取值、流式输出与工具调用的组合方式、错误码等细节上仍有差异。迁移时要用自己真实的回路逐项做冒烟测试,不要只跑一遍官方示例就认为没问题。
| 对照点 | OpenAI Chat Completions | Anthropic Messages | Gemini Interactions API | 中文厂商 OpenAI 兼容端点 |
|---|---|---|---|---|
| 参数 schema 字段 | function.parameters | input_schema | tools 数组平铺 name、description、parameters,无包装层 | 同 OpenAI |
| 调用请求形态 | message.tool_calls 列表 | content 块 tool_use,input 已是对象 | steps 列表内 function_call 步骤,arguments 已是对象并带 id | 同 OpenAI |
| 结果回填 | role: "tool" 消息加 tool_call_id | user 消息内 tool_result 块加 tool_use_id | 新交互 input 放 function_result,call_id 对上步骤 id,previous_interaction_id 串接 | 同 OpenAI |
| 停止信号 | finish_reason: "tool_calls" | stop_reason: "tool_use" | 无 finish_reason 对应字段,看 steps 里的步骤类型判读 | 同 OpenAI |
| 强制调用 | tool_choice 五种取值 | tool_choice 对象 auto、any、tool、none | generation_config 的 tool_choice,取值 auto、any、none、validated | 多数支持 auto 与 none,其余以文档为准 |
| 并行开关 | parallel_tool_calls 参数 | disable_parallel_tool_use 布尔 | 原生支持,一次回复可含多个 function_call 步骤 | 各厂商不一 |
| 严格模式 | strict: true | strict: true | 无 strict 字段,schema 遵从交给 tool_choice 的 validated 档 | DeepSeek 走 beta 端点,其余以各家文档为准 |
各家的接口写法会一直变化,但底层机制很稳定。为了减少接口变化带来的问题,可以在工程侧加一层很薄的适配;还有一种更彻底的办法,是让工具接入本身标准化,第 5 章的 MCP 就是为此设计的。
各家厂商分歧最大的是构造工具结果消息的逻辑,这一步值得单独抽成一个函数,上层回路只调用它。OpenAI 的实现返回 {"role": "tool", "tool_call_id": ..., "content": ...};Anthropic 的实现返回一条 user 消息,里面包含 tool_result 块。两套分支收在同一个入口里,对外只暴露统一的签名 make_tool_reply(call_id, text)。
解析调用请求这一侧的做法相同:extract_tool_calls(resp) 把 OpenAI 的 tool_calls 列表和 Anthropic 的 tool_use 块统一转换成 [(id, name, args_dict)]。这层适配的成本不过是两三个函数、百来行代码;好处是换厂商时所有改动都集中在这一处,回路的其余部分不用改。系统需要长期同时支持多个协议时,这一层可以升级为正式的协议适配层,第 5 章的 MCP 客户端承担的就是这个角色。
最小版写出来不到三十行,厂商名收进一个变量,换厂商只改这一处。
python
import json
PROVIDER = "openai" # 换厂商只改这一处,可放进配置:openai 或 anthropic
def make_tool_reply(call_id: str, text: str) -> dict:
"""把执行结果包装成当前厂商的回填消息,上层回路只调这一个入口。"""
if PROVIDER == "openai":
return {"role": "tool", "tool_call_id": call_id, "content": text}
return {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": call_id, "content": text}
]}
def extract_tool_calls(resp) -> list:
"""把两家厂商的调用请求统一成 (id, 工具名, 参数字典) 列表。"""
if PROVIDER == "openai":
msg = resp.choices[0].message
return [(c.id, c.function.name, json.loads(c.function.arguments))
for c in (msg.tool_calls or [])]
return [(b.id, b.name, dict(b.input))
for b in resp.content if b.type == "tool_use"]接回 4.2 的 run_agent 只动两行,解析循环换成 for call_id, name, args in extract_tool_calls(resp),回填换成 make_tool_reply(call_id, result),循环与历史管理都不用动。
4.4 工具执行器的工程细节
回路图里的“执行工具”这一步,在 demo 阶段只是一行 fn(**args),放到生产环境就成了一个独立的子系统。模型只负责发出调用请求,请求之后的所有事情都由执行器处理:参数不合法、外部接口宕机、执行卡住、同时收到三条请求,每种情况都要有对应的处理。这一节逐个讲执行器要做的四件事:参数校验、超时控制、错误回填和并行与串行。
校验要拦截两类错误输入。第一类是 arguments 不是合法的 JSON,官方 API 参考明确写了它可能解析失败。第二类是 JSON 本身合法,但不符合 schema:Chat Completions 默认不开启严格模式,模型完全可能传来缺字段或类型错误的参数。
这两类错误输入都不应该靠抛异常来处理,稳妥的做法是把失败信息包装成 tool 消息回填。模型读到报错,多半会自己修改参数再发一次,比整个会话直接中断好得多。校验使用 jsonschema 库(pip install jsonschema),对照同一份参数 schema 再检查一遍,代码放在后面一起看。
超时控制用来应对外部系统中难以预料的情况。工具背后可能是数据库查询,也可能是第三方接口,网络抖动或对端响应慢时,如果执行器没有超时保护,就会一直等待,整条回路随之停止。做法是用 asyncio.to_thread 把同步函数放到线程里执行,外面再用 asyncio.wait_for 限定时间,到时就停止等待。超时时长可以按工具分档:查询内部数据库设 5 秒,调用第三方接口设 30 秒,这是常见的起始值。超时后回填的文案也有讲究:与其笼统地写“执行失败”,不如写“超过 10 秒未返回,请缩小日期范围或稍后重试”,给模型一个可以照着做的下一步。
调用第三方接口较多的执行器,还应该自己做一层限速。用时间戳建立一个滑动窗口,把调用频率控制在对端限额之内;收到限流响应时,按对方返回的 retry-after 提示退避重试。主动控制频率,比收到 429 之后再被动重试更稳妥,也更节省调用次数。
写回填文案时最重要的一点:错误信息的第一个读者是模型,不是人。把 Python 的 traceback 原样回填是最差的做法,内容长、费用高,还会暴露内部细节。比较好的写法是一个简短的 JSON,error 写错误类别,message 写自然语言描述。
错误至少要分三类,处理方式各不相同。执行环境错误,例如超时、网络不通,回填后可以让模型重试或换一种方式;参数错误,例如缺字段、类型不对,把报错原文回填,让模型自己核对并修正;业务错误,例如查无此门店,这其实不算异常,而是正常的业务结果,直接作为数据回填,模型会转述给用户。再细分还有两类:401 鉴权错误要上报给人处理,429 限流要退避后重试。这两类如果也交给模型处理,只会让故障扩大。
并行还是串行执行,要提前定策略。一条响应返回多个 tool_calls 时,纯只读的工具可以并发执行,用 asyncio.gather 一次收集结果;包含写操作的调用默认串行,按模型给出的顺序逐个执行。判断依据是操作是否幂等,4.5 节详细讲。不论执行顺序如何,tool 消息都要按原 tool_calls 的顺序追加到历史里,通过 tool_call_id 保持对应关系,这样能保证模型读到的内容顺序不乱。完整的执行器代码如下。
python
import asyncio
import json
from jsonschema import validate, ValidationError # pip install jsonschema
def tool_reply(tool_call_id, payload) -> dict:
"""把任意结果包装成一条 tool 消息。"""
return {"role": "tool", "tool_call_id": tool_call_id,
"content": json.dumps(payload, ensure_ascii=False)}
async def execute_tool_call(tool_call, toolbox, schemas, timeout=10.0) -> dict:
"""执行单个工具调用。任何失败都不抛异常,错误也作为结果返回给模型。"""
name = tool_call.function.name
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
return tool_reply(tool_call.id, {"error": "invalid_arguments_json",
"message": f"参数不是合法的 JSON:{e}"})
schema = schemas.get(name)
if schema is None:
return tool_reply(tool_call.id, {"error": "unknown_tool",
"message": f"可用工具:{sorted(toolbox)}"})
try:
validate(instance=args, schema=schema)
except ValidationError as e:
return tool_reply(tool_call.id, {"error": "schema_violation", "message": e.message})
try:
result = await asyncio.wait_for(asyncio.to_thread(toolbox[name], **args),
timeout=timeout)
return tool_reply(tool_call.id, result)
except asyncio.TimeoutError:
return tool_reply(tool_call.id, {"error": "timeout",
"message": f"工具 {name} 超过 {timeout} 秒未返回,"
f"请缩小查询范围或稍后重试"})
except Exception as e: # 工具自身的异常走同一出口
return tool_reply(tool_call.id, {"error": "tool_failed",
"message": f"工具 {name} 执行出错:"
f"{type(e).__name__}: {e}"})
async def run_tool_calls(tool_calls, toolbox, schemas, read_only=frozenset()) -> list:
"""全部只读就并行执行,含写操作则按模型给出的顺序串行执行。"""
if tool_calls and all(tc.function.name in read_only for tc in tool_calls):
return list(await asyncio.gather(
*(execute_tool_call(tc, toolbox, schemas) for tc in tool_calls)))
replies = []
for tc in tool_calls:
replies.append(await execute_tool_call(tc, toolbox, schemas))
return replies假设模型传来的 arguments 缺少 date 字段,只有 {"store_id": "ST001"},required 校验会直接把它拦下来。执行器不抛异常,回填的 JSON 中 error 是 schema_violation,message 是校验库的报错原文,说明缺少 date。下一轮模型读到这条报错,通常会补上日期再发一次,任务继续进行。整个过程不需要人工介入,原本会中断的会话,变成了多一轮往返的正常流程。
execute_tool_call 始终遵循一条设计原则:不向外抛出异常,任何失败都转换成给模型看的信息。这样一来,执行器成为会话流程的一部分,错误不再是回路的终点,而是模型下一轮判断的依据。把它接回 4.2 的 run_agent,替换中间手写的执行部分,回路就具备了用于生产的基本条件。权限校验、沙箱隔离这类安全功能同样属于执行器的职责,留到第 13 章讲安全时再补充。
执行器只负责执行,而不少问题的根源不在执行这一层,而在工具本身的设计。参数拆分得零碎、描述含糊、返回一大段原始 JSON,执行器再完善也弥补不了。
4.5 工具设计法
设计工具可以参考产品设计的思路:它有明确的使用对象,就是模型;有文档,就是描述和 schema;有交互界面,就是返回的文本;也有迭代周期,根据调用数据逐步改进。不同的是,产品的用户会直接反馈问题,而模型只会选错工具、填错参数,问题体现为不容易查的指标,所以设计者要更主动地检查。
描述写得好不好,检验标准只有一条:模型读完这段文字,能不能直接判断眼前这个请求该不该用这个工具。三项内容缺一不可:什么场景下用、具体做什么、边界在哪里。有一组常见的对照可以说明:反例是名为 book、描述只有“Book something”的定义,模型既不知道要订什么,也不知道需要哪些参数;改成名为 reserve_table 的正例,效果就完全不同。完整定义如下。
json
{
"type": "function",
"function": {
"name": "reserve_table",
"description": "为顾客创建餐厅订位。需要餐厅编号、用餐时间和人数。仅支持未来 30 天内的订位,每单最多 20 人。",
"parameters": {
"type": "object",
"properties": {
"restaurant_id": {
"type": "string",
"description": "餐厅编号,格式为 RST 开头加四位数字,如 RST0012"
},
"datetime": {
"type": "string",
"description": "用餐时间,ISO 8601 格式,如 2026-08-25T19:00:00+08:00"
},
"party_size": {
"type": "integer",
"description": "用餐人数,1 到 20 之间的整数"
}
},
"required": ["restaurant_id", "datetime", "party_size"]
}
}
}这段描述把动作和对象说得很明确,每个参数都附有格式示例和取值范围,哪些必填一目了然,模型照着填基本不会出错。描述里同时写明了边界:只能预订未来 30 天内的座位,每单最多 20 人。请求超出范围时,模型会先向用户确认,而不是等工具调用之后才收到业务报错。
同一批工具的描述之间,要避免高度重叠。两份描述都适用于同一个请求时,模型选哪个就不确定了,这时宁可把两个工具合并成一个,用参数指定要执行的动作,也不要保留语义重叠的两个工具。工具名也有简单的原则:动宾结构比单独的名词清楚,get_daily_revenue 一看就知道动作和对象,换成 revenue 就只能靠描述补充说明了。
参数怎么拆分?有三条原则。第一,互斥的布尔参数换成枚举:toggle_light(on, off) 允许出现两个都为 true 的自相矛盾输入,换成一个 state 枚举,这类错误就不会出现。第二,不要让模型重复代码已经掌握的信息。以退款为例,order_id 在上一轮已经查到,提交退款时再让模型原样重复一遍,只会多一个抄错的环节,在安全上也没有任何好处。应该把 order_id 从参数里去掉,由调用方代码注入,模型只填写它真正需要判断的内容,比如退款原因。第三,必填参数尽量少,可选参数一律给默认值,模型少填一个参数,就少一次出错的可能。
工具总数也有一条经验值:通常控制在 20 个以内。原因是工具一多,模型的出错率就会上升;这是工程经验,不是硬性规定。官方现在的口径也是如此:OpenAI 的函数调用指南建议一次对话开始时提供的函数不超过约 20 个,并注明这只是软性建议。工具越多,模型越容易选错,在没有达到任何硬性上限之前,准确率就已经开始下降。Cursor 在产品层面直接做了限制,把各 MCP 服务器合计的激活工具数限制在几十个的量级,超出的工具不会随请求发给模型。
工具数量确实很大时,2026 年的新办法是工具检索。OpenAI 的 tool_search(需要 gpt-5.4 及以上模型)和 Anthropic 的 Tool search tool,都可以让模型按需检索工具定义,不需要把整份清单随请求一起发送。
这两家之外的模型没有内置这项能力,替代办法是借用第 6 章的向量检索,拿它检索工具定义。具体做法是给每份工具描述计算嵌入(把文本转成可以比较语义相似度的数值),存入索引。请求进来时,先用任务描述检索一轮,只把最相关的几份定义放进请求,其余的不进上下文。自己实现这个方案,要多注意一个问题:漏召回。没有被检索到的工具,模型从来没有见过,即使选错了也不会留下调用日志,排查时就缺少一条线索。常见的补救办法有两个:放宽 top-k,一次多取几份;再给高频工具保留固定名额,不经过检索。
工具库继续增大时,语义检索的效果也会下降。研究已经测量过这条下降曲线:工具超过一百个之后,靠描述检索的选对率持续下降,名称相近的工具之间的干扰越来越明显。工程上的对策是把选工具这件事从模型手里往上收一层:在工具前面设置一个网关,全部工具清单都放在网关后面,模型只能看到两个元工具,一个按需求搜索工具,一个使用凭据执行工具。选工具变成了查资料,全量清单由网关管理,提示里工具部分的开销也就有了上限。
返回结果怎么组织,只要记住使用者是模型就行。字段名最好能自我说明,revenue_yuan 比 value 好,单位直接写在字段名里;错误要带语义,除了 store_not_found,再配一句自然语言说明;大结果要截断,先返回前 20 条,再带上 total_count: 358,模型觉得不够时会再发一次带分页参数的请求。返回内容同样按输入 token 计费,把几 MB 的原始 JSON 整体回填,既增加费用,又容易把关键信息淹没,这笔开销在第 7 章讲上下文预算时还会再算。
也不要把“替模型处理”做得过头。返回完整的结构化条目,让模型自己做语义上的过滤和排序,常常比代码预先汇总好的摘要更有效。取舍的原则是:语义判断交给模型,机械的格式整理交给代码。还有一个问题要注意:模型有时会凭训练数据里的旧知识直接回答,绕过工具、不查实时数据,看起来是答案说得很顺,数据却是旧的。对策有两个:一是在描述里写明“必须调用本工具”的场景,二是在评测里专门加入“该调用而未调用”的检查项,第 12 章会展开。
还有一个常见的选择:一段固定的业务规则,应该写进系统提示,还是做成工具?判断依据是信息的来源。规则只需要模型知道,比如回复要带工号、语气要客气,写进系统提示就行;规则需要外部数据或真实动作来支撑,比如判断会员等级要查库、优惠要真正核销,就做成工具。介于两者之间的情况,可以这样处理:提示能解决八成的,不要急着做成工具,因为 schema、校验、错误回填都是需要维护的代码;剩下反复出错的两成,再固化成工具,用结构化的约束弥补提示的不足。
修改提示只需要几秒钟,修改工具则要经过测试。成本差别明显,能用提示解决的就先不要动工具;从提示逐步固化成工具,也就成了智能体系统自然的演进路径。
比固化更进一步的做法,是让模型直接编写程序来串联工具。调用顺序写成一小段脚本,中间结果保存在执行环境的变量里,不返回上下文;脚本运行在一个默认拒绝、只允许白名单工具的解释器里,每次外部调用之前还可以加一道审批。工具多、流程复杂时,这种做法在 token 和轮次上都更节省,代价是要多维护一个受控的代码执行环境。它在工业界的具体形态,第 21 章分析编码智能体时详细讲。
存量系统较多的团队有两个现成的起点。一个来自接口文档:REST 服务的 OpenAPI 规范里,接口名、参数和说明都是现成的,可以用程序自动转换成工具定义。转换后不要直接上线,机器生成的描述比较生硬,要按这一节的标准人工改写。另一个来自代码:在 Python 项目里把参数声明成 pydantic 的模型类,model_json_schema() 可以直接生成合规的 schema,字段描述写在 Field 上统一维护;Instructor 这类库把这套做法连同结构化输出一起封装成了框架,第 3 章讲的原生约束够用时,不需要为此引入框架。
怎么保证幂等?先说明概念:幂等(Idempotency),指同一个操作无论执行一次还是多次,最终结果都相同。查询天然是幂等的,写操作大多不是,连续下两次订单就会产生两个不同的结果。在智能体场景里,重复执行的概率比一般系统高得多:模型认为上一轮没成功,就再发一次;执行器超时后自动重试;用户换一种说法重新提出需求,这些情况都可能让同一个写操作执行两遍。
在工具设计层面可以设置三层保障。第一层:写操作带上调用方生成的 idempotency_key,服务端按这个键去重,相同键的请求只生效一次,支付和电商系统早就采用这种做法。第二层:先查后写,创建之前先用唯一键查询是否已存在。第三层最可靠:把“创建”和“确认提交”拆成两个工具,中间留出时间让模型核对,也可以插入人工审批。关键写操作怎么配合人机协同,第 15 章展开。
最后一点:把工具当作产品来运营。上线只是第一版,调用日志就是用户反馈,要定期查看四个信号。长期没有被调用的工具,不是描述没写清楚,就是确实用不上;误选率一直很高的,大多与其他工具的描述重叠;参数校验失败较多,多半是描述或 schema 在误导模型;超时率较高,原因通常在工具的实现或拆分粒度上。
修改描述就等于修改行为,又没有编译器检查,这是工具迭代最大的风险。要做两方面的保障:破坏性改动要更换工具名或提高版本号,让旧的调用可以追溯;修改之前,再用第 12 章的任务集跑一遍回归测试。
工具多到一定规模后,还有另一种做法:技能。技能不是又一个工具,而是把步骤说明打包起来、按需加载的做法:平时只把名字和描述提供给模型,描述匹配时才加载正文,这套机制在第 21 章 21.5 展开。这样一来,是否加载完全取决于描述,描述就相当于路由表。两份描述一旦重叠,路由就有歧义,同一个请求这次匹配到这个技能,下次匹配到另一个。问题不在于偶尔误触发一次,而在于开发者无法预期哪个技能会被实际使用。使用技能的目的,就是让它能可靠地触发。
落实到具体做法,有三条:往技能库里加新技能之前,先把现有描述检查一遍,发现重叠的就划清边界;对重要的技能,在描述旁边配几条正反例,写出该触发和不该触发的说法各几条;描述修改后重新测试一遍,触发结果与预期不符就是回归。
4.6 运行环境
工具描述可以说明每个工具,但管不了智能体运行的环境:智能体在哪个仓库里运行、这个仓库有哪些约定,都不在工具描述的范围内。2026 年前后,工程界在环境方面积累了不少新经验,起因是各家工具厂商都开始让智能体在真实的代码仓库里工作。做法是在仓库根目录放一份 AGENTS.md,写明智能体在这个仓库里工作要遵守的约定。这个文件现在已经是跨工具的通用标准,OpenAI Codex、Gemini CLI、Cursor 都支持,开放格式页上登记采用它的项目,已经数以万计。
写法上有一个反面案例:把全部规则写进一个很长的文件。它会占用任务本来需要的上下文,智能体也分不清哪些规则仍然有效,而且内容过时的速度比维护的速度还快。比较稳妥的写法是一份约一百行的目录式文件,细节分散到 docs 目录下的专题文档里,智能体按需读取。目录始终保留,细节用到时才读取,这就是渐进披露(progressive disclosure)在仓库层面的应用。
这份百行文件里该写什么,各家的实践已经总结出一张清单。排在最前面的是命令:构建、跑测试、类型检查和格式化,每条都写成可以整段复制到终端的形式,参数带全,智能体照着执行不会出错。其次是代码风格和项目约定:命名规则、错误处理方式和哪类改动要经过哪种评审。再往后是项目结构:哪类代码放在哪个目录、入口在哪里、测试在哪里。技术栈要写明版本,写“Python 3.12”比只写“Python”更可靠,模型据此给出的依赖说明准确得多。写得越具体越好,“运行测试”不如“跑 pytest tests/ -x -q”。
拿运营助手的仓库写一份样例,长这样(节选,细节按各自仓库定)。
text
# 运营助手仓库约定
## 常用命令
- 跑测试:pytest tests/ -x -q
- 类型检查:python3 -m mypy src/
- 格式化:python3 -m black src/ tests/
## 代码约定
- 工具函数必须带类型注解和 docstring,docstring 会直接成为工具描述
- 工具的错误一律回填给模型,不在执行器里抛异常中断会话
- 修改工具描述先过 tests/test_tool_registry.py 的回归用例
## 项目结构
- src/agent/:回路与执行器
- src/tools/:工具实现,新增工具在这里注册
- tests/:pytest 用例与评测任务集
## 技术栈
- Python 3.12,依赖清单钉死版本,见 requirements.txt
- 模型走 OpenAI 兼容端点,模型名从环境变量 AGENT_MODEL 读命令整段照抄就能跑,版本和入口都写在字面上,智能体不用猜。
凡是能交给机器检查的规则,就不要只写在文档里。用 linter 和结构性测试来保证架构约定,报错信息则有意写成模型可以直接照着修改的表述。报错一旦触发,修正所需的信息就进入了上下文,规则写一次,处处生效。
环境管理的另一件事是凭证。智能体要调用模型服务、连接数据库,密钥不能写在代码里,更不能放进提示词,通行做法是从环境变量或密钥管理服务读取,代码里只保留变量名。多套环境各自使用各自的密钥,开发库的密码与生产库分开,配置跟随环境,不跟随代码。第 5 章手写服务器时,凭证是从环境变量读取的,那条规则在这里扩展为整套环境的管理。
配置跟随环境,具体来说是三套环境各有一份配置。开发环境就是在自己机器上改代码、随手运行的那一套,数据用的是脱敏样本,出错影响不大;用于评测的是隔离环境,12.1 会专门讲它的规则;真正服务用户的是生产环境,改动只能通过发布流程。三套环境使用同一份代码,差别全在配置:数据库地址、模型名和开关参数,逐项写在各自的配置文件里。智能体在这套分层中的权限也要事先确定:开发期可以自由读写,评测期只读并记录,生产期的每个动作都要接受审计。
还有边界和可复现两个要求。智能体会直接操作文件系统和网络,运行环境必须设定边界:开发期把可写范围限制在工作目录内,生产期使用容器,出站网络访问设置白名单,这其实就是第 13 章要讲的沙箱,落到了日常开发里。可复现是另一个要求:依赖锁定版本,环境能根据一份清单重新构建,智能体今天能完成的任务,换一台机器、隔一个月仍然能完成;评测在同一环境里跑两轮,分数才有可比性,第 12 章讲的评测环境隔离,说的就是这条要求落到评测里的样子。
最后一点容易被忽略:智能体对自身运行环境的了解。它不知道自己在哪台机器上、装了哪些包、能连接哪些服务,这些信息要么写进 AGENTS.md,要么提供一个查询环境的工具,开始工作前先自检一遍。环境信息给足了,智能体发现环境对不上时会先报告,而不是继续执行。
环境问题会随智能体数量增加而变多。一个智能体在工作目录里运行,规则容易制定;三个智能体同时在同一个仓库工作,就要防止写文件时互相覆盖、跑测试时占用同一个端口,日志要分目录保存,出了问题才能分清是谁写的。第 10 章编排模式里,监督者给各个帮手分配的不只是任务,还有环境中的位置。单智能体阶段,环境只是运行背景;多智能体阶段,环境本身成为需要设计的对象,第四部分会详细展开。
还有一条关于依赖选择的建议:优先选择成熟、可组合的技术。API 稳定、文档齐全、在训练语料中常见的库,智能体用起来更可靠。有时候,为一个简单功能自己重写一段带测试的实现,比迁就一个行为不透明的外部库更划算。
这些做法基于同一个判断:智能体表现的上限,有很大一部分不取决于模型,而取决于它所处环境的可读程度。工具描述、报错文本和仓库文档,都是这个环境的一部分。
本章小结
- 4.1 工具定义:模型读到的只有名字、描述和参数 schema 三部分,描述要交代使用场景、做什么和边界;定义随每次请求发送、按 token 计费,当配置管理,进 git、改动过评审。
- 4.2 调用回路五步:定义、发起、执行、回填、续写;arguments 是 JSON 字符串,用前先 json.loads;回填靠 tool_call_id 配对,带 tool_calls 的 assistant 消息必须先进历史,漏掉下一次请求直接 400。
- 4.3 各家方言:机制一致,差异在字段名与消息结构,OpenAI 的 parameters、tool_calls、finish_reason 对应 Anthropic 的 input_schema、tool_use 块、stop_reason;迁移时用真实回路逐项冒烟测试。
- 4.4 执行器:任何失败不抛异常,错误包成 tool 消息回填;错误分三类,环境错误可重试、参数错误回填原文让模型自纠、业务错误当正常数据回填;只读工具并行、写操作串行,tool 消息按原 tool_calls 顺序追加。
- 4.5 工具设计:互斥布尔换枚举、必填参数尽量少、可选参数给默认值;工具总数控制在 20 个以内(官方软建议);写操作的幂等有三层保障,idempotency_key 去重、先查后写、创建与确认拆成两个工具。
- 4.6 运行环境:AGENTS.md 写成约一百行的目录式文件,命令、约定、结构和技术栈版本写具体;三套环境同一份代码,差别全在配置;智能体表现的上限,很大一部分取决于环境的可读程度。
模型从头到尾只做三件事,读取定义、生成参数和处理执行结果,智能体的行动能力来自整套工程结构;第 5 章的 MCP 和第四部分的多智能体协作,底层用的都是这一章的机制。
参考资料
《Agentic GraphRAG》(Anthony Alcaraz,O'Reilly 2026 中译)第 4 章(工具检索的规模化研究线:语义检索随工具数的退化曲线、网关蒸馏为搜索与执行两个元工具),见 txt-ref-zh/agentic-graphrag/
《AI Agents: The Definitive Guide》(Nicole Königstein,O'Reilly 中译)第 7 章(程序化工具调用:模型写程序串工具、默认拒绝的解释器、中间结果不回上下文),见 txt-ref-zh/ai-agents-definitive-guide/
《AI Agents in Action》(Michael Lanham,Manning 2024 中译,第 1 版)第 5 章(OpenAPI 规范转工具定义的接入捷径与人工改写纪律;工具返回完整结构化条目让模型自行语义过滤的正向启发式),见 txt-ref-zh/ai-agents-in-action/
《Build a Multi-Agent System (from Scratch) With MCP and A2A》(Val Andrei Fajardo,Manning MEAP)第 2 章(pydantic 模型类派生工具参数 schema 的路径),见 txt-ref-zh/multi-agent-mcp-a2a/
《Agentic Architectural Patterns for Building Multi-Agent Systems》(Ali Arsanjani、Juan Pablo Bustos,Packt 2026-01)第 8 章(客户端主动限速器:时间戳窗口压频率、retry-after 退避),见 txt-ref-zh/agentic-architectural-patterns/
OpenAI 文档 Function calling(tools、tool_choice 取值、strict、parallel_tool_calls、Responses API 现状;开局可用函数控制在 20 个以内的官方软建议、保持初始函数集小以提升准确率;tool_search 按需检索加载工具定义、需 gpt-5.4 及以上模型)https://developers.openai.com/api/docs/guides/function-calling 核实于 2026-08,2026-09-02 复核补 tool_search
OpenAI API 参考 Chat Completions(tool_calls 结构、arguments 可能为非法 JSON、role 为 tool 的消息、tool_choice 五种形态、工具名 64 字符限制)https://developers.openai.com/api/docs/api-reference/chat/create 核实于 2026-08
OpenAI 文档 Deprecations(Assistants API 2025-08-26 公布废弃、2026-08-26 关停)https://developers.openai.com/api/docs/deprecations 核实于 2026-08
OpenAI 公告 Function calling and other API updates(2023-06-13 函数调用与 gpt-3.5-turbo-0613、gpt-4-0613)https://openai.com/index/function-calling-and-other-api-updates/ ,核实于 2026-08-29
OpenAI 公告 New models and developer products announced at DevDay(2023-11-06 并行函数调用、GPT-4 Turbo、JSON mode)https://openai.com/index/new-models-and-developer-products-announced-at-devday/ 核实于 2026-08
Hugging Face 数据集页:NousResearch/hermes-function-calling-v1(Hermes 2 Pro 函数调用与结构化输出能力的训练混合集,11,578 行)https://huggingface.co/datasets/NousResearch/hermes-function-calling-v1 核实于 2026-09-17
Anthropic 文档 Tool use with Claude(input_schema、tool_use 与 tool_result 块、tool_choice 对象、disable_parallel_tool_use、strict、隐藏系统提示的 token 开销)https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview 核实于 2026-08(2026-08-30 复核仍一致;2026-09-17 三核:Opus 5 下 tool_choice 为 auto/none 时系统提示 286 token、any/tool 时 406 token,两组为各自独立总量口径,非叠加)
Anthropic 文档 Tool search tool(按需检索并加载工具定义:成百上千个工具的场景,超出的工具延迟加载,30 到 50 个以上工具选择准确率开始退化)https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool 核实于 2026-09-02
Google 文档 Function calling(Interactions API 扁平声明、function_call 步骤与 function_result 回填、previous_interaction_id、store=false 无状态重放、generation_config.tool_choice 及 validated 档、并行与组合式调用)https://ai.google.dev/gemini-api/docs/function-calling 核实于 2026-08(2026-09-17 复核:interactions.create 响应只含模型生成的步骤 thought 与 function_call,function_result 步骤由开发者随下一交互的 input 回填)
DeepSeek 文档 Tool Calls(OpenAI 兼容端点、strict 的 beta 端点、V3.2 起支持)https://api-docs.deepseek.com/guides/tool_calls 核实于 2026-08
Kimi 开放平台文档 Tool Use(国内与国际两套端点、kimi-k3、工具名正则,事实核于国际平台文档页,端点地址见该页)https://platform.kimi.ai/docs/api/tool-use 核实于 2026-08,2026-09-03 复核两套端点均为 {0,127} 口径
智谱开放平台文档 OpenAI API 兼容(glm-5.3、temperature 为开区间 (0,1)、OpenAI SDK 不低于 1.0.0,端点地址见该页)https://docs.bigmodel.cn/cn/guide/develop/openai/introduction 核实于 2026-08
阿里云 Model Studio 文档 Compatibility of OpenAI with DashScope(OpenAI 兼容的调用形态、qwen 系列支持 tools,端点地址见该页)https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope 核实于 2026-08
《Build an AI Agent (From Scratch)》第 3 章 Enabling actions: Tool use(工具调用五步、工具定义最佳实践对照、错误处理分类、工具数量控制在 20 个以内的通用准则),见 txt-ref-zh/build-ai-agent-from-scratch/
《AI Agent 开发实战》第 2 章 LLM 和 Agent(functions 到 tools 的演进、并行函数调用、tool_calls 消息协议),见 txt-ref/ai-agent开发实战/
《Build a Multi-Agent System (from Scratch)》第 2 章 Working with Tools(函数签名转 JSON Schema、参数校验与错误回填的实现),见 txt-ref-zh/multi-agent-mcp-a2a/
《Building Reliable AI Systems》(Manning)第 7 章(agent-legible 环境四实践、AGENTS.md 目录式写法、机械不变量、可组合技术偏好),见 txt-ref-zh/building-reliable-ai-systems/(全书译文,数字保真核对通过)。
《Claude Code Architect》第 6 章(技能描述工程:触发测试三种说法都该触发、误触发测试相近请求不该触发)、第 8 章(技能碰撞:描述重叠带来时触时不触与不可预言行为、开发者无法指望哪个技能会触发而可靠触发是安装技能的意义、存量技能库定期逐对读描述查碰撞),见 txt-ref-zh/claude-code-architect/。
AGENTS.md 开放格式官网(跨工具约定,Codex、Gemini CLI、Cursor 等读取仓库根目录的 AGENTS.md),https://agents.md/ ,核实于 2026-08。
Max Gfeller《Building Agentic Applications with CrewAI and MCP》(Manning,MEAP)第 1 章(工具数量的厂商侧印证:OpenAI 单请求 128 个工具的接口上限且性能更早退化、Cursor 激活 MCP 工具总数限 40——书方写作时口径;2026-09-01 复核,OpenAI 当前指南只给“开局少于 20 个函数”的软建议、未标 128 硬上限,Cursor 官方文档已不再标注具体数字,故正文按当前口径转述),见 txt/crewai-mcp.txt(英文原文,第 1 章,行 356-371)