编程Agent重试时,该保留失败记录吗
文章比较失败尝试回滚与持续累积历史两种Agent重试策略,讨论旧补丁和错误堆栈对后续生成的锚定影响。摘录披露了隔离环境、模型配置和隐藏测试规模,但没有给出具体对比成绩。
AI 深度解读
AI 编程助手第一次改错后,继续把补丁和报错塞进聊天记录,可能会让下一轮更贵,也更难跳出原来的思路。作者做了一组对照:保留失败历史,或者回滚代码、删除失败对话,只留下简短教训。两种方式最终都通过了测试,后者少调用一次模型,API 成本下降 80.3%。
这组实验值得看的地方,是它把「失败后该记住什么」变成了可测量的问题。不过,省下多少 token 有直接数据支持;模型是否因此摆脱了错误思路,还需要更谨慎地判断。
失败记录为什么可能拖累下一次修改
常见的 Agent 循环会把模型生成的失败补丁以 role: "model" 加入 messages[],再把完整报错以 role: "user" 追加进去,让模型继续修。
作者认为,这容易产生自我条件化偏差(Self-Conditioning Bias):模型看到自己之前写过的代码,倾向于沿着那套方案打补丁。任务如果需要多个模块一起调整,它却可能继续逐个修文件。
实验选择了一条由 validator.py、settlement.py、statement.py 组成的支付结算流水线。第一次修改只处理了 validator.py,测试失败。接下来,两条路线开始分叉:
- Mode A 使用
AccumulatedFailedHistoryRunner,保留失败补丁和完整堆栈。第一轮留下 36,606 字符,第二轮又增加 36,264 字符。 - Mode B 使用
PrunedFailedAttemptRunner,失败后回滚工作区,删除失败回答和原始堆栈,只给初始提示补充一份 927 字符、增加 392 token 的结构化教训。
失败需要留下可复用的信息,但失败过程未必需要完整留在下一轮上下文里。
这里的「上下文污染」是作者对现象的解释。大量重复日志会扩大输入,这是确定的;它们是否必然让模型固守旧方案,这次实验还不能单独证明。
少一次重试,具体省在哪里
这是 AI Labo 系列的 E19 实验,候选编号 C015,在作者的 41 种模式中排名第 4。运行编号为 e19-20261028-vm223-01,模型是 gemini-3.8-flash,参数为 thinkingLevel: LOW。
环境是隔离的 Proxmox VM223,实例名 ai-e19-223,配置包括 0 NICs、virtio-serial QGA relay 和 systemd UID 分离。作者用这套环境限制网络访问,并通过隐藏的 20 个用例评分。隔离设计有助于控制执行条件,但不等于结果可以直接推广到所有仓库。
两种模式最终都是 20 / 20,通过率 100%。区别主要在过程:
| 指标 | 保留失败历史 | 回滚并裁剪 |
|---|---|---|
| 模型调用次数 | 3 次 | 2 次 |
| 最后一轮输入 | 38,134 token | 2,301 token |
| 累计输入 | 59,944 token | 4,210 token |
| API 成本 | $0.0558 | $0.0110 |
作者报告,最后一轮输入减少 94.0%,累计输入减少 93.0%,按累计输入计算的效率为 14.24 倍,API 成本下降 80.3%,完成速度为原来的 1.57 倍。
过程也有区别。Mode A 第二轮只补了 settlement.py,再次失败,该轮输入已经达到 19,912 token;第三轮才提交三个文件的完整修改。Mode B 第一轮输入为 1,909 token,第二轮以 2,301 token 的输入一次提交三个模块,通过全部测试。
这支持一个具体结论:在这次任务和日志条件下,裁剪路线用更少输入完成了修复。它没有证明通过率更高,因为两组最终都成功了。
真正该保留的是约束,而不只是报错摘要
Mode B 的教训并非简单一句「测试没过」。error_trace_pruner_v1 删除了失败回答 MSG[1] 和堆栈 MSG[2],然后补入了非常具体的业务规则。
校验模块要使用 ALLOWED_CURRENCIES=('USD','EUR','JPY'),拒绝空列表和 bool,允许 unit_cents>=0,要求 0<=chargeback_bps<=1000,并返回一个新的五键字典。
结算模块要用 for k in order: 保持首次出现顺序,仅在 chargeback_bps > 300 时收取 10% 准备金;折扣边界是 >= 15000,GROWTH 为 10%,ENTERPRISE 为 20%,其他情况为 10%;返利还要满足 rebate_applied = min(fee_rebate, after_discount)。
报表模块要使用 {merchant_id}#{currency}: ...,并保留 TIER_DISCOUNT: -{tier_discount}、REBATE_APPLIED: -{rebate_applied} 的负号格式。结构化记录还包含 remaining_issues: 4。
因此,这个方案同时做了两件事:删除冗长历史,以及把失败转成明确的跨模块约束。后者可能同样关键。
原文给出的循环展示了如何让失败补丁和原始堆栈不进入正式历史:
import subprocess
def run_step_with_error_pruning(messages: list[dict], lesson_log: list[str], call_llm, run_tests) -> bool:
# 1. 用干净的基础消息与此前失败教训构造提示
prompt_msgs = list(messages)
if lesson_log:
prompt_msgs[-1] = {
**prompt_msgs[-1],
"text": prompt_msgs[-1]["text"] + "\n\n[PRUNED_FAILED_ATTEMPT_LESSONS]:\n" + "\n".join(lesson_log),
}
model_reply = call_llm(prompt_msgs)
# 2. 应用补丁并运行确定性测试
passed, short_failure_reason, _raw_trace = run_tests(model_reply)
if passed:
messages.append({"role": "model", "text": model_reply})
return True
# 3. 失败后回滚,不把 model_reply 或 _raw_trace 加入 messages
subprocess.run(["git", "checkout", "--", "."], check=True)
lesson_log.append(f"- Attempt #{len(lesson_log) + 1} rolled back: {short_failure_reason}")
return False
要看的是失败分支:它只保存 short_failure_reason。示例没有交代如何从测试结果生成前面那份详细教训,实际接入时,这部分需要自己实现。
我的判断
我赞成把「供人排查的完整记录」和「供模型重试的输入」分别保存。原始日志适合追溯,下一轮模型更需要失败位置、预期结果和已经确认的约束。
但「删除失败代码让模型摆脱锚定」还属于需要更多验证的解释。原文只展示了一个运行编号,没有给出重复运行、波动范围,也没有提供「保留失败补丁但压缩日志」这样的对照组。无法据此分清收益分别来自回滚、缩短日志,还是那份质量很高的教训。
日志条件也有前提。展示内容包含从 FRAME_002 到 FRAME_094 的重复帧和局部变量转储;如果团队原本只传入精简测试摘要,节省幅度可能小得多。
代码同样不能直接照搬。git checkout -- . 会丢弃受跟踪文件的未提交修改,也不会清理新建的未跟踪文件。若工作区有用户原先的改动,它并不等于「恢复到本轮尝试前」。可靠实现需要单独的工作区或明确快照。
这次实验最扎实的证据是输入和成本下降,行为改善的原因仍需要拆开验证。
能拿来做什么
- 给自己的 AI 编程循环增加失败摘要,保留测试名、预期值、实际值和涉及模块;完整堆栈另存,下一轮按需读取。
- 遇到跨文件修复时,把已经确认的联动关系写进教训,例如校验、计算和输出格式必须一起修改,避免只留下「某个文件报错」。
- 在独立工作区或快照上执行尝试,让回滚准确恢复到本轮开始前,并覆盖新增文件,保护已有改动。
- 用同一批任务比较保留历史、只压缩日志、回滚并裁剪三种策略,同时记录通过率、调用次数、累计输入和耗时,再决定是否采用。