我让 3 个子 Agent 同时改同一个文件,没打架——因为偷了 Git 的一个冷门功能
文章目录引子一、原来的子 Agent 为什么是假并行二、怎么改成真正并行三、只读类型无限制并行GENERAL 怎么办四、Git Worktree冷门但正好解决这个问题的功能五、ThreadLocal一句话让 FileTool 指向正确的 worktree六、线程安全全景七、Worktree 失败怎么办总结引子你让 AI 同时分析 SQL 注入和 XSS 漏洞。它启动了 2 个子 Agent——然后第二个干等了 30 秒。因为代码里SubagentRunner.run()是同步阻塞的。我花了一下午把子 Agent 从排队改成并行又用 Git 的一个冷门功能解决了多 Agent 同时写文件的冲突。整个过程比我想象的顺利得多——因为架构一开始就把线程安全考虑进去了。一、原来的子 Agent 为什么是假并行先看原来的代码// TaskTool.execute() — 第 77 行StringresultSubagentRunner.run(ai,dispatcher,registry,config,prompt,maxRounds,type);returnToolResult.success(result);SubagentRunner.run()内部是一个for循环最多跑 10 轮 LLM 调用。这个方法不返回父 Agent 就没法往下走。即便父 LLM 一次返回了 3 个task()调用AgentLoop 的处理逻辑也是for each tool call: tools.execute(req) ← task() 在这里面卡住了 第二个 task() 要等第一个跑完才能进循环本质串行排队不是并行。二、怎么改成真正并行新建一个SubagentManager核心就一个固定线程池privatefinalExecutorServicepoolExecutors.newFixedThreadPool(4);submit()方法把子 Agent 提交到线程池立刻返回任务 IDpublicStringsubmit(...){StringidUUID.randomUUID().toString().substring(0,8);pool.submit(()-{StringresultSubagentRunner.run(...);// 在线程池的线程里跑notifications.add(完成通知);// 跑完塞进队列});returnid;// 立刻返回不等待}TaskTool.execute()从同步等结果变成StringidsubagentManager.submit(...);returnToolResult.success(子 Agent [id] 已启动完成后自动通知。);父 Agent 的循环每轮调drain()扫一遍通知队列有结果就注入对话历史父 LLM 调用 task() → 已启动 [a1] 下一轮 drain() → 子 Agent [a1] 完成: 发现 3 处 SQL 注入... 父 LLM 看到结果 → 决定下一步效果父 Agent 不阻塞。同一轮 LLM 返回 3 个 task()三个子 Agent 同时启动。三、只读类型无限制并行GENERAL 怎么办问题来了EXPLORE/PLAN/VERIFICATION这三种只读的10 个同时跑都没事。但GENERAL是会写文件的GENERAL-A: file(write, UserService.java, 版本A) GENERAL-B: file(write, UserService.java, 版本B) → 后写的覆盖先写的A 的修改静默丢失这就是为什么要用Git worktree。四、Git Worktree冷门但正好解决这个问题的功能git worktree能在一个仓库里创建多个独立的工作目录git worktree add --detach /tmp/agent-worktrees/wt-a1这条命令干了什么在/tmp/agent-worktrees/wt-a1创建一个完整的项目副本--detach表示不创建新分支用 detached HEAD 模式这个目录完全独立——有自己的文件、自己的.git元数据创建速度很快秒级因为它共享底层 git 对象不是cp -r每个需要写文件的 GENERAL 子 Agent启动前先拿一个独立 worktreeGENERAL-A: /tmp/agent-worktrees/wt-a1/UserService.java ── 写这里 GENERAL-B: /tmp/agent-worktrees/wt-b2/UserService.java ── 写这里 父 Agent: ./UserService.java ── 原文件不动三个互不干扰。改同一个文件也不会互相覆盖。子 Agent 完成后通过git diff把修改拉回来StringdiffworktreeManager.getDiff(worktreePath);// git -C /tmp/wt-a1 diff → 所有文件改动父 LLM 在通知里看到完整的 git diff由它决定要不要把改动合入主分支。子 Agent 只管改父 Agent 负责判。五、ThreadLocal一句话让 FileTool 指向正确的 worktreeworktree 有了但FileTool有个大问题——它的工作目录是静态字段// FileTool.javaprivatestaticPathWORKSPACE;// 启动时设置之后不变所有线程都共享这一个WORKSPACE。子 Agent 在 worktree 里调file(write, UserService.java, ...)怎么保证它写到/tmp/wt-a1/而不是主目录加一行 ThreadLocalprivatestaticfinalThreadLocalPathWORKSPACE_OVERRIDEnewThreadLocal();// 子 Agent 启动前FileTool.setWorkspaceOverride(worktreePath);// 当前线程 → worktree// safePath() 里PathworkspaceWORKSPACE_OVERRIDE.get();if(workspacenull)workspaceWORKSPACE;// 没人设就用默认// 子 Agent 结束后finally 块FileTool.clearWorkspaceOverride();// 当前线程恢复为什么 ThreadLocal 而不是传参因为FileTool的调用链路是LLM → ToolDispatcher → FileTool.execute()。中间没有任何地方让你顺便传一个 workspace 进去。ThreadLocal 是唯一不破坏现有架构的注入方式。线程安全保证ThreadLocal 的 set 只影响当前线程线程 1 设了/tmp/wt-a1不影响线程 2 的/tmp/wt-b2。比锁优雅得多。六、线程安全全景整个子 Agent 系统从头到尾不需要一把锁原因资源安全机制SubagentRunner内部状态全局部变量每次调用 new 一份AIServiceAPI 调用无状态 HTTP 客户端天然线程安全ToolDispatcher / ToolRegistrywithout()返回新副本不共享FileToolworkspaceThreadLocal线程级隔离通知队列ConcurrentLinkedQueue运行任务跟踪ConcurrentHashMap AtomicInteger唯一的共享是 DeepSeek API Key——但两个 HTTP 请求之间不存在竞争就像两台电脑用同一个 WiFi。七、Worktree 失败怎么办worktree 可能创建失败——不是 git 仓库、有未提交的修改、磁盘满了。GENERAL 子 Agent 拿不到 worktree 时不降级到主 workspaceif(worktreePathnull){// 直接报错不跑子 Agentnotifications.add(错误通知:git worktree 创建失败请检查...);return;}父 LLM 看到错误后有三种选择bash(git status)检查状态修好后重试改用task(typeexplore)做只读分析自己动手改不用子 Agent关键原则写操作拿不到隔离环境就拒绝执行。宁可不干活不能瞎干活。总结异步不阻塞— 子 Agent 提交到线程池后立刻返回父 Agent 继续循环只读无限制并行— Explore/Plan/Verification 纯读不冲突GENERAL Worktree 隔离— 每个写操作子 Agent 跑在独立 git worktree 里ThreadLocal 防串— 一句话让 FileTool 指向正确目录线程级隔离失败不降级— worktree 拿不到就报错绝不动主 workspace整轮改造 4 个新文件 6 个修改文件核心改动不到 300 行。点赞收藏面试能讲 10 分钟。