你把一个大重构拆给两个并行 Agent——一个负责 src/auth/,另一个负责 src/api/。结果两个都觉得 src/utils/helpers.ts 需要改。Agent A 写了一个新的 formatToken() 辅助函数。与此同时,Agent B 在 Agent A 写入之前就已经读取了原始文件,它加了一个 parseHeaders() 函数后写回——把 Agent A 的 formatToken() 整个覆盖掉。最终文件里有 parseHeaders() 却没有 formatToken(),认证模块在运行时抛出 TypeError: formatToken is not a function。没有冲突标记,没有任何警告,就是静默的数据丢失。
最快的修复(截至 2026 年 6 月): 给每个 Agent 各自独立的文件系统,让它们物理上无法覆盖彼此,最后再统一合并。如果你用 Claude Code,这个能力现在已经内置——每个会话用 claude --worktree <name> 启动(原生 worktree 支持于 2026 年初上线),或者在子 Agent 的 frontmatter 里加 isolation: worktree,每个 Agent 就在自己的私有 checkout 里改文件。对于框架编排(LangGraph、CrewAI、AutoGen),等价做法是:通过单一的串行合并步骤来写,绝不让两个并行节点写同一个文件路径。本文接下来会讲诊断、内置修复,以及非 Claude 场景下的手动修复。
你属于哪一类?
| 现象 | 最可能的原因 | 跳转到 |
|---|---|---|
| 改动静默消失,没有冲突标记 | 两个 Agent 共用一个工作树,后写者覆盖前者 | Step 1、Step 2 |
LangGraph 抛出 INVALID_CONCURRENT_GRAPH_UPDATE | 并行节点在没有 reducer 的情况下写同一个 state key | Step 5 |
git status 显示真正的合并冲突标记 | 你已经用了 worktree/分支(这是安全情况) | Step 2、FAQ |
| 同一文件被两个执行相同任务的 Agent 各改了一次 | 重复/重试的任务被派发给了两个 worker | 预防建议 |
CrewAI 在多进程下抛 LockException | 旧版本 CrewAI 的旧锁后端问题 | FAQ |
常见原因
1. 多个 Agent 共用一个工作树(后写者覆盖)
在同一个仓库目录里跑多个 Claude Code 实例,或任意多个 Agent,它们都在同一个工作树里改文件。两个 Agent 在 t=0 读到同一份文件,各自独立修改后都写回。操作系统只保留第二次写入,第一次的改动消失了,且没有任何冲突标记。
怎么判断:检查所有 Agent 进程是否共用同一个 cwd。在 macOS/Linux 上趁运行时执行 lsof | grep helpers.ts(或 fuser src/utils/helpers.ts),看是否有多个进程对同一文件持有写句柄。在 Claude Code 里执行 git worktree list——如果只有一个 worktree,而你启动了多个会话,它们就在互相覆盖。
2. 编排层没有文件级锁
大多数框架编排——LangGraph、CrewAI、AutoGen——默认不对文件加锁。Agent 的 write_file / Write 工具会全量覆盖文件。即使两个 Agent 改的是不同函数,后写入的 Agent 也会用它读取时的版本(不含第一个 Agent 的改动)把文件覆盖掉。
怎么判断:在你的工具封装里搜索写操作前是否有 mutex、filelock 或文件认领机制。如果没有,对共享路径的并发写就是不受保护的。
3. 共享工具文件没有从 Agent 职责范围里排除
当你拆分工作(「Agent A 负责 auth,Agent B 负责 api」)时,共享工具文件(helpers.ts、constants.ts、types.ts、package.json、tsconfig.json)落在两个领域之外,却被两边都改。没有人声明「共享工具一次只能由一个 Agent 写」。
怎么判断:列出每个 Agent 改动过的文件,找重叠。用 git 的话,对每个 Agent 的分支执行 git diff --name-only,再求交集(见 Step 1)。
4. Agent 在写入前读到了过期的文件版本
在步骤之间相隔仅毫秒的异步流水线里,两个 Agent 都在任一方写入之前完成了 read_file 调用。两者都基于同一份原始内容生成改动,于是第二次写入丢弃了第一次的改动。
怎么判断:在运行 trace 里对比 read_file 和 write_file 工具调用的时间戳。如果针对同一路径的两次 read_file 都在任何 write_file 之前完成,写丢失竞态就必然发生。
5. fan-in 步骤没有合并或冲突检查
流水线 fan-out 到并行 Agent 再 fan-in 回来,但 fan-in 节点只是简单取最后一个 Agent 的输出(state.update(agent_output))。没有合并,也没有冲突解决。谁最后完成谁就赢,哪怕它的输出是不完整的。
怎么判断:检查工作流图里的 fan-in 节点。具体到 LangGraph,如果两个并行节点写同一个 state key 而该 key 没有 reducer,运行时会抛出 INVALID_CONCURRENT_GRAPH_UPDATE,而不是静默丢数据——这个报错其实是框架在保护你。
最短修复路径
Step 1:检测哪些文件被不止一个 Agent 改过
# 每个 Agent 应提交到自己的分支,然后对文件列表求交集:
git diff --name-only main...agent-a-branch | sort > agent_a_files.txt
git diff --name-only main...agent-b-branch | sort > agent_b_files.txt
comm -12 agent_a_files.txt agent_b_files.txt # 两个 Agent 都改过的文件 = 冲突候选
如果你的 Agent 共用一个工作树(没分支),就改造写文件工具,把 agent_id, path, timestamp 追加到一个共享日志里,然后找被两个 agent ID 写过的同一路径。
Step 2:把每个 Agent 隔离到各自的 worktree
git worktree 是一个独立的工作目录,有自己的文件和分支,但共享同一份 .git 历史。两个 Agent 处在两个 worktree 里,物理上就无法覆盖彼此的文件;真正的冲突会以普通的 git merge 冲突形式暴露出来,你可以解决它,而不是静默丢失。
Claude Code(内置,截至 2026 年 6 月): 原生 worktree 支持于 2026 年初上线。让每个会话在自己的 worktree 里启动:
# 在分支 worktree-feature-auth 上创建 .claude/worktrees/feature-auth/
claude --worktree feature-auth
# 在第二个终端里:
claude --worktree feature-api
--worktree(短选项 -w)默认从 origin/HEAD 分叉;在 settings 里把 worktree.baseRef 设为 "head" 可以改成从你当前的本地 HEAD 分叉。把 .claude/worktrees/ 加进 .gitignore。由于全新 checkout 没有未跟踪文件,可以加一个 .worktreeinclude 文件(用 .gitignore 语法)来复制本地配置(.env 等)。会话干净退出时,Claude 会自动移除 worktree 及其分支;如果你有改动,它会提示保留还是丢弃。Agent 运行期间,Claude 会持有 git worktree lock,使清理 sweep 无法删掉它。
手动方式(任意工具):
git worktree add ../agent-a-work -b agent-a-branch
git worktree add ../agent-b-work -b agent-b-branch
cd ../agent-a-work && run_agent_a &
cd ../agent-b-work && run_agent_b &
wait
# 显式合并——冲突会变成真实、可恢复的 git 冲突
git checkout main
git merge agent-a-branch
git merge agent-b-branch
git worktree remove ../agent-a-work && git worktree remove ../agent-b-work
Step 3:对 Claude Code 子 Agent,在 frontmatter 里开启 worktree 隔离
如果你在一个会话内委派给子 Agent,就把隔离设为常驻,让每个子 Agent 在私有 checkout 里改文件。每个子 Agent 会拿到一个临时 worktree,并在它无改动结束时自动移除:
---
name: refactor-helper
description: Refactors a module in isolation
isolation: worktree
---
You refactor the assigned module...
Claude Code 的 Agent Teams 也建立在同一机制上:每个队友拿到自己的 worktree,一个共享任务列表用状态标志来认领工作,使两个队友绝不会同时去改同一个文件。即便后端 Agent 和前端 Agent 都要动 package.json,它们改的也是各自的私有副本,合并只在最后发生一次。
Step 4:如果无法隔离,就在写入前认领文件
当 Agent 必须共用一个工作树(没有 worktree)时,用锁把写操作串行化。同机 Agent 用进程内锁;跨进程或跨机器的 Agent 用 Redis 锁。永远做原子写入(临时文件 + rename),这样即使写到一半崩溃也不会损坏文件:
import threading, os, tempfile
_file_locks: dict[str, threading.Lock] = {}
_registry_lock = threading.Lock()
def claim_file(path: str) -> threading.Lock:
abs_path = os.path.abspath(path)
with _registry_lock:
lock = _file_locks.setdefault(abs_path, threading.Lock())
if not lock.acquire(timeout=30):
raise RuntimeError(f"Could not claim file within 30s: {abs_path}")
return lock
def atomic_write(path: str, content: str) -> None:
d = os.path.dirname(os.path.abspath(path))
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, path) # 在 POSIX 和 Windows 上都是原子操作
# 用法:在锁内读取「当前」文件,这样永远不会改到过期副本
lock = claim_file("src/utils/helpers.ts")
try:
content = read_file("src/utils/helpers.ts")
atomic_write("src/utils/helpers.ts", agent.edit(content))
finally:
lock.release()
关键细节是在锁「内部」读取文件——这才能堵住原因 4 的过期读竞态。
Step 5:在 LangGraph 里给并行 state key 加 reducer
如果并行节点要写同一个 state key,别依赖默认赋值(后写者覆盖)。给该 key 加上 reducer 注解,让多个写入合并而不是覆盖。这也是 LangGraph 在未加 reducer 的 key 收到并发写时抛出 INVALID_CONCURRENT_GRAPH_UPDATE 报错的修复方法:
from typing import Annotated, TypedDict
import operator
class State(TypedDict):
# 并行分支把各自的文件改动追加进来,而不是互相覆盖
edited_files: Annotated[list[str], operator.add]
对于真正的文件写入(在图 state 之外),让 fan-out 节点保持纯净:每个并行节点返回一个 patch/diff 对象,然后在 fan-in 节点里串行应用所有 patch。串行应用彻底消除竞态。
Step 6:给 fan-in 节点加冲突检查
def fan_in(state: dict, agent_outputs: list[dict]) -> dict:
edited = [set(out["edited_files"]) for out in agent_outputs]
for i in range(len(edited)):
for j in range(i + 1, len(edited)):
overlap = edited[i] & edited[j]
if overlap:
raise ConflictError(f"Agents {i} and {j} both edited: {overlap}")
return merge_outputs(agent_outputs)
如何确认已修复
- 重跑同一个并行任务。结束后执行 Step 1 的求交集——
comm -12应该什么都不输出,说明没有文件被两个 Agent 写过。 - 确认两个 Agent 的贡献都保住了:
git log --oneline应显示两个分支都已合并,grep formatToken src/utils/helpers.ts(以及另一个 Agent 的符号)都应能命中。 - 跑构建/测试。原来的
TypeError: formatToken is not a function应该消失。 - 如果你用了 worktree,运行时
git worktree list会为每个活跃 Agent 显示一个 worktree,结束后再收敛回主 checkout。
预防建议
- 任何会改文件的并行工作都用 worktree 隔离。在 Claude Code 里就是每个会话用
--worktree,或每个子 Agent 加isolation: worktree;在框架之外则每个 Agent 用git worktree add。这样冲突会以真实的合并冲突形式暴露,绝不会静默覆盖。 - 启动 Agent 前显式定义文件归属范围;并行阶段把共享文件(
tsconfig.json、package.json、types.ts)设为只读,它们的改动统一走一个串行的并行后步骤。 - 在写锁「内部」读取当前文件,而不是之前,这样任何 Agent 都不会改到过期副本。
- 使用原子写入(临时文件再
os.replace()),即使锁失效,写入失败或崩溃也不会损坏文件。 - 给每个会被并行写入的 LangGraph state key 加 reducer(
Annotated[..., operator.add]);合并前在 fan-in 节点加重叠检查。 - 让 task ID 幂等,使重试或重复的任务不会被两个 worker 各处理一次(这是双写的常见来源)。
- 先跑一遍 dry run,列出每个 Agent「将要」改哪些文件,正式运行前先审查重叠。
常见问答 (FAQ)
Q: Claude Code 现在会自动处理这个问题吗?
A: 只有开启隔离才会。原生 worktree 支持于 2026 年初上线,但在同一目录里启动的多个会话仍然共用一个工作树,依然会互相覆盖。每个会话用 claude --worktree <name>,或在子 Agent 上设 isolation: worktree,每个 Agent 就在私有 checkout 里改文件,最后再合并。截至 2026 年年中,团队反馈每个开发者并行跑 4–8 个 worktree 是可靠区间,再多通常瓶颈就在 review 而非 Claude。
Q: LangGraph 会自动处理吗?
A: 对图 state 来说算是会:如果两个并行节点在没有 reducer 的情况下写同一个 key,LangGraph 会抛出 INVALID_CONCURRENT_GRAPH_UPDATE,而不是静默丢数据。修复办法是给该 key 加注解,例如 Annotated[list, operator.add]。但图 state 之外的裸文件写入得不到任何保护——你仍然需要 worktree 或写锁。
Q: 能不能直接用 Git 自带的 merge 事后解决冲突?
A: 可以,前提是每个 Agent 都提交到自己的分支(worktree 免费就给了你这一点)。git merge 会把重叠的改动暴露成冲突标记让你手动解决。不重叠的改动会自动合并。对于大型并行重构,这是最可靠的做法,且不需要任何自定义加锁代码。
Q: CrewAI 跨进程跑任务时抛 LockException 怎么办?
A: 这是旧版本 CrewAI 锁后端的已知问题。2026 年的一个版本修复了并发多进程执行下的锁异常,并让加锁后端可被覆盖。升级到最新版 CrewAI,然后用 pip show crewai 确认安装的版本。
Q: 给每个 Agent 用独立的 Docker 容器能解决吗? A: 容器对文件「改动」提供了很强的隔离,但没有合并语义——你仍然需要一个步骤把每个容器的输出合并起来。那其实还是 worktree 加显式合并的模型,只是隔离更重。
Q: 两个 Agent 确实需要改同一个文件,怎么办? A: 串行化它们。Agent A 改完并提交,Agent B 在编辑前读取 A 之后的状态。如果两者的改动确实互不相干(不同函数、行不重叠),串行执行几乎不增加开销,却能消除所有冲突风险。