Agent 的工具调用:状态、重试与失败边界
让模型调用工具很容易:给个 schema,它就会生成参数。难的是工具失败之后它该怎么办。这篇记录我们在生产环境跑了四个月的一套约定,核心思想只有一条——把失败当成一等公民设计,而不是异常路径。
一、循环的基本形状
一个工具调用循环最小可用的骨架:
def run(task, tools, max_steps=8):
msgs = [{"role": "user", "content": task}]
for step in range(max_steps):
out = model(msgs, tools=tools)
if not out.tool_calls:
return out.text
msgs.append(out)
for call in out.tool_calls:
result = dispatch(call)
msgs.append({"role": "tool",
"tool_call_id": call.id,
"content": result})
return "已达到步数上限,未能完成任务。"
这段代码能跑通演示,但每一行都藏着一个生产环境会炸的地方。下面逐个说。
二、错误信息要写给模型看
最常见的错误是把异常堆栈原样塞回上下文:
# 不好
Traceback (most recent call last):
File "/srv/app/tools/db.py", line 88, in query
cur.execute(sql)
psycopg2.errors.UndefinedColumn: column "created" does not exist
LINE 1: SELECT * FROM orders WHERE created > ...
模型看到这个,大概率会随便换个列名再试一次,然后再错一次。有用的错误信息应该包含可执行的下一步:
# 好
{
"status": "error",
"code": "UNKNOWN_COLUMN",
"message": "表 orders 没有列 'created'。",
"available_columns": ["id", "user_id", "created_at", "amount", "status"],
"retryable": true
}
改成这样之后,一次性修正率从 31% 涨到 88%。这不是模型能力问题,是我们没给它修复所需的信息。原则很简单:如果一个人类工程师看到这条错误也不知道下一步做什么,那模型也不会知道。
三、区分可重试与不可重试
让模型自己判断该不该重试是不可靠的,应该由工具层显式声明。我们的分类:
| 类别 | 例子 | 处理 |
|---|---|---|
| 瞬时故障 | 超时、429、502 | 框架自动重试,指数退避,不进上下文 |
| 参数错误 | 列名错、日期格式错 | 返回给模型,附可选值,允许重试 |
| 权限不足 | 403 | 返回给模型并标记 retryable=false |
| 结果为空 | 查询无匹配记录 | 这是成功,不是错误 |
最后一行踩过大坑。早期我们把空结果当作错误返回,模型会不断变换查询条件去"找到点什么",最后编出一条不存在的记录。改成明确的成功响应 {"status":"ok","rows":[],"count":0} 之后,模型会正常地回答"没有找到符合条件的订单"。
四、副作用与幂等
只读工具重试没有代价。写操作重试可能造成重复下单、重复发信。我们的做法是要求所有有副作用的工具接受一个幂等键:
def dispatch(call, run_id):
spec = TOOLS[call.name]
if spec.side_effect:
# 同一次运行内,相同工具+相同参数只执行一次
key = sha256(f"{run_id}:{call.name}:{canonical(call.args)}")
if (cached := idem_store.get(key)) is not None:
return cached
result = spec.fn(**call.args)
idem_store.set(key, result, ttl=3600)
return result
return spec.fn(**call.args)
这条规则让"模型忘了自己已经发过邮件、又发一次"这类问题基本消失。注意参数需要做规范化(键排序、数值格式统一),否则语义相同但字面不同的调用会绕过缓存。
五、上下文会爆,而且是悄悄爆
八步循环里,每一步的工具返回都堆在上下文里。一个返回 200 行 JSON 的查询工具调三次就是几万 token。症状不是报错,而是模型开始遗忘任务开头的约束条件。
我们的三条措施:
- 工具层截断。任何工具返回超过 4 KB 就截断,并附上
"truncated": true, "total_rows": 1832以及一个可用于取剩余部分的游标。让模型知道还有更多,而不是以为看到了全部。 - 历史折叠。超过 4 步之后,把最早的工具调用与结果压缩成一行摘要:「第 1 步:查询 3 月订单 → 返回 1832 条,已取前 20 条」。原始内容存到运行日志里,不占上下文。
- 任务约束置底。把原始任务和硬性约束在每次调用前重新附加到消息末尾。前面写过位置效应,结尾的召回率明显高于中间。
六、什么时候该停
无限循环是 agent 最贵的失败模式——不报错、不返回,只是持续烧 token。我们设了四道闸:
- 步数上限:8 步。超过就带着已有信息作答,并明确说明未完成的部分。
- 挂钟超时:单次运行 90 秒。
- 重复检测:连续三次调用相同工具且参数相同,判定为卡死,强制跳出。
- 成本上限:累计 token 超阈值即终止。这一道是兜底,前三道都失效时防止账单失控。
触发任何一道闸时,返回给用户的必须是诚实的部分结果:已经查到了什么、还差什么、建议怎么办。最糟糕的处理是让模型在信息不足时硬凑一个完整答案。
七、可观测性
最后一点,也是回头看最值得早做的:给每次运行分配一个 run_id,把每一步的模型输入输出、工具调用参数、返回、耗时全部结构化落盘。线上出问题时,能不能在两分钟内复现出模型当时看到的确切上下文,决定了排查是十分钟还是一整天。我们最初省掉了这一步,为此付出的代价远超实现它的工作量。