Python Agent 工具调用最佳实践与避坑

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_prsslack_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 侧,一个可用的分层策略是:

  1. 可重试的瞬时错误(网络抖动、429、503):在工具内部做有限次退避重试,重试仍失败再上报。这一层不该由模型决策,模型对「重试」没有时间概念。
  2. 参数类错误(枚举值不对、ID 不存在):直接回传错误原文和可选值范围,让模型纠错重试,这是它最擅长的一类修复。
  3. 业务规则拒绝(额度不足、权限不够):作为正常结果返回,用清晰的语言描述原因。这不是 bug,是有信息量的结果。
  4. 未知异常:捕获后转成统一的错误结果,同时打日志和上报。给模型的信息应控制在「这对下一步有什么用」,不必把完整堆栈塞进上下文——那只会白烧 token。

另外务必设一道循环上限。没有最大轮次和 token 预算的 Agent,卡住时就是无限循环,成本曲线会非常难看。常见做法是设一个十几轮的硬上限,超限时把当前状态和已获得的信息交给一个「收尾」提示,让模型基于已有信息给出部分答案,而不是直接报错。

并行调用:语义差异比语法差异更麻烦

并行工具调用的收益是实打实的。一次响应里带上多个独立调用,可以省掉多轮往返;实测数据里,多步任务的整体延迟下降幅度在 40% 到 70% 区间。但这里有个常见误解:「并行」指的是请求层面的批量,不是你实现了并发。模型只是在一个回合里发出了 N 个调用请求,真正并发执行是你的活。很多人开了这个开关却看不到任何提速,原因就是分发循环里还在逐个 await

三个 provider 的 API 语义差别不小,照着 OpenAI 写的代码直接搬到 Anthropic 上会静默出错:

对比项OpenAIAnthropicGoogle Gemini
关闭并行的开关parallel_tool_calls=falsetool_choice 里的 disable_parallel_tool_use=true限制为单个允许的工具名
响应结构message.tool_calls 数组content 里多个 tool_use多个 functionCall part
结果关联键tool_call_idtool_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_loadingToolSearchTool() 的组合;OpenAI 官方的最佳实践里也明确建议优先使用命名空间。
  • 确定性排序:MCP 规范(2026-07-28 修订版)要求服务端在工具集合未变化时按确定顺序返回工具列表,理由写得很直白——确定性顺序让客户端能可靠缓存工具列表,并提升工具进入模型上下文时的 prompt cache 命中率。这是个容易被忽略但收益直接的细节。

如果工具来自 MCP Server,还有一条安全建议值得照做:MCP 规范本身建议始终保留人在环路中、具备拒绝工具调用的能力,并要求应用清楚展示当前暴露了哪些工具、在工具被调用时给出明显的视觉提示、对操作弹出确认。工具调用是把模型的意图变成真实世界动作的那一步,这一步留一道人工闸门是合理的成本。

把工具当产品来做:用评估驱动改进

Anthropic 那篇文章里最值得抄的一条方法论是:先给工具建一套评估,再让 Agent 帮你改工具。做法是构造一批真实的(而不是编造的)任务提示,跑一遍,记录工具选择是否正确、参数是否正确、返回结果是否够用;然后拿着这份评估结果,让编码 Agent 去迭代工具描述和 schema,每次改完重跑评分。

这套流程的价值在于把「凭感觉调提示词」变成「有分数可比的迭代」。工具描述写得怎么样,只有用真实任务量过才知道。

最后补一条和返回内容有关的实操建议:工具返回的应该是高信号信息。返回稳定的语义标识(slug、UUID)而不是内部自增 ID 或不可读的引用;只返回模型做下一步决策需要的字段。一个返回几百 KB 原始 JSON 的工具有两个坏处——白烧上下文,以及关键信息被淹没在噪声里,模型反而抓不住重点。分页、字段裁剪、必要时的摘要,都应该在工具内部完成,而不是把原始响应丢给模型去消化。

结语

工具调用看起来是个很薄的接口,但生产环境的可靠性几乎都堆在这薄薄一层上。可以把上面这些收敛成一句可执行的检查清单:描述写全(含不该用的场景)、约束进 schema、参数必校验、错误按类分流并回传给模型、并行前先想清楚依赖关系、工具变多就上命名空间与按需加载、有副作用的一定留人工确认。

这几条里没有一条依赖某个特定框架。换个 SDK,它们依然成立——这也是为什么值得先把它做对,再去挑框架。