Tensor NotesNotes on machine learning
Systems

Agent 的工具调用:状态、重试与失败边界

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 "已达到步数上限,未能完成任务。"

这段代码能跑通演示,但每一行都藏着一个生产环境会炸的地方。下面逐个说。

Agent 工具调用状态机:规划、调用、观察、重试、回答
图 1 · 带失败边界的调用循环。retry 是显式状态,不是隐藏在 dispatch 内部的行为

二、错误信息要写给模型看

最常见的错误是把异常堆栈原样塞回上下文:

# 不好
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} 之后,模型会正常地回答"没有找到符合条件的订单"。

约定瞬时故障对模型不可见(框架吞掉并重试),语义错误对模型完全可见(带修复线索)。混淆这两类是大多数 agent 不稳定的根源。

四、副作用与幂等

只读工具重试没有代价。写操作重试可能造成重复下单、重复发信。我们的做法是要求所有有副作用的工具接受一个幂等键:

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。我们设了四道闸:

  1. 步数上限:8 步。超过就带着已有信息作答,并明确说明未完成的部分。
  2. 挂钟超时:单次运行 90 秒。
  3. 重复检测:连续三次调用相同工具且参数相同,判定为卡死,强制跳出。
  4. 成本上限:累计 token 超阈值即终止。这一道是兜底,前三道都失效时防止账单失控。

触发任何一道闸时,返回给用户的必须是诚实的部分结果:已经查到了什么、还差什么、建议怎么办。最糟糕的处理是让模型在信息不足时硬凑一个完整答案。

七、可观测性

最后一点,也是回头看最值得早做的:给每次运行分配一个 run_id,把每一步的模型输入输出、工具调用参数、返回、耗时全部结构化落盘。线上出问题时,能不能在两分钟内复现出模型当时看到的确切上下文,决定了排查是十分钟还是一整天。我们最初省掉了这一步,为此付出的代价远超实现它的工作量。

上一篇:KV Cache 的显存账本:PagedAt…下一篇:RLHF 到 DPO:偏好对齐的三年演化…

继续阅读

Related