Skip to content
Agent Workflow 设计:任务拆解、节点编排与执行状态管理
1. 概述
单个 LLM 调用可以完成的动作是有限的。一个需要多步推理、访问外部工具、或要求人工确认的任务,通常需要把工作拆成多个步骤,再按一定顺序组合起来。Agent Workflow 就是用来描述这种多步组合的结构化执行模型。
可以将 Workflow 看作一个程序化的任务编排层:
- 每个节点执行一个确定性操作,或者一次 LLM 调用;
- 节点之间通过边(edge)连接,构成一个执行图;
- 所有节点共享一个状态对象(state),用于保存中间结果;
- 运行引擎(runtime)负责决定下一个执行哪个节点,并在节点完成后更新状态。
设计 Agent Workflow 的核心目标有三个。
第一是可控性。LLM 输出具有随机性,直接让一个 Agent 顺序调用多次模型,中间一旦出现偏题或者非法输出,流程就可能脱离预期。将流程拆成节点后,每个节点的输入输出可以校验,分支判定是显式的,流程可被追踪。
第二是可恢复性。LLM 调用可能超时、返回错误,或者需要等待人工输入。通过持久化执行状态,进程重启后可以从最近的检查点继续,而不必重新执行整个流程。
第三是可观测性。节点是明确的结构单元,每个节点的开始、结束、失败都可以写入日志和监控指标。这比观察一个黑盒式的 Agent 循环更容易定位问题。
与传统自动化工作流相比,Agent Workflow 的特殊之处在于部分节点的工作由 LLM 完成。LLM 节点的输出在语义上符合预期的概率不是 100%,因此编排层需要提供与 LLM 相关的校验、重试和回退机制。这是 Agent Workflow 与一般 BPM 工作流的主要差别。
2. 基本概念
2.1 Agent
Agent 是一个以 LLM 为核心、通过调用工具完成目标的程序单元。在 Workflow 的语境下,Agent 可以是一个节点,也可以是被编排的整个程序。这里采用后一种视角:将复杂的 Agent 程序显式建模为 Workflow,而 Workflow 中的每一步(如“生成代码”“执行测试”)由单独的 Agent 节点完成。
2.2 Task、Node 与 Edge
Task 是工作单元的最小描述,例如“调用一次模型”“查询一次数据库”。Node 是 Task 在 Workflow 图中的抽象表示。Node 可以带类型:
llm:调用语言模型;tool:调用外部工具或函数;condition:根据当前状态决定走向;human:等待人工输入;subgraph:嵌套另一个 Workflow。
Edge 是节点间的有向连接。Edge 可以带条件,只有条件满足时才走这条边。下面的代码定义一个最小的图结构。
js
const workflow = {
nodes: [
{ id: 'analyze', kind: 'llm' },
{ id: 'plan', kind: 'llm' },
{ id: 'execute', kind: 'tool' },
{ id: 'verify', kind: 'llm' },
],
edges: [
{ from: 'analyze', to: 'plan' },
{ from: 'plan', to: 'execute' },
{ from: 'execute', to: 'verify' },
],
};这个定义本身不产生执行行为。它只是描述了一个结构:先分析,再计划,然后执行,最后验证。要让这个结构运行起来,需要 runtime 按边的顺序访问节点,并在每次执行后保存结果。
2.3 State 与 Runtime
State 是 Workflow 执行过程中共享的数据对象。它以可序列化的形式保存:已完成节点、当前节点、节点之间的数据传递、错误信息等。
Runtime 是一个执行引擎,负责解释 Node 和 Edge 定义,维护 State,并在合适的时机调用节点函数。
js
async function runWorkflow(workflow, initialState) {
let state = { ...initialState };
let current = workflow.edges[0].from;
while (current) {
const node = workflow.nodes.find((n) => n.id === current);
state = await runNode(node, state);
current = nextNode(workflow, node, state);
}
return state;
}runWorkflow 是一个示意实现:current 保存当前节点 id,runNode 执行节点逻辑并返回新 state,nextNode 根据当前 state 和边条件选出下一个节点。真实引擎还需要处理并行、错误恢复和数据持久化。基本的执行模型就是由 runtime 维护 state 并推动流程前进。
注意,图中的 Node 是定义,与正在执行的 Task 实例不同。同一个 Node 可以执行多次(例如循环),每次执行会生成独立的执行记录。
3. 任务拆解:从目标到可执行计划
在把目标转为 Workflow 时,需要先决定任务拆解的策略。不同策略决定了图和状态的结构。
3.1 固定流水线
固定流水线将任务分解为若干固定顺序的步骤。每个步骤的输入来自前一个步骤的输出。适合流程稳定、步骤不随输入变化的场景,例如内容分类、信息抽取、格式转换。
js
const pipeline = [
{ id: 'normalize', kind: 'llm' },
{ id: 'extract_fields', kind: 'llm' },
{ id: 'format_output', kind: 'tool' },
];
async function runPipeline(pipeline, input) {
let data = input;
for (const node of pipeline) {
data = await executeNode(node, data);
}
return data;
}runPipeline 按 pipeline 数组的顺序依次调用每个节点,前一个节点的输出直接作为后一个节点的输入。这里的 data 同时承担节点输入和节点输出两种角色。固定流水线的优点是简单、可预测;缺点是无法处理需要动态调整步骤的任务。
3.2 Plan-and-Execute
Plan-and-Execute 模式将任务分成两个阶段:Planner 生成计划,Executor 按照计划逐步执行。计划本身是普通数据,写入 State,因此可以被修改或重新规划。
js
async function runPlanAndExecute(goal, createPlanner, createExecutor) {
const planner = createPlanner();
const executor = createExecutor();
let plan = await planner.makePlan(goal);
const results = [];
for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i];
const context = results.map((r) => r.output).join('\n');
const stepResult = await executor.execute(step, context);
results.push({ step, output: stepResult });
if (stepResult.error) {
plan = await planner.revisePlan(goal, results);
}
}
return results;
}Planner 的输入是目标本身和已经执行的结果;Executor 的输入是计划步骤和上下文。这个示例中,results 数组是跨步骤传递信息的载体,revisePlan 使用完整的执行记录重新生成剩余计划。这种拆解比固定流水线更灵活,适合步骤会随中间结果变化的任务。
3.3 层次分解
层次分解把任务看成树:顶层任务分解为子任务,子任务继续分解。在 Workflow 中,每个子任务可以对应一个子图(subgraph)。这样做的好处是每个层级的节点职责单一,且可以在不同层级插入校验和回退。
js
const subgraph = {
id: 'draft_and_review',
nodes: ['draft', 'review', 'revise'],
edges: [
{ from: 'draft', to: 'review' },
{ from: 'review', to: 'revise', condition: (s) => !s.approved },
{ from: 'revise', to: 'review' },
],
};这个例子描述了一个内部循环:review 不通过时回到 revise,再由 revise 进入 review。对父图来说,draft_and_review 只是一个节点,父图不关心其内部的循环次数。
3.4 动态规划
动态规划是指一边执行一边生成下一步。每完成一个节点,就根据当前状态决定后续节点。它和 Plan-and-Execute 类似,但不要求先产生完整计划。
js
async function runDynamic(state) {
while (!state.isFinished) {
const action = await decideNextAction(state);
if (action.type === 'done') break;
state = await executeAction(action, state);
}
return state;
}decideNextAction 在每一轮读取当前 state 并返回下一步动作,executeAction 执行该动作并更新 state。动态规划适合完全开放、无法预定义结构的任务。缺点是流程的不可预测性高,不利于调试。在设计取舍上,能从预定义图开始就从预定义图开始;只有预定义图无法覆盖的情况,才让模型动态决策下一步。
3.5 拆解方式的选择
固定流水线、Plan-and-Execute、层次分解和动态规划不是互斥的。一个 Workflow 可以外层是 Plan-and-Execute,内层某个步骤是固定流水线。选择拆解方式时,主要考虑三个问题:
- 步骤是否稳定?稳定则选固定流水线。
- 步骤是否依赖于执行过程中的信息?是则考虑 Plan-and-Execute。
- 是否需要人审或长时间等待?是则需要显式的人工审批节点,而不是让 Agent 自动继续。
评估拆解质量可以从三个维度进行:每个节点是否只承担一个职责;节点间的数据依赖是否清晰;执行失败后是否能从较少节点处恢复。如果一个节点做了三件事,或一个节点依赖了十几个其他节点的结果,拆解就不够细。
4. 节点编排:流程控制、子图与人工审批
节点编排解决的问题是:给定一组节点,如何描述它们之间的执行顺序。基础的编排方式包括顺序、并行、条件、循环,以及子图和人工审批。
4.1 顺序、并行与汇聚
顺序是最简单的编排方式,一条边从一个节点指向另一个节点。
并行需要两个原语:扇出(fan-out)将一个节点接到多个后续节点,扇入(fan-in)将多个节点汇聚到一个节点。在 Node.js 中,扇出对应的执行方式是并发启动多个节点:
js
const state = {
...previousState,
fanOutNodes: ['search_a', 'search_b', 'search_c'],
};
// 使用 JavaScript 的 Promise.all 并发执行多个节点
const results = await Promise.all(
state.fanOutNodes.map((id) => runNode(id, state))
);
const nextState = mergeResults(state, results);Promise.all 并发启动 search_a、search_b、search_c 三个节点。这里 state.fanOutNodes 数组仅用于说明扇出结构,实际引擎中扇出关系通常由边的定义决定。扇入节点在所有扇出节点结束之后启动。并行节点之间如果不需要通信,它们的执行顺序不保证。需要全部汇总结果时,汇聚节点从 state 中读取各节点的输出。
4.2 条件分支与循环
条件分支用带条件函数的边实现。条件函数接收 State,返回布尔值,决定是否选择这条边。
js
const edges = [
{ from: 'classify', to: 'handle_spam', condition: (state) => state.category === 'spam' },
{ from: 'classify', to: 'handle_normal', condition: (state) => state.category !== 'spam' },
];当 classify 节点的输出写入 state.category 后,Runtime 依次计算两条边的 condition 函数,选择结果为 true 的那条边。如果两条边都不满足,流程应进入错误处理路径,而不是停在当前节点。
循环可以表示为一个节点通过条件边回到自身,也可以由 Runtime 内部的 while 循环实现。
js
async function runLoop(state) {
let step = 0;
while (state.quality < 0.95 && step < 10) {
state = await runNode('improve', state);
step++;
}
if (state.quality < 0.95) {
throw new Error('quality not reached');
}
return state;
}这个例子中,循环条件是 state.quality < 0.95 && step < 10,即质量未达标且未超过最大步数时持续执行 improve 节点。循环退出后若质量仍未达标,则抛出错误。在显式 Workflow 中,循环必须有可量化的终止条件。没有终止条件的循环会让一个执行永远卡在某一节点。
4.3 子图
子图是把一组节点打包成一个节点。子图可以嵌套,形成层级结构。子图在父图中执行时,其内部状态与父状态合并或隔离,取决于引擎设计。
js
const workflow = {
nodes: [
{ id: 'preprocess', kind: 'tool' },
{ id: 'research', kind: 'subgraph', graph: researchGraph },
{ id: 'compose', kind: 'llm' },
],
};执行到 research 节点时,Runtime 进入 researchGraph 定义的子图,按子图的边规则推进,子图结束后将结果写入当前 state。使用子图的好处是复用和隔离。多个地方可以引用同一个子图定义,而子图内部的节点不会和父图节点的 id 冲突。
4.4 人工审批节点
人工审批节点表示为 human 类型节点。执行到该节点时,流程暂停,状态进入等待外部输入的状态。外部系统通过接口提交审批结果后,Runtime 恢复执行。
js
const nodes = [
{ id: 'generate_draft', kind: 'llm' },
{ id: 'human_review', kind: 'human' },
{ id: 'publish', kind: 'tool' },
];
const edges = [
{ from: 'generate_draft', to: 'human_review' },
{
from: 'human_review',
to: 'publish',
condition: (state) => state.humanDecision === 'approved',
},
{
from: 'human_review',
to: 'generate_draft',
condition: (state) => state.humanDecision === 'rejected',
},
];人工审批与普通节点的一个关键区别是等待时间可能很长。Runtime 通常需要在审批期间将状态持久化,并在外部信号到达后唤醒执行。Temporal 的信号(signal)机制即用于这种场景:工作流可以在 signal 上阻塞,收到信号后继续执行,而不需要 worker 一直保持连接[2]。
4.5 LLM 节点的结构化输出与工具调用
LLM 节点的输出需要进入 State 供后续节点使用。直接保存模型返回的纯文本会导致下游难以解析,因此通常要求 LLM 以结构化形式输出。
一种方式是限定 JSON Schema。运行时把 Schema 传给模型接口,要求模型返回符合 Schema 的 JSON。
js
const schema = {
type: 'object',
properties: {
sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
confidence: { type: 'number' },
},
required: ['sentiment', 'confidence'],
};另一种方式是函数调用(function calling / tool calling)。模型返回一个工具调用请求,Runtime 执行对应函数,并把函数结果传回模型。工具调用特别适合搜索、计算等外部操作。
js
const tool = {
name: 'search_docs',
description: 'Search product documentation',
parameters: {
type: 'object',
properties: {
keyword: { type: 'string' },
},
required: ['keyword'],
},
};注意,不管使用哪种方式,模型输出都可能不符合 Schema。Workflow 中的 LLM 节点应当有输出校验环节,解析失败时触发重试或回退,而不是直接让下游节点收到格式错误的数据。
5. 状态管理:执行上下文与数据传递
State 是 Workflow 的中枢。每个节点的输入来自 State,节点输出写回 State。因此,State 的最小集合设计直接影响引擎的复杂度。
5.1 状态的最小集合
一个可恢复的 Workflow 状态至少包含以下信息:
- Workflow 标识;
- 当前执行到的节点;
- 每个节点的状态(未开始、执行中、已完成、失败、等待外部输入);
- 已完成节点的输出数据;
- 节点的失败信息和重试次数。
js
const state = {
workflowId: 'wf_20250101_001',
currentNode: 'human_review',
nodes: {
generate_draft: { status: 'completed', output: { draft: '...' } },
human_review: { status: 'blocked', decision: null },
publish: { status: 'idle' },
},
attempt: {
generate_draft: 1,
},
};上面代码中的字段名只用于说明概念,具体引擎的字段名可能不同。重要的是哪些信息必须持久化:当前节点、节点输出、节点状态和重试次数。缺少其中任何一项,都无法在进程重启后准确恢复。
5.2 节点间数据传递
节点间的数据传递有两种方式:直接传递和通过 State 传递。
直接传递是指一个节点的输出作为另一个节点的输入参数,适合简单的顺序流程。通过 State 传递是指节点把结果写入共享数据区,下游按 key 读取,适合并行和复杂分支。
在 Plan-and-Execute 中,State 中的计划、已执行步骤列表、中间结果构成了 executor 的完整上下文。更新 State 时使用不可变对象替换(immutable update)更容易追踪变化:
js
const nextState = {
...state,
nodes: {
...state.nodes,
[node.id]: {
status: 'completed',
output: node.output,
},
},
currentNode: nextNodeId,
};对象展开运算符创建了新对象,state 本身不会被修改。每次状态变迁都产生一个新的不可变快照,便于记录历史,也便于在崩溃后重放。
5.3 上下文窗口限制
LLM 节点的输入往往是 State 中与当前步骤相关的部分,而不是整个 State。把大量中间结果直接放进提示词会超出上下文窗口,也会增加模型选择信息的难度。
可以把节点定义为声明它需要哪些字段,由 Runtime 只把相关字段组装成语境。也可在步骤间插入摘要节点,将之前的所有输出压缩为一段摘要。
js
const state = {
nodes: {
search_1: { output: { result: '...' } },
search_2: { output: { result: '...' } },
},
};
// 在 compose 节点前插入一个 summarize 节点
const summarizePrompt = `
请将下面两份搜索结果压缩为 200 字以内的摘要:
1. ${state.nodes.search_1.output.result}
2. ${state.nodes.search_2.output.result}
`;summarize 节点的作用是缩小 State 与提示词之间的体积差。两条搜索结果先被压缩成一段摘要,再作为 compose 节点的输入。上下文管理属于 State 设计的范畴,它决定了哪些数据进入提示词,哪些数据留在持久化存储中。
6. 持久化与恢复:检查点、幂等与断点续跑
当 Workflow 运行时间较长,或者等待人工审批时,执行进程可能重启或崩溃。持久化与恢复机制保证已经完成的工作不丢失。
6.1 检查点
检查点(Checkpoint)是将 State 保存到外部存储的动作。每次节点完成后保存一次,或在一定次数的状态变更后保存一次。
js
async function saveCheckpoint(state, store) {
await store.set(`workflow:${state.workflowId}`, JSON.stringify(state));
}
async function loadCheckpoint(workflowId, store) {
const raw = await store.get(`workflow:${workflowId}`);
return raw ? JSON.parse(raw) : null;
}保存检查点的频率决定恢复粒度。每次节点完成后保存,崩溃后最多丢失一个节点的进度。每 N 个节点保存一次,可以减少存储写入,但崩溃后的恢复位置会靠后。
6.2 事件历史与确定性重放
检查点相当于快照。另一种更细的持久化方式是事件历史:不直接保存最终状态,而是把执行过程中的每次状态变化追加写入日志。恢复时从头重放日志,获得最终状态。
Temporal 采用事件历史机制。每个 Workflow Execution 的事件历史被追加写入中心服务,worker 崩溃后按事件历史恢复到上一次进度;已经完成的 Activity 不会被重复执行[1]。为了让重放得到一致的结果,Workflow 代码必须是确定性的——同样的输入和事件序列必须产生同样的输出。不确定的调用(例如读取当前时间、生成随机数、访问外部 API)必须放在 Activity 中执行,而 Activity 的执行结果会记录到事件历史中。
这种设计的优点是:不需要在每个节点执行后都保存完整快照,恢复精度高;缺点是事件历史会不断增长,长时间运行的 Workflow 需要归档机制。
6.3 幂等
由于恢复和重试机制,同一个节点可能被重复执行。如果节点有外部副作用(例如发送邮件、扣减余额),需要在执行前检查是否已经执行过。
幂等执行的一种简单实现是使用幂等键:
js
async function executeIdempotent(node, idempotencyKey, store) {
const existing = await store.get(idempotencyKey);
if (existing) {
return existing.result;
}
const result = await node.run();
await store.set(idempotencyKey, result);
return result;
}后续重放或重试时,相同的幂等键会直接返回已保存的结果,不触发节点副作用。幂等键通常由 workflow id、节点 id 和执行序号组成。
6.4 断点续跑
断点续跑指从持久化的检查点恢复执行,而不是从头开始。恢复时要跳过已经处于 completed 状态的节点,只执行状态为 idle、in_progress 或 failed 的节点。
js
async function resumeWorkflow(workflowId, store) {
const state = await loadCheckpoint(workflowId, store);
if (!state) {
throw new Error(`no checkpoint found: ${workflowId}`);
}
return runWorkflow(workflow, state);
}对于 in_progress 的节点,如果它的上一次执行已经触发了副作用但未记录结果,需要结合幂等机制判断。无法判断时,可以采用“至少一次”语义并让节点实现幂等,或者“最多一次”语义通过分布式锁避免重复触发。
7. 异常处理:重试、超时与回退
LLM 节点和工具节点都会失败。异常处理需要区分瞬态错误和永久错误。瞬态错误对应网络超时、限流,可以重试;永久错误对应参数不合法、Schema 不匹配,应直接失败或走回退流程。
7.1 重试与退避
重试不应立即执行,而应等待一定时间。指数退避让每次重试的等待时间翻倍,并加入随机抖动,避免多个任务同时重试造成限流。
js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function withRetry(fn, { maxAttempts = 3, baseDelayMs = 100 }) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) {
break;
}
const delay = baseDelayMs * 2 ** (attempt - 1) + Math.random() * 50;
await sleep(delay);
}
}
throw lastError;
}调用示例:
js
const result = await withRetry(() => callModel(prompt), {
maxAttempts: 5,
baseDelayMs: 200,
});重试次数和基础延迟是配置项,应根据模型接口的限流阈值设置。过高的基础延迟会拖慢恢复,过低的延迟会加剧限流。
7.2 超时
LLM 调用可能长时间不返回。每个节点应设置超时时间,超时后触发中断。Node.js 的 AbortController 可以用来中断请求。
js
async function withTimeout(fn, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn({ signal: controller.signal });
} finally {
clearTimeout(timer);
}
}controller.abort() 触发信号,fn 内部应监听该信号并抛出中断异常。超时之后的操作策略与重试策略配合。通常超时算作一次失败,进入重试流程。
7.3 LLM 输出不合法
LLM 输出不合法是 Agent Workflow 特有的异常类型。常见情况包括:模型返回的不是合法 JSON;JSON 缺少必要字段;工具调用参数不符合 Schema;列举的 enum 值未定义。
处理方式有三类:
- 校验失败后带着错误信息重新请求模型,要求修正输出;
- 用规则修正输出(例如截取第一个 JSON 片段);
- 多次修正仍失败,则将该节点标记为失败,进入回退。
js
async function callLLMWithValidation(prompt, schema, validator) {
const raw = await callModel(prompt);
try {
return validator(raw);
} catch (error) {
const repairPrompt = `${prompt}\n\n上一次输出无法通过校验:${raw}\n错误:${error.message}\n请重新生成。`;
const repaired = await callModel(repairPrompt);
return validator(repaired);
}
}第一次校验失败后,函数把原始输出和错误信息拼进新提示词,要求模型重新生成。第二次 validator 仍然可能失败;更完整的实现应在外层再加循环和次数上限。
7.4 回退策略
当主路径连续失败时,回退策略提供降级方案。回退可以发生在节点级别或 Workflow 级别。
节点级别的回退,例如主用的模型接口不可用时,切换到一个更小或更便宜的模型:
js
async function callWithFallback(primaryCall, fallbackCall) {
try {
return await primaryCall();
} catch (error) {
console.warn('primary call failed, using fallback', error);
return await fallbackCall();
}
}Workflow 级别的回退,是指当某个关键节点失败时,绕过该节点,改用一个简化但仍能完成目标的流程。例如“生成文章”节点失败后,走“模板填充”流程。回退流程应在 Workflow 定义中显式声明,而不是运行时临时写逻辑。
8. 可观测性与测试
8.1 日志与追踪
每个节点执行时应输出结构化日志:节点 id、状态、耗时、输入输出摘要、错误信息。用 trace id 串联一次 Workflow 的所有节点日志。
js
function logNodeEvent(event, state, extra = {}) {
console.log(JSON.stringify({
workflowId: state.workflowId,
node: extra.nodeId,
event, // 'start' | 'end' | 'error'
...extra,
timestamp: new Date().toISOString(),
}));
}从日志中应能回答三个问题:当前执行到哪个节点;阻碍执行的原因是什么;哪些节点被重试过。
8.2 状态可视化
Workflow 定义本身是图结构,运行时状态也可以输出为图。把 state 中的节点状态导出为 Mermaid 图,可以直观看到每个节点的状态:
js
function toMermaid(workflow, state) {
const lines = ['```mermaid', 'graph LR'];
for (const [id, nodeState] of Object.entries(state.nodes)) {
lines.push(` ${id}[${id} - ${nodeState.status}]`);
}
for (const edge of workflow.edges) {
lines.push(` ${edge.from} --> ${edge.to}`);
}
lines.push('```');
return lines.join('\n');
}这种图对调试条件分支是否走了预期路径、并行节点是否都被触发,有直接帮助。
8.3 测试方法
Workflow 的测试重点是节点调度的逻辑,而不是模型本身的质量。一种常用做法是用 stub 代替模型调用,让每个测试用例只验证流程行为。
js
function createMockLLM(response) {
return async () => response;
}
const workflow = createWorkflow({
llm: createMockLLM(JSON.stringify({ steps: ['search', 'write'] })),
tools: {
search: async (keyword) => ({ results: [] }),
},
});
const result = await runWorkflow(workflow, initialInput);
assert.equal(result.nodes.write.status, 'completed');使用 stub 之后,测试变成确定性的:同样的输入,每次执行产生的中间日志和最终状态一致。这样可以针对“规划失败后重新规划”“审批驳回后回到生成节点”等分支编写可重复的测试。
对状态持久化逻辑的测试,可以使用内存存储实现与真实存储相同的接口,验证检查点保存后再加载的状态等价。
9. 主流编排框架的设计差异与取舍
在设计自己的 Agent Workflow 引擎之前,可以先观察两类既有方案。
一类是通用工作流引擎,例如 Temporal。它以事件历史形式持久化每个 Workflow Execution,worker 崩溃后通过重放恢复;Workflow 代码必须是确定性的,非确定性的调用必须放入 Activity[1]。这种设计强调可靠性、恢复能力,但对 LLM 场景没有专门的抽象,模型调用需要作为 Activity 实现。
另一类是面向 LLM 的编排框架。这类框架通常以图模型为基础,节点可以直接是 LLM 调用或工具调用,并且内建检查点机制。它们与模型交互的集成度更高,提供了 Planner、Agent 这类高级抽象。
两类框架的核心差异在持久化模型:
- 事件历史重放:恢复精度高,但要求确定性;
- 状态快照:实现简单,但需要管理快照的一致性;
- 事件日志与快照结合:使用快照减少恢复所需事件数,同时用事件日志记录快照间的变化。
另一个差异是流程的可修改性。事件历史重放要求事件顺序固定,适合步骤稳定的流程。图模型对动态规划和子图嵌套表达更直接,适合流程会随执行变化的场景;但如果不把状态持久化,崩溃后丢失的风险更高。
选择框架时,可以按以下问题判断:
- 是否需要跨语言、跨团队的长期运行支持?大型通用引擎更合适。
- 是否需要人工审批和外部 signal 唤醒?需要引擎支持暂停与恢复。
- 是否需要表达复杂的并行/条件/子图关系?图模型的原生表达能力更贴近需求。
- 是否需要频繁修改流程?事件历史模式下代码变更会影响重放,需要版本管理。
没有适合所有场景的单一设计。Agent Workflow 领域仍处于快速演进阶段,在构建长期使用的系统时,把 Workflow 定义、节点实现和 runtime 解耦,可以为将来更换框架留出余地。
10. 参考链接
[1] Temporal Documentation. Temporal Architecture. https://github.com/temporalio/temporal/blob/main/docs/architecture/README.md
[2] PMC. Durable execution with Temporal. https://pmc.ncbi.nlm.nih.gov/articles/PMC12424653
