
1. 先交代背景单 Agent 卡在哪AgentTeams 为什么选择并行扇出做 Code Agent 项目久了我越来越觉得单代理的瓶颈不在模型能力而在任务链路太“线性”。早期我搭的 agent 就是典型的循环拿到任务读代码改代码跑测试有问题再回头改。一条流水线走到黑看起来逻辑清晰实际上每一步都在等上一步。遇到一个跨模块的改动整个链路被拉得特别长中间只要有一次工具调用超时或者上下文漂移后面节奏全乱。这个感受在代码库变大以后尤其明显。单个 agent 要同时兼顾“看接口定义”“查调用方”“设计改动方案”“写实现”“补测试”这五件事时Prompt 里的上下文会迅速膨胀。更麻烦的是很多信息彼此之间没有强依赖完全不需要串行处理。比如一个前端接口改动接口签名设计、后端适配、前端调用方升级、测试用例补充这四块理论上可以同时进行互相只依赖一份“接口契约”可是在单代理模型里它们还是被硬生生排成了先后顺序。后来我接触到 AgentTeams 的设计思路核心就是两个组件TeamFanout和TeamCollect。名字很直白一个负责把任务“扇出去”一个负责把结果“收回来”。但它的价值不在名字而在背后那套关于“什么任务能并行、拆多细、怎么收拢、冲突怎么处理”的完整机制。这篇文章我打算把它拆开来讲重点放在并行机制的工程实现上适合已经在做多代理编排、或者正打算把单代理升级成多代理团队的读者。1.1 不是模型变笨了是串行链路把错误放大了我见过不少团队在做多代理的时候第一步就是堆 agent一个负责写代码一个负责 review一个负责测试人多了问题更多了。为什么因为 review 的 agent 必须等写代码的 agent 彻底结束才能开始测试 agent 又要等代码和 review 都过。整条链路的时延不是加法是乘法。更隐蔽的问题是错误放大。单代理跑一个长任务模型在中途出现理解偏差后面还能凭全局上下文自己纠回来一点。可一旦拆成多个 agent每个子任务拿到的是“局部信息”一旦上游传下来的信息有偏差下游所有 agent 都会沿着错误方向走。到最后 Collect 阶段会发现五个子任务产出了三套互相矛盾的结果而且你还说不清是哪个环节先歪的。所以我一直觉得多代理架构的第一目标不是“把活分掉”而是“把串行依赖切开让彼此独立的部分并行跑起来”。这正是 TeamFanout 的存在理由它不是简单地把一个 Prompt 复制给多个模型而是先把任务切成可以并行的子问题再为每个子问题分配独立的执行上下文。1.2 扇出加汇合Code Agent 协作的基本模型TeamFanout 和 TeamCollect 本质上是一对“扇出—汇合”原语思路跟 MapReduce 里的 Map 和 Reduce 很像但放到 Code Agent 场景里复杂程度高了一个量级。MapReduce 处理的是无状态的数据分片而 Code Agent 的子任务会改代码、跑命令、读上下文它们之间可能存在隐性的文件依赖和资源竞争。AgentTeams 的做法是把并行过程收敛成三个阶段规划阶段TeamFanout 根据总目标做任务分解生成一组带依赖约束的子任务。执行阶段多个子代理并发执行每个子代理只看到自己相关的代码片段和工具权限。汇合阶段TeamCollect 把各子代理的产物按统一协议收拢处理冲突生成最终结果。这个模型看起来简单但每个阶段都有值得深挖的工程细节。下面我按组件拆开讲先讲 TeamFanout 的设计再讲 TeamCollect 的归并策略。2. TeamFanout子任务划分、上下文预算与扇出边界TeamFanout 要做的事绝不只是在代码里写个for循环然后丢给几个 worker。拆得不好并行比串行还慢拆得太细Collect 阶段会被碎片化结果淹没。我自己的经验是扇出过程必须先做“分诊”再做拆分最后用上下文预算约束每个子任务的范围。2.1 先做“分诊”再决定哪些任务不该扇出不是所有代码任务都适合并行。我在实际项目里总结出三条筛选规则凡是触犯其中一条的宁可留在主链路里串行执行。第一类是强依赖任务。比如“先改数据库迁移脚本再改 ORM 模型再改业务代码”这三步天然存在前后顺序。虽然可以拆成三个 agent但每个 agent 都必须等上一步的结果才能开工拆了反而增加调度开销。第二类是小任务。一个只需要改两三行配置的活单独起一个代理光模型初始化、上下文加载、工具预热的时间就比手工改还长。第三类是全貌决策任务。比如“优化整条链路的性能”这种目标必须有人先看全局得出方向性结论之后才有条件往下拆。判断完这三类之后剩下的任务才进入 TeamFanout 的候选池。实际操作中我一般会用一个规划器代理来做分诊它不是写代码的只负责读代码库结构、列依赖、给任务打标签。规划器输出的是一份结构化拆分结果大概是这个样子{ goal: 为订单服务增加导出功能, fanout_tasks: [ { task_id: task-001, objective: 新增导出接口定义与参数校验, depends_on: [], context_files: [api/order_routes.py], output_format: code_diff }, { task_id: task-002, objective: 实现导出任务后台异步处理逻辑, depends_on: [task-001], context_files: [services/order_export.py], output_format: code_diff }, { task_id: task-003, objective: 补充订单导出的集成测试用例, depends_on: [task-001, task-002], context_files: [tests/test_order_export.py], output_format: code_diff } ] }注意depends_on字段这是并行机制的核心。真正能立刻扇出的只有task-001另外两个必须等它完成。所以 TeamFanout 不是一个“一把梭”的分发器它内部其实维护着一张有向无环图每完成一个节点就解锁一批新节点投入执行。2.2 子代理的上下文预算与工具清单Code Agent 的并行和普通服务端并发最大的区别在于每个子代理都要占用上下文窗口。假设模型上下文是 128k主协调器自己已经用了 20k那剩下的 108k 不可能全部分给子代理因为 TeamCollect 阶段还要把子代理的产物放回来汇总这部分也要占上下文。我给 TeamFanout 定了一条很死板的规则每个子任务的输入上下文预算不能超过模型窗口的 20%。128k 的窗口单子任务最多给 26k 左右。这个预算要覆盖任务描述、相关代码片段、历史上下文摘要三部分。如果某个子任务需要的代码面明显超出预算说明任务拆得太大需要继续往下拆而不是硬塞。工具权限也得跟着任务走。一个只负责“补充测试用例”的子代理不需要给它数据库写入权限也不需要它能修改生产配置文件。经验之谈工具暴露面越大子代理跑偏的概率越高。我见过一个负责文档生成的 agent因为工具包里带着代码搜索能力结果自己绕进去查了半天根本无关的历史代码。TeamFanout 在扇出时给每个子代理配一份白名单工具列表是控制这种漂移最直接的手段。2.3 扇出上限不是越大越好现在主流大模型的推理服务都有吞吐限制扇出的子任务太多会在 token 层面互相排队。我一般按两个维度掐上限并发 worker 数默认不超过 4除非代码库结构非常清晰我可以放宽到 8。同时写入的代码文件冲突密度如果多个子任务会改到同一个模块扇出数量就要压低。一条经验公式可以参考max_workers min(模型服务并发上限 - 2, 待修改文件数 / 2)。比如这次任务涉及 8 个文件模型服务并发上限是 10那取min(8, 4)并发设 4 就够。设太高没有意义文件锁一卡后面全在等。3. TeamCollect结果汇合时的排序、冲突裁决与部分失败语义Fanout 把任务拆出去了真正考验工程能力的是 Collect。扇出阶段出了问题最多是任务执行慢一点Collect 阶段出了问题那就是把错误结果合入代码库后面要花几倍时间回滚。所以我把 TeamCollect 看得比 TeamFanout 更重。3.1 让每个子代理都按统一协议交作业没有统一协议的多代理协作本质上是灾难。子代理 A 返回一个 diff子代理 B 返回一段 Markdown 说明子代理 C 直接回了一段修正后的完整文件到了 Collect 阶段你根本没办法律化地合并它们。AgentTeams 的解法是给每个子任务绑定一个output_format子代理必须按这个 schema 返回结果。我常用的协议大概长这样{ task_id: task-002, status: success, summary: 实现了导出任务的异步处理核心逻辑在 order_export.py, changed_files: [ { path: services/order_export.py, change_type: modified, language: python } ], code_diff: diff --git a/services/order_export.py ..., test_results: [ { name: test_export_csv, passed: true, duration_ms: 1280 } ], risks: [导出大文件时内存占用偏高建议后续接入流式写入] }这里最关键的不是 status而是code_diff必须基于最新的主干代码生成。Collect 阶段做合并时如果发现某个子代理的 diff 基线已经过期必须立刻标记冲突而不是强行应用。我一直强调结构化输出是因为自由文本在 Collect 阶段不可信。模型生成的 Markdown 说明写得多漂亮都没用代码库里最终生效的必须是可以机械应用、可以机械回滚的 diff 格式。收起你对自然语言的好感这里是工程领域。3.2 同文件冲突的两种判定行号级与语义级并行改代码最头疼的就是多个子代理改到同一个文件。TeamCollect 要做冲突检测我总结了两个层级。第一层是行号级冲突。两个 diff 都修改order_export.py一个改了第 30 行一个改了第 88 行行号不重叠理论上可以直接合并。但如果一个 diff 在文件头部新增了导入语句导致行号整体偏移另一个 diff 基于旧行号生成的修改就会错位。收集器在合并前先重算行号是最基本的健壮性要求。第二层是语义级冲突。两个 diff 改了不同行但一个定义了一个函数build_export_task()另一个在另一个文件里也定义了同名函数合并后出现重复定义。这种问题行号检测不出来只能靠符号表交叉检查。我的做法是在 Collect 阶段维护一个“本次会话修改符号表”每个 diff 应用前先检查它引入的符号是否与已有的符号表冲突。这里没有银弹语义冲突检测本身就是一条需要持续投入的工程线。我的最低建议是至少做行号级检测并在最终的合并评审阶段留给一个人或一个模型做整体检查不要让 Collect 阶段完全无人值守。3.3 失败不重来Collect 的分区收编策略并行任务里必然有失败的子任务。最简单的策略是“一个失败全部重来”这在多代理场景下成本高到你无法接受——其他子代理可能已经跑了十几分钟。TeamCollect 的思路是分区收编把已完成且通过冲突检测的产物先合入代码库失败或超时的子任务单独标记出来进入下一轮重试队列。重试时只需要重新派发失败的那几个任务但要注意新派发的任务需要基于“已经合入部分结果后的最新代码”来生成 diff否则基线又不对了。部分成功语义对用户感知也很重要。交付结果时我会明确列出哪些子任务成功合入哪些子任务失败或超时失败原因是什么模型推理错误、工具超时、冲突无法自动解决这样即使整个任务没有完全跑完前期的产出也不会白费。比起那种“全盘成功或全盘失败”的二进制结果这种渐进式交付在真实开发流程里友好得多。4. 从 Fanout 到 Collect 的链路追踪出问题该查谁并行机制上线之后你最需要的是一个能回答“到底是谁搞坏了这次合并”的追踪系统。多代理一跑起来日志是爆炸的没有 trace 维度去关联排障就像在垃圾场里找一根针。4.1 用 trace_id 贯穿扇出与汇合我习惯在 TeamFanout 生成任务时就为整个请求生成一个trace_id它贯穿规划、扇出、子任务执行、收集合并的完整链路。每个子任务在这个 trace 下再展开自己的task_id和agent_id所有日志、token 消耗、工具调用记录、耗时都挂在这条链路上。这样定位问题非常高效。如果 Collect 阶段报告冲突我第一时间能查到是哪个 task 引入了符号冲突它当时依赖的代码基线是哪一版它执行过程中调用了哪些工具。有一次线上并发任务把同一个配置文件改乱了我靠 trace 定位到两个子任务几乎同一秒加载了旧版配置问题根源立刻清楚——不是模型写错了是扇出时没有给它们都传递最新的配置基线。4.2 三个关键指标耗时、token、重试链路追踪的数据最终要沉淀成指标不沉淀指标的追踪系统排一次障可以长期优化没用。我重点看三个指标一是每个子任务的端到端耗时分布。如果多数子任务都在 1 分钟完成有一个卡了 10 分钟先看它是不是等工具返回等了太久。二是token 消耗拆分。扇出阶段拆得越细子任务描述和上下文准备阶段消耗的 token 越多Collect 阶段收到的产物越大归并消耗也越高。这两个数字记录后可以反推拆分粒度的合理性。三是重试率。单个子任务的重试率超过 20%基本可以判断要么任务描述含混不清要么工具清单给得太宽导致它反复试错。这些指标我一般按天聚合观察趋势。重点不是某次任务跑得好不好而是随着代码库演化并行机制是否还在健康运转。5. 一组可以直接参考的落地参数与调优经验理论说完了列一份可以“抄作业”的配置参考。这些参数是我在真实项目里验证过、并持续在用的默认值不保证对所有场景最优但作为起点很稳。5.1 我给 AgentTeams 的默认配置agent_teams: fanout: max_workers: 4 max_dependency_depth: 3 subtask_context_budget_ratio: 0.2 tool_whitelist_per_task: true collect: timeout_seconds: 120 conflict_detection: line_and_symbol_level partial_success_enabled: true max_retry_per_task: 2 require_code_diff: true tracing: enabled: true trace_header: x-agent-teams-trace-id collect_daily_snapshot: true几个值得解释的地方max_dependency_depth: 3指的是子任务的依赖链不超过三层。超过三层的拆法本质上说明规划器把串行任务硬拆成并行徒增调度成本。subtask_context_budget_ratio: 0.2前面提过128k 窗口下给单个子任务 26k 左右输入预算。如果你的任务普遍需要更多上下文先压任务粒度而不是调这个比例。require_code_diff: true是硬开关。任何任务只要涉及代码修改必须返回标准 diff。不接受“我修改了 xx 文件”这样的自然语言描述。5.2 实测中的表现与调优笔记我手上一个中型项目代码库约 20 万行涉及 30 多个模块。以前用单代理串行做一个跨模块功能平均耗时 40 到 50 分钟中途成功率不到六成。切到 AgentTeams 并行机制后相同类型任务平均耗时可压到 15 分钟左右一次交付成功率到了八成以上。不过第一版上线时也踩过坑。第一次调优是扇出数量。我一开始把max_workers设到 8想着并行度拉满结果因为多个任务都依赖读同一个公共模块模型服务端排队严重整体耗时反而比 4 并发慢。降回 4 之后单任务延迟高了一点点但整体吞吐反而上去了。这不是模型变快了是排队时间变短了。第二次调优是Collect 的冲突检测。早期我只做行号级检测结果合并出来的代码语法没问题、符号却重复定义CI 阶段才爆出来。加了一层符号级检测之后这类问题少了七八成。剩下的漏网之鱼主要发生在跨文件引用场景靠人工评审兜底。5.3 真实接入后的三个坑如果你准备照着这套机制落地提前跟你说三个我踩过的坑。第一个坑是子代理读到的代码版本不一致。扇出之前规划器会把相关文件路径打包给子代理但子代理实际执行时是从 git 分支读取代码。如果扇出过程中有别人推了代码不同子代理可能看到不同版本。我现在统一用快照分支来隔离扇出时从主干创建一个临时分支Collect 成功后才合并回主干。第二个坑是Collect 阶段的上下文超载。子代理返回的产物如果都塞给 Collect 的模型上下文瞬间爆掉。我的处理方法是让 Collect 阶段不直接吃原始 diff而是先让一个轻量模块把每个 diff 转成摘要和风险标签真正给到模型做决策的是压缩后的信息完整 diff 只在落盘时保留。第三个坑是超时时间不能一刀切。不同任务的耗时差异极大读代码的任务几秒就完跑测试的任务可能要三五分钟。现在我会根据depends_on的层级和工具类型给子任务单独计算超时时间。比如涉及执行测试用例的子任务超时设置为基础值的 3 倍避免收集器把还在正常运行的子任务误判为失败。6. 收个尾并行机制不是架构终点是编排起点把这套 Fanout 和 Collect 的机制跑通之后我最大的一点体会是并行机制的真正价值不是把任务变快而是把“编排”这件事从隐式变成显式。单代理时代任务拆解、上下文分配、结果合并都藏在模型的一次次推理里出了问题说不清道不明。有了 TeamFanout 和 TeamCollect这些环节全部变成了可以设计、可以观测、可以干预的工程组件。现在再接到“能不能让多个 Agent 一起干活”的需求我心里就有谱了先做任务分诊把强依赖和弱依赖分开再定上下文预算和扇出上限然后约定结构化输出协议让 Collect 阶段可以机械合并最后把链路追踪和指标踩好不放过任何一次慢请求和冲突。如果你正在做自己的多代理系统我建议不要一上来就去追求复杂的协议和框架。先手工把一次任务拆成三个子任务用最朴素的并发方式跑通再把冲突检测和失败重试补上就够了。等这个最小闭环稳定了再去思考 AgentTeams 这类高阶机制也不迟。最后分享一个小经验并行任务的子代理各自跑得再快也没用瓶颈永远在汇合点。所以你的精力分配上Collect 的归并策略、冲突解决和失败处理值得投入比 Fanout 多一倍的注意力。我把太多精力浪费在让 Fanout 更快上最后发现真正决定系统上限的是那个默默收拢结果、解决冲突的 Collect。