工作流编排:Chaining、Routing 与 Parallelization

目录

规划给出“想怎样完成任务”,工作流则把步骤的依赖、状态和失败处理交给明确的执行机制。串联、路由与并行是三种常用结构;是否使用它们,取决于任务关系,而不是系统是否被称为 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 协作。

评论