Python Agent 工具调用最佳实践与避坑
工具调用到底在做什么
先把词统一。业界把「让模型调用你写的函数」这件事叫 function calling 或 tool calling,两者基本同义。一个完整的回合包含三件事:
- 工具定义:你用 JSON Schema 告诉模型有哪些函数可用,每个函数叫什么、干什么、收什么参数;
- 工具调用:模型判断需要外部数据或动作时,不再输出自然语言,而是返回一个结构化调用请求,指明函数名和参数;
- 工具结果:你的代码真正执行这个函数,把结果按约定格式回传,模型基于结果继续推理或作答。
关键认知是:模型从不执行任何东西,它只负责生成一个调用意图。真正跑代码的是你的进程。这意味着所有校验、权限、限流、审计的责任都在你这边。很多人第一反应是「模型会调用我的函数」,实际上是「模型会请我调用我的函数」,主动权一直没交出去。
描述写得越具体,工具被用对的概率越高
Anthropic 在 2025 年 9 月那篇《Writing effective tools for agents》里给了一个定位:工具是确定性系统与非确定性 Agent 之间的一种新契约。给开发者写 API 和给 Agent 写工具,是两种不同的工作。
官方文档里最重的一条建议是:描述是工具性能第一决定因素,建议每个工具至少写三到四句话,覆盖这些内容——它做什么、什么时候该用、什么时候不该用、每个参数的含义和对行为的影响、有什么已知限制或不会返回什么。
官方甚至直接给了好坏对照。差的是「Gets the stock price for a ticker.」这种一句话;好的版本会写清楚:返回的是主要交易所的最新成交价、单位是美元、仅在用户询问某只股票当前或最近价格时使用、不提供公司的其他信息。后者信息量并不大,但每一句都在减少模型的猜测空间。
归纳成可直接执行的规则:
- 把「不该用」也写进去。写清楚什么时候该用另一个工具。检索类工具尤其需要——
search_products的描述里直接说明「浏览类目请用 list_categories」,能显著减少选错工具。 - 用命名空间给工具分组。跨多个服务时,把服务名作为前缀,例如
github_list_prs、slack_send_message。工具库变大、或者启用按需加载之后,这一点从「好看」变成「必需」。 - 合并同类操作,减少工具数量。不要为每个动作单独造一个工具(create_pr、review_pr、merge_pr),而是合成一个工具加一个
action参数。工具越少,选择歧义越小。 - 参数名用蛇形命名,只用字母数字和下划线。LangChain 文档提醒过,部分 provider 会因为空格或特殊符号直接报错;Anthropic 对工具名的约束是
^[a-zA-Z0-9_-]{1,128}$。
把约束放进 Schema,而不是留给提示词
能用 schema 表达的约束就不要用自然语言叮嘱。枚举值、数值范围、必填项,这些写在 schema 里是模型生成时的硬约束,比在系统提示里写一句「limit 不要超过 50」靠谱得多。
{
"name": "search_products",
"description": "Search the product catalog. Use this when the user names or describes a product. NOT for category browsing - use list_categories for that. Returns at most `limit` matches with id, title and price.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search terms, max 100 chars"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
"category": {"type": "string", "enum": ["electronics", "books", "clothing"]}
},
"required": ["query"]
}
}
三种主流 provider 都能从类型注解自动生成 schema,所以 Python 侧的写法可以很干净:
- LangChain:
@tool装饰器把类型注解转成输入 schema,函数 docstring 自动成为工具描述;复杂输入用args_schema传 Pydantic 模型或原始 JSON Schema;类型注解是必须的,缺了会直接报错。 - OpenAI Agents SDK:
@function_tool包装普通 Python 函数,函数签名和 docstring 决定 schema,没有类型的参数需要显式声明类型映射。 - PydanticAI:直接用 Pydantic 模型描述输入,校验在框架层完成。
还有一个能明显提升复杂工具可靠性的手段:给出调用样例。Anthropic 支持在工具定义里附 input_examples,填一组符合 schema 的示例输入,对嵌套对象、可选参数多、格式敏感的工具有帮助。文档里的建议顺序是——描述优先,示例作为复杂工具的补充。
参数校验:永远不要相信模型给的 JSON
schema 是给模型看的提示,不是运行时的担保。除了 OpenAI 的严格模式(下面单独说),没有任何 provider 承诺参数一定符合 schema。所以分发之前必须有独立的一层校验,这一步不能省。
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class SearchArgs(BaseModel):
query: str = Field(min_length=1, max_length=100)
limit: int = Field(default=10, ge=1, le=50)
category: Literal["electronics", "books", "clothing"] | None = None
async def dispatch(name: str, raw_args: dict[str, object]):
if name != "search_products":
return {"is_error": True, "message": f"unknown tool: {name}"}
try:
args = SearchArgs.model_validate(raw_args)
except ValidationError as exc:
# 把校验错误原文回传,模型据此纠正重试
return {"is_error": True,
"message": f"invalid arguments: {exc.errors()}"}
return await search_products(**args.model_dump())
两个细节值得强调。第一,校验失败要把具体哪里错了回传给模型,而不是返回一句「参数错误」。模型拿到字段级的错误说明,下一轮自我纠正的成功率明显更高;只给一句笼统的错误,它往往会原样再试一次。第二,别在校验层做「宽松修正」——把 "50" 悄悄转成 50 看着贴心,但会把上游数据质量问题掩盖掉,真正该修的地方反而查不出来。
OpenAI 的严格模式,以及它和并行调用的冲突
OpenAI 侧有一个特殊机制:函数定义里可以设 "strict": true,让模型输出的参数严格符合你给的 schema(前提是 schema 落在其支持的 JSON Schema 子集内)。官方文档也明确了分工建议——要把模型接到你的工具和数据上,用 function calling;要约束模型回复用户时的结构,用结构化输出。
但这里有一条必须知道的限制:严格模式与并行工具调用互斥。开启并行调用时,模型会回退到非严格 schema。如果你要求参数百分百符合 schema,只能二选一——要么关掉并行,要么开着并行然后自己用 Pydantic 之类的工具做事后校验,接受偶发无效调用作为重试成本。Anthropic 和 Gemini 没有这个限制。
错误处理:把异常变成模型能读懂的信息
这是新手最常犯的一类错误:工具函数里抛异常,异常一路穿透整个 Agent 循环,进程直接挂掉。生产环境里工具失败是常态——接口限流、数据库超时、上游返回脏数据。正确的做法是把失败转成结构化的、模型可理解的结果,让它有机会换个参数、换个工具或者如实告诉用户做不到。
Anthropic 的做法很明确:tool_result 支持 is_error: true 字段。并行调用时还有一条容易忽略的规则——如果你选择不执行某个调用(比如串行执行时前一个已经失败),仍然要为它返回一个 tool_result,标记为错误并说明原因,不能直接跳过。
results = [
{"type": "tool_result", "tool_use_id": "toolu_02", "is_error": True,
"content": "Not executed: the preceding write_file call failed."}
]
落到 Python 侧,一个可用的分层策略是:
- 可重试的瞬时错误(网络抖动、429、503):在工具内部做有限次退避重试,重试仍失败再上报。这一层不该由模型决策,模型对「重试」没有时间概念。
- 参数类错误(枚举值不对、ID 不存在):直接回传错误原文和可选值范围,让模型纠错重试,这是它最擅长的一类修复。
- 业务规则拒绝(额度不足、权限不够):作为正常结果返回,用清晰的语言描述原因。这不是 bug,是有信息量的结果。
- 未知异常:捕获后转成统一的错误结果,同时打日志和上报。给模型的信息应控制在「这对下一步有什么用」,不必把完整堆栈塞进上下文——那只会白烧 token。
另外务必设一道循环上限。没有最大轮次和 token 预算的 Agent,卡住时就是无限循环,成本曲线会非常难看。常见做法是设一个十几轮的硬上限,超限时把当前状态和已获得的信息交给一个「收尾」提示,让模型基于已有信息给出部分答案,而不是直接报错。
并行调用:语义差异比语法差异更麻烦
并行工具调用的收益是实打实的。一次响应里带上多个独立调用,可以省掉多轮往返;实测数据里,多步任务的整体延迟下降幅度在 40% 到 70% 区间。但这里有个常见误解:「并行」指的是请求层面的批量,不是你实现了并发。模型只是在一个回合里发出了 N 个调用请求,真正并发执行是你的活。很多人开了这个开关却看不到任何提速,原因就是分发循环里还在逐个 await。
三个 provider 的 API 语义差别不小,照着 OpenAI 写的代码直接搬到 Anthropic 上会静默出错:
| 对比项 | OpenAI | Anthropic | Google Gemini |
|---|---|---|---|
| 关闭并行的开关 | parallel_tool_calls=false | tool_choice 里的 disable_parallel_tool_use=true | 限制为单个允许的工具名 |
| 响应结构 | message.tool_calls 数组 | content 里多个 tool_use 块 | 多个 functionCall part |
| 结果关联键 | tool_call_id | tool_use_id | 按位置顺序对应 |
| 结果回传方式 | 每个调用一条 role: "tool" 消息 | 全部结果放在同一个 user 消息里 | 同一 user 消息的 parts |
| 严格 schema + 并行 | 互斥 | 可共存 | 可共存 |
| 单回合内链式依赖调用 | 不支持 | 不支持 | 支持 |
几条容易踩的规则:
- OpenAI 的
arguments是字符串,不是对象。必须自己json.loads。这一条每年都在坑新人。 - Anthropic 要求所有
tool_result放在同一条 user 消息里,每条用tool_use_id对齐;分散到多条消息或者漏掉一个,接口直接返回 400。 - Anthropic 的
tool_use块会和text块混在同一个响应里——模型会边解释边调用。分发前必须先按类型过滤,否则会在文本块上取.input然后崩掉。 tool_result块要排在消息里所有文本内容之前。
Python 侧的标准写法是用 asyncio.gather 并发分发,并且用信号量给并发数设上限——上限应该参照最慢的下游服务,而不是模型的发散意愿:
import asyncio, json
SEM = asyncio.Semaphore(8) # 按下游最慢服务的承受能力设定
async def run_tool(name: str, args: dict, tools: dict):
async with SEM:
return await tools[name](**args)
async def dispatch_all(tool_calls, tools: dict):
async def one(call):
# OpenAI: arguments 是 JSON 字符串
args = json.loads(call.function.arguments)
try:
out = await run_tool(call.function.name, args, tools)
return {"role": "tool", "tool_call_id": call.id,
"content": json.dumps(out, ensure_ascii=False)}
except Exception as exc:
return {"role": "tool", "tool_call_id": call.id,
"content": json.dumps({"error": str(exc)})}
return await asyncio.gather(*(one(c) for c in tool_calls))
什么时候应该关掉并行
并行不是无脑开。有一类场景必须串行:工具之间存在顺序依赖,后一个需要前一个的输出;或者写操作共享状态,并发执行会互相踩。前一种模型通常自己能识别,它会先只发第一个调用;后一种模型判断不了,得你强制。
还有一个更微妙的代价:实践中大约有一成的 Agent 在切到并行后,任务成功率会掉 2 到 5 个百分点,原因是模型偶尔过度批量、漏掉了调用之间的隐含依赖。所以并行和串行要在你自己的任务集上对比评估,不能只看延迟。比较务实的做法是混合模式——只读工具(检索、查询、抓取)开并行,有副作用的写工具强制串行。
工具一多,就该管上下文了
工具数量涨上去之后会出现新问题:每轮请求都要把全部工具的 schema 塞进上下文,token 成本线性上升,模型选错工具的概率也跟着涨。Anthropic 文档里给了一个很具体的数字:仅工具定义本身就有固定开销,Claude Sonnet 4.6 在 auto 模式下是 497 tokens,在强制调用模式下是 589 tokens,这部分会叠加在正常的输入输出计费上。工具数量翻几倍,这块开销就不可忽略了。
现在的标准应对是三件套:
- 命名空间分组:按服务或领域把工具归到命名空间,模型先选组再选工具,选择面被有效收窄。
- 按需加载:把不常用的工具标记为延迟加载,配合一个工具检索入口,让模型在运行时加载需要的子集。OpenAI Agents SDK 提供了
defer_loading加ToolSearchTool()的组合;OpenAI 官方的最佳实践里也明确建议优先使用命名空间。 - 确定性排序:MCP 规范(2026-07-28 修订版)要求服务端在工具集合未变化时按确定顺序返回工具列表,理由写得很直白——确定性顺序让客户端能可靠缓存工具列表,并提升工具进入模型上下文时的 prompt cache 命中率。这是个容易被忽略但收益直接的细节。
如果工具来自 MCP Server,还有一条安全建议值得照做:MCP 规范本身建议始终保留人在环路中、具备拒绝工具调用的能力,并要求应用清楚展示当前暴露了哪些工具、在工具被调用时给出明显的视觉提示、对操作弹出确认。工具调用是把模型的意图变成真实世界动作的那一步,这一步留一道人工闸门是合理的成本。
把工具当产品来做:用评估驱动改进
Anthropic 那篇文章里最值得抄的一条方法论是:先给工具建一套评估,再让 Agent 帮你改工具。做法是构造一批真实的(而不是编造的)任务提示,跑一遍,记录工具选择是否正确、参数是否正确、返回结果是否够用;然后拿着这份评估结果,让编码 Agent 去迭代工具描述和 schema,每次改完重跑评分。
这套流程的价值在于把「凭感觉调提示词」变成「有分数可比的迭代」。工具描述写得怎么样,只有用真实任务量过才知道。
最后补一条和返回内容有关的实操建议:工具返回的应该是高信号信息。返回稳定的语义标识(slug、UUID)而不是内部自增 ID 或不可读的引用;只返回模型做下一步决策需要的字段。一个返回几百 KB 原始 JSON 的工具有两个坏处——白烧上下文,以及关键信息被淹没在噪声里,模型反而抓不住重点。分页、字段裁剪、必要时的摘要,都应该在工具内部完成,而不是把原始响应丢给模型去消化。
结语
工具调用看起来是个很薄的接口,但生产环境的可靠性几乎都堆在这薄薄一层上。可以把上面这些收敛成一句可执行的检查清单:描述写全(含不该用的场景)、约束进 schema、参数必校验、错误按类分流并回传给模型、并行前先想清楚依赖关系、工具变多就上命名空间与按需加载、有副作用的一定留人工确认。
这几条里没有一条依赖某个特定框架。换个 SDK,它们依然成立——这也是为什么值得先把它做对,再去挑框架。