1. 多 Agent 并行协作的冲突根源与隔离思路
1.1 为什么多个 Agent 同时改代码会“打架”
先说结论:多 Agent 并行开发时最典型的翻车场景,不是模型能力不够,而是它们共享同一个工作目录。我最早做 Agent 编排的时候,图省事让三个 Agent 同时跑在同一个仓库里,结果一个在改utils.py,另一个在重构utils.py的调用方,第三个还在跑测试。十分钟后git status一片红,谁改了哪一行根本分不清,git diff里混着三份互不相关的改动,回滚都不知道从哪下手。
这个问题的本质是文件系统层面的写冲突。Git 本身是为“单人串行提交”设计的,工作区(working tree)只有一份,暂存区(index)也只有一份。多个进程同时写同一份工作区,会出现几类典型故障:
- 覆盖写:Agent A 刚写完文件,Agent B 基于旧内容又写了一遍,A 的改动直接消失。
- 半成品污染:Agent A 改到一半,Agent B 跑测试时读到的是残缺代码,测试结果全是假阳性。
- 索引竞争:两个进程同时
git add,index.lock 冲突,报Unable to create index.lock。 - 分支切换踩踏:一个 Agent 执行
git checkout切分支,另一个 Agent 的工作区瞬间被换掉。
很多人第一反应是“那就加锁串行跑”。串行确实能解决冲突,但代价是吞吐量归零——本来三个 Agent 并行能压缩到三分之一的时间,串行后一点没省。而且 Agent 任务往往有长尾,一个卡住全队列都堵着。
1.2 worktree 隔离:给每个 Agent 发一套独立工作区
git worktree是 Git 2.5 之后内置的能力,它允许同一个仓库同时检出多个工作区,每个工作区绑定不同的分支,但共享同一个.git对象库。这句话拆开看有两个关键点:
第一,每个 worktree 有自己独立的文件目录、独立的 index、独立的 HEAD。Agent A 在/work/a里改文件,Agent B 在/work/b里改文件,物理上就是两个目录,谁也碰不到谁。这就从根上消灭了写冲突。
第二,它们共享对象库。也就是说 commit、blob、tree 这些底层对象只存一份,不会因为开了十个 worktree 就把仓库体积翻十倍。分支之间还能互相cherry-pick、merge,回流改动非常方便。
打个比方:主仓库像一栋楼的图纸档案室,worktree 就像按同一份图纸盖出来的多间独立办公室。每间办公室里的家具怎么摆互不影响,但档案室里的资料是共享的。Agent 各自在自己的办公室里干活,干完了把成果登记回档案室就行。
相比“复制整个仓库目录”这种土办法,worktree 的优势很明显:
| 方案 | 磁盘占用 | 分支管理 | 回流难度 | 对象库一致性 |
|---|---|---|---|---|
| 复制目录 | 每份全量拷贝 | 各自独立,易分叉 | 需手动 diff/patch | 容易不一致 |
| 多 worktree | 仅增量文件 | 统一由主仓库管理 | 直接 merge/cherry-pick | 天然一致 |
| 同目录并行 | 无额外占用 | 冲突频发 | 几乎无法回流 | 混乱 |
我实测过一个中等规模项目(约 8 万行代码),开 6 个 worktree 额外占用的磁盘不到 200MB,而复制 6 份目录要吃掉将近 3GB。差距非常直观。
1.3 反馈回流:隔离之后怎么把成果收回来
隔离只是第一步,真正难的是回流。Agent 在各自 worktree 里干完活,产物怎么合并回主线?这里有几个层次的设计:
- 代码回流:Agent 在自己的分支上 commit,主控进程按顺序
merge或cherry-pick到集成分支。 - 状态回流:Agent 的执行结果(成功/失败/日志/产物路径)要写回一个共享的反馈通道,供调度器决策。
- 冲突回流:如果两个 Agent 改了同一块逻辑,merge 时必然冲突,这时候需要有人(或规则)来裁决。
我踩过最大的坑就是只做了代码隔离,没做反馈回流。Agent 各自跑完,日志散落在六个目录里,我得手动一个个翻,效率反而比串行还低。后来我加了一层“反馈文件”机制:每个 Agent 把结构化结果写到一个约定路径,主控进程轮询收集。这一下子就把整个流程盘活了。
2. worktree 隔离的落地细节与脚本实现
2.1 环境准备与 worktree 基础命令
先把基础打牢。git worktree的核心命令就四条,记住它们基本够用:
# 新增一个 worktree,绑定新分支 agent/task-1 git worktree add ../work-agent-1 -b agent/task-1 # 基于已有分支新增 worktree git worktree add ../work-agent-2 agent/task-2 # 列出所有 worktree 及其状态 git worktree list # 删除 worktree(先删目录再 prune,或直接 remove) git worktree remove ../work-agent-1 git worktree prune几个容易忽略的细节:
- 路径不要放在主仓库内部。如果你把 worktree 建在
.git同级或子目录里,Git 会警告甚至拒绝。我一般统一放在仓库同级的../worktrees/目录下,结构清晰。 - 分支不能重复检出。同一个分支不能同时被两个 worktree 占用,这是 Git 的硬约束。所以每个 Agent 必须有自己的分支,命名建议带任务 ID,比如
agent/task-<uuid>。 git worktree list是你的仪表盘。养成习惯,每次调度前先看一眼,避免残留的僵尸 worktree 占着分支。
注意:如果你的 Git 版本低于 2.5,
worktree命令不存在。用git --version确认一下,低于 2.5 的建议升级,现在主流发行版自带的都远高于这个版本。
2.2 目录规划与分支命名规范
多人多 Agent 场景下,命名规范比技术本身更重要。我见过太多团队因为分支名乱起,最后git branch列表几百条,根本认不出哪个是哪个。
我目前用的规范是这样的:
仓库根目录/ ├── project/ # 主仓库(集成分支 main) └── worktrees/ # 所有 Agent 工作区 ├── agent-<taskId>/ # 每个任务一个目录 └── ...分支命名:agent/<taskId>/<short-desc>,例如agent/t1/refactor-utils。taskId 用短 UUID 或递增编号,short-desc 用两三个词概括任务。这样git branch --list 'agent/*'一眼就能扫出所有在跑的任务。
目录和分支的对应关系建议写进一个manifest.json,主控进程维护它:
{ "t1": { "worktree": "../worktrees/agent-t1", "branch": "agent/t1/refactor-utils", "status": "running", "startedAt": "2025-01-01T10:00:00Z" } }这个 manifest 是反馈回流的基础设施。没有它,你根本不知道哪个目录对应哪个任务,回收的时候全靠猜。
2.3 创建与回收 worktree 的 Shell 脚本
下面是我实际在用的脚本,分创建和回收两部分。先看创建:
#!/usr/bin/env bash set -euo pipefail REPO_ROOT="${1:?usage: create-worktree.sh <repo-root> <task-id> <desc>}" TASK_ID="${2:?missing task-id}" DESC="${3:?missing desc}" WORKTREE_BASE="$(dirname "$REPO_ROOT")/worktrees" BRANCH="agent/${TASK_ID}/${DESC}" WORKTREE_PATH="${WORKTREE_BASE}/agent-${TASK_ID}" mkdir -p "$WORKTREE_BASE" # 从当前集成分支切出新分支 cd "$REPO_ROOT" BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then echo "branch ${BRANCH} already exists, reusing" git worktree add "$WORKTREE_PATH" "$BRANCH" else git worktree add "$WORKTREE_PATH" -b "$BRANCH" "$BASE_BRANCH" fi echo "worktree ready: $WORKTREE_PATH (branch: $BRANCH)"这个脚本有几个设计考量。set -euo pipefail是必须的,任何一步失败都要立刻停,否则会留下半成品 worktree。BASE_BRANCH取当前分支而不是硬编码main,是为了支持“从任意集成分支拉活”的灵活场景。分支已存在时走复用逻辑,避免重复创建报错。
回收脚本更关键,因为回收不干净会留下僵尸 worktree 和孤儿分支:
#!/usr/bin/env bash set -euo pipefail REPO_ROOT="${1:?usage: cleanup-worktree.sh <repo-root> <task-id>}" TASK_ID="${2:?missing task-id}" WORKTREE_PATH="$(dirname "$REPO_ROOT")/worktrees/agent-${TASK_ID}" cd "$REPO_ROOT" if [ -d "$WORKTREE_PATH" ]; then # 先检查是否有未提交改动 if [ -n "$(git -C "$WORKTREE_PATH" status --porcelain)" ]; then echo "WARN: uncommitted changes in $WORKTREE_PATH, skipping removal" exit 1 fi git worktree remove "$WORKTREE_PATH" fi git worktree prune echo "cleaned up worktree for task $TASK_ID"这里我特意加了一道未提交改动检查。早期版本直接remove --force,结果有一次 Agent 崩溃前没 commit,改动全丢了,白跑半小时。现在只要检测到脏工作区就拒绝删除,让人工介入判断,宁可留着也不误删。
实操心得:
git worktree remove默认拒绝删除有未提交改动的 worktree,这是保护机制,别用--force图省事。真需要强删时,先把改动git stash或 commit 到临时分支。
2.4 并行调度的并发控制
worktree 解决了隔离,但并发数不能无限开。我一开始贪心,同时开 12 个 Agent,结果机器 CPU 打满、内存爆掉,Agent 之间抢资源反而更慢。后来做了压测,找到几个经验阈值:
- CPU 密集型任务(编译、跑测试):并发数 ≈ CPU 核心数。
- IO 密集型任务(大量文件读写、网络请求):并发数可以到核心数的 2 到 3 倍。
- 混合型:从核心数起步,观察负载再调。
调度器里我用一个简单的信号量控制并发:
MAX_PARALLEL=4 running=0 for task in "${TASKS[@]}"; do while [ "$running" -ge "$MAX_PARALLEL" ]; do wait -n # 等任意一个子进程结束 running=$((running - 1)) done run_agent "$task" & running=$((running + 1)) done waitwait -n是 bash 4.3+ 的特性,能等“任意一个”子进程结束,比轮询优雅得多。如果你的环境 bash 版本低,可以用jobs -p配合轮询,但体验差不少。
3. 反馈回流机制的设计与实现
3.1 反馈通道的三种形态
隔离做完,Agent 各自跑各自的,但主控进程怎么知道它们干得怎么样?这就是反馈回流要解决的问题。我实践下来有三种通道形态,各有适用场景:
第一种是文件通道。每个 Agent 把结果写到一个约定路径,比如../worktrees/agent-<taskId>/.agent-result.json。主控进程轮询这些文件。优点是简单、无依赖、跨语言;缺点是实时性差,轮询有延迟。
第二种是标准输出通道。Agent 进程把结构化日志打到 stdout,主控进程捕获并解析。优点是实时、天然流式;缺点是日志和业务输出容易混在一起,解析要小心。
第三种是消息队列通道。Agent 把结果发到 Redis、RabbitMQ 之类的队列,主控订阅。优点是解耦彻底、支持分布式;缺点是引入了外部依赖,小项目没必要。
我现在的默认选择是文件通道 + stdout 混合:关键结果(成功/失败/产物路径)写文件,过程日志走 stdout。这样既有可靠的最终状态,又有实时的过程可见性。
3.2 结构化反馈文件的字段设计
反馈文件的内容设计直接决定了回流逻辑好不好写。我踩过的坑是字段太少,后来不得不加字段,导致新旧格式不兼容。现在我的 schema 固定成这样:
{ "taskId": "t1", "status": "success", "branch": "agent/t1/refactor-utils", "commit": "a1b2c3d", "changedFiles": ["src/utils.py", "tests/test_utils.py"], "artifacts": ["dist/report.html"], "error": null, "startedAt": "2025-01-01T10:00:00Z", "finishedAt": "2025-01-01T10:05:30Z", "metrics": { "tokensUsed": 12345, "durationMs": 330000 } }几个字段值得展开说。status用枚举值success/failed/partial,不要用布尔值,因为“部分成功”是真实存在的状态。commit记录 Agent 最终提交的 SHA,回流时直接cherry-pick这个 commit,不用去猜分支头。changedFiles用于冲突预判——如果两个任务的改动文件有交集,merge 前就要预警。metrics是给调度器做决策用的,比如 token 消耗超预算的任务要降优先级。
注意:反馈文件一定要原子写入。Agent 写文件时如果被主控进程读到半截,JSON 解析会失败。做法是先写临时文件再
mv,mv在同一文件系统内是原子的。
3.3 回流脚本:从收集到合并
收集和合并是两个阶段。收集阶段扫描所有反馈文件,合并阶段按策略把改动合回集成分支。先看收集:
#!/usr/bin/env bash set -euo pipefail WORKTREE_BASE="${1:?usage: collect-results.sh <worktree-base>}" RESULT_DIR="${WORKTREE_BASE}/../results" mkdir -p "$RESULT_DIR" for wt in "$WORKTREE_BASE"/agent-*; do [ -d "$wt" ] || continue result_file="${wt}/.agent-result.json" if [ -f "$result_file" ]; then task_id="$(basename "$wt" | sed 's/^agent-//')" cp "$result_file" "${RESULT_DIR}/${task_id}.json" echo "collected: $task_id" else echo "missing result: $wt" fi done合并阶段我做了冲突预判,这是整个回流里最有价值的一环:
#!/usr/bin/env bash set -euo pipefail REPO_ROOT="${1:?usage: merge-results.sh <repo-root> <results-dir>}" RESULTS_DIR="${2:?missing results-dir}" cd "$REPO_ROOT" INTEGRATION_BRANCH="$(git rev-parse --abbrev-ref HEAD)" # 收集所有任务的改动文件,检测交集 declare -A file_owners conflicts=() for result in "$RESULTS_DIR"/*.json; do [ -f "$result" ] || continue task_id="$(basename "$result" .json)" while IFS= read -r f; do if [ -n "${file_owners[$f]:-}" ]; then conflicts+=("$f: ${file_owners[$f]} vs $task_id") else file_owners[$f]="$task_id" fi done < <(jq -r '.changedFiles[]' "$result") done if [ "${#conflicts[@]}" -gt 0 ]; then echo "POTENTIAL CONFLICTS DETECTED:" printf ' %s\n' "${conflicts[@]}" echo "aborting merge, resolve manually" exit 2 fi # 无冲突,按顺序 cherry-pick for result in "$RESULTS_DIR"/*.json; do [ -f "$result" ] || continue commit="$(jq -r '.commit' "$result")" status="$(jq -r '.status' "$result")" [ "$status" = "success" ] || continue git cherry-pick "$commit" done echo "merge complete on $INTEGRATION_BRANCH"这段脚本的核心是先检测再合并。file_owners这个关联数组记录每个文件被哪个任务改过,一旦发现同一文件被两个任务碰过,立刻中止并打印冲突清单。这比直接 merge 然后处理冲突要高效得多——冲突在 merge 阶段暴露时,上下文已经乱了,而在预判阶段暴露,你还能清楚地知道是哪两个任务在抢同一块代码。
3.4 冲突裁决的三种策略
预判出冲突后怎么办?我总结了三种策略,按场景选:
策略一:串行化。把冲突的任务排成队列,一个跑完再跑下一个。适合冲突文件少、任务本身不长的场景。缺点是牺牲并行度。
策略二:人工介入。把冲突清单推给人,由人决定保留哪个。适合关键路径代码,比如核心业务逻辑。我一般对src/core/下的文件强制走人工。
策略三:自动重跑。让后一个 Agent 基于前一个的结果重新执行。适合任务本身幂等、重跑成本低的场景。实现上就是等前一个 merge 完,再给后一个 Agent 发新任务,让它基于最新代码重做。
实际项目里我通常是混合使用:非核心文件走策略一,核心文件走策略二,测试类任务走策略三。这个决策逻辑可以写进调度器的配置里,按文件路径模式匹配。
4. 常见问题与排查技巧实录
4.1 worktree 相关的高频故障
这一节全是血泪。我把实际遇到过的 worktree 故障整理成速查表:
| 现象 | 根因 | 解决 |
|---|---|---|
fatal: '<branch>' is already checked out | 分支被另一个 worktree 占用 | 换分支名,或先 remove 占用的 worktree |
Unable to create 'index.lock' | 同目录多进程并发操作 | 确认每个 Agent 独立 worktree,检查是否有残留进程 |
worktree 目录删了但git worktree list还在 | 目录被手动删除,元数据残留 | git worktree prune |
git worktree add报路径已存在 | 上次回收不干净 | 检查目录内容,确认无改动后手动删再 add |
| Agent 提交后主仓库看不到分支 | 提交在 worktree 的独立 HEAD 上 | git branch确认分支存在,用git log <branch>查看 |
其中分支重复检出是最常见的。我一开始给所有 Agent 都用agent/task这个分支名,第二个 Agent 一启动就报错。后来改成带 taskId 的命名,问题消失。这个坑很典型:worktree 的分支必须全局唯一,因为一个分支在任意时刻只能被一个工作区检出。
还有一个隐蔽的坑:worktree 里的.git是个文件不是目录。它内容是一行gitdir: /path/to/main/.git/worktrees/<name>。有些工具(尤其是老版本的 IDE 和构建脚本)会假设.git是目录,遇到文件就报错。我遇到过某个打包脚本在 worktree 里跑失败,排查半天才发现是它硬编码检查.git目录。解决办法是在脚本里用git rev-parse --git-dir动态获取,别硬编码路径。
4.2 反馈丢失与状态不一致的排查
反馈回流最烦人的问题是状态不一致:主控以为任务在跑,其实 Agent 早就崩了;或者 Agent 写完了结果,主控没收到。
排查这类问题我有一套固定流程:
第一步,看进程。ps aux | grep agent确认 Agent 进程是否还活着。如果进程没了但状态还是 running,说明是崩溃没上报。
第二步,看反馈文件。检查.agent-result.json是否存在、是否完整。文件不存在说明 Agent 没走到写结果那步;文件存在但 JSON 解析失败,说明写入不是原子的。
第三步,看 worktree 状态。git -C <worktree> status看有没有未提交改动。有改动但没 commit,说明 Agent 在提交前挂了。
第四步,看日志。stdout 日志里通常有崩溃前的最后输出,能定位到具体哪一步。
我后来加了个心跳机制来预防这类问题:Agent 每隔 30 秒更新一次反馈文件里的heartbeatAt字段,主控进程发现某个任务超过 90 秒没心跳,就判定为失联,标记为stale并触发清理。这个机制把“任务卡死无人知”的概率降到了几乎为零。
实操心得:心跳超时阈值不要设太短。我一开始设 30 秒,结果 Agent 跑长任务(比如编译大项目)时正常阻塞也被误判。后来改成 90 秒,配合 Agent 在长操作前主动发一次心跳,误判就没了。
4.3 并发数调优与资源争抢
并发数不是越大越好,这个我在 2.4 提过,但值得再展开。资源争抢的表现很隐蔽:Agent 不报错,但每个都变慢,整体吞吐反而下降。
我做过一组对照实验,同一个任务集(20 个中等规模重构任务),不同并发数下的总耗时:
| 并发数 | 总耗时 | CPU 峰值 | 内存峰值 | 备注 |
|---|---|---|---|---|
| 1 | 42 min | 25% | 1.2 GB | 串行基线 |
| 2 | 24 min | 50% | 2.1 GB | 接近线性加速 |
| 4 | 15 min | 88% | 3.8 GB | 最佳点 |
| 6 | 16 min | 100% | 5.4 GB | 开始争抢 |
| 8 | 21 min | 100% | 7.1 GB | 明显劣化 |
可以看到 4 并发是拐点,再往上加收益递减甚至为负。原因是 CPU 打满后,Agent 之间的上下文切换和内存压力开始主导。所以并发数要压测确定,别拍脑袋。
另外,如果 Agent 会调用外部 API(比如模型推理),并发数还要受速率限制约束。我遇到过并发 4 个 Agent 同时打 API,触发限流,全部重试,反而更慢。这种情况要在调度器里加令牌桶限速,把 API 调用速率控制在配额内。
4.4 脚本健壮性的几个关键点
最后说说脚本本身。我早期的脚本很脆,跑几次就出问题,后来总结了几个必须做的加固:
第一,所有路径用绝对路径或基于脚本位置解析。用$(cd "$(dirname "$0")" && pwd)拿到脚本目录,再拼相对路径。别依赖调用者的当前目录,否则换个地方跑就崩。
第二,所有外部命令检查退出码。set -e是基础,但有些命令在管道里失败不会触发,需要set -o pipefail。我吃过git worktree add | tee log的亏,add 失败了但 tee 成功,脚本继续往下跑,留下一堆烂摊子。
第三,临时文件和锁要清理。用trap注册清理函数,脚本退出(正常或异常)时都执行:
cleanup() { rm -f "$LOCK_FILE" "$TMP_FILE" } trap cleanup EXIT INT TERM第四,幂等性。脚本要能重复执行不出错。比如创建 worktree 前先检查是否已存在,存在就复用而不是报错。这在 Agent 重试场景下特别重要。
第五,日志要带时间戳和任务 ID。多 Agent 并行时,日志混在一起,没有标识根本没法排查。我统一用[$(date -Iseconds)] [task-$TASK_ID] message的格式,事后 grep 某个任务一目了然。
这套东西搭起来之后,我现在跑多 Agent 并行基本是“设好任务、启动、收结果”三步,中间不用盯着。偶尔出问题,靠反馈文件和日志也能快速定位。真正花时间的反而是任务拆分本身——怎么把一个需求切成互不冲突的子任务,这个比技术实现更考验人。我的经验是,拆分时尽量按文件边界切,让每个 Agent 负责一组独立的文件,冲突预判阶段就能直接放行,回流几乎零成本。