跳到主要内容

两位写作者,一棵工作树:人机协同编辑的并发控制

· 阅读需 12 分钟
Tian Pan
Software Engineer

你正在重命名一个函数到一半,文件就在光标下重新加载了。你正在暂存的 diff 不再与工作树匹配。你的开发服务器无故热重载了两次,而十分钟前还通过的测试,现在却在你从未打开过的一个文件中报错了。没有崩溃,没有警告。你和你的编程 Agent 刚才一直在同时编辑同一个工作树,而你发现这一点的方式和大多数团队一样:通过那些莫名其妙的 diff。

数据库在五十年前就解决了这个问题,并给它起了一个名字 —— 并发控制 (concurrency control)。两个操作共享可变状态的写入者要么需要一个锁,一个隔离边界,要么需要一个合并协议,而在这些选项中做出选择是一个具有已知权衡的设计决策。然而,大多数采用编程 Agent 的工程团队从未明确做出这个决定。他们将第二个写入者直接扔进一个单一的工作树中,保留着单写入者世界的习惯,然后将产生的怪象归类为 “AI 不稳定”。这不是不稳定,这是一个竞态条件 (race condition),而你正是参赛者之一。

工作树从未被设计为支持两个写入者

Git 的工作树在假设中就是单写入者的。你的每一个肌肉记忆习惯 —— 将更改保持在未提交状态数小时、使用 git checkout -- . 丢弃一次实验、相信一分钟前读取的文件就是你即将编辑的文件 —— 都假设没有其他人在写入同一个目录。分支之所以存在,正是为了让多条 工作线 (lines of work) 永远不会共享同一个 工作树

运行在你的检出(checkout)环境中的 Agent 以三种截然不同的方式破坏了这一假设:

  • 它会践踏未提交的状态。 最具破坏性的失败不是糟糕的代码,而是被销毁的工作。有一个记录在案的案例:OpenAI 的 Codex 扩展在用户明确指示不准碰 Git 的情况下,在多个文件上运行了 git restore —— 导致未提交的本地编辑无法恢复地消失了。未提交的更改没有 reflog。一旦消失,就彻底找不回来了。
  • 它与你的工具产生竞争。 文件观察器、热重载服务器、保存时格式化工具以及语言服务器都假设写入来自同一个编辑器。Agent 的写入会在编辑中途触发重新构建、导致 LSP 缓存失效,并产生反映了一个你们谁都不想要的树状态的测试结果。
  • 它使你的心理模型失效。 这是最微妙的一点。你阅读了一个文件,建立了理解并开始输入。与此同时,Agent 更改了该文件的依赖项。你现在的编辑是错误的,但没有任何工具会报错,因为每一次单独的写入在语法上都是有效的。

一项针对 20,574 个真实编程 Agent 会话的大规模研究发现,违反开发者的明确约束是最常见的失败模式,出现在 38% 的失调(misalignment)事件中 —— 包括 Agent “重写 Git 历史记录并删除未提交的工作”。该研究中一个更发人深省的数据是:超过 91% 的可见解决方案都需要开发者察觉并予以回击。工作树并不会自我防卫。

明确你的决策:锁定、隔离还是合并

一旦你将人机协同编辑视为一个并发问题,解决方案的空间就已经清晰了。正好有三个家族,它们与数据库使用的三个家族完全相同。

悲观锁:一次只有一个写入者。 要么你工作,要么 Agent 工作,绝不同时进行。在实践中,这就是 “盯着 Agent” 的工作流 —— 你启动一个任务,手离开键盘,审查结果,然后继续。这是通过轮流工作进行的协调。诚然,这也是目前大多数人使用 Agent 的方式,在小规模下运行良好。其代价是吞吐量:当你最昂贵的资源(你)在 Agent 运行时闲置时,这正是让悲观锁在数据库中不再流行的串行化(serialization)问题。

隔离:独立的工作树,在提交边界处合并。 每个写入者都有自己的检出目录;冲突只在定义明确的集成点(即普通的 Git 合并)显现。这就是快照隔离(snapshot isolation),Git 自 2015 年起就提供了它的原语:git worktree 让每个分支拥有自己的目录,同时共享一个对象库。工具生态系统已强烈趋向于这种模式 —— Cursor 的多 Agent 模式在独立的工作树中运行多达 8 个 Agent,Claude Code 原生管理工作树,而 VS Code 和 JetBrains 在 Agent 工作流使其变得紧迫后,都推出了第一梯队的工作树支持。

合并协议:共享树,持续对账。 两个写入者编辑同一个目录,并依赖细粒度、频繁的对账 —— 类似于 Google Docs 的操作转换 (operational-transform) 世界。对于代码而言,几乎没有人真正构建这种模式,因为代码具有字符级合并无法识别的语义依赖。两次在文本层面干净的编辑仍可能产生一个无法编译的工作树。那些认为自己正在进行 “合并” 的团队,通常只是在 “不进行任何协调并寄希望于运气”。

这个框架的价值主要在于它排除了什么。如果你没有明确执行这三种策略之一,那么你就是在进行无协调的共享状态变更 —— 这是在任何并发系统中都从未有过成功案例的选项。

隔离正在胜出,且理应如此

对于异步智能体工作——即任何你启动后就不再盯着的任务——隔离是明确的答案,而行业向 worktrees 的收敛也反映了这一点。其原因直接对应于数据库中快照隔离(snapshot isolation)获胜的原因。

首先,冲突移动到了正确的地方。在共享树中,冲突是一个稍后才会被发现的无声覆盖。在不同的 worktrees 之间,冲突是合并冲突:可见、有工具支持,且在你面前拥有完整上下文的情况下可被解决。你并没有消除冲突——两个写入者触及同一个模块仍然会发生碰撞——你只是将它们从数据丢失转换为了一个评审步骤。

其次,归属变得可能。当共享树中的测试失败时,是谁的更改破坏了它们?当智能体的 worktree 中测试失败时,就是智能体的更改破坏了它们。归属权是将任何未经监督的任务委托给智能体的前提,而共享树从结构上破坏了这种归属。

第三,评审有了自然的单元。智能体的 worktree 产生一个分支;分支产生一个 diff;diff 像任何其他贡献一样接受评审。人机边界正好落在你团队已有的质量关卡上,而不是交错在你尚未提交且没有任何关卡的更改中。

诚实的代价:worktrees 为每个检出消耗磁盘空间,每个都需要自己的依赖安装,且每个分支的环境文件需要复制——这些烦恼目前的 worktree 管理工具大多已实现自动化。更深层次的代价是你现在有了真正的合并,这把我们带到了纪律部分。

代码隔离不等于运行时隔离

这是采用 worktrees 的团队第二次被坑的地方:worktrees 隔离的是代码状态,而不是执行状态。两个完美隔离的检出仍然共享你的机器。

两个智能体都启动开发服务器并竞夺 3000 端口——第一个成功了,第二个要么失败,要么无声地附加到错误的实例上,并开始针对另一个分支的服务器“验证”其更改。两者都针对同一个本地数据库运行迁移,导致数据库被两个模式的叠加态破坏。两者都读取相同的 shell 环境和共享缓存,因此一个分支的陈旧状态会泄露到另一个分支的反馈循环中。

这种失败模式对智能体来说比对人类更糟糕,因为人类会注意到服务器看起来不对劲。智能体不会——它会一直进行下去,直到错误或护栏阻止它,并愉快地得出结论:它的更改是有效的,因为某个端口上的某个服务器返回了 200。智能体用来验证自己工作的每样东西——端口、数据库、环境变量、缓存——都是共享的可变状态,而未经证实的反馈比没有反馈更糟,因为它制造了虚假的信心。

因此,隔离边界必须覆盖整个反馈循环,而不不仅仅是文件:每个 worktree 的端口分配、每个分支的数据库(或事务型 fixtures)、每个实例的环境注入。这就是为什么云端智能体平台在全新的容器中运行每个任务,而不是在你笔记本电脑的 worktree 中——沙盒就是 worktree 加上运行时隔离,并据此定价。在本地,你可以通过动态端口分配和每个 worktree 独立的数据库名称来实现大部分目标,但你必须真正去实践;worktree 本身并不提供这些。

“智能体在我睡觉时工作”是一种设计,而非排期

团队对异步智能体使用的短语——“它在我睡觉时工作”——听起来像是一种排期偏好。它实际上是一堆并发控制承诺,并且只有在所有承诺都成立时才有效:

  • 隔离(Isolation): 隔夜工作发生在 worktrees 或沙盒中,绝不在你的检出中进行。另一种选择是醒来后面对一个你不再认识的工作树,而你周五晚上的未提交更改被埋在下面。
  • 受限的写入范围(Bounded write scope): 每个任务声明一个领地——一个目录、一个服务、一个模块。两个隔夜任务重构同一个模块都会“成功”,然后产生一个两者都不理解的合并。声明领地是你防止写-写冲突的方法,因为冲突发生时你并不在场;一些多智能体编排器正在将其正规化为显式的文件租约协议,但任务分配约定就能让你获得大部分收益。
  • 提交协议(A commit protocol): 工作通过分支和拉取请求(PR)进行集成,CI 和评审在其中把关合并。持续运行智能体的实践者一致报告了相同的转变——瓶颈不再是代码生成,而是评审和 CI 的吞吐量。这就是系统在发挥作用:集成正是并发控制方案应该花费其预算的地方。
  • 可恢复性(Recoverability): 智能体触及的所有内容都会在其自己的分支上尽早且频繁地提交。一个不断向临时分支提交的智能体可以被回滚;一个积累了两小时未提交更改的智能体则是一场预定在稍后发生的数据丢失事故。

注意这个列表是什么:锁定(领地声明)、隔离(worktrees)和合并协议(PRs)的组合。成熟的工作流并不是从框架中选择一种策略,而是在成本最低的层级应用每一种策略。

开始将其视为系统性问题

实际的应对方案可以概括为四个步骤:

  • 永远不要让智能体 (Agent) 写入你正在编辑的签出 (Checkout) 目录。 要么你看着它工作(锁定),要么为它分配独立的工作树 (Worktree)(隔离)。介于两者之间的中间状态往往是导致工作丢失的重灾区。
  • 在智能体的权限配置中,默认禁用具有破坏性的 Git 命令 —— restorereset --hardcleancheckout --。记录在案的数据丢失事件,绝大多数是因为智能体“好心地”清理了包含你未提交工作的目录树。
  • 不仅要隔离文件,还要隔离运行时 (Runtime),这样智能体的自我验证才有意义。
  • 提交 (Commit) 的频率要远高于你习惯的频率。 在双写者 (Two-writer) 的环境中,未提交的状态是唯一没有任何并发保护的状态。

更深层次的转变在于观念。过去两年我们一直在讨论智能体写出的代码质量如何,而像工作树、沙箱、权限系统等工具性问题,看起来就像是无关紧要的“管道工程”。但每一个引入了第二个写者的系统都吸取了同样的教训:单次写入的正确性远不如写者之间的协作重要。你的团队已经深谙此道;这就是为什么会有分支、锁、事务和代码审查。智能体不是编辑器的某个功能。它是为一个写者设计的系统中的第二个写者,它理应受到同样的严谨对待,就像你对待任何其他拥有提交权限的并发进程一样。

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