规划给出“想怎样完成任务”,工作流则把步骤的依赖、状态和失败处理交给明确的执行机制。串联、路由与并行是三种常用结构;是否使用它们,取决于任务关系,而不是系统是否被称为 Agent。
继续以订单异常调查为例:查询订单与查询事件可以并行,查询支付状态需要先拿到支付请求标识,最终说明又需要综合证据。这些关系可以由代码显式表达,让模型在允许的节点内完成语义判断。
1. 三种模式分别控制什么
| 模式 | 关键问题 | 订单调查中的例子 |
|---|---|---|
| Chaining 串联 | 哪个结果是下一步的前提? | 获取支付请求 ID 后查询支付方 |
| Routing 路由 | 当前任务应进入哪个处理路径? | 直接状态解释、补充调查或等待信息 |
| Parallelization 并行 | 哪些任务可以同时推进? | 独立查询订单与事件记录 |
工作流可以有条件、循环和等待,不必全部表示为 DAG。ReAct 循环也可以作为工作流的一步,普通函数则足以实现简单、稳定的几步流程。显式拓扑有助于检查依赖,但不会自动带来持久化、权限或故障恢复。
2. 串联的契约是输入、输出与完成条件
一个步骤不应只留下“成功/失败”标签。它至少需要有身份、输入版本、输出、执行状态和完成条件,外部操作还需要稳定的业务请求 ID。
| 步骤 | 前置条件 | 完成证据 |
|---|---|---|
| 查询订单 | 当前主体有权访问订单 | 返回匹配订单及查询时间 |
| 查询支付状态 | 已取得对应支付请求 ID | 返回该请求的明确状态或未知结果 |
| 综合说明 | 必要证据已收集或缺口已明确 | 判断能关联实际来源 |
依赖任务失败时,后续步骤不能把空值当作正常结果继续。可选证据缺失可以形成有限回答,必需证据缺失则应等待、降级或失败。两者需要在契约中提前区分。
Checkpoint 解决恢复位置,不保证操作只执行一次
“步骤执行成功后保存 checkpoint”存在一个故障窗口:外部操作成功,但进程在保存记录前崩溃。恢复后重跑可能重复副作用。
Temporal 的 Activity 定义文档讨论了活动执行与幂等要求。无论是否使用这类框架,应用都需要处理操作重试与重复执行。
| 中断位置 | 恢复时需要做什么 |
|---|---|
| 尚未开始动作 | 验证当前前置条件,再执行 |
| 外部动作已提交,结果未确认 | 根据原业务请求 ID 查询或恢复 |
| 结果已确认,checkpoint 未保存 | 依靠幂等记录识别已有结果 |
| checkpoint 已保存 | 检查工作流版本与结果有效性,再继续 |
恢复记录还应绑定输入、工作流版本和执行主体;不能把旧版本步骤序号直接套到新步骤列表。多个执行者恢复同一任务时,需要租约、并发版本或其他防重复调度机制。
短且廉价的只读链路可以选择重新运行,长任务和有副作用任务则通常更需要持久状态。判断依据是恢复代价与正确性要求,不是一个固定的美元门槛。
3. 补偿是业务动作,不是撤销时间
Saga 可以协调跨步骤的业务补偿,但不是给每个不可逆动作加一个反向函数就完成了。已发邮件无法保证收回,已经发生的退款也不是简单“再扣回去”即可恢复。
可靠的补偿流程需要保存正向与补偿操作状态、处理结果未知、允许恢复,并为补偿失败安排后续处置。补偿顺序通常与依赖有关,不是所有业务都机械逆序。
只有进程内 compensations=[] 的示例无法跨崩溃恢复;如果动作成功却还没入栈,它也会漏补偿。记录与业务操作之间的原子性、幂等和对账关系必须由真实存储与接口协议解决。
在订单调查里,读取操作通常无需 Saga;如果扩展到取消订单、释放库存和退款,就需要按各业务域的状态设计。不要把所有跨系统查询都等同于分布式事务。
4. 路由决定处理方式,不能决定是否绕过权限
路由器可以由规则、分类器、模型或它们的组合实现。简单且稳定的条件先用代码表达;需要理解开放文本时,再考虑分类或模型调用。
| 路径 | 适用条件 | 后续边界 |
|---|---|---|
| 状态解释 | 对象明确,只需说明已有状态 | 保持对象授权 |
| 补充调查 | 当前信息不足,需要多源查询 | 控制查询范围、时限与预算 |
| 资料检索 | 需要适用政策或处理手册 | 过滤权限与版本 |
| 等待输入 | 缺少订单标识或任务目标不清 | 只请求必要信息 |
| 操作处理 | 用户要求改变业务状态 | 独立执行授权、业务条件与审批检查 |
| 不支持 | 系统能力或授权范围不能完成 | 明确说明可交付范围 |
输入可以同时包含多个诉求,路由未必只能选一个标签。不同路径也可以共用后续步骤,不应把“需要审批”设计成唯一安全路径,然后让其他路径绕过审批。
分数必须有明确含义
embedding 的余弦相似度不是正确概率,LLM 自报 confidence 也不自动校准。阈值需要在带标注的验证集上选择,观察混淆矩阵、拒绝率与业务损失。
“不确定就用更贵模型”与“不确定就拒绝”都不是普遍最优。可能的动作包括补充信息、选择覆盖更广的只读路径、交给人工或明确不支持。判断取决于误路由代价、延迟与可恢复性。
路由日志应保留版本、选中路径、允许记录的输入特征、结果质量与成本。不能凭假设的 80%/15%/5% 流量比例设计三层路由,再把它写成普遍生产经验。
5. 并行要求独立的不只是输出
两个步骤没有直接数据依赖,也可能竞争同一业务对象、限额、连接池或预算。例如两个退款操作分别看似独立,却都依赖同一笔可退余额。并行前还要检查共享状态、冲突与资源限制。
对同一组独立任务,串行改并行不会自动增加 N 倍 token:调用次数与输入相同,基础 token 量可能相同;区别主要在调度、瞬时容量和失败重试。新增多个模型候选来投票,才是增加了工作量。
理想并行耗时接近最长分支,而不是必然提速 N 倍。串行前后处理、排队、限流、网络与最慢分支都会影响实际收益。
6. 有限批次的并行查询示例
下面的 Python 3.11+ 示例适合独立、可取消的只读查询。输入 jobs 是“任务名到异步函数”的映射;函数在获得并发名额后才创建具体查询。
import asyncio
async def fan_out(jobs, concurrency=4, timeout=30):
if concurrency < 1 or timeout <= 0:
raise ValueError("invalid concurrency or timeout")
if not jobs:
return {}
semaphore = asyncio.Semaphore(concurrency)
async def run_one(factory):
async with semaphore:
return await factory()
tasks = {
name: asyncio.create_task(run_one(factory))
for name, factory in jobs.items()
}
try:
done, pending = await asyncio.wait(
tasks.values(), timeout=timeout
)
finally:
# 外层取消或等待结束,都回收仍在运行的本地协程
for task in tasks.values():
if not task.done():
task.cancel()
await asyncio.gather(*tasks.values(), return_exceptions=True)
outcomes = {}
for name, task in tasks.items():
if task in pending:
outcomes[name] = {"status": "timeout"}
elif task.cancelled():
outcomes[name] = {"status": "cancelled"}
elif task.exception() is not None:
outcomes[name] = {
"status": "error",
"error_type": type(task.exception()).__name__,
}
else:
outcomes[name] = {"status": "success", "value": task.result()}
return outcomes
这里的 timeout 是整个批次的等待预算,包括等待并发名额的时间。结果区分成功、错误、超时与取消;外层取消会继续向上传播,不会被改写成普通业务失败。
取消是协作式的,依赖协程响应取消并释放资源。阻塞事件循环、吞掉取消或清理耗时过长,都可能让实际返回时间超过等待预算。它也不能撤销已提交到远端的操作,因此不应直接作为退款等写操作的安全重试器。具体行为见 Python asyncio 文档。
这个实现会为有限批次创建所有本地任务;处理海量数据应使用有界队列和固定 worker,避免任务对象本身占满内存。并发上限之外,还可能需要按请求数、token、租户配额和总费用限流。
7. 部分失败应由交付条件决定
| 策略 | 何时适用 | 必须交代什么 |
|---|---|---|
| 全部必要结果到齐 | 缺任一结果都不能得出结论 | 超时或失败时任务不完成 |
| 接受部分结果 | 缺失信息不影响有限结论 | 明确缺失来源和结论范围 |
| 首个有效结果 | 多个等价只读来源可相互替代 | 先验证结果有效,再停止等待 |
| 有限重试后降级 | 契约允许重试,且有可用替代 | 保留重试预算与降级状态 |
gather(..., return_exceptions=True) 是一种收集方式,不是通用正确答案。全部必要任务任一失败就应取消其余任务时,可以考虑结构化任务组;允许部分结果时,则应单独记录各分支状态。
“首个有效结果”不能通过先 gather 等待全部任务,再选择最大相关性分数来实现。前者需要在任务完成时逐个验证结果,并取消、回收剩余任务;后者是等待后的质量选择,延迟与语义都不同。来自不同来源的相关性分数也未必可直接比较。
8. Map-Reduce 还需要保护跨块关系
分块处理再汇总适合可拆解材料,但不是所有文档都能独立总结。前文定义、后文例外、跨页表格或版本说明可能改变某块的含义。
Map 输出应尽量保留来源、对象标识、条件、冲突与必要原文引用;Reduce 应检查覆盖与矛盾,不能只把每块摘要重写得更流畅。Reduce 可以是确定性聚合,不必总由模型实现。
超过上下文预算时可以分层汇总,但分组应按实际 token 预算而非固定条数。十个短结果与十个长结果占用完全不同。每层还要保留输出预算,并检测汇总是否确实缩小。
若仅按条数做教学示例,fanout 至少为 2;fanout=1 会让结果数一直不变而无法终止。更真实的实现还要处理超长单项、空集合、汇总失败和误差累积。没有必要预设“达到窗口 50% 就必须分层”的统一阈值。
9. 组合起来看一次订单调查
下图展示调查路径内部的依赖:订单和事件并行查询,支付查询等待订单标识,结果汇聚后再判断是否足够交付。
拓扑只表达主要依赖,每个查询仍要执行授权、预算与错误处理。汇聚也不是把各分支文本拼起来:必须区分已确认状态、未知结果和证据冲突。
若支付查询持续超时,可以交付“当前仍未确认”的调查结果,或在用户授权范围内继续核实。不能为了让工作流显示成功,改写成“支付失败”;也不能因某个分支失败而跳过权限检查,去访问更宽的数据源。
10. 用恢复与失败用例检查编排
验证工作流时,正常路径之外还要覆盖:
- 步骤成功但 checkpoint 写入前中断,确认不会重复产生业务副作用
- 工作流版本变化后恢复,确认旧游标不会误指新步骤
- 多个分支部分失败或超时,确认缺失信息不会被当作成功结果
- 外层取消,确认本地任务被回收,远端已开始动作仍可追踪
- 路由器返回未知类别,确认进入明确兜底而非任意默认路径
- 并行操作竞争同一业务对象,确认服务端约束仍成立
- Reduce 输入过长或未缩小,确认有终止与分段处理方式
工作流的收益在于把依赖、调度和恢复变得明确。节点内部可以是普通代码、检索、模型调用或 Agent 循环。只有当独立上下文、工具范围和专业分工带来实际收益时,才值得进一步升级为多 Agent 协作。