生成式软件工程
搜索
开始阅读/第 3 部分 · 管住变化/第 8 章

并行协作与项目演进

一个人干活时,版本管理只是存档;几个人或几个 Agent 同时干活时,它决定了活能不能干成。

这一页讲什么
  • 多个 Agent 同时改一份目录,具体会怎么互相覆盖;
  • 工作树能隔开什么、隔不开什么;
  • 上锁与合并两种协作策略,各自要付什么代价;
  • 人数一多,为什么沟通关系会按平方增长;
  • 最小可行产品、技术债,以及从零开始为什么比改成熟项目更难。

一、两个 Agent 改同一份目录

先看一个具体场面。你让 Agent A 改界面,让 Agent B 补后端接口,两个都开在同一个项目目录里。它们各自读文件、改文件、跑测试,看起来各干各的。

三分钟以后问题开始冒出来:

这三种麻烦的共同点:它们都不报错。没有冲突提示,没有异常报错,只是结果悄悄变成了另一个样子。人做同样的动作会互相看见、互相说一句,Agent 之间没有这个通道。

再往下想一层,覆盖不只发生在文件层面。两个 Agent 各自基于「现在的状态」作出判断,判断之间互相作废,可它们都不知道。举一个不带任何代码的例子:项目规定任何时候至少有一名值班员在岗,现在甲、乙都在岗。Agent A 看到乙在岗,把甲改成离岗;Agent B 同时看到甲在岗,把乙改成离岗。两处修改落在不同文件里,文本合并毫无冲突,结果却是没人值班。各自都按规则行事,组合起来违反了规则。

关键结论 并行协作的第一个动作是隔开工作环境,第二个动作是提供一个能把成果合回来的通道,第三个动作是给出一种检查组合结果的办法。缺了第三样,前两样只是把一个错误变成两个错误。

二、工作树:一个仓库,几个工作目录

给每个 Agent 各克隆一份仓库是一个办法,代价是空间与同步:几个目录各自维护远端、各自 fetch,中间还多了一层搬运。Git 提供了一个更适合本机并行的机制,叫工作树(worktree,也叫工作区检出)。

同一个仓库可以同时关联多个工作目录。每个目录有自己的工作区、自己的 HEAD、自己的暂存区;它们共享仓库里的对象和大部分引用。新建一个工作目录只要一条命令:

PS> git worktree add -b agent-ui ../project-ui
Preparing worktree (new branch 'agent-ui')
HEAD is now at 7d1e4a2 记下第一次练习的目标

PS> git worktree add -b agent-memory ../project-memory
Preparing worktree (new branch 'agent-memory')
HEAD is now at 7d1e4a2 记下第一次练习的目标

PS> git worktree list
C:/Users/你的用户名/gse-practice        7d1e4a2 [main]
C:/Users/你的用户名/project-ui          7d1e4a2 [agent-ui]
C:/Users/你的用户名/project-memory      7d1e4a2 [agent-memory]

三个目录,一个仓库。A 在 project-ui 里改界面,B 在 project-memory 里改记忆模块,两人提交的内容都直接进了同一个仓库,不需要互相 push、pull。要汇合的时候走常规的 merge 或 rebase,冲突标记仍然会出现。

flowchart TB
  R["同一个仓库 共享对象与引用"] --> W1["工作目录 project-ui 分支 agent-ui"]
  R --> W2["工作目录 project-memory 分支 agent-memory"]
  W1 --> MG["合并 或 变基 汇合"]
  W2 --> MG
  MG --> C["共同的主线"]

工作树隔开的东西很明确:文件、暂存区、当前分支。它隔不开的东西同样明确,这些才是并行时真正容易出事的地方:

共享资源冲突表现缓解办法
端口两个 Agent 抢同一个 3000 端口,后起的那个起不来用环境变量给每个目录分配不同端口
数据库一个跑了迁移,另一个的测试数据被污染每个目录独立数据库,或者各自容器化
依赖与缓存每个目录都要重装一遍依赖共享包缓存,把安装写进初始化脚本
被忽略的文件新目录里没有 .env,因为这类文件本来就不进版本历史初始化脚本自动复制配置模板
生活类比 四个小组在同一间自习室做课程设计,桌子是分开的,草稿互不干扰,这叫隔离了工作区。可打印机只有一台、插座只有两个、参考书只有一套,谁先用仍然要抢。工作树把桌子分开了,打印机和插座还得另外排队。

三、上锁与合并:两种协作策略

环境的隔离解决了「互相覆盖文件」,解决不了「谁先动共享的东西」。协作策略有两个基本方向。

1. 先上锁,再做

由主 Agent 规划,把任务拆成边界明确的部分。子 Agent 要修改某项共享资源时,先去取一把互斥锁;取到之后重新读一遍最新状态,改完、跑完必要检查、留下可恢复的快照,再释放锁。锁在这里是一套约定:同一时刻只允许一个参与者修改约定范围内的状态。

以上面两个改记忆格式的任务为例。先取锁的那一个完成一次前后一致的修改并存下快照;后取锁的那一个拿到锁以后重新读取最新内容再动手。谁都没有基于过期状态写入,冲突从「事后处理」变成「事前避免」。

2. 各做各的,汇合时检查

另一种方向是让所有人基于各自的版本往前推进,等到汇合的时候再处理差异,也就是 merge 与 rebase 那一套。日常写代码大体上就是这么干的,它允许并行探索,代价是把矛盾推到汇合那一刻集中爆发。

关键区别 上锁策略在工作之前协调,靠调度和互斥保证同一时刻只有一个人写;合并策略在工作之后协调,靠版本比较收拾差异。两者可以叠起来用:粗粒度的东西上锁,细粒度的东西合并。选哪种主要看共享资源是什么、冲突多不多、返工贵不贵,参与者是 AI 还是人,在这个选择里反倒次要。

3. 锁的代价

上锁听起来干净,代价一点不少,而且这些代价在操作系统课和数据库课里都出现过:

注意 上锁最危险的一种用法是「以为锁保护住了不变量,其实只保护住了一个文件」。跨文件、跨表、跨服务的约定,必须找到与它覆盖范围相称的临界区。想不清楚这一点,锁只会让人更放心地犯错。

四、人多以后,困难从技术转到了沟通

把参与者数量往上加,会发生一件数学上很确定的事。若 n 个人彼此都可能需要直接沟通,潜在的沟通关系有 n(n−1)/2 条:3 个人是 3 条,6 个人就是 15 条,10 个人是 45 条。翻一倍的人,翻四倍的线。

flowchart LR
  A["3 人 3 条关系"] --> B["6 人 15 条关系"]
  B --> C["10 人 45 条关系"]

这个公式数的是潜在关系,实际耗时不会严格按它增长,因为清楚的接口和明确的分工能把大量「必须当面说清」的关系消掉。但它把一件事说得很清楚:人一多,卡住项目的环节从「写不出来」挪到了「说不清楚」。

几个典型场面,对照自己的小组作业经历看:

这些场面里没有一行代码写错,它们全都是「同一件事在不同人脑子里成了两件事」。版本管理能保存状态、保留历史和教训、允许多条探索路线存在,它做不了的是替团队确定优先级、替团队找到那个愿意把整件事接起来的人。

五、最小可行产品:先做一个能用的

上面那些麻烦里有一大半的来源是同一个:大家都在对着想象做东西。对付想象最有效的办法是把它变成一个能看见、能点、能批评的东西。

最小可行产品(Minimum Viable Product,缩写 MVP)指的是:做出足够小、又能真正投入使用的版本,用它检验最关键的假设。开工之前先写下两个问题:

  1. 这个版本要验证什么?
  2. 出现什么结果,就说明我们想错了?

第二问最容易被跳过。只有事先说明需要什么证据,第一版做出来之后才判断得了应该继续完善还是改方向。

拿「会成长的个人助手」举例。第一版不必包揽邮件、日程、浏览器和长期记忆,可以先挑一个你每天都会遇到的场景:记录一项偏好,下一次相关任务里用上它,并且允许你纠正。这个版本可以很粗糙,但你能亲自观察它有没有帮上忙、记忆有没有被正确取用、纠正之后行为有没有真的变化。

一旦开始使用,藏在形容词里的问题就会变成具体的分歧:「更主动一点」可能意味着每天固定时间提醒,也可能意味着只在发现明确遗漏时提示;「「更了解我」可能需要记住长期偏好,也可能先要让人学会忘掉一次性的指令。真实使用产生的这些分歧,会反过来检验早期的规格和结构。

生活类比 先做一辆能骑的滑板车,缺两个轮子的轿车可以往后放。滑板车虽然简单,却能立刻回答「我到底需不需要代步、需要怎样的代步」;缺轮子的轿车再宏伟,也给不出任何可以检验的答案。

MVP 有它盖不住的东西。原型走通正常路径,转专业、课程替代、规则追溯、人工批准这些角落可能还没露出獠牙;试用期里没人碰的边界情况,恰恰是日后最贵的地方。更硬的一关是需求本身变了:把「一门交换课程替代一门本校课程」改成「允许两门交换课程合抵一门必修课」,受牵连的地方遍布下拉框之外:课程对应关系、学分计算、毕业审核、成绩单显示以及一串测试都要动;已经认定过的学分要不要重算,已经批准的特例还算不算数,也都要给出答案。快速做出原型很有价值,需求冲突、历史包袱和持续变化仍然要有人处理。

六、技术债与认识的进步

第一版做完、用起来之后,多半会发现当初的设计有点别扭。为了赶进度先凑合的写法、临时加的一个字段、绕过去的一段逻辑,都会在后续修改时收利息。这笔利息有个名字:技术债(technical debt),指将来修改系统时必须额外偿还的成本。

值得区分两种来源,它们的处理方式完全不同。

来源典型情形处理方式
图省事欠下的明知有更好的写法,为了先跑起来先凑合,且没记下来记进待办,安排时间偿还
认识进步欠下的当初的设计在当时有充分理由,做出来用起来之后才明白概念应该怎么分承认它,写下新的理解,找时机调整结构

第二种更值得留意。即使团队认真设计,也可能在学习之后发现原来的结构已经落后于新的理解。这件事说明认知往前走了一步,不必当成失误。真正要紧的是把这个发现记下来,并且知道什么时候该动手调整。

这也解释了为什么「没有什么不能变」是一条实用准则。落到软件上,它问的是系统能不能承受需求变化:能不能替换一项记忆策略而不用重写全部界面?能不能增加一种输入方式而不把核心行为复制一份?围绕已经看见的变化组织边界,再用实际反馈修正设计,比一次把未来想清楚更现实。

注意 早期的描述会成为后来的默认前提。第一次写下的「设计」往往变成以后代码和讨论的地基,接手的 Agent 读到现有实现,又会顺着它继续生成。一个当时图方便的临时选择,就这样慢慢变成了没人质疑的规矩。最初的描述里值得写清楚的是:现在要解决的具体问题、第一版怎样才算可用、哪些行为可以检验、哪些选择仍然允许变化。

七、从零开始,为什么比改成熟项目更难

这一条听起来反直觉:新项目没有历史包袱,代码干净,文件少,应该更好干。实际经验常常相反。

Agent 干活有一份隐藏燃料:仓库里已有的模式。它修一个已有页面时,会照着旁边另一个页面写,命名、目录、错误处理全跟着走。空项目里一条先例都没有,它只能按训练数据里最常见的写法来,而那套写法与你想要的东西未必一致。更要紧的是,空项目里没有测试可以照抄,没有检查工具告诉你什么算对,没有配置文件说明这个项目要往哪个方向长。

还有一个更根本的原因:成熟项目已经替你回答了大量问题。用户是谁、代码怎么分层、什么叫兼容、测试怎么跑,这些约束都已经固定,做局部修改时沿着它们走就行。空项目里约束少、自由度大,意图、规格与实现之间的缺口有更多机会被默认选择悄悄填满。

「会成长的个人助手」至少留下了几个必须自己回答的决定:成长指的是记住事实、修正偏好,还是学会执行新任务?助手什么时候该主动行动?记错了以后怎么纠正?如果一开始就把所有记忆设计成永久有效,后来才发现大量信息有时间和场景限制,早期那个看起来很确定的数据结构,就把一项需求误解固定在实现里了。

对比项成熟项目从零开始
可模仿的样本多,照着写就行没有,只能靠训练数据里的常见写法
验证骨架已有测试与检查命令要自己搭,不搭就无从判断对错
成文约定写在配置与文档里只存在于你的脑子里
历史包袱多,约定不统一少,但也没有依据

所以从零起一个项目时,最划算的顺序是先把三样东西立起来:一条能跑起来的构建与测试命令,一个最简单但能通过的测试,一份说明目录结构的配置文件。三样加起来半小时能写完,它们决定了后面几十次协作的质量。

八、把成功的做法沉淀下来

项目跑顺之后,会积累出一批「每次都得这么做」的动作:新产品从哪一步开始、并行开几个工作目录、合并之前跑哪几条检查、Agent 犯过的哪类错误要提前叮嘱。这些做法如果只留在记忆里,换一个人、换一个会话、换一个项目就全部重来一遍。

把它们写成文件,是让一次成功变成可复用资产的唯一办法。可用的载体有好几种,粗细不同:

判断一份沉淀值不值得写下来,有个简单标准:同一件事说到第三遍,就该写进文件。写的时候遵守一条纪律,默认否定——自动加载的每一条内容都要证明自己能改变参与者的行为。证明不了就删掉。上下文窗口有限,无关内容会稀释注意力、增加成本,还会让整份文件被当成「大概无关」而整体淡着处理。

多人多 Agent 的场景里,这份沉淀还有一个额外的好处:它把只有老成员才知道的隐含规矩变成了公开可查的东西。所谓部落知识——那种「大家都知道要用新的那个写法」的经验——恰恰是并行协作里最贵的一种信息,因为它没有写在任何地方,却决定了每一次改动对不对。

关键结论 版本管理保存的是「项目经过了什么」,沉淀下来的是「我们学会了什么」。前者让你能退回去,后者让下一次不必从零再摸一遍。两者都指向同一件事:把可靠的状态和可靠的做法留在项目里,不要留在人的记性里。
现在就做 三件事,二十五分钟内做完:
① 在一个已有提交的仓库里执行 git worktree add -b try-parallel ../try-parallel,在新目录里改一个文件并提交,回到原目录用 git log --oneline --all 找到这条新提交,说出两个目录共享了什么;
② 写下你的小项目的 MVP 两问:这个版本要验证什么、出现什么结果就说明想错了,每问一句就够;
③ 打开项目根目录,新建或补写一份配置文件,只写三行:构建命令、测试命令、一条禁区。写完问一个 AI 助手「怎么验证这个项目的改动是对的」,看它能否只靠仓库里的东西答上来。

自测

1. 两个 Agent 在同一份目录里并行工作,最容易出事的三种情况是什么?为什么说它们最危险?
一是 A 读过文件后 B 重写了它,A 基于旧内容提交,B 的修改被覆盖;二是测试跑到一半被测代码被改,输出与代码版本对不上;三是两个都执行同类整理命令,后执行的把先执行的成果又动了一遍。危险在于它们都不报错:没有冲突提示、没有异常说明,结果只是悄悄变成另一个样子。
2. 工作树能隔离什么?不能隔离什么?至少要补哪些东西才算真能并行?
能隔离工作区文件、暂存区和当前分支,让几个 Agent 各改各的,且共享同一个仓库的对象与引用。不能隔离端口、数据库、外部服务、依赖缓存,也不自动带上被 Git 忽略的配置文件。要真并行,还得给每个工作目录配独立端口与独立数据库,并写一份初始化脚本把配置模板和依赖带过去。
3. 上锁有哪些代价?为什么「锁一个文件」常常不够?
代价有四类:锁太粗会把并行排成串行,锁太细盖不住跨文件的不变量;任务之间互相等对方持有的锁会死锁;持有者崩溃后锁怎么回收没有答案;排队期间读到旧内容、拿到锁后继续用,锁就失效。只锁一个文件不够,是因为很多约定跨文件存在,接口定义与调用方必须一起改,锁的范围必须与不变量覆盖的范围相称。
4. 六个人的团队潜在沟通关系有多少条?这个数字说明什么、又没说明什么?
n(n−1)/2 代入 6 得 15 条;3 个人是 3 条,10 个人是 45 条。它说明人数增加时协调成本按平方上升,瓶颈会从「写不出来」转到「说不清楚」。它没有说明实际耗时一定按这个公式增长,因为清楚的接口和明确的分工能把大量必须直接沟通的关系消掉。
5. MVP 开工前必须回答的两个问题是什么?它掩盖了哪些困难?
两个问题是:这个版本要验证什么,出现什么结果就说明想错了。它掩盖的是边界情况(原型走通正常路径,特殊规则与人工批准还没露出问题),以及需求变更的代价(一条规则改动会牵动对应关系、学分计算、审核、显示与测试,已认定的结果要不要重算也需要答案)。
6. 技术债有哪两种来源?哪一种更值得警惕,为什么?
一种是图省事欠下的:明知有更好的写法,为了先跑起来凑合,且没有记下来;另一种是认识进步欠下的:当初的设计在当时有充分理由,做出来用起来之后才明白概念该怎么分。后一种更值得警惕,因为它披着「我学到了新东西」的外衣出现,很容易被当成好消息放过;如果没有记录下来并安排调整,旧结构会一直留着。
7. 从零开始的项目为什么反而更难让 Agent 干好?应该先立哪三样东西?
因为 Agent 很大程度靠模仿仓库里已有的模式:命名、目录、错误处理都跟着邻居走。空项目没有先例,它只能按训练数据里最常见的写法来;同时没有现成测试可照抄、没有检查命令、没有写下来的约定,意图与实现之间的缺口就被默认选择填满。先立三样:一条能跑起来的构建与测试命令、一个最简单但能通过的测试、一份说明目录结构的配置文件。