跳到主要内容

被你的 Coding Agent 污染的热重载循环

· 阅读需 13 分钟
Tian Pan
Software Engineer

一个编程智能体(Coding Agent)和一个热模块替换(HMR)开发服务器,各自独立看都是神奇的存在。但把它们放在同一个工作目录里,它们就成了一个没有任何同步原语的生产者-消费者对。智能体写入文件,监听器触发。开发服务器重载到一个仅存在 90 毫秒的状态,随后就被智能体的下一次写入替换了。错误覆盖层反映的是文件系统已经越过的快照。智能体读取那个覆盖层,将其视为事实依据,并针对一个本就会被下一次保存抹去的问题编写修复方案。

在单行编辑时你不会注意到这一点。但在智能体进行协调的多文件更改时——比如在组件间重命名 prop、通过 hook 传递新字段、拆分模块——从“开始”到“完成”之间的每个中间状态在结构上都是损坏的。监听器无法分辨中间状态和最终状态的区别。而观察监听器输出的智能体,也无法区分真实的错误和其自身正在进行的工作所产生的伪影。

团队构建的是一个紧密的反馈循环,服务于保存一次、观察重载、思考、然后再次保存的人类开发者。而现在接入的是一个写入速度超过防抖(Debounce)调节上限,并将循环输出读回自身推理过程的参与者。那不是反馈循环,而是一个挂着 Token 计量表的自激振荡器。

在你添加智能体之前,监听器就已经在撒谎了

文件监听器一直是一种近似方案。内核报告原始的 inode 事件;监听器试图将其翻译为“开发者保存了文件”。编辑器并不配合。VS Code 的原子保存会产生一个临时文件的 CREATE 事件,一个将其交换到位的 RENAME 事件,通常还有一个后续的 WRITE 事件——一次保存产生三个事件。设置了 backupcopy=auto 的 Vim 可能会发出五个。JetBrains IDE 有其自己的安全写入序列。fsnotify 社区为此已经争论了十年。

标准的缓解措施是防抖:收集事件,等待平静,然后发出一个。TypeScript 的 tsc --watch 通过 scheduleProgramUpdate 进行 250 毫秒的防抖。编辑器重载的通用指南是 100–500 毫秒,这对于两次连续保存至少间隔几秒的人类来说效果很好。在理想情况下,Vite 可以在 50 毫秒内完成单文件 HMR 更新,并在更改跨越未处理的边界或触及循环依赖时回退到全页重载。

这些数值是为那些思考时间远超打字时间的参与者校准的。编程智能体反转了这个比例。它可以在监听器的防抖窗口内完成工具调用、观察结果、计划下一次编辑并调度新的写入。监听器的“平静期”启发式算法正是为了避免在中间状态触发而存在的,但却被一个永不停止的生产者击败了。从监听器的角度来看,智能体的编辑表现为一种持续的震颤——而开发服务器则针对错误的快照进行了重载。

三种看起来像是智能体变傻了的失败模式

这种模式在智能体的对话记录中产生了特定的、可辨识的行为。它们都不会自称是“监听器竞争”。它们看起来像是质量退化。

第一种是幻象错误螺旋。智能体编辑文件 A,计划在同一个逻辑更改中编辑文件 B。第一次写入后,监听器触发,类型检查器针对半应用的更改集重新运行,错误覆盖层报告 A 现在引用了一个 B 尚未暴露的符号。智能体观察到错误并进行推理:也许是导入错了,也许符号应该换个名字,也许 A 的签名需要调整。它为幻象编写了一个“修复方案”。当修复方案落地时,原始计划已被遗忘,B 从未被触及,而交付的差异(diff)只是围绕一个本会自行消失的问题所做的防御性补丁。

第二种是瞬态错误振荡。智能体做了一个更改。错误覆盖层消失。智能体做第二个更改。在第二次写入完成之前,监听器触发了(在多兆字节的文件重写,或智能体并行调度的系列小文件中会遇到这种情况)。覆盖层中出现了一个瞬时的语法错误——那种只在开发服务器生命周期中存在一帧的错误——而智能体将其视为实时状态。下一次编辑撤回了第二次更改。错误消失。智能体再次尝试。记录显示,智能体在 3 万个 Token 的消耗下,对同一个编辑进行了三次修改和回滚。

第三种是保存时 Lint 放大。带有 TypeScript 插件的 ESLint 计算开销很大——在中型项目中大约有 10 秒的开销。如果智能体通过一个在每次写入后都会触发“保存时 Lint”的监听器管道进行编辑,那么智能体对项目“当前错误”的读取将滞后于文件系统,滞后时间等于 Lint 管道的延迟。智能体将在第 N+2 轮中看到反映第 N 轮状态的错误,并将其当作现状进行推理。每一步推理都建立在两轮编辑前的世界快照之上。

在这三种情况下,智能体本身并没有损坏。是架构将智能体的感官接入了一个从未为该类消费者设计的信号源。

v0 循环是公开演示

这并非理论推测。Vercel 的 v0 产品曾记录过一起事件:由于文件修改竞争条件,Agent 陷入了无限的“读取-编辑”循环,在会话被终止前,用户端无法停止该过程。在有人察觉到它进入自反状态之前,该会话已经消耗了数万个额度。其直接原因是两个参与者——Agent 和平台的文件同步层——在重叠的时间尺度上修改相同的文件,而 Agent 对结果的读取不断产生它随后试图“修复”的差异(Delta)。

每当编程 Agent 的治理环境与实时开发工具并行运行时,都会出现同样的模式。Facebook 的文件监听器 Watchman 被记录为与 Claude Code 的沙箱不兼容,正是因为这两种观察文件系统的方式无法协作。Webpack 和 Next.js 有一长串的热模块替换(HMR)问题,这些问题在文件系统变化速度超过打包器快照模型处理能力时就会浮现——人类开发者偶尔会遇到这些问题,而一个拼命写作的 Agent 则会使其成为常态。

Token 成本视角也至关重要。有关 Agent 在一个会话中以微调的参数调用同一工具 73 次的报告屡见不鲜;一起记录在案的 v0 事件在用户成功终止前消耗了超过 47,000 个 Token。循环检测研究已经确定了一种粗略的启发式算法:对 Agent 在连续轮次中的工具调用进行哈希处理,并将两个相同的调用视为陷入循环的强烈信号。然而,由监听器驱动的振荡击败了这种算法,因为每一次接续的“修复”都与上一次略有不同——Agent 正在进行真实的编辑,只是针对一个移动的目标做了错误的修改。

让编辑与观察不再互搏的模式

修复方法不是让监听器更快或让 Agent 更慢,而是引入一种在原始易用性设计中缺失的同步原语。

事务性编辑窗口。 将一组相关的文件更改视为一个事务,由治理环境开启、完成,然后通知监听器。在此窗口期间,开发服务器的反应会被暂停,或者其输出会被拦截。Agent 将连贯的更改作为一个事件提交到磁盘,而不是作为部分内容的流。这与数据库事务的想法相同——多个写入对任何观察者来说都是原子性的——并应用于监听器关注的文件系统。其实现可以像监听器防抖逻辑所遵循的标记文件一样廉价,也可以像在提交前缓冲写入的 FUSE 覆盖层那样严谨。

来自治理环境的静默模式。 让治理环境暴露一个“Agent 正在编辑中”的信号,开发服务器、类型检查器和 Linter 可以订阅该信号。当静默模式开启时,监听器暂停反应;当信号清除时,它们一次性对最终状态进行对账。现代开发服务器暴露了足够的内部接口来实现这一点:Vite 的 import.meta.hot.invalidate() 和全量重载触发器是可脚本化的,大多数处于监听模式的 Linter 也可以通过 IPC 通道暂停。其成本仅为一行配置和对多文件工作进行括号式封装的纪律。

观察门控。 在开发服务器的输出和 Agent 的上下文之间设置一个小过滤器。为每个错误遮罩、类型检查结果和 Lint 诊断标记上它们计算时所针对的文件系统时间戳。将该时间戳与 Agent 最近的写入进行比较。如果诊断结果是针对早于最新写入的状态计算的(即该诊断在结构上已陈旧),则在它进入 Agent 上下文之前将其舍弃。Agent 只会看到与其刚刚产生的状态保持一致的诊断结果。治理环境负责维护 Agent 感官所依赖的一致性契约。

先编辑并对比,后保存并观察。 让 Agent 在内存中暂存所有相关的更改,针对建议的最终差异(Diff)进行自审,然后才对每个文件执行一次写入。监听器对每个文件最多触发一次,开发服务器针对 Agent 已经确定的状态进行重载。这更接近人类开发者进行多文件更改时的实际工作方式——你不会在重命名的过程中进行保存。其代价是 Agent 失去了通过读取中间编译输出来增量“摸索”更改的能力。但在这种环境下,这种能力往往弊大于利。

关于 Agent 文件编辑策略的基准测试文献也指向了同一个方向。原子化文件重写和脚本生成的补丁比顺序编辑要便宜得多、快得多——一份已发表的对比报告显示,脚本生成的成本比逐行流式编辑低 3.5 倍,速度快 6.5 倍——而且它们还具有在每个文件上仅跨越一次监听器阈值的特性。Token 经济学论据和同步正确性论据最终指向了同一种模式。

评估你是否真正消除了差距

即便团队安装了上述任何方案,仍需验证 Agent 是否不再对自己产生的“影子”进行推理。这种评估比实现本身更有趣,因为这种失败模式对 Agent 的自我报告是不可见的——Agent 不会宣布它处于监听器循环中。它只会悄无声息地在任务中表现得越来越糟。

对开发服务器进行插桩(Instrument),将每个错误遮罩、类型检查失败和 Lint 诊断归因于导致其变化的更改集,并按文件系统时间戳进行索引。在 Agent 会话结束后,回放轨迹并将诊断结果分为两类:Agent 应该看到的(针对稳定的编辑后状态计算的)和 Agent 永远不该看到的(在活动编辑窗口期间计算的)。相关的指标是 Agent 的推理轮次中有多少比例是基于第二类数据的。在一个发布周期内跟踪该比例;随着同步层的成熟,它应该趋于零。

对于功能性评估,运行模拟多文件编辑场景来测试跨编辑间隔——跨目录重命名一个 Prop,通过三层结构传递一个新字段,将一个 Hook 拆分为两个——并根据交付的差异是 Agent 计划的差异,还是围绕其观察到的伪影进行的防御性修补,来对 Agent 进行评分。评分者应该是与预期最终状态的确定性比较,而不是人工评审;人工评审会将防御性修补合理化为“看起来也行”。

另一个有用的二级指标是 Agent 在重复执行同一任务时计划的方差。监听器循环会产生很高的计划方差——Agent 在不同运行中达到不同的终点,因为它对不同的“影子”做出了反应。随着同步性的提高,方差会消失,Agent 的输出会像确定性编译器一样具有可重复性。

大多数团队都会忽略的架构转变

初次遇到这种情况时,人们的直觉往往是去调试代理:更好的提示词、更智能的循环检测、更长的思考步骤。这一切都没有触及根本原因。根本原因在于,“紧密反馈循环”曾是一个单参与者系统的开发者人体工程学属性,而团队在将系统转变为多参与者系统时,却没有重新协商协议。

开发服务器监听器的契约隐含地规定为:一个人类以人类的速度保存文件;重新加载以反映其保存的内容。当第二个参与者是一个以机器速度写入、并将其推理建立在监听器反应之上的代理时,该契约在双方都遭到了无声的违背。代理的写入速度超过了监听器的防抖(debounce)处理;代理将监听器的输出视为稳定的观测结果,而非嘈杂的启发式反馈。双方都未察觉契约已然改变。

核心任务是让契约变得显式。确定哪个参与者是编辑的生产者,哪个是状态的消费者。确定哪些算作已提交的更改,哪些是处理中的草稿。确定允许代理观察哪些状态,以及通过何种过滤器。同步原语并不一定需要很重——一个标记文件、一个 IPC 通道、一个进程内门控——但它必须存在,且团队中必须有人负责。

那些在未定义此契约的情况下就将编程代理引入实时开发环境的团队,其实并不是在交付编程代理。他们交付的是生产者-消费者竞态,其成本体现为浪费的 token、防御性的 diff,以及让评审人员疑惑为什么这个 PR 比描述中显示的要诡异得多。一旦你洞察到这一点,修复成本就很低。难点在于意识到,你的开发循环中的参与者已经不再是以前的模样了。

References:Let's stay in touch and Follow me for more thoughts and updates