工作流、规划与协作:怎样组织复杂任务
从固定步骤到动态选择与任务协作,建立依赖、结果核验和协调成本的直觉。
工作流、规划与协作:怎样组织复杂任务
一个文件检查很容易排成固定步骤:读文件、匹配声明、生成报告。换成"找出项目中与某个问题有关的文件并解释原因",开始时你并不知道要查哪些位置、查几个。
任务组织方式应该跟着这个不确定性变化,而不是默认"多加几个 Agent"。这一篇讲三件事:工作流和 Agent 的分界线画在哪里;五种常见编排模式各自适合什么任务;以及把任务交给另一个执行者时,需要写清楚哪些东西。
分界线是画出来的,不是模糊的
第 2 篇引用过 Anthropic 的定义,这里再回到它,因为它决定了本篇所有选择:
Workflows are systems where LLMs and tools are orchestrated through predefined code paths. Agents, on the other hand, are systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks.
这条线的判断标准只有一条:下一步做什么,是代码预先写好的,还是模型根据上一步的结果决定的。
Anthropic 在同一篇文章里并没有使用"边界模糊""自主程度分级"这类说法。它把两种系统当成两种明确的工程形态来描述,并直接给出建议:"When building applications with LLMs, we recommend finding the simplest solution possible, and only increasing complexity when needed."
所以要纠正一个常见的说法——工作流和 Agent 之间不是一片灰色地带。选择题的形状是清楚的:任务路径能提前写死,就写成工作流;路径必须等观察结果才能确定,才需要 Agent。
这张表是选择顺序,不是能力排行榜。动态能力会同时增加时延、消耗和验证难度。Anthropic 在同一篇文章里把这句话说得更彻底:"This might mean not building agentic systems at all." 如果固定流程能覆盖九成情况,就不要为了剩下的一成把整个流程改成 Agent。
它也说明了代价的方向:"Agentic systems often trade latency and cost for better task performance, and you should consider when this tradeoff makes sense." 这个交换划算才值得。
五种编排模式
Anthropic 把常见的编排方式归为五种。它们都是工作流形态——路径预先定好,只是分支逻辑的复杂程度不同。
有两个细节值得单独拿出来。
第一,并行和编排者-执行者长得像,区别在子任务从哪来。 Anthropic 原文说得很直接:"Whereas it's topographically similar, the key difference from parallelization is its flexibility—subtasks aren't pre-defined, but determined by the orchestrator based on the specific input." 并行的子任务是你事先列好的清单;编排者-执行者的子任务数量和内容,由编排者看到具体输入后才决定。这正好对应前面的检查任务:检查几个已知文件可以预先列出,但要改哪几个文件,往往要读完才知道。
第二,加一层编排是有代价的。 Anthropic 在同一篇文章里提醒:"They often create extra layers of abstraction that can obscure the underlying prompts and responses, making them harder to debug." 多一层调度,就多一层提示和响应的藏身处。排查问题时你能直接读到的中间产物变少了。判断是否值得加层,一个实用的标准是:加了之后,你还能不能直接从事件流里看到每一次模型调用和它的完整响应。不能,就得先解决可观测性再谈编排。
并行有两种意思
Anthropic 对并行的描述里,藏着一个新手容易忽略的区分:
LLMs can sometimes work simultaneously on a task and have their outputs aggregated programmatically. This workflow, parallelization, manifests in two key variations:
- Sectioning: Breaking a task into independent subtasks run in parallel.
- Voting: Running the same task multiple times to get diverse outputs.
Sectioning(切分)是把一件事拆成互不依赖的几份,各做各的,最后合并。检查五个目录就是这一类。
Voting(投票)是同一个任务跑好几遍,取多数结果或让模型在几个答案里挑一个。它解决的问题不是"太慢",而是"一次不够准"。这跟第 10 篇讲的可靠性直接相关:第 10 篇会说明,一次成功不能证明稳定,多次运行观察到的波动才是模型行为的真实特征。
区分这两者很重要,因为它们的成本结构完全不同。Sectioning 的成本随任务数线性增长但总量不变;Voting 的成本直接翻倍,换来的只是置信度。
一个检查任务会落在哪一格
把上面的模式套回那个教学任务(检查 src/greet.ts 中的命名函数声明,报告实际匹配到的名称),几种排法的差别会立刻显出来:
值得注意最后一行。评估者-优化者的适用条件里,Anthropic 给出了两个判断信号:"The two signs of good fit are, first, that LLM responses can be demonstrably improved when a human articulates their feedback; and second, that the LLM can provide such feedback." 翻译成可操作的话:你先能说清楚哪里不合格,第二点通常就自动成立。如果你写不出检查清单,这个模式就不该上。
计划是安排,不是完成证明
模型可以写出"读取文件、检查声明、生成报告"三项计划,写得整齐、编号清晰。真正的进度仍然来自执行和产物,不来自它把计划勾成完成。
计划的价值在于允许被现实推翻。文件缺失时需要澄清,工具能力不足时需要说明范围,预算耗尽时需要交代未完成部分。反复生成更漂亮的计划,补不回没有发生的读取。
一个可操作的判断:看完模型写下的计划,你应该能指出每一步产出什么可检查的东西。
第三行是典型的"看起来像计划"。改法不是把它写得更详细,而是补上产出物:分析代码质量 → 列出超过 80 行的函数及其行号。
先看依赖,再决定并发
检查两个互不依赖的只读文件,可以各自执行后汇总;两个任务同时改同一份报告,可能覆盖彼此结果。能不能并行,取决于共享资源和数据依赖,跟工具数量无关。
动手拆之前先回答三个问题:
三个答案都是"不共享、可以直接拼、可以继续跑"时,并行才真的简单。任何一个答案含糊,并行带来的协调成本就会超过它省下的时间。
教学上可以拆成:
检查文件 A ─┐
├─ 汇总实际结果 ─ 验证报告
检查文件 B ─┘图里的汇总必须等待两项结果,并处理失败或缺失。不能因为某个子任务回复快,就假定全部任务已经完成。判断是否真的全部完成,看汇总那一步是否收到了全部分支的结果,而不是看模型说"都完成了"。
并发上限怎么写
要限制并发,常见做法是"同时最多跑 N 个"。Pi 官方示例里的 subagent 扩展给了一个可以直接照抄的写法,它同时用了两个不同的上限:
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;这两个数字含义不同,值得分开理解:MAX_PARALLEL_TASKS 限制一次最多接受几个任务,超出就直接拒绝;MAX_CONCURRENCY 限制同时真正在跑的有几个。
if (params.tasks.length > MAX_PARALLEL_TASKS)
return {
content: [
{
type: "text",
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
},
],
details: makeDetails("parallel", true)([]),
};实际的并发控制靠一个不复杂但正确的辅助函数:
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(concurrency, items.length));
const results: TOut[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(limit).fill(null).map(async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await fn(items[current], current);
}
});
await Promise.all(workers);
return results;
}这段代码里有三个可以直接借用的设计。
一是用固定数量的 worker 抢同一个下标,而不是为每个任务 Promise.all 一次。前者无论任务有多少,峰值并发恒等于 limit。后者会让 N 个任务同时起来,N 大了直接把机器压垮。
二是结果按原下标写回数组,不按完成顺序追加。所以汇总时不用关心谁先跑完,顺序天然稳定,输出可复现。
三是并发数被夹在任务数以内(Math.min(concurrency, items.length))。只有 2 个任务时不会白白开 4 个 worker。
用起来是这样的:
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
const result = await runSingleAgent(
dispatchDefaults,
resolvedTasks[index],
t.agent,
t.task,
undefined,
signal,
makeDetails("parallel"),
);
allResults[index] = result;
return result;
});委派的三种模式
同一个扩展里,三种委派方式的参数形状正好对应三种编排模式。官方 subagent 扩展把参数定义成:
const SubagentParams = Type.Object({
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
agentScope: Type.Optional(AgentScopeSchema),
confirmProjectAgents: Type.Optional(
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
});工具描述里把三种模式说得很清楚:"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder)."
chain 模式里的 {previous} 占位符是个巧妙的约定:第二步的任务文本里写 {previous},运行时会替换成上一步的实际输出。这让"先扫描,再按扫描结果修复"这类顺序依赖可以用同一套参数表达,不必自己拼字符串。
agentScope 的默认值是 "user",取值有 "user"、"project"、"both"。confirmProjectAgents 默认 true,会在运行项目本地 agent 之前先问一次。这两项和第 9 篇讲的信任边界直接相关——能被委派的不只是任务,还有权限。
委派任务时,写清楚这六件事
把任务交给另一个执行者(子 Agent、并行分支、外部服务)时,交代不清的代价会由你承担。原文已经列出了必备要素,这里做成可以直接填的清单,每一项配一个反面例子:
目标写得含糊最常见。"检查一下代码"没法验收,因为没人能判断"检查"到什么程度算够。改成"找出 src/ 下所有导出了命名函数的文件,列出函数名和所在行号",验收标准就出来了。
能力这一项容易被忽略,但它是安全和成本的主要开关。只说明"可以读文件"和说明"可以读文件、可以执行命令、可以联网",对应的是完全不同的权限面。第 9 篇会专门讲这件事。
预算要写在任务里而不是只存在你脑子里。"最多 20 轮工具调用,或累计 5000 token 后停下并说明剩余工作"是一条可执行的指令;"注意控制成本"不是。
完成信号的作用是让失败能被发现。如果不定义失败长什么样,子任务返回一句"我没能完成"和返回一份空报告,在上层看起来是一样的。
Pi 官方不提供 sub-agents
这一点值得单独说清楚,因为它决定了你在别处看到的多智能体方案是从哪来的。Pi 的 README 写得很直接:
Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way.
也就是说,你现在看到的多智能体方案,多数来自生态包,而不是 Pi 内置。Pi 内置的是执行循环、工具调度、上下文管理和会话存储;多智能体编排、计划模式这类东西,它刻意留给你自己装或者自己写。
这不是缺点,但它改变了一件事:读到的多智能体文章里那些"标准做法",很多是某个包的选择,不是平台的约定。你应该先确认对方用的是哪个包、哪个版本,再判断它的委派协议是否适用于你。上面那个 agent / task / tasks / chain 参数形状,就是某一个包的选择,不是通用标准。
Pi 官方的 subagent 示例正好是这类"自己写"的产物:它把编排者-执行者的思路落成了一次工具调用——传入一批任务,每个任务指定一个 agent 名,扩展负责起独立进程、跑完再汇总。它同时也是第五种模式的一种实现思路:每个子 Agent 拿到自己的系统提示和工具集,跑完由编排者检查结果。
多个执行者不会自动形成一致意见
即使所有子 Agent 都用同一个模型,它们也可能在不同上下文下得出不同结论。上下文隔离减少了相互干扰,代价是重要背景不会自动共享。共享得太多会增加噪声,共享得太少会遗漏限制;分工并不能免除信息组织工作。
还要处理并行失败、重复执行、取消传播和费用摊销。这些都不是"多开几个 Agent"就自动解决的。具体来说:
- 并行失败:一个分支失败,是让汇总步骤等它重试,还是跳过它继续?这需要在委派时就写进完成信号。
- 重复执行:同一份代码被两个分支改写,先写的会被后写的覆盖,事件流里能看到两次
tool_execution_start,但看不出哪次的结果留下来了。 - 取消传播:主任务被停止时,子进程是否会一起停,取决于你用的编排方式是否实现了信号传递。
- 费用摊销:八路并行意味着一次任务的成本可能是单路的八倍,而这在预算里通常没有预留。
怎么看出这次跑的是哪种形态
前面讲的是怎么选。选完之后还有一个可核对的问题:这次实际跑出来的,是固定路径还是动态路径。这不需要看模型怎么描述,看事件流就能判断。
判据是回合数与工具调用的对应关系:
用 Pi 的 JSON 模式可以直接数出来:
pi --mode json "Review this repository" | jq -c 'select(.type=="turn_start" or .type=="turn_end" or .type=="tool_execution_start")'多路并行在事件流里的样子也很清楚:多个 tool_execution_start 会带着各自的 toolCallId 出现,它们的 tool_execution_end 完成顺序不固定。一次两路并行的运行,事件流大致是这样:
{"type":"tool_execution_start","toolCallId":"call_a1","toolName":"read","args":{"path":"src/greet.ts"}}
{"type":"tool_execution_start","toolCallId":"call_b2","toolName":"read","args":{"path":"src/index.ts"}}
{"type":"tool_execution_end","toolCallId":"call_b2","toolName":"read","result":{"content":[{"type":"text","text":"export * from \"./greet\""}],"details":{}},"isError":false,"durationMs":3}
{"type":"tool_execution_end","toolCallId":"call_a1","toolName":"read","result":{"content":[{"type":"text","text":"export function greet(name: string) {...}"}],"details":{}},"isError":false,"durationMs":2}call_b2 先完成,call_a1 后完成——顺序不由调用顺序决定。所以判断"并行分支是否全部结束",不能靠数结束事件的先后,要靠数 toolCallId 的集合是否与开始事件的集合一致:
pi --mode json "Review this repository" | jq -r 'select(.type=="tool_execution_start" or .type=="tool_execution_end") | "\(.type) \(.toolCallId)"' | sort | uniq -c每个 toolCallId 出现两次(一始一终)才算这个分支走完了。只看到开始没看到结束,就是有一个分支还悬着,无论汇总步骤是不是已经回复。
这个方法在第 2 篇出现过,这里再点一次它的用途:它是判断"该不该拆"的最后一道检验。拆了之后如果事件流里各分支的 toolCallId 都成对出现,出了问题可以定位到具体分支;如果整个过程只有一个 toolCallId,说明这次拆分没有真正生效。
什么时候先不要拆
任务很小、结果高度依赖共享状态、验收条件模糊时,先把单条任务链做清楚通常更容易定位问题。拆分之前至少说明:各自交付什么,谁汇总,冲突怎么处理,怎样判断完成。
判断值不值得拆,可以用一个更朴素的问法:如果这次拆错了,出了问题你能在几分钟内定位到是哪个分支的锅吗。答案是否,就先别拆。
小结
工作流和 Agent 之间是一条清晰的线:下一步由代码决定,还是由模型决定。五种编排模式覆盖了大多数固定路径的组合,代价是多一层抽象会遮住调试线索。并行的两种变体——切分和投票——解决的是不同问题,成本结构也不同。
并发之前先答三个问题——共享什么状态、怎么汇总、失败要不要停其余;然后用固定数量的 worker 和按原下标写回的结果数组限制并发峰值。委派任务时写清六件事:目标、输入、能力、预算、交付格式、完成信号。
下一篇会处理另一半问题:当执行者可能看到不该听的话,宿主凭什么还能拒绝执行。