一个代码修改Agent因缺少停止规则,在同类编译错误上循环868次、运行三小时并产生约138美元成本,最终还留下重复导出和破损JSX。案例揭示自主编码循环必须设置调用预算、进展检测、错误去重和人工接管条件。
这是游戏工厂系列 8 篇文章中的第 5 篇。
我盯着终端不断滚动的输出,突然注意到这个 Agent 已经运行了一个多小时。尝试构建、被编译器拒绝、尝试打补丁。始终是同一个文件、同一种错误模式,只是每次都会提出一种新的修复方案。
等我最终叫停它时,调用次数已经达到 868 次,三个小时过去了。它一直处理的那个文件里出现了两个 export default 语句、两个 return (...) 代码块——其中一个成了孤立的 JSX,连闭合都没有——还有一些已经正确应用主题的字符串和原始主题字符串混杂在一起,彼此紧挨着,就像模型同时开始了好几次尝试,却每次都在思路进行到一半时停了下来。至少在最后一百次调用中,React 编译器一直在拒绝这个文件。
按照该模型的公开定价计算,这次运行大约花了 138 美元。
按照 Builder 自己的规则,它并没有做错任何事。因为规则里根本没有要求它停下来。
Builder 是整个流水线中的第四个 Agent。轮到它运行时,Designer 已经生成了规格说明,Image-Gen 已经生成了符合主题的图标,Background-Gen 也已经制作好了背景图。Builder 的工作,就是接过所有这些产物,把它们真正接入代码。
Builder 运行在一个扁平的 while 循环中:调用模型、分派模型选择的工具、把结果反馈给模型,然后不断重复,直到模型调用 mark_build_complete,或者发生严重到必须中止的错误。循环本身只有八行代码。真正有意思的是循环里的工具集。
Builder 拥有的工具比流水线中的其他任何 Agent 都多。它可以读取文件、写入文件、列出目录中的文件、复制文件、给文件打补丁(查找一个完全匹配的字符串并将其替换),还可以调用终端工具。一共六种操作,每一种都对应实际工作中的一个步骤。工具的多样性很重要:构建过程中的大部分工作是复制操作,一部分是写入,还有一小部分是有针对性的补丁。
这里设置了两个人工关卡。在 Agent 接触任何代码之前,它要先提出计划并等待批准。构建完成后,它会展示输出结果,然后再次等待确认。
在烧掉 138 美元的那个版本中,并没有强制性的编译关卡。模型能够看到编译器输出——它会通过终端工具自行运行 npm run build、读取错误并尝试打补丁——但编译失败时,没有任何机制能够阻止循环继续执行。模型只会不停尝试。后来我添加了一个强制编译关卡:模型发出完成信号后才运行编译;如果出现错误,就把错误反馈给模型,但只允许进行有限轮次的修复;如果始终无法收敛,则回滚整个构建。不过,我接下来要讲的这次事故发生时,这套结构还不存在。
这个赌场项目的代码库是一个 React 应用,后端由 AWS serverless functions 提供支持。游戏里的每个符号、每种颜色和每条获胜消息都直接写死在源代码中。Builder 的任务,是从中派生出一个新的主题版本:原样复制结构性文件,根据规格说明替换颜色、字体和 API endpoints,再修改主游戏组件,让滚轮、获胜消息和教程都能体现新主题。
以古埃及主题版本为例:用黄金色和青金石色调替换原有配色;在符号配置中,用象形文字名称替换云服务名称;把原本与 AWS 架构有关的获胜消息,改成与法老有关的内容。代码结构保持不变,只替换内容层。
这种复制加补丁的模式非常适合小型、独立的文件。CSS 配置、音效文件清单、主题元数据——它们都有清晰的边界。Builder 处理这些文件时毫无怨言。
但主游戏组件完全是另一回事。
这个文件太大了,根本无法安全编辑。
SlotGame.js 大约有 1,900 行,其中包含旋转逻辑、胜负计算状态机和滚轮动画处理——而在这些代码中,还到处散落着与主题相关的字符串。条件分支里嵌着获胜消息,JSX 里写死了教程文案,十几个不同位置引用了符号,而且每处引用周围的语法都略有不同。
我已经把颜色替换、字体替换和静态字符串替换移到了普通 Python 代码中——这些确定性的处理会在模型参与之前运行。这大幅缩小了需要打补丁的范围,但还不够。
剩下的补丁依然会撞上补丁工具本身带来的问题:为了修改一行代码,模型必须精确复现它周围的整个代码块。每一个括号、每一级缩进,以及目标前后的每一个空行都必须完全一致。面对一个 1,900 行的文件,模型不可能可靠地记住所有空白字符的细节。它只能猜。有时能猜对,但更多时候只是看起来差不多,而这种“差不多”已经足以导致失败。
规格说明中的符号名称有时会包含撇号。设计文档里出现一个名为 Chef's Trio 的符号听起来很合理,但如果直接把它放进 JavaScript 的单引号字符串中,就会导致解析失败。模型并不能始终正确转义这些字符。编译器会拒绝这个文件,Agent 读取错误后尝试修复撇号,却又可能顺手改变某一级缩进,导致另一个地方出错。
每次失败的补丁都会让文件变得更糟。
每次编辑后,磁盘上的文件都会与模型记忆中的版本产生一点差异。模型会根据上一次读取时看到的内容生成补丁,但每次修改之后,文件就已经不再与那份快照相同。于是,模型实际上是在针对一个磁盘上早已不存在的版本提出补丁。由于周围上下文已经发生变化,精确字符串匹配会失败;而模型不知道文件当前究竟是什么样子,只能猜测代码块现在可能是什么样,再尝试一次,并且往往又猜错。
有一种方法可以限制这个问题:强制模型在每次打补丁之前,重新完整读取当前文件。但我当时没有这么做。在那些运行顺利的任务中,每一轮都重新读取 1,900 行代码,token 成本看起来太浪费了。但对于那些运行不顺的任务,这样做原本可以更早发现文件状态已经发生漂移。
重复结构是这类故障最典型的特征。当精确匹配补丁工具拒绝模型提供的目标字符串时,模型就会退而求其次,通过写入工具重写文件中更大范围的内容——有时它不会替换原有内容,而是把替代内容插在原内容旁边,最终生成两个完全相同的代码块。两个 export default 语句、两次 return (...) 调用,以及漂浮在结构代码块之间、再也无法与任何内容衔接的孤立 JSX。
这个循环没有上限。
868 次调用。三个小时。由于模型每次调用都会重新读取同一段不断增长的对话,累计使用了超过 4.13 亿个缓存输入 token。按照 Sonnet 的公开定价计算,花费约为 138 美元——而且这还是在启用了 prompt caching 的情况下。Bedrock 的缓存意味着,每次调用只需支付完整输入成本的一小部分。如果没有缓存,账单还会再高一个数量级。缓存让 Builder 在正常运行时具备了可行性;与此同时,它也让最坏情况下每次调用的成本低到不易察觉,以至于直到循环失控一个小时后,我才注意到问题。
我当然从理论上知道,由付费 API 驱动的无界循环存在风险。但从理论上知道,和亲眼看着它发生,是两种截然不同的体验。
最终的解决方案并不是让模型变得更加谨慎,而是彻底消除编辑这个文件的必要。
这个组件之所以很难安全打补丁,是因为它把结构性代码和内容混在了一起。主题相关的字符串并没有被隔离,而是编织在旋转逻辑和胜负计算分支之中。修复方法就是把二者分离:将主题相关文本移入组件在运行时读取的配置文件。这样一来,Builder 针对这个组件的任务就从修改 1,900 行 JSX,变成生成一个 JSON 文件。
根据规格说明生成 JSON 配置,用普通 Python 就能完成——函数读取规格说明中的字段,再将它们序列化成组件所需的数据结构。无需模型参与,无需字符串匹配,也无需编辑文件。组件在运行时读取配置,模型则完全不再接触这个组件。
我不会在这里完整解释这套修复方案——整个重新设计的过程,以及它对于“流水线中的哪些部分究竟应该使用模型”这一问题的启示,是本系列最后一篇文章的主题。
两点,都很简短。
一个修改代码的 Agent 是否安全,取决于你要求它编辑的目标有多大,以及目标采用了怎样的结构。结构与内容边界清晰的小文件,是可行的编辑对象。把多种关注点交织在一起的大文件则不是。如果你的 Agent 正在给一个大文件打补丁,而且表现很糟糕,那么先检查文件,再检查模型。
任何由付费 API 驱动的模型循环,在你放心让它执行真实任务之前,都必须设置硬性的轮次上限。这个限制不应该事后才作为安全措施补上,而应该成为循环获准运行的前提。上限并不会改善 Agent 的推理能力,但它能让最坏情况下的运行成本保持在可承受范围内,而不是给你一个意外的惊吓。
上一篇:图像 Agent。下一篇:Tester Agent。完整的重新设计方案,以及那条更普遍的经验——什么应该成为 Agent,什么不应该——会在本系列的最后一篇文章中讨论。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。