Agent 场景下轻量 Workflow 引擎设计与实践:Step、Context、Runner 核心抽象

发布时间:2026/10/12 22:51:14
Agent 场景下轻量 Workflow 引擎设计与实践:Step、Context、Runner 核心抽象 1. 为什么 Agent 场景下传统 Workflow 引擎开始力不从心做过自动化编排的人都有一个共同感受传统 Workflow 引擎是为确定性流水线设计的每一步的输入输出、分支条件、重试策略在编排阶段就写死了。但 Agent 场景完全不同——Agent 的每一步决策依赖上一轮模型输出输出本身带有不确定性可能触发工具调用、可能要求人工确认、也可能直接给出终态。把这种运行时才确定下一步的逻辑硬塞进 DAG 编排器里结果就是节点爆炸、条件分支嵌套到无法维护。我在一个模拟项目里做过对比用传统编排方式实现一个资料检索 摘要 校验 归档的四步 Agent 流程光是处理摘要质量不达标需要回退重做这一条分支就写了十几个条件节点。而换成轻量 Workflow 设计后整个流程的核心逻辑压缩到不到两百行代码回退逻辑由 Agent 自己根据上下文判断编排层只负责状态流转和边界控制。轻量 Workflow 的核心思路可以概括成一句话把怎么走的决策权交给 Agent把走到哪了、能不能继续、出错怎么办的控制权留在编排层。这个分工是整篇内容的主线后面所有的设计取舍都围绕它展开。适合读这篇内容的人有三类一是正在做 Agent 应用、被编排复杂度折磨的开发者二是想从零搭一套轻量编排框架、不想引入重型依赖的工程师三是需要评估到底该不该上 Workflow 引擎的技术决策者。我会把设计动机、核心抽象、状态管理、错误处理、实测踩坑都讲透代码用 Python 伪代码示意思路可以直接迁移到其他语言。2. 轻量 Workflow 的核心抽象三个对象撑起整个编排层2.1 Step、Context、Runner 的职责边界轻量不等于简陋。一套能跑生产的最小 Workflow抽象层只需要三个对象多一个都是负担。Step步骤是最小执行单元它只做一件事接收当前上下文返回一个结果和下一步的意图。注意这里返回的是意图而不是下一个步骤的 ID这是和传统编排最大的区别。Step 内部可以调用模型、调用工具、读写外部存储但它不关心整个流程长什么样。Context上下文是贯穿整个流程的状态容器。它需要承载三类信息流程级的元数据流程 ID、当前步数、累计耗时、业务数据Agent 产出的中间结果、控制信号是否需要人工介入、是否触发终止。Context 的设计要点是可序列化——任何时刻都能把整个流程状态落盘进程重启后能恢复。Runner执行器负责驱动循环取出当前 Step、执行、根据返回的意图决定下一步、更新 Context、判断终止条件。Runner 是唯一持有流程全局视图的对象也是唯一需要处理超时、重试、并发的地方。class Step: def run(self, ctx: Context) - StepResult: raise NotImplementedError class StepResult: output: dict # 本步产出会合并进 Context next_hint: str # 下一步意图如 continue / retry / finish need_human: bool False class Runner: def execute(self, ctx: Context, steps: dict): while not ctx.finished: step steps[ctx.current_step] result step.run(ctx) ctx.merge(result.output) ctx.current_step self.resolve(result.next_hint, ctx) ctx.persist() # 每步落盘这套抽象的好处在于新增一个业务能力只需要写一个 Step 类注册到 steps 字典里完全不用动 Runner。我在实际项目里加过敏感词校验结果去重成本统计三个 Step每个都是独立文件互不影响。2.2 为什么用意图而不是下一节点 ID传统编排里Step A 执行完直接返回 Step B 的 ID这叫硬编码跳转。问题在于Agent 场景下A 执行完到底该去 B 还是 C往往取决于 A 的输出内容而这个判断逻辑如果写在 A 里A 就承担了编排职责职责就糊了。用意图解耦之后Step 只表达我想继续我想重试我搞定了我需要人帮忙这几种语义具体映射到哪个 Step 由 Runner 统一维护一张意图路由表。这样做的直接收益是流程拓扑的调整只改路由表不改任何 Step。我们后来把校验失败重试从回到摘要步改成回到检索步只动了一行路由配置。注意意图的种类要克制。我见过有人设计了二十多种意图最后路由表比业务代码还长。实践中四种意图continue / retry / finish / escalate能覆盖九成以上场景特殊需求用 Context 里的标志位表达不要无限扩张意图枚举。2.3 上下文膨胀轻量设计最容易翻车的地方Context 承载所有中间结果跑几十步之后很容易膨胀到几 MB每次落盘和传递都是开销。我的处理办法是分层存储Context 里只放控制必需的小数据状态标志、当前步、关键 ID大块的业务数据检索到的文档、模型长输出存到外部 KVContext 里只留引用。这个设计有个隐藏好处调试时可以单独回放某一步的输入不用把整个流程的中间态都加载进来。代价是多了一次 IO但在 Agent 场景下模型调用本身就是秒级开销这点 IO 完全可以忽略。3. 状态流转与持久化让流程能中断、能恢复、能重放3.1 每步落盘带来的三个实际收益Runner 里那句ctx.persist()是整套设计的命脉。每执行完一步就把 Context 序列化落盘看起来是性能负担实际收益远超成本。第一是断点续跑。Agent 流程动辄跑几分钟甚至更久进程崩溃、部署重启、机器回收都是常态。有了每步落盘重启后从ctx.current_step继续即可前面已经花掉的模型调用成本不会白费。我在一个批量处理任务里因为没做落盘一次意外重启导致已经跑完的八十多个流程全部重来那次教训之后落盘成了硬性要求。第二是人工介入。当 Step 返回need_humanTrueRunner 把 Context 挂起并落盘等人工在管理后台确认后从落盘点恢复执行。这套机制让Agent 自动跑 关键节点人工把关的混合模式变得非常简单。第三是可重放调试。线上出问题时把出问题那一步的 Context 快照拉出来本地重跑这一步输入完全一致问题必然复现。这比看日志猜原因高效太多。3.2 序列化格式的选型取舍Context 序列化用 JSON 还是二进制我的结论是默认 JSON大字段外置。JSON 的优势是可读、跨语言、调试友好。缺点是体积大、不支持复杂类型。但在分层存储的前提下Context 本体通常只有几 KBJSON 完全够用。真正大的数据放在外部存储Context 里只存 key。如果确实遇到 Context 本体就很大的场景比如要携带完整的对话历史可以考虑 MessagePack 或 Protobuf但要注意这会牺牲可读性。我的建议是先用 JSON 跑起来真遇到性能瓶颈再换不要过早优化。序列化方案体积可读性跨语言适用场景JSON中高好默认选择Context 本体小MessagePack小低好Context 本体较大Protobuf最小低好强 schema 约束、高性能要求Pickle中无差仅限单语言内部使用不推荐3.3 幂等性重试之前必须先想清楚的事有了重试机制就必须面对幂等性问题。一个 Step 重试时它上次执行产生的副作用写库、发消息、扣费会不会重复我的做法是给每个 Step 引入执行令牌Context 里维护step_attempt计数Step 内部所有有副作用的操作都带上(flow_id, step_name, attempt)作为幂等键。外部系统根据这个键去重重复请求直接返回上次结果。对于无法做幂等的操作比如发送通知策略是先记录意图再执行Step 先把我要发通知写进 Context 并落盘再真正发送。如果发送后崩溃恢复时看到 Context 里已有发送意图就知道这条可能已发出交由人工判断而不是盲目重发。提示不要指望所有 Step 都能做到严格幂等。务实的做法是把 Step 分成可安全重试和需人工确认两类在注册时就标注清楚Runner 根据标注决定重试策略。4. 错误处理与重试把不确定性关进笼子4.1 错误分类决定处理策略Agent 流程里的错误五花八门但归归类其实就三种处理策略完全不同。瞬时错误网络抖动、限流、临时不可用。这类错误重试就能解决策略是带退避的自动重试重试次数控制在 3 次以内退避用指数加随机抖动避免重试风暴。业务错误模型输出格式不对、工具返回结果不符合预期。这类错误重试往往没用因为同样的输入大概率还是同样的输出。正确做法是把错误信息回灌给 Agent让它调整策略重新生成这本质上是一次带反馈的重试。致命错误配置错误、依赖缺失、权限不足。这类错误重试一万次也没用应该立即终止流程并告警把 Context 完整落盘供排查。def classify(error) - str: if isinstance(error, (TimeoutError, RateLimitError)): return transient if isinstance(error, (ParseError, ValidationError)): return business return fatal def handle(error, ctx): kind classify(error) if kind transient and ctx.attempt 3: return retry_with_backoff if kind business: ctx.feedback str(error) # 回灌给 Agent return retry_with_feedback return abort_and_alert4.2 退避策略的参数怎么定指数退避的公式是delay base * (2 ** attempt) random(0, jitter)。base 取多少、jitter 取多少直接决定重试效果。我的经验值base 取 1 秒jitter 取 0 到 base 之间的随机值。这样第一次重试等 1 到 2 秒第二次 2 到 3 秒第三次 4 到 5 秒。加 jitter 的目的是打散多个流程同时重试的时间点避免它们在同一时刻一起冲击下游。如果下游是模型服务还要考虑它的限流窗口。有些服务是按分钟限流的退避时间太短会在窗口内反复撞墙。这种情况可以把 base 提到 5 秒让重试节奏和限流窗口错开。4.3 熔断别让一个坏依赖拖垮整个流程重试解决的是偶发失败熔断解决的是持续失败。当下游连续失败超过阈值继续重试只是浪费资源应该直接熔断让流程快速失败或走降级路径。轻量实现不需要引入完整的熔断库一个计数器就够维护每个依赖的连续失败次数超过阈值比如 5 次就标记为熔断状态后续请求直接返回降级结果同时启动一个定时器过一段时间放一个探测请求过去成功就恢复。这套逻辑不到五十行代码但能显著提升系统在依赖故障时的表现。我在一个项目里加了这个之后下游服务故障时整个流程的失败率从全线崩溃变成了部分降级用户体验完全不同。5. 实测踩坑那些文档里不会写的教训5.1 循环检测Agent 可能自己把自己绕死Agent 有了重试和回退的自由度之后最危险的情况是陷入死循环A 步失败回退到 BB 又产出让 A 失败的结果来回横跳。我遇到过最夸张的一次两个 Step 互相回退了几十次烧掉大量模型调用才被人工发现。解决办法是给回退加预算。Context 里维护每个 Step 的执行次数超过上限比如 5 次就强制终止并告警。同时记录回退路径如果发现 A→B→A→B 这种重复模式提前介入。def check_loop(ctx): ctx.step_counts[ctx.current_step] 1 if ctx.step_counts[ctx.current_step] MAX_STEP_ATTEMPTS: raise LoopDetected(f{ctx.current_step} 执行超过 {MAX_STEP_ATTEMPTS} 次) path ctx.recent_path[-6:] if len(path) 6 and path[:3] path[3:]: raise LoopDetected(f检测到循环路径: {path})5.2 上下文污染旧数据干扰新决策Context 是累积的这意味着前面步骤写入的数据会一直存在。问题在于当流程回退重做时上一次的中间结果还留在 Context 里Agent 可能读到过期数据做出错误判断。我的处理方式是按步骤命名空间隔离每个 Step 写入 Context 时带上自己的命名空间前缀回退时清空该命名空间下的所有数据。这样既保留了跨步骤共享的能力又避免了脏数据干扰。这个坑很隐蔽因为大部分时候旧数据和新数据恰好一致问题不会暴露。只有在回退且数据发生变化时才出问题而这时候排查起来非常困难因为你会看到 Agent 用了一个不该存在的值。5.3 超时设置全局超时和单步超时要分开一开始我只设了全局超时结果一个慢 Step 把整个流程的时间预算吃光后面的步骤还没跑就被全局超时掐断了。后来改成双层超时每个 Step 有自己的超时比如模型调用 60 秒整个流程有全局超时比如 10 分钟。单步超时触发时走重试逻辑全局超时触发时直接终止。参数怎么定单步超时按该步骤 P99 耗时的 1.5 倍设置全局超时按流程平均耗时的 3 倍设置。这样既能容忍偶发的慢请求又不会让流程无限期挂着。5.4 并发流程的资源争抢批量跑流程时多个流程同时调用模型服务很容易触发限流。我最初的实现是无脑并发结果大量请求被限流重试又加剧了拥堵。正确的做法是在 Runner 层做并发控制用一个信号量限制同时执行的流程数同时给模型调用加一个令牌桶限速。并发数不是越大越好要压测出下游能承受的极限然后留 20% 余量。踩坑点现象根因解决方式死循环两 Step 反复回退缺少回退预算执行次数上限 路径检测上下文污染Agent 读到过期数据回退未清理旧数据命名空间隔离 回退清理超时误伤慢步骤拖垮全局只有全局超时单步 全局双层超时限流雪崩重试加剧拥堵无并发控制信号量 令牌桶限速6. 从轻量到够用什么阶段该考虑升级轻量 Workflow 不是银弹它有明确的适用边界。流程步骤在十几个以内、拓扑相对稳定、单机或少量实例就能扛住时这套设计非常舒服。但出现下面几个信号时就该考虑引入更重的方案了。信号一流程拓扑频繁变化且需要可视化。当业务方要求自己拖拽配置流程轻量的代码注册方式就不合适了需要带 UI 的编排平台。信号二需要跨团队共享和复用 Step。轻量方案里 Step 是代码跨团队复用意味着跨代码库依赖管理成本高。这时候需要把 Step 服务化用统一的协议调用。信号三单机吞吐到瓶颈。轻量 Runner 是单进程驱动流程量大时需要自己做分布式调度。如果不想自己造轮子就该上成熟的工作流引擎。我的建议是先用轻量方案跑通业务把 Step 的边界和接口稳定下来再考虑是否升级。很多团队一上来就上重型引擎结果大部分能力用不上反而被框架的约束绑住手脚。轻量方案的最大价值不是永远够用而是让你快速验证业务同时保留升级的余地——因为 Step 的抽象是通用的迁移到任何引擎都不难。最后分享一个我在实际项目里坚持的习惯每个 Step 都必须能独立测试。给定一个构造好的 ContextStep 的 run 方法应该能脱离 Runner 单独跑输出可断言。这个习惯逼着我把 Step 的依赖都通过 Context 注入而不是在 Step 内部直接 new 出来。结果是整个流程的可测试性大幅提升改一个 Step 不用担心影响其他部分。这个约束看起来是负担实际是轻量设计能长期维护的关键。