Agent在规划阶段生成了一个工具调用,结果工具执行到一半抛了KeyError,整个任务链直接中断。这类问题往往不是模型能力不够,而是参数校验和异常处理没做扎实。比如天气查询工具要求city是一个1到50字符的字符串,模型却给了空字符串,或者给了一个包含额外字段的对象。执行端如果没有校验,就可能直接触发KeyError或TypeError,任务链断裂。更麻烦的是,这类错误信息经常不会回传给模型,模型不知道哪里错了,只能重复生成类似的调用。下面从失败原因、参数校验、异常处理到完整封装,系统地把这个问题拆开。

一、工具调用失败的高频原因
工具调用链路可以拆成三段:模型生成调用参数、框架路由与反序列化、工具函数执行。失败可能发生在任何一个阶段,但绝大多数可预防的问题集中在第一阶段和第三阶段。模型生成参数时,受到概率生成机制、上下文长度和schema理解偏差的影响,很容易出现字段缺失、类型漂移、枚举越界或嵌套结构错误。例如模型把数字参数写成了字符串,把日期格式写成了YYYY/MM/DD而不是YYYY-MM-DD,或者凭空多出一个工具定义里不存在的字段。
第三阶段的问题则更隐蔽。工具函数内部可能因为网络抖动、第三方接口返回结构变化、文件不存在等原因抛出未捕获异常。如果工具调用框架没有兜底,这个异常会直接向上传播,导致整个Agent执行中断。更糟的是,有些错误信息包含大量堆栈,但并不适合原样回传给模型。模型看到一长串traceback,往往无法提取关键修正信息,下一次调用还会犯同样的错。
一个没有参数校验的典型工具函数可能是这样的:
def get_weather(city: str, days: int = 1):
url = f"https://api.weather.com/{city}?days={days}"
response = requests.get(url)
return response.json()["forecast"]
这里至少存在三个风险:city为空时URL拼接错误,days传入字符串时requests可能报错,返回结果里没有forecast字段时直接抛KeyError。任何一个风险触发,都可能让Agent任务链崩溃。
二、参数校验:把失败拦截在执行前
参数校验的核心思路是把工具定义与校验逻辑分离。工具对外声明一个严格的输入schema,框架在执行前先校验参数,校验不通过就立即返回结构化错误,而不是调用工具函数。这样既能减少模型错误参数对工具内部的污染,也能给模型提供清晰的修正信号。
Pydantic是目前比较适合做这件事的工具。它既能声明字段类型、长度、范围、正则等约束,又能直接输出JSON Schema供模型参考。一个天气查询工具的参数模型可以这样定义:
from pydantic import BaseModel, Field, ValidationError
class WeatherInput(BaseModel):
city: str = Field(..., min_length=1, max_length=50)
days: int = Field(1, ge=1, le=14)
unit: str = Field("celsius", pattern="^(celsius|fahrenheit)$")
在执行工具前,先调用model_validate对原始参数做校验。校验失败时,Pydantic会返回一个ValidationError对象。不要把这个异常直接抛给上层,而是把它转成模型能理解的结构化错误。错误信息里至少应该包含出错字段路径、错误类型和简要原因。比如模型传了空字符串,错误可以表达为city字段长度不能小于1。模型拿到这个信息后,下一次生成时会更倾向于补全字段。
def call_weather_tool(raw_args: dict):
try:
parsed = WeatherInput.model_validate(raw_args)
except ValidationError as exc:
return {
"status": "invalid_arguments",
"errors": exc.errors()
}
return get_weather(parsed.city, parsed.days, parsed.unit)
如果工具数量很多,可以维护一个工具名到schema的映射,统一在调度层完成校验。这样就不需要每个工具函数重复编写校验代码,也能保证错误格式一致。同时,工具注册时可以把Pydantic模型生成的JSON Schema同步给模型,让模型在生成参数时就有明确的约束依据。
三、异常处理:失败后如何恢复而不是崩溃
参数校验只能拦截执行前的输入问题,无法覆盖工具内部运行时的异常。一个完整的工具调用封装需要在三个层面处理异常:工具函数内部处理可预期的业务异常,调度层捕获未处理异常,Agent循环层根据错误结果决定重试、降级或终止。
工具函数内部应该尽量把可预期的错误转成返回结果,而不是抛出异常。例如第三方接口返回错误码时,可以返回一个包含状态和提示的结构体,而不是让requests直接抛异常。对于网络超时、临时性故障这类问题,则可以在调度层做有限次重试。下面是一个带重试的通用调度示例:
def safe_call(tool_name, arguments, retries=2):
last_error = None
for attempt in range(retries):
try:
return execute_tool(tool_name, arguments)
except ToolTimeoutError as exc:
last_error = exc
continue
except ToolPanicError as exc:
return {"status": "fatal", "message": str(exc)}
return {"status": "timeout", "retries": retries}
超时控制同样不能忽视。一个工具如果长时间不返回,会阻塞整个Agent任务。可以用asyncio.wait_for给工具调用加上超时限制,超时后返回明确的状态,而不是让任务无限等待。对于某些非关键工具,还可以配置降级策略。比如实时天气获取失败时,回退到最近一次缓存数据,或者直接返回“无法获取实时数据”,让Agent根据这个结果继续规划下一步,而不是整体中断。
四、完整实践:一个稳定的工具调用封装
把参数校验、异常捕获和结构化返回放到一起,可以得到一个比较稳定的工具执行器。工具注册时绑定Pydantic schema和实际处理函数,调用时先校验再执行。这样既能拦截大部分输入错误,也能保证运行时异常不会直接击穿上层。
from typing import Any, Callable
from pydantic import BaseModel, ValidationError
class ToolExecutor:
def __init__(self):
self.tools = {}
self.schemas = {}
def register(self, name: str, schema: type[BaseModel], handler: Callable[..., Any]):
self.schemas[name] = schema
self.tools[name] = handler
def invoke(self, name: str, raw_args: dict):
schema = self.schemas.get(name)
if schema is None:
return {"status": "error", "message": f"unknown tool: {name}"}
try:
parsed = schema.model_validate(raw_args)
except ValidationError as exc:
return {"status": "invalid_arguments", "errors": exc.errors()}
try:
data = self.tools[name](**parsed.model_dump())
return {"status": "success", "data": data}
except Exception as exc:
return {"status": "tool_error", "message": str(exc)}
这个封装虽然简单,但已经覆盖了三个关键点:输入参数校验、工具不存在时的明确错误、运行时异常的兜底捕获。实际项目中还可以扩展超时控制、重试策略和降级回退。更重要的是,所有错误都返回结构化结果,Agent侧可以据此重新生成参数或调整后续计划。
需要注意的是,错误信息回传给模型时,不要直接拼接堆栈。把错误类型和字段路径用简洁的文本描述清楚即可。比如参数校验失败时,只返回city字段不能为空,而不需要把Pydantic的完整错误对象原样塞给模型。这样模型消耗的token更少,修正行为也更稳定。工具调用失败并不可怕,可怕的是失败之后既无法恢复,也无法解释。把参数校验和异常处理做扎实,Agent的稳定性会提升一个明显台阶。