最近在做一个 AI 驱动的 API 自动化测试平台,踩到一个很典型的架构问题:
平台里同时长着两套让多个 LLM 协同干活的范式——一套是”多 Agent 编排”(Coordinator 把任务拆成多个阶段,调度若干 Worker),另一套是”确定性流水线”(固定的 8 个步骤一步步往下走)。
一开始我以为后加的流水线是在重复造轮子,应该收敛成一套。聊深了才发现:它俩根本不是一个层面的东西,而且哪个功能该用哪套,是有判据的。
这篇就把这个判据讲清楚。它不只适用于测试平台,任何”让 AI 分工干活”的系统都用得上。
先破一个误区:并行 ≠ 多 Agent 协作
很多人(包括当时的我)有个模糊印象:
“确定性流水线在跑的时候,不也是好几个 Agent 同时在工作吗?那它跟多 Agent 编排有啥区别?”
这里藏着一个关键混淆。我们看流水线里实际发生了什么——它在”生成测试代码”那一步是这样的:
# 把一个类切成 N 个 chunk,每个 chunk 并行调一次 LLM 生成测试semaphore = asyncio.Semaphore(max_parallel_writers)tasks = [generate_one_chunk(sem, chunk) for chunk in chunks]results = await asyncio.gather(*tasks) # N 路并发,跑完一起收这是并行没错,但这些并行的 LLM 调用之间互相不通信、不协商、不依赖。它们只是被同时撒出去(fan-out),跑完一起收回来(gather)。
这跟”多 Agent 协作”是本质区别。多 Agent 协作里,Agent A 的产出要喂给 Agent B,B 跑挂了要有 Agent 来决定”接下来重试还是换个策略”——它们之间有数据流、有依赖、有动态决策。
所以准确的说法是:
- 确定性流水线 = 固定的主流程 + 可并行的”无协作” LLM 调用
- 多 Agent 编排 = 运行时动态决策的任务图 + Agent 之间真实的协作
把”并行”当成”多 Agent”,是这一切混淆的源头。并行只是一种执行方式,协作才是 Agent 编排的本质。
两者各自的真本事是什么
确定性流水线擅长的
步骤写死、可预测、可重放、成本可控。它适合这种任务:
- 步骤边界事先就确定(不管输入怎么变,123456 步都一样)
- 子任务之间没有依赖,可以撒出去并行
- 不需要 LLM 在中途决定”下一步干嘛”
典型例子就是单元测试生成:切分代码 → 并行生成 → 合并 → 编译修复 → 测覆盖率。这条链是固定的,每个 chunk 又互相独立,天然适合 fan-out。
多 Agent 编排擅长的
它的护城河不是并行,而是这三样确定性流水线给不了的东西:
- 动态任务分解——任务边界事先不知道,得让 LLM 现场拆。
- 带依赖的任务图——A 的输出喂给 B,编排器知道谁等谁。
- LLM 驱动的错误恢复——某步挂了,由 LLM 判断该
重试 / 跳过 / 降级 / 中止 / 调整参数。
最后这一条尤其关键。在我们的编排器里,错误恢复是一个有五种策略的决策模块:
class RecoveryActionType(str, Enum): RETRY = "retry" # 重试 SKIP = "skip" # 跳过 FALLBACK = "fallback" # 降级到备用方案 ABORT = "abort" # 中止 ADJUST = "adjust" # 调整后再来确定性流水线遇到失败,只能走预设的 if/else 分支;而编排器能让 LLM 看着现场情况现做决定。这就是”动态”和”固定”的分水岭。
一条可以直接用的判据
判断某个功能该走哪条路,别按”这是什么功能”去分,按控制流去分。问自己三个问题:
- 步骤边界事先确定吗? 换一批不同的输入,步骤还一样吗?
- 子任务之间有数据依赖链吗? A 的输出要喂给 B 吗?
- 失败时需不需要 LLM 现场决定怎么办?
- 三个都”否” → 确定性流水线(fan-out 并行)
- 任何一个”是”,尤其第 3 个 → 多 Agent 编排
落到我们平台的几个功能点上,一张表就清楚了:
| 功能点 | 步骤固定 | 有依赖链 | 失败需现场决策 | 该走哪条 |
|---|---|---|---|---|
| 测试案例生成(一批 case 并行生成) | 是 | 否(case 互相独立) | 否 | 确定性 fan-out |
| 单测 / 分支测试生成 | 是 | 否(代码块独立) | 编译修复是固定循环 | 确定性流水线 |
| API 端到端测试(解析→生成→执行→分析→报告) | 否 | 是(强链) | 是 | 多 Agent 编排 |
为什么 API 端到端测试是编排的主场?三条全中:
- 步骤边界不固定:被测系统千差万别——有的接口要先登录拿 token 再测,有的接口之间有调用顺序依赖。这得 LLM 现场拆,写死的流水线一遇到新系统就僵。
- 强数据依赖:
规划 → 执行 → 分析 → 报告是一条链,不是可以并行撒出去的独立单元。 - 动态决策密集:一个用例失败了,要判断是断言写错、是环境问题、还是该回头补测——这正是那五种恢复策略存在的意义。
反过来,案例生成、单测生成就不该上编排:它们是 fan-out 无协作,套上编排只是白白增加 LLM 决策的开销和不确定性。
别陷入”二选一”:正确形态是分层嵌套
讨论到这里最容易得出一个错误结论:那我把系统拆成”编排派”和”流水线派”两套独立系统就行了。
不对。这恰恰是我们平台当时的隐患——两套范式各跑各的,谁也不认识谁,维护时得同时读懂两套。
正确的形态是分层嵌套:上层编排,叶子 fan-out。
编排器本来就该支持”一个阶段内并行跑一组互不依赖的 Worker”。所以:
- 案例生成不该是一条”独立的流水线”,而该是编排里的一个并行阶段——阶段内 fan-out 出 N 个生成器,阶段之间靠依赖关系串到执行环节。
- 单测 / 分支生成这种”整条链都固定、而且是离线代码任务、跟编排的数据完全没往来”的,才适合彻底独立成流水线。
- 长期方向:让流水线成为可以被编排当成一个”确定性工具节点”调用的东西,而不是和编排器老死不相往来的第二套系统。
一句话收口:
控制流需要 LLM 在运行时决策 → 编排;纯固定 → 流水线;而流水线最好作为编排的一个叶子存在,不是它的竞争对手。
一个真实踩坑:重复实现会悄悄长歪
最后讲个很具体的教训,跟上面的架构判断直接相关。
因为”把进度推送给前端”这个需求两套范式都有,结果它被各抄了一遍——两份几乎逐行相同的推送器。当时觉得”反正就几十行,抄一份省事”。
后来审计代码才发现,这两份已经悄悄长得不一样了:
| 流水线 A 的推送器 | 流水线 B 的推送器 | |
|---|---|---|
序号自增 _next_seq | self.seq += 1(无锁) | 用 threading.Lock 保护 |
| 失败重试 | 没有 | 有 |
而流水线里恰好有真多线程并行的环节。那个无锁自增在多线程下序号会冲突、会丢——这是个潜伏的并发 bug。它目前没爆,纯属侥幸:并行的那段刚好没接推送器。哪天有人想给并行环节也加个进度条,接上去就直接踩雷。
这件事的教训是双重的:
- 重复代码不只是冗余,它会让两份实现各自演化、行为分叉——一个有锁一个没锁,一个会重试一个不会。等你发现的时候,它们已经是两个不同的东西了。
- 一个公共能力(推进度、调外部接口、读配置……)只要出现第二次,就该抽成共享的那一份。我后来的处理是抽一个统一的推送客户端:底层只有一份”怎么发、怎么算序号、怎么重试、超时读配置”,两套范式各自只保留”推什么内容”的语义差异。范式还是两套(合理),但推送只有一份实现、一套并发语义,那个 bug 也就自动消失了。
小结
- 并行不等于多 Agent 协作——并行是执行方式,协作(依赖 + 动态决策)才是编排的本质。
- 选型看控制流,不看功能名:三个问题,任何一个”是”就上编排,全”否”就用流水线。
- 编排和流水线不是二选一,而是上层编排、叶子 fan-out 的嵌套关系。
- 公共能力出现第二次就该抽共享实现,否则它会悄悄长歪成两个不一致的东西。
把”这一步到底需不需要 LLM 现场拿主意”想清楚,比纠结”用不用多 Agent”有用得多。