AI Agent常在工具调用失败后仍自信报完成,作者建议所有Agent流程必须包含强制结果验证步骤,并给出具体工程模式。
"已插入 N 行。完成。"——然而一行数据都没进去
我把大量客户工作交给 AI Agent 处理。生产环境部署、报告生成、批量数据插入——每个跑通的流程都会被封装为一个 skill,到现在已经积累了几十个 skill 支撑我的日常运作。
最终把我"打崩"的是一个批量插入任务。Agent 跑完后,它说:
"已插入 N 行。完成。"
和其他完成报告一模一样。我差点就信了。临时起意去后台看了一眼——结果一行都没加进去。插入命令中途失败了,错误被吞掉了,然后 Agent 信心满满地报告完成。
那一刻脊背发凉。万一这是一个报告里的数字呢?万一这是一份发给客户的可交付物呢?我已经放过了多少"完成"报告,其实根本没完成?
这是最恶劣的一种幻觉。不是听起来合理的句子,而是把"工作已经完成"这个事实本身给捏造了出来。人类面对一句自信的"完成。"时是不会追问的。
追溯根源,问题不在于模型不诚实。而是行动和检查是同一个步骤。
一个 LLM Agent 的一轮交互大致长这样:
调用一个工具(执行插入)
查看返回值,生成下一句话
第二步是问题所在。当工具的返回值是空的、模糊的、或者干脆超时了,模型会续写出最可能的下一句——"大概成功了"。在训练数据中,"执行了插入"之后最自然的接续就是"完成",所以它当然就输出了这句话。
Agent 从来没有真正去查看它刚修改过的世界状态,却声称完成了。既然从未查看,自然也发现不了失败。
对策很简单。任何有副作用的操作,必须在之后重新读取世界状态并加以确认,才能说"完成"。 我把这个叫做完成契约(completion contract),作为共享规则落地到所有 skill 中。就三条:
任何有副作用的操作——创建、更新、删除、上传、插入——在说"完成"之前,必须用独立的命令重新获取其存在性和状态。插入了行?重新数一遍。上传了文件?访问那个 URL 确认。创建了记录?按 id 查出来。永远不要把同一个工具调用的返回值当作证据——那正是"我觉得我做到了"这种幻觉的温床。
空输出、错误、超时,永远不要自行填充。如果计数、路径或 id 读不出来,不要猜——原样报告"空"或"失败"。
进入账本或报告的数字,必须是经过重新获取确认过的值。任何未经确认的值都不应被写入。
换句话说:举证责任转移给了 Agent。不是"它应该成功了",而是"我刚刚亲自重新读取了,它确实在那里"。
效果对比如下。
> 正在插入数据
[tool] db_insert(...) ← 空返回值,但没有明确判定为错误
> 已插入 N 行。完成。 ← 没看结果就声称完成
> 正在插入数据
[tool] db_insert(...) ← 空返回值
> 插入未返回任何结果。正在验证行是否存在。
[tool] db_count(where=...) ← 重新获取
> 0 行匹配。插入未成功落地(失败)。
是否需要查看错误日志并重试?
第二个 Agent 不可能撒谎。只要在行动之后加一个去查看世界状态的步骤,"我觉得我做到了"就物理上不可能发生了。
你可能觉得护栏库已经处理了这个问题。我找过——guardrails-ai、deepeval、promptfoo,还有几个其他的。它们是好工具。只是保护的位置不同。
它们验证的核心是 LLM 生成的文本:格式对不对、是否有害、事实是否一致、是否接近预期答案。它们给输出的内容打分。
但"我觉得我做到了"这种幻觉,文本上毫无破绽。"已插入 N 行。完成。"语法正确、内部一致。再怎么对文本打分都 catch 不到它——因为问题不在文本,而在这个动作之后世界的实际状态。
这里存在一个断层。验证输出的工具一大堆,但据我搜索,没有任何一个是在动作之后重新获取世界状态并把报告与现实对账的。我们已经走到 Agent 真正在改写世界的阶段,而验证却停在了"Agent 说了什么"。
最重要的一点先说:这个契约现在就能用,不依赖任何东西。 把这三条规则作为一个段落,落入 Agent 的 system prompt、CLAUDE.md 或 AGENTS.md:
## Completion contract
任何有副作用的操作(创建、更新、删除、上传、插入),
在用独立命令重新获取到结果状态并展示原始结果之前,不得报告为完成。
空输出、错误和超时一律原样报告为"空"或"失败"——
永远不要用一个想象出来的 id、路径或计数来填充。
就这一条,在我自己的 setup 里立竿见影地减少了虚假完成。零成本、零依赖。如果只打算试一件事,就试这个。
实际跑起来我发现:写在 prompt 里的纪律,在忙碌的回合中会被悄悄打破。随着上下文增长,模型会"不小心"略过那段文字,重新开始说"完成。"就像人类信誓旦旦说要小心,结果一忙就破了戒一样。
所以总有一天你会想要把纪律写进代码里强制执行——就像把 code review 里的口头提醒换成 linter,让它机械性地让 CI 失败。我希望"你在说完成之前真的重新获取了吗?"这件事有机器背书,而不是靠良好意愿。
于是我做了 enforcement 的那部分——genchi
我发布了一个小工具,用机器机制而非善意来支撑完成契约:genchi(現地現物——亲临现场,亲眼确认)。
npm i @hyuga/genchi
它只做一件事:运行一个 probe 重新获取真实状态,并且只根据这个 probe 给出裁决。
import { gate, expect } from '@hyuga/genchi';
await db.insert(rows); // 副作用
await gate({
action: 'insert 45 rows',
probe: () => db.count({ where: { batch: 123 } }), // ← 重新获取真实状态,不是动作的返回值
expect: expect.count(45),
});
// 能走到这一行,意味着 45 行确实在那里。否则抛出 GenchiIncomplete。
核心在于 verify / gate 什么都不接受,只接受 probe。证据必须来自在断言完成的那一刻调用某个函数,而不是从"旁边递过来"的一个值。空结果、错误和超时不会被吞掉;它们被报告为失败,而不是被想象成成功。返回计数为 0(什么都没落地)同样算作不完整。
有一个需求是我声称做到了但实际没有的。0.3.0 之前的 README 说这样可以让"我觉得我做到了"在结构上无法写出。是这么一行:
const result = await doTheInsert(); // 假设什么都没落地
await verify({ action: 'insert 45 rows',
probe: () => result.inserted, // 动作自己的返回值
expect: expect.count(45) }); // → ok: true
probe 是一个函数,而 JavaScript 里没有任何机制能强迫一个函数去做 I/O。更糟的是,CLI 对一个 --probe "echo 45"(实际上什么都没重新获取)打印出了 re-fetched: 45——这个工具的整个主旨是"不要报告你没检查过的东西",结果在自己的输出里断言了一个它根本没有检查的东西。两个问题都在 0.3.0 里修正了:措辞改成了"the probe returned",--help 也主动声明了这个限制。
要求提供 probe 实际买到的是:一个专门放重新读取的地方——一个被有意写出来的表达式——以及不会被悄悄吞掉的拒绝。这个是值得拥有的。但不如我写到的那么大,差距在于那种只有主动攻击自己的包而不是重读它才能发现的东西。
还有一个给不写 JS 的 Agent 用的 CLI。给它一个重新获取的命令:
genchi verify --probe "psql -tAc 'select count(*) from t where batch=123'" --count 45
# exit 0=已验证 / 1=空或不一致 / 3=probe 失败。原始 probe 输出始终作为证据输出。
配合 Claude Code,一个 Stop hook 可以拦截仍有未验证完成契约的回合(adapters/claude-code)。运行时不需要 LLM 和 API key——零依赖的静态片段。
坦白说。据我搜索,"在动作之后重新获取世界状态并验证"是一个真实的空白。但我不认为目前有大量的人已经被"伪造完成"专门坑过——他们同时还让 Agent 改写了真实的东西。需求可能略微超前于时代。
我还是故意把它做成了框架无关的——Claude Code hook 剥离成了一个薄薄的 adapter,核心从任何 Agent 都可以调用。我有一个 linter(carrylint)它的全部信息是"不要把你的环境写死进去",所以我自己的工具是 Claude-Code-only 的话就太讽刺了。不同于我那些静态 linter(reflint 校验引用完整性、skills-lint 校验 skill 冲突、carrylint 校验运行时可移植性),这是我的工具里第一个在运行时去查看世界状态的东西。
如果你曾经对一件被报告完成但实际没完成的事有过那种脊背发凉的感觉,这个应该能用上。
最恶劣的 AI Agent 幻觉不是一段文案——而是捏造了工作已经完成的事实。根源在于行动和检查是同一个步骤。
只有一个修复方案:"完成。"必须从重新获取的真实状态中得出。空的和失败的要原样报告为空和失败。
现有护栏验证的是文本输出。在动作之后重新获取世界状态并与现实对账,是一个空白。
契约现在就能用,一个 prompt 段落就够了。想用代码强制执行:npm i @hyuga/genchi。
去怀疑再多一个"完成。"吧。