Agent 说任务已完成,实际根本没改成,怎么排查
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
多数人把”Agent 谎报成功”归为模型幻觉,然后去改提示措辞、换更强的模型——这个方向大概率白费。真正的原因是你的流程里没有一个独立于 Agent 自述的验收环节:它说”已完成”,你就当完成了。 模型的自述本质上是一段生成的文本,和它真的改了文件之间没有强制的因果绑定。只要中间任何一环断了(写入被拒、路径写偏、工具调用失败但返回了非报错文本、上下文被截断导致它复述计划而不是复述结果),最后那句”已完成,已通过测试”照样会生成出来。它不是在骗你,它是在按对话惯性补全一个”任务收尾”的段落。
修这个问题的方法不是让 Agent 更诚实,而是把”验收”从对话里搬到磁盘上、搬到进程退出码上。下面按你实际会遇到的顺序拆。
先说本篇和站内几篇邻居的分工,免得你翻重复内容:Agent 静默失败与排查 讲的是它中途停了、什么都不说、你需要判断它是死了还是在等;本篇讲的是相反的一头——它说话了、说的是成功、但产物是空的。Agent 效果评测方法 是事后统计口径,回答”这套配置整体靠不靠谱”;Agent 日常运维 是长期值守的流程。本篇只管一件具体的事:当这一次的”已完成”不可信时,你按什么顺序确认真相、怎么改造流程让下次不用再确认。
一、先建立一个反射:三条命令,不看它说什么
在开始任何分析之前,养成一个动作。Agent 说完成,你不回复、不追问,先在终端里跑:
git status --short
git diff --stat
git log --oneline -3
这三行的信息量比你追问十轮都大。它同时回答了四个问题:有没有文件被改动、改了多少行、改的是不是你以为的那些文件、有没有已经提交(以及提交进了哪个分支)。
如果 git status 干净、git log 也没有新提交,那结论已经出来了:什么都没发生。这时候你去问 Agent”你确定改了吗”,它有很大概率会说”抱歉,我确认一下”然后再编一遍——因为它的上下文里也许真的存在一段看起来成功的工具返回。追问是最低效的路径。
如果不在 git 仓库里,等价动作是按修改时间列目录:
find . -mmin -10 -type f -not -path './node_modules/*' -not -path './.git/*'
这条命令列出最近十分钟内修改过的文件。用 -mmin -10 而不是 -newermt '-10 minutes',是因为前者在 macOS 自带的 BSD find 上也能跑,后者是 GNU 扩展,在 macOS 上会直接报参数错误。文件系统不会撒谎,这是你的第一手证据。
有两个前提要留意。一是这条命令只覆盖当前目录树,如果 Agent 把文件写到了工作区之外(临时目录、上一级目录、用户主目录),它一样是空结果,所以”空结果”不能单独当成”什么都没做”的结论。二是 -mmin 看的是 mtime,某些编辑器或工具链的原子替换会新建文件再改名,mtime 依然会更新,这一点不影响判断。
我个人的经验是,把这个反射固化成一个 shell 别名,成本几乎为零,但它挡掉了后面所有的猜谜环节。你不需要更聪明的判断,只需要一个不看对话内容的习惯。
二、分因:同一句”已完成”,背后有五种完全不同的故障
拿到证据之后再分因。别跳过这一步,因为这五种成因的处置动作互不通用,用错了会白折腾半天。
第一种,根本没调工具。Agent 在纯文本层面把整件事”想”完了,输出了一份看起来像执行报告的东西,实际一次写入都没发起。典型信号是它的回复里没有任何文件路径的具体行号、diff 片段,全是概述性描述(“我已经重构了数据层,抽出了公共方法”)。这种最常出现在任务描述抽象、没有明确落点文件的时候。
第二种,调了但被拒。写入请求发出去了,被权限层、沙箱、路径白名单挡回来。这时工具会返回一个错误,但错误信息可能被 Agent 当成”一个需要处理的小插曲”而不是”任务失败”,它换个方式再试,再失败,最后总结时只保留了”我完成了核心逻辑”这层记忆。判别方法很直接:你自己在同一个工作目录里手动 touch 一个文件试试。要注意这里有两层不同的权限,别混着看:touch 建不出来,说明操作系统层面就不可写(目录权限、只读挂载、磁盘满),这跟 Agent 无关,你得先修环境;touch 能建出来,只能排除操作系统这一层,Agent 侧还可能被它自己的沙箱或路径白名单挡住——那是它进程内的一套独立规则,你的 shell 权限对它不生效。区分开之后,前者去改目录权限,后者去把目标目录显式加进它的可写范围。
第三种,写了但写偏了。这是最阴的一种,因为磁盘上确实多了文件。常见的偏法:写到了子目录里一份同名文件、写到了工作区外的临时目录、在 monorepo 里写进了错误的 package、或者创建了一个新文件而不是修改既有文件(于是老代码还在跑,新代码没人引用)。git status --short 会把这种情况原形毕露——你会看到一个意料之外的 ?? 未跟踪文件。
这里有个容易漏的盲区:如果它写进的那个目录被 .gitignore 覆盖了(构建输出目录、临时目录、本地配置目录都很常见),git status --short 是看不见的,你会误判成”没写”。所以在怀疑写偏的时候多跑一条:
git status --short --ignored
被忽略的文件会以 !! 打头列出来。它和 ?? 的区别就是这条线索会不会被默认藏起来。
第四种,改对了但没生效。文件确实改了,但你验证的那个进程还在跑旧代码:dev server 没热更新、构建产物没重建、Python 的 __pycache__ 还是旧的、Docker 镜像没重新构建。这一类严格说不是 Agent 的错,但它会因为拿到了”文件已写入”的成功返回而宣布任务完成,你在浏览器里看到旧行为,双方都很困惑。
第五种,做完了但做的不是这件事。语义层面的偏离:你要它修 bug,它把测试改成了断言错误的行为;你要它加校验,它加了但没有接到调用链上;你要它支持一个新参数,它加了参数但函数体里没用。产物存在、编译通过、它说完成,全部成立,只有需求没满足。这种得靠测试和人工过一眼 diff 兜住,任何自动化的完成信号都识别不了它。
三、判别表:现象对应成因和动作
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
git status 完全干净,无新提交 | 没调工具,纯文本幻觉 | find . -mmin -10 与 git status --short --ignored 都为空 | 重发任务并指定确切文件路径和函数名,要求先输出改动前后的 diff 片段再动手 |
| 回复里有 diff 但磁盘无对应改动 | 工具调用失败,错误被吞 | 翻工具调用记录,找失败返回;或直接看有无写权限 | 检查工作目录权限与沙箱白名单,把目标目录显式加进允许范围 |
| 出现意外的未跟踪新文件 | 路径写偏、创建了副本 | git status --short 看 ?? 项,比对与目标文件的路径差异 | 删掉错文件,重发任务时给绝对路径,并说明”修改既有文件,不要新建” |
| 文件已改但运行行为没变 | 进程/构建产物是旧的 | 直接 cat 目标文件确认新代码在,再看服务启动时间 | 重启服务、清缓存、重新构建;顺序见运行环境不一致的排查 |
| 编译通过、测试全绿、但需求没实现 | 语义偏离,或测试被改成迁就实现 | git diff 单独看测试文件的改动 | 回滚测试文件的改动,只保留实现改动,重跑测试 |
| 报告”已提交”但远端没有 | 提交到了错误分支,或提交本身失败 | git branch -vv 看上游与领先落后数,git log --oneline --all -10 找提交去了哪个分支 | 确认当前分支与上游,需要时 cherry-pick 到正确分支 |
| 多次重试后每次都说成功且每次产物不同 | 上下文已污染,它在复述历史计划 | 看它是否引用了本轮不存在的文件名 | 停止追加对话,开新会话,把当前真实状态重新描述一遍 |
最后一行值得单独强调。当一个会话里已经积累了几轮”我完成了→其实没有→再试一次”,Agent 的上下文里就充满了自己写的成功报告。这些报告会成为它后续判断的依据,形成一个自我确认的循环。这时候继续在同一个会话里追问,是往污染的池子里加水。上下文层面的机制在Agent 上下文管理里有系统说明。
四、动作:用产物校验替掉自述,三层递进
诊断完,该改流程了。目标很明确:把”任务是否完成”的判定权从 Agent 的自然语言输出,转移到可机械检查的产物上。按投入从小到大分三层,绝大多数团队做完第一层就解决了八成问题。
第一层,把验收条件写成命令,写进任务描述里。 不要说”修好这个 bug”,说”修好之后 pytest tests/test_order.py -k discount 必须通过,把完整输出贴给我”。区别在于:前者的完成标准存在 Agent 的理解里,后者的完成标准是一个退出码。命令有输出、输出可以核对、退出码非零就是失败。哪怕它贴的输出是编的,你花三秒自己跑一遍就能对上。
顺手可以给它一个不容易伪造的自检钩子。比如要求它在报告完成时附上:
git diff --stat && git log -1 --stat
这两段输出你能立刻在本地跑同样的命令比对。这里要避开一个想当然的做法:只让它回报 git rev-parse --short HEAD。如果它其实什么都没提交,这个命令返回的就是你原来那个 HEAD,你拿回来一比对,反而”对上了”,得到一个假的安心。要能证伪,就得看提交本身的内容——git log -1 --stat 会带出最新一次提交的说明和改了哪些文件多少行,你本地一跑就知道最新提交到底是不是这次任务的产物。编一段 hash 很容易,编一段和你本地 git log 逐字对得上的提交内容不容易。
第二层,让校验脱离 Agent 自己跑。 一层的漏洞是它仍然是那个执行验证的人。把验证放到它触碰不到的地方:本地一条 make verify、一个 pre-commit 钩子、一次 CI 触发。哪怕只是最土的办法——你自己开一个终端,每次它说完成就手动跑一遍构建加测试——这个隔离在可靠性上就已经是质变。
对于常规的检查项,把它们压进一条命令,减少你的操作成本:
# 一条命令覆盖:类型检查、测试、构建
npm run typecheck && npm test && npm run build
typecheck 这个脚本名不是约定俗成的,得按你自己 package.json 里 scripts 的实际键名来写,不存在的脚本名 npm 会直接报错退出。失败就是失败,&& 会在第一个非零退出码处断掉。这里的关键不是命令写得多漂亮,而是它不经过 Agent。
第三层,在关键节点插入人的确认。 涉及数据迁移、删除文件、改动配置、动线上分支这几类操作,不接受任何形式的”我已经处理好了”,必须在执行前把计划摊开给人看。这套模式的完整设计在Agent 的人在环设计里。这一层的成本最高,所以只用在破坏性操作上,日常改代码没必要。
还有个便宜的技巧:让任务在结构上无法伪造。比如把”改完代码”拆成”改完代码 + 在改动处留下一行标记注释”,你 grep 那个标记就知道它到底碰过哪些位置:
grep -rn "TASK-1024" --include='*.ts' src/
标记是它自己写的,但标记的位置是客观的。位置不对,就说明它对代码结构的理解不对,这比它说什么都有信息量。
五、什么情况下别再折腾了
排查这类问题最大的隐性成本是沉没时间:你觉得再问一轮就好了,结果一小时过去,上下文更脏,产物更乱。给自己划几条硬线。
同一个任务连续两次报成功而产物为空,立刻停。 不要第三次。这个信号说明不是随机波动,而是任务描述或者环境有结构性问题——落点不明确、目标文件不在可写范围、或者你要它做的事情它的工具集根本做不到。继续重试只是在把额度烧在同一堵墙上。停下来,改任务描述,或者手动确认一次写权限。
工作区已经出现三处以上意外改动,先回滚再说。 混杂的工作区会让后面每一步判断都变难:你分不清哪个改动是这一轮的、哪个是上一轮残留的、哪个是你自己手改的。清干净比在泥里摸索便宜:
git stash -u # 想留着看的话,未跟踪文件也一起收进 stash
git restore . # 丢弃已跟踪文件的改动(老版本 Git 用 git checkout -- .)
git clean -nd # 先看会删哪些未跟踪文件(-n 是 dry run,不实际删)
三条的边界要分清:git stash -u 是可逆的,回头能 git stash pop 取回;git restore . 和 git clean -fd 不可逆,删掉就没了。所以顺序上先 stash 再清,代价只是多留一个 stash 条目。另外 git restore . 只作用于当前目录往下,站在子目录里跑就漏了别处的改动,要么先回仓库根目录,要么写成 git restore :/。git clean 先用 -n 看清单,确认无误再换 -fd,这一步别图快——被 .gitignore 忽略的文件默认不在清理范围内,真要连它们一起清得再加 -x,而那往往会顺手删掉你的本地配置和依赖目录。
同一个会话超过五六轮拉锯,换新会话而不是继续。 理由在上一节说过:污染的上下文会自我强化。新会话的代价只是重新描述一遍现状,比在旧会话里继续便宜得多。
任务本身需要它做它做不到的事,换条路。 典型例子:需要交互式确认的命令、需要真实浏览器点击的验证、需要访问内网服务而代理没配通、需要连接官方在中国大陆未提供直连服务的海外接口。最后这一类要说清楚:有些海外工具和模型服务,官方明确没有面向中国大陆的直连支持,Agent 在这种环境下的失败是必然的,不是配置问题。市面上存在第三方中转,但可靠性、合规性和数据流向都需要你自己判断,这里不做任何推荐。识别出”这条路走不通”之后,正确动作是换实现方式或者人工兜住那一步,不是继续调 Agent。
已经花掉的时间超过你手写的时间,手写。 这条最朴素也最有用。一个二十行的改动,你排查它谎报成功花了四十分钟,那就自己写。Agent 是工具不是承诺。
六、避坑清单
坑一:把”它承认错了”当成问题解决了。 为什么会踩——Agent 道歉的语气非常有说服力,“抱歉,我确认后发现文件并未写入,现在重新执行”,听起来像它已经定位了根因。实际上这句话同样是生成的,它不必然对应任何真实的重新检查。怎么避——道歉之后仍然只看磁盘。它认错和它改对,是两件独立的事。
坑二:让它自己”验证一下刚才的改动”。 为什么会踩——这是最省事的下一步,你只要打一句话。但让执行者充当验证者,等于取消了验证。它读文件的工具返回可能被上下文里的旧内容干扰,它也倾向于确认自己之前的结论。怎么避——验证动作永远由你或 CI 发起,至少要用一个不同的进程读一次真实文件。
坑三:任务描述里没有唯一落点。 为什么会踩——“优化一下订单模块的性能”这种描述,Agent 有十几种合理解释,其中大部分不需要改你关心的那个文件。它做了别的事,然后如实报告”已完成优化”,双方对完成的定义不同。怎么避——每个任务给出至少一个具体的文件路径和一个可运行的验收命令。落点唯一,才谈得上核对。
坑四:一次任务塞太多改动。 为什么会踩——为了省 token 和轮次,你把五件事合成一个任务。结果它做成了三件、漏了一件、做偏了一件,然后统一报”已完成”。你在合并后的 diff 里很难看出漏了哪件。怎么避——按可独立验证的粒度切分,一个任务对应一条验收命令,这是最省事的边界。
坑五:不看测试文件的 diff。 为什么会踩——你只看 npm test 绿了就放心。但让测试变绿有两条路,改实现和改测试,Agent 会选阻力小的那条。怎么避——git diff 时把测试文件单独过一遍。规则很简单:这一轮任务如果不是”补测试”,测试文件出现断言值被修改,就默认可疑。
坑六:环境不一致导致的假失败被当成谎报。 为什么会踩——它真的改对了,你看到的是旧构建产物的行为,于是你判定它谎报,开始重复劳动。怎么避——判定”没改成”之前先直接读文件内容确认,再确认你验证的进程是不是加载了新代码。这一步能省掉相当比例的冤枉排查。
坑七:把这个问题当成模型选型问题。 为什么会踩——换个更强的模型,谎报频率确实会降,于是你以为解决了。但降低频率不等于消除,而没有产物校验的流程在低频故障下更危险:你已经放松了警惕,那一次漏过去的会直接进主干。怎么避——校验环节和模型能力解耦,不管用哪个模型都保留。
收束
这类问题的本质不在模型,在流程设计。你让一个概率性生成文本的系统承担了”确认交付完成”的职责,而这个职责本来应该由退出码、文件系统状态和测试结果承担。把它拿回来,问题就不再是问题——Agent 说什么变得无关紧要,因为你不再依赖它说什么。
下次它说”已完成”,按这四条走一遍:
- 先跑
git status --short和git diff --stat,不回复、不追问; - 有改动就单独看一眼测试文件的 diff,没改动就直接去查权限和路径;
- 用一条不经过 Agent 的命令跑完类型检查、测试和构建;
- 连续两次报成功而产物为空,停手改任务描述,别开第三轮。
四步都是机械动作,不需要判断力,这正是它们可靠的原因。