一个 Agent 连续三次用完全相同的参数调用 search_orders,返回结果一模一样。第四次,它又调了一次。
这是最容易被发现、也最容易被粗暴处理的一类故障。常见做法是在 Runtime 里加个计数器,同一个 (tool_name, args) 出现三次就拦截。代码十行,上线很快,看起来问题解决了。
但它同时会误杀另一类调用:Agent 在轮询一个异步任务,get_operation_status 连续返回 RUNNING,指纹完全相同,而业务上它必须继续等。同一个计数器,对前者是正确的熔断,对后者是错误的打断。
更麻烦的是,还有两种形态它根本看不见:A→B→A→B 的振荡循环,单看“连续相同”永远触发不了;以及写操作超时后的重试,指纹相同,但拦截意味着丢失一次合法的补偿机会,不拦截意味着重复下单。
所以“重复调用”不是一种问题,是四种。方案得先能区分它们。
一、把问题拆成四种
形态 | 指纹表现 | 业务含义 | 正确处理 |
|---|---|---|---|
读操作原地空转 | 连续相同 | 无新信息,浪费预算 | 拦截并推动转向 |
合法轮询 | 连续相同 | 必须继续等待 | 放行,但计入预算 |
写操作重复副作用 | 相同或近似 | 可能重复执行不可逆动作 | 幂等键去重,不靠指纹 |
振荡循环 | A→B→A→B | 多步来回,无进展 | 周期检测 + 进展熔断 |
这张表里最关键的一行判断是:参数相同 ≠ 重复请求,重复调用 ≠ 无进展。
用户说“再买一件同样的商品”,参数一模一样,这是真实意图,不是重复。轮询接口返回 RUNNING,参数一模一样,这是合法等待,不是空转。反过来,Agent 用略微不同的 offset 反复读同一个文件,每次参数都不同,指纹拦截完全失效,但它确实在原地打转。
结论是:指纹只能作为信号,不能作为熔断依据。真正该被治理的对象是“无进展”。 下面所有设计都围绕这个判断展开。
二、重复调用为什么发生
在写治理方案之前,值得先看清楚诱因。大部分重复调用不是模型“笨”,而是工具契约给了它错误的信号。
返回值缺少稳定标识。 工具返回一段自然语言或者一个没有 ID 的列表,模型无法基于结果做下一步判断,只能重试同一个调用碰运气。如果 create_order 返回 order_id,模型下一步就知道该查这个 ID,而不是再创建一次。
错误分类缺失。 所有失败都返回 error: something went wrong,模型无法区分“网络抖动可以重试”和“参数非法重试也没用”,于是对不可重试错误反复重试。这不是模型的问题,是契约没有告诉它。
重试语义混淆。 很多实现把指数退避当作重试治理的全部。但 backoff 解决的是负载节奏,它不解决重复副作用——等得再久,第二次下单还是第二次下单。
长上下文里早期结果被截断。 多轮之后,模型看不到第一步已经查过的结果,于是再查一遍。这不是循环,是记忆问题,但表现和循环一样。
没有硬边界。 没有轮次上限、没有预算、没有终止条件,循环可以一直转下去,直到外部超时。
前四条都能通过改工具契约显著降低发生率。第五条是 Runtime 的责任。两者都不能省。
三、检测:把信号采集和决策分开
检测层的职责是采集信号,不是直接拦。把这两件事混在一起,是很多实现误杀合法重试的根源。
3.1 规范化指纹
def fingerprint(tool_name: str, args: dict) -> str:
normalized = json.dumps(args, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(f"{tool_name}:{normalized}".encode()).hexdigest()按 key 排序是必须的,否则 {"a":1,"b":2} 和 {"b":2,"a":1} 会被当成两次不同调用。但要注意,规范化只能解决参数顺序问题,解决不了“语义相同、字面不同”的情况——那需要输出侧的相似度判断。
3.2 三种检测模式并行
连续重复计数:同一指纹连续出现次数。简单,但只覆盖第一种形态。
振荡检测:维护最近 N 步的指纹序列,检测 A→B→A→B 周期。实现上可以用一个滑动窗口加周期匹配,阈值通常设 3 个完整周期。
输出相似度:对工具返回做哈希;如果是自然语言结果,用嵌入余弦相似度。连续多次输出近乎相同(比如相似度 ≥ 0.92),即使参数不同,也是一个强信号——这正是“参数微调但实质无进展”的场景。
三种信号各自不完美,但叠加起来覆盖面足够。关键是它们都只产生“疑似”标记,不直接触发拦截。
3.3 进展标记
这是检测层最重要、也最难做对的部分。进展的定义依赖任务类型:
有明确后置条件的任务(下单、状态轮询、文件写入):用业务条件判断,确定性高;
有结构输出的任务(搜索结果、数据库查询):用关键字段是否变化判断;
开放式任务(研究、信息搜集):只能退化为“输出无变化 + 振荡检测”的弱信号组合。
诚实地说,第三类没有干净的解法。多读一篇文档算不算进展?参数微调后拿到语义相近的结果算不算?这些问题没有通用答案。工程上的处理是:对有后置条件的任务用确定性进展判定,对开放式任务接受一定的误判率,用预算兜底。 不要在开放式任务上追求完美的进展检测,那是伪需求。
四、治理:读写分离,信号与熔断分离
这是方案的主体。核心原则两条:读操作和写操作走不同的去重路径;熔断依据是进展,不是重复次数。
4.1 读操作:确定性指纹拦截
读操作无副作用,拦截的代价最低,可以用最直接的规则。
只对标记为
read的工具生效;同一指纹连续出现第 3 次开始阻断,前两次放行(第一次正常调用,第二次可能是合法重试);
阻断不是静默丢弃,而是注入纠正信号:告诉 Agent“这个调用已经执行过,结果没有变化,请换参数、换工具,或基于已有结果收敛”。
if tool.kind == "read" and repeat_count(fp) >= 3:
return CorrectionSignal(
reason="repeated_read_no_progress",
last_result=cache[fp],
hint="Do not call this again with the same arguments. "
"Use the existing result, change parameters, or finish the task."
)拦截动作本身是确定性的、零 API 开销的,这是它作为第一道屏障的价值。但它只解决“原地踏步”,对振荡和写操作无效。
4.2 写操作:稳定幂等键,不用参数哈希
这是最需要纠正的一个常见错误。写操作的重复判定不能用 (tool_name, args) 哈希,原因有两个:
第一,参数相同可能是真实意图。用户想再买一件,Agent 就应该再买一件,指纹拦截会直接违背用户意愿。
第二,重试的“同一逻辑操作”身份,不应该由参数决定,而应该由编排层生成的稳定 key 决定。同一次工作流里的同一步骤,无论重试多少次,都应该映射到同一个幂等键。
idempotency_key = sha256(f"{run_id}:{step_id}".encode()).hexdigest()执行语义:
首次调用执行,结果按幂等键缓存;
相同幂等键的后续调用直接返回缓存,不重新执行;
首次调用抛异常不缓存,保留合法重试能力;
超时返回
UNKNOWN,不是FAILED。
最后一条值得单独说。网络断开只说明没收到答案,不说明远端没干活。把超时判定为 FAILED 然后直接重试,是重复副作用最常见的来源。正确做法是进入对账流程:用幂等键或业务 ID 去查远端状态,确认已执行就取结果,确认未执行才重试。
try:
result = tool.execute(args)
cache.set(idempotency_key, result)
return result
except TimeoutError:
return ToolResult(status="UNKNOWN", reconcile_key=idempotency_key)UNKNOWN 是一等状态,不是错误状态。这个区分直接决定了写操作幂等设计是否正确。
4.3 熔断依据:连续无进展,而不是重复次数
把前面的信号汇总成熔断决策。规则从“同一调用重复 N 次”改成“连续 K 步无进展标记”。
if progress_monitor.consecutive_no_progress() >= K:
raise LoopDetected(
last_k_steps=trace.recent(K),
signal="no_progress",
suggestion="Inspect recent results and choose a different strategy."
)K 按任务类型配置。轮询类任务 K 可以大一些(因为合法重复多),开放式任务 K 小一些(因为无进展的代价高)。
这个改动的意义在于:轮询的合法重复不会被误杀,因为它虽然有重复指纹,但每次都在等待一个真实变化的状态,进展监视器不会把它标记为无进展。而真正的空转会被更快切断,因为它不仅重复,还没有任何新信息。
4.4 多重预算与硬终止
单一 max_turns 不够。一轮模型响应可能包含多个工具调用,轮次上限和调用次数上限是两回事。需要分别设限:
模型轮次上限;
总工具调用次数上限;
同类调用重试次数上限;
墙钟时间上限;
Token / 费用预算。
任一触发即终止,返回结构化诊断,而不是笼统的 iteration limit reached:
{
"terminated_by": "budget",
"budget_type": "total_tool_calls",
"limit": 50,
"consumed": 50,
"last_progress_step": 31,
"hint": "No progress in the last 19 tool calls."
}这个诊断对排查至关重要。只告诉用户“超限了”,等于什么都没说。
4.5 提示词纠正:辅助手段,不是边界
在硬拦截之外,可以在连续重复时向 system prompt 注入纠正提醒。提醒要具体到工具名、次数、参数:
You have called
search_orders3 times with identical arguments. The result has not changed. Do not call it again with the same parameters. Inspect the latest result and choose a different action, or finish the task.
但必须清楚:提示词是提醒,不是边界。 模型可能忽略提醒,也可能在长上下文里根本没看到。停止控制权必须在 Runtime,由确定性代码执行。把防循环的希望寄托在 prompt 上,是把正确性交给概率。
五、几个值得单独讨论的设计决策
停止权归 Runtime,不归 Prompt。 这条前面说过,但它值得重复,因为它是很多实现出错的起点。
写操作去重靠幂等键,不靠参数哈希。 参数哈希会把“用户想再买一件”和“重试导致的重复下单”混为一谈。
UNKNOWN 是一等状态。 超时不是失败,是未知。未知需要先对账,不能直接重试。
Backoff 和重试资格分开。 是否可重试由错误分类和幂等状态决定;等多久由 backoff + jitter 决定。把两件事塞进一个函数里,是重复副作用的常见温床。
幂等键不能只放进程内 set()。 多 Agent 共享同一业务资源时,重复检测需要跨实例的协调层——共享存储加幂等键表。进程内去重只能防单实例内的重复,防不了两个实例同时下单。
用 Cost per Successful Task 衡量,不是调用次数。 调用次数降了,但成功率也降了,实际成本可能更高。这个指标比“平均调用次数”更接近真实经济性。
六、怎么证明方案真的有效
一套治理方案上线后,最危险的状态是“看起来没出问题”。循环可能只是还没被触发,或者触发了但没被记录。
Trace 要记全。 每一步的模型输入摘要、工具调用、原始返回、进展标记、预算消耗。没有这些,失败无法回放,回放不了就无法定位。
关键指标至少四个:循环检出率、误杀率(合法重试被拦截的比例)、UNKNOWN 对账时长、Cost per Successful Task。误杀率尤其重要——一个把合法轮询全部拦掉的方案,检出率 100%,但它是失败的。
行为层评测,不是结果层。 任务最终成功了,中间空转 20 次,结果层评测看不出来。要在 trace 上评:重复率、无进展步数、预算效率。
故障注入。 人为制造超时、重复返回、模糊错误,测试治理是否真的生效。不要等生产环境来帮你做这个测试。
注意采样。 重复调用是模型行为故障,单次采样可能漏检低概率循环。评测时要明确采样次数和检出置信度的关系,否则评测集本身会给出虚假的安全感。
七、落地路线
不需要一次搭完。按阶段引入,每阶段可独立上线、可回滚:
阶段 | 引入能力 | 验收标准 |
|---|---|---|
0 | 工具契约(稳定标识、错误分类)+ 结构化 trace | 能回放任意一次失败执行 |
1 | 超时、错误分类重试、读操作指纹拦截 | 合法重试不误杀,空转能被截断 |
2 | 写操作幂等键 + | 重复副作用为零,超时不再误判 |
3 | 进展检测 + 跨实例协调 | 循环检出率与误杀率达到设定阈值 |
阶段 1 就能拦住大部分读操作空转,投入最小。阶段 2 解决最危险的写操作重复副作用。进展检测放在阶段 3,是因为它最难做对,也最依赖前两阶段积累的 trace 数据。
八、没解决的问题
开放式任务的进展语义是模糊的。 语义相似度只是弱信号,阈值定高了漏检,定低了误杀。目前没有通用解,只能按任务类型分别调,并接受误判。
多 Agent 共享状态下的循环检测。 单个 Agent 的本地历史不够用,跨实例协调需要额外的基础设施。这个问题和幂等键的分布式存储是同一个问题的两个面,但很少被一起讨论。
工具生态的契约标准缺失。 每个团队都在重造稳定标识、错误分类、状态字段的约定。如果 Function Calling 的 schema 层能把这些作为一等公民,很多重复调用在源头就不会发生。
回到开头那个场景。四次相同的 search_orders 调用,正确的处理不是第四次拦下它,而是第一次就让它返回一个带稳定标识的结构化结果,让模型知道下一步该查什么。如果它还是重复了,Runtime 应该识别出“连续无进展”,而不是“连续相同”——这两者的区别,就是合法轮询和死循环的区别。
重复调用是信号,无进展才是熔断依据。读操作靠确定性指纹,写操作靠稳定幂等键和对账,进展检测决定何时切断,多重预算做最后兜底。停止权归 Runtime,不归 Prompt。