大概一年前我接了一个客服 Agent 项目。最开始想着省事直接套用当时团队里已有的通用流程编排引擎结果接连踩了好几个大坑一个简单的多轮对话流程硬是被引擎的重型概念拆成了五六个节点类型上下文在节点之间传来传去改一版流程要重新打包部署。后来我彻底想明白了问题不在流程本身而在通用编排工具天生不是为 Agent 这种“决策型、动态路径、高并发挂起”的场景设计的。于是前后折腾了两三周重新用一套轻量 Workflow 方案替代了原来的引擎跑下来稳了很多迭代速度也上来了。这篇文章就把这套思路完整拆开讲讲面向 Agent 的轻量 Workflow 该怎么设计为什么这么设计以及真实上线之后还要在哪些地方补课。无论你是正在搭 Agent 应用、准备做流程编排还是纯粹被传统工作流引擎的复杂度劝退这篇文章都值得从头到尾看一遍。我会把核心抽象、执行引擎、Agent 接缝设计和上线后的隐藏问题一次性讲清楚。1. 为什么通用编排撑不住 Agent 场景三个真实痛点先说清楚一句话我不是反对工作流引擎这个品类而是反对“把一个面向业务审批、数据集成场景的重型引擎直接搬到 Agent 场景”。这类引擎在设计之初的目标和我们完全不一样所以用起来才会到处拧巴。1.1 节点粒度的错位传统流程引擎里的节点很多是为“人”设计的。典型的节点类型包括人工任务、审批网关、子流程、消息推送、定时器事件。一个节点往往承载着业务语义填一张表单、批一个流程、发一封通知。粒度粗语义重生命周期长。到了 Agent 场景我们需要处理的是一连串小而快的动作“调用一次大模型”“查一次数据库”“执行一个工具”“根据结果决定下一步走哪条路”。这些动作粒度很细并且几乎每一个都可能因为模型输出、工具返回、参数异常而中途改变走向。用传统引擎的粗粒度节点来表达这些细颗粒动作要么硬塞进同一个“服务任务”类型里写一堆 if-else 分支要么拆出一堆自定义节点把引擎的扩展点全用一遍。结果是流程定义文件越来越厚真正可复用的编排逻辑反而被埋没了。我在那个项目里第一次重构时把原来一个 800 行的流程定义精简到 150 行左右逻辑还更清楚原因就是节点粒度对齐了。1.2 上下文传导的沉重负担传统工作流经常会把上下文当作一个独立的持久化对象用专门的上下文服务来存取甚至支持上下文的历史版本追溯。这在长流程、多人协作、人工审批的场景里是必要的因为出问题时要能审计“当时到底传了什么值”。但在 Agent 场景中大多数流程的运行时间是秒级到分钟级。上下文的主要任务就两件把 Agent 的当前状态带进节点把节点的输出带回给后续节点。如果这时候还套一套上下文中心化的机制每个节点启动都要反序列化一个大对象、执行完再序列化回去性能损耗之外更大的问题是让流程代码被迫和上下文框架耦合读起来非常累。我见过一个项目上下文对象里塞了几十层嵌套字段节点 A 写入的字段名和节点 B 读取的字段名稍微不一致运行时就静默拿到空值。最后排查半天问题居然出在“同一个语义字段在两个节点里用了不同 key”。轻量方案里上下文就是一个简单的 Map谁写入、谁读取一眼就能看穿这种低级错误几乎不会发生。1.3 流程可变性被预编译扼杀这是最致命的一点。传统引擎为了稳定性和审计需要会把流程先部署、再实例化。流程一旦发布运行中的实例通常不能随意修改要改动只能重新发布新版本并且要做版本管理。但 Agent 场景的流程本质上是一个“运行时决策过程”。模型输出是完全动态的工具返回的格式是不可穷举的用户输入更是千变万化。我们甚至连“这个流程有几条分支”这种问题都不该在编码阶段问死因为模型可能给出我们没预想过的路径。比如一个退款流程用户可以问“我的订单怎么还没发货”“我要退货”“我改地址”三个问题可能走向三个方向的子流程而具体走向哪个分支要靠 LLM 实时判断。如果流程结构被预编译死掉这种灵活性就无从谈起。所以面向 Agent 的轻量 Workflow第一个设计原则就是流程定义是数据不是代码流程结构可以在运行时被动态解释甚至可以由模型输出驱动改变。这是和传统引擎最大的分水岭。下面用一个表格把两类工作流的差异做个快速对比后续所有设计都是从这张表出发的。对比维度传统流程引擎面向 Agent 的轻量 Workflow节点粒度粗面向业务流程/人工任务细面向动作/工具调用/LLM 推理上下文中心化持久化支持审计追溯轻量 Map节点间显式传递流程可变性预编译、版本化管理、运行中难改动动态解释、随时更新、可由模型输出驱动分支运行时长分钟级到天级支持挂起数天秒级到分钟级支持短暂挂起和恢复状态存储数据库多表 历史快照单一状态快照断点续跑扩展方式自定义节点类型、插件体系统一节点接口 工具注册表2. 轻量 Workflow 的核心抽象用状态机思维做减法做减法比做加法难。设计轻量 Workflow 时我给自己定的标准是能用一种机制解决的绝不用第二种机制。最后沉淀下来的核心抽象只有四样节点、边、运行时状态、共享上下文。2.1 最小节点类型集很多工作流框架上来就给一堆节点类型开始节点、结束节点、任务节点、排他网关、并行网关、包容网关、子流程节点、事件节点……等。轻量方案里我建议只保留三个类型task 节点执行一个具体动作。这个动作可以是调用 LLM、执行工具、查询数据也可以是调用一段自定义函数。动作本身不内置在框架里而是通过“动作名称 参数”映射到注册表。condition 节点纯逻辑判断计算出下一个要进入的节点。判断条件由简单的表达式引擎解析不用写代码。end 节点流程结束把最终输出写回上下文或外部响应。这里没有单独的“并行网关”。因为并行是执行调度层面的问题不是定义层面的问题——在一组边里标记并发执行即可不需要为此发明一个新节点类型。这一点后面执行引擎章节会再展开。没有“人工审批节点”也不是缺陷因为人工审批本质上就是一个 task 节点只不过这个 task 的执行方式是把流程挂起等外部回调后再恢复。把它降级成普通节点好处是让人工介入的时机变得完全可控不必被一个专门的审批节点类型绑死。2.2 边与条件表达式的设计边是节点之间的连接轻量方案里一条边由from、to和可选的condition组成{ id: re-fund-flow, nodes: [start, intent, query_order, refund_check, feedback, end], edges: [ { from: start, to: intent }, { from: intent, to: query_order, condition: intent query_order }, { from: intent, to: refund_check, condition: intent refund }, { from: refund_check, to: feedback, condition: refund_status approved }, { from: refund_check, to: end, condition: refund_status rejected }, { from: feedback, to: end } ] }Condition 表达式我建议做得很克制支持字段访问、等值比较、逻辑与或非、三元表达式就足够了。不要引入脚本语言否则流程又开始变重。凡是需要复杂计算的逻辑一律下沉到 task 节点里的代码去算把计算结果写进上下文再由 condition 做简单判断。这里有一个很关键的设计细节condition 的求值对象是“当前上下文切片”而不是整个上下文。这样做的目的是避免条件表达式不小心读到不该读的字段同时方便做并行分支之间的数据隔离。我在实现里会给每个节点一个input_paths配置声明这个节点只能看到哪些字段其余字段一概不可见。看起来多写了几行配置但换来了流程的可读性和安全性非常值。2.3 一棵上下文树走到底上下文的模型我最终定为一个全局可读写的 Map加上节点级的输入输出视图。所有节点共享同一个 Map但对某个具体节点来说它只能通过input_paths读指定字段通过output_key把结果写回指定路径。这样设计的好处是流程调试非常直观我只要打出一条日志就能看到每个节点读进来什么、写出去什么。几乎不需要额外的 Debug 工具出问题时把上下文快照一打印整个流程的数据流动一目了然。还有一点值得说明不要在上下文里存“大块数据”。比如工具返回的超长文本、图片 base64、完整网页 HTML这些应该存到底层存储里上下文里只放一个引用 ID。否则一个流程跑完上下文动辄几百 KB既拖慢序列化也让排查问题变得很痛苦。我自己在这个点上是吃过亏的——当时直接把一次检索返回的整篇文章塞进了上下文结果状态快照写入数据库时直接把连接池打满排查了半天才发现是上下文体积爆炸。3. 一个能跑起来的极简执行引擎主循环与调度策略抽象讲完该落地了。轻量 Workflow 的执行引擎不需要微服务、不需要独立部署它就是一段几百行的代码可以被嵌入到你的 Agent 服务里也可以作为一个独立的库被调用。3.1 运行时状态的数据结构一个流程实例从启动到结束运行时只需要三样东西workflow_id指向流程定义。instance_id唯一实例标识用于日志、恢复和幂等。current_node当前所在节点。history访问过的节点序列用于回溯和审计。context共享上下文 Map。statusrunning / waiting / completed / failed。waiting_reason如果处于 waiting 状态存一个对象说明在等什么等待工具回调、等待人工操作、等待 LLM 返回等。用 TypeScript 的定义大概是这个样子interface WorkflowInstance { workflowId: string; instanceId: string; currentNode: string; history: string[]; context: Recordstring, any; status: running | waiting | completed | failed; waitingReason?: { type: tool | human | llm; detail: any; }; createdAt: number; updatedAt: number; }这里面最容易被忽略的是waitingReason。Agent 场景下绝大多数流程实例的一生都花在“等待”上等模型响应、等工具返回、等用户确认。传统引擎把等待隐藏在数据库事务里我们这里选择把它显式化为状态的一个字段好处是挂起和恢复的逻辑可以完全复用不需要为“等待”单独开一套机制。3.2 主循环像遍历图一样推进流程执行引擎的核心是一个循环在当前节点执行完成后找到所有从当前节点出发的边过滤出条件为真的边进入下一个节点然后重复。async function runWorkflow(instance: WorkflowInstance): Promisevoid { while (instance.status running) { const nodeDef getNode(instance.workflowId, instance.currentNode); if (!nodeDef) throw new Error(node not found: ${instance.currentNode}); const result await executeNode(instance, nodeDef); if (result.status waiting) { instance.status waiting; instance.waitingReason result.waitingReason; break; } const next resolveNextNode(instance, nodeDef); if (!next) { instance.status completed; break; } instance.history.push(instance.currentNode); instance.currentNode next; } }executeNode内部会根据节点类型分发async function executeNode(instance: WorkflowInstance, nodeDef: NodeDef) { const input pickContext(instance.context, nodeDef.inputPaths ?? []); if (nodeDef.type task) { const action getAction(nodeDef.action); if (!action) throw new Error(action not found: ${nodeDef.action}); if (nodeDef.action.startsWith(llm.)) { return runLlmNode(instance, nodeDef, input); } if (nodeDef.action.startsWith(tool.)) { return runToolNode(instance, nodeDef, input); } if (nodeDef.action.startsWith(human.)) { return runHumanNode(instance, nodeDef, input); } return action.run(input, instance.context); } if (nodeDef.type condition) { const value evaluateExpression(nodeDef.expression, input); return { status: ok, data: value }; } if (nodeDef.type end) { return { status: end, data: input }; } throw new Error(unsupported node type: ${nodeDef.type}); }这里我把llm.*、tool.*、human.*作为 task 节点的内置动作前缀处理普通开发者还能继续注册自定义 action。这样一个统一接口就覆盖了 Agent 场景 90% 以上的节点需求。3.3 并行、条件与人工挂起三种执行模式并行。上面说过我把并行从节点类型降级为边的属性。具体做法是当一组边被标记为parallel: true时引擎不再逐个进入而是同时启动多个子执行流等所有子流都结束或有一个失败之后再汇合到共同的汇合点。汇合点也是一个普通节点不需要专门的网关类型我处理时会在上下文中为并行分支各放一个命名空间{ edges: [ { from: query_source_a, to: merge, parallel: true, namespace: branch_a }, { from: query_source_b, to: merge, parallel: true, namespace: branch_b } ] }merge节点读取branch_a.result和branch_b.result合并后继续。这种实现方式只增加了一个字段和有限的并行调度逻辑不需要引入新的节点种类。条件。条件节点就是求值表达式把结果写进一个约定好的字段然后依赖边的 condition 做路由。前面已经展示了结构这里不重复。人工挂起。人工节点很特殊。它的执行逻辑是生成一个待办任务把任务的 token 写进waitingReason然后把instance.status置为 waiting直接 return。这样引擎主循环就会自然停下。外部系统比如前端审批页、IM 机器人的按钮回调处理完人工操作后调用一个恢复接口把人工填写的表单结果合并进上下文再把流程状态改回 running重新调用runWorkflow即可。这套挂起机制的实现代价非常小却能支撑起“人工介入”这种传统引擎最重的功能。我在项目里就靠它实现了“大额退款需要人工二审”的逻辑整个实现不超过 100 行。4. 和 Agent 的接缝设计LLM 节点、工具调用与动态分支Workflow 引擎本身跑起来并不难难的是让它和 Agent 深度咬合。这一章讲三个接缝点LLM 节点怎么设计、工具怎么接入、分支如何由模型驱动。4.1 LLM 节点prompt 模板 schema 解析LLM 节点本质上是一个“动作是调模型”的 task 节点。但它的配置要比普通 task 多两块prompt 模板和输出 schema。{ id: intent, type: task, action: llm.chat, inputPaths: [user_message, order_info], outputKey: intent_result, promptTemplate: intent_detect_v1, responseSchema: { type: object, properties: { intent: { enum: [query_order, refund, feedback, other] }, confidence: { type: number } }, required: [intent, confidence] } }promptTemplate 指向一个存储好的模板模板里用占位符引用 inputPaths 中的字段。responseSchema 用来指导模型输出厂商 SDK 一般支持 JSON Schema 约束输出或者 function calling 格式直接透传即可。节点执行时返回的结构化结果被写入outputKey后续 condition 节点就能用intent_result.intent做路由。这里有一个非常实用的经验同一个 LLM 节点最好支持多模型回退。比如主模型超时或报错时自动降级到一个更便宜更快的备选模型。这个逻辑不要写进各个节点要在llm.chat这个 action 内部统一实现。我在实际项目中配置了“主模型 8 秒超时降级到备选模型”效果立竿见影整体可用性提升了一个档次。4.2 工具注册表让节点可挂载任意能力工具调用是 Agent 和外界交互的主通道。轻量 Workflow 里我不建议单独发明“工具节点”而是复用 task 节点通过工具注册表把工具暴露给 LLM 决策。工具注册表的核心结构interface ToolDef { name: string; description: string; inputSchema: Recordstring, any; run: (args: any, ctx: ToolContext) Promiseany; idempotent?: boolean; }工具的执行函数里会接收一个ToolContext里面包含 instance_id 和 request_id。这个 request_id 非常重要后面讲幂等时会重点说。注册表本身可以做成一个简单的插件列表每个工具只要实现上面的接口就能被 workflow 里的tool.*动作直接调用。在设计工具接入时要克制住“让工具直接穿过 workflow”的冲动。我在早期版本里允许工具函数直接读写 workflow 上下文结果工具代码和流程逻辑耦合得乱七八糟。后来改成工具只能接收入参、返回结果不允许碰上下文代码清爽了很多。工具必须保持无状态状态的保存和流转始终归 workflow 管。4.3 动态分支让模型决定路径而不是写死 if-else在很多 Agent 场景里分支并不只是“根据某个枚举值选路”而是要“根据模型的综合理解选路”。传统条件节点很难处理这种模糊决策我的方案是引入一个专门的“决策节点”本质是带特殊 prompt 的 LLM 节点。{ id: route_decide, type: task, action: llm.route, inputPaths: [user_message, retrieved_context, history], outputKey: route_decision, options: { query_order: { description: 用户查订单, nextNode: query_order }, refund: { description: 用户要退款, nextNode: refund_check }, advice: { description: 需要给建议, nextNode: advice_node } }, fallbackNode: default_handle }执行这个节点时引擎构建出一个只包含选项描述的 prompt要求模型输出一个选项标识。返回值会被写入route_decision然后 workflow 对边的 condition 求值route_decision.target refund。比硬编码规则好在哪里好在这种路由可以很方便地引入额外的上下文比如用户情绪、历史订单状态、实时库存等模型综合判断后选路规则则不需要跟着业务调整一遍遍改。由于动态分支引入了模型的不确定性一定不要忘记fallbackNode配置。当模型输出内容无法匹配任何选项或解析失败时流程必须有一个不会挂掉的退路。还有一点动态分支的可追踪性比普通条件节点更重要。我建议引擎在任何路由决策发生之后都把“输入摘要 模型原始输出 最终选择的节点”写入日志。这样一来线上如果出现错误分流排查时会愉快很多。5. 上线后吞掉的五个隐藏问题状态持久化、超时、幂等、可观测性与降级这一章是整篇文章里最有“血泪感”的部分。轻量 Workflow 能跑通 demo和能在线上稳定运行是两码事。我把上线前后遇到的最典型的五个问题列出来每个都给出了我的解决方案希望你能少走弯路。5.1 状态快照与断点续跑轻量 Workflow 的状态很薄所以可以“无脑持久化”每当一个节点执行完成、流程进入一个新节点时把整个 instance 对象做一次快照写入 Redis 或数据库。快照的 key 用 instance_id值就是整个 JSON。这个策略简单到粗暴但真的很管用。服务重启后把所有 status 为 running 或 waiting 的实例捞出来直接从current_node继续跑即可。需要注意的是快照写入的频率。如果一个流程有几毫秒级的小节点每个节点都写库可能会成为瓶颈。我的做法是普通节点直接同步写快照LLM 节点和工具节点在进入等待之前写一次恢复之后再写一次。这几个点的写入间隔都在几秒以上完全够用。断点续跑还有一个隐藏好处可以方便地做“人工回退”。比如某个流程跑错了分支运营同事可以从历史快照里选一个更早的时间点把流程重置到那个状态重新跑。这在传统引擎里几乎是不可想象的在轻量方案里只是“换一份快照”这么简单。5.2 三类超时与重试策略Agent 场景里超时至少有三种必须分别处理LLM 调用超时主模型 8 秒无响应降级到备选模型备选也失败标记节点失败走重试或兜底。工具调用超时外部接口慢是常事。我统一给工具执行套了一个 Promise.race 的超时包装超时后默认打点告警并返回一个“超时错误”结果给流程而不是直接抛异常终止流程。人工等待超时人工节点挂起后超过 N 小时无人处理应该能触发提醒或者自动转接。这个超时存进waitingReason由一个后台定时任务扫描处理。重试策略我采用指数退避但会根据节点类型差异化配置LLM 节点重试间隔短因为大多是瞬时抖动工具节点重试间隔可以稍长遇到下游限流时也能有效退避人工节点一般不自动重试而是发提醒。这里特别提醒一句超时重试不是银弹一定要有最大重试次数。否则一个失败的流程会一直在重试把资源耗尽。我在设计里给每个节点都加了一个 maxRetries 配置超过次数后直接进入失败处理。5.3 幂等防止工具被重复执行这是 Agent 基础设施里最容易翻车的一块。你向订单系统发起退款接口第一次请求超时了重试时如果接口不是幂等的用户就可能被退两次款。轻量 Workflow 无法保证外部工具是幂等的但可以在编排层做防护。我的做法是为每个工具调用生成一个 request_id存入上下文工具执行前先把 request_id 写入“进行中”列表如果流程因为失败重试到同一个工具节点引擎检查发现当前工具调用已有进行中的 request_id就改为“查询结果”而不是“重新执行”。具体就是在工具注册表里增加可选的queryByRequestId能力声明支持幂等查询的工具走这条安全路径不支持的则先用一个 dryRun 参数提前探测或者强制要求业务方改造。在最坏情况下至少要保证 workflow 内部不会因为自己的重试导致重复执行这个底线要守住。工具侧是否幂等是对方的承诺编排侧是否防重是自己的责任。5.4 全链路日志每个节点耗时和 token 消耗轻量 Workflow 的日志系统不复杂但必须做并且字段要统一。我在每次节点执行时打一条结构化日志包含 instance_id、workflow_id、node_id、节点类型、执行耗时、输入字段名列表、输出字段名列表、动作名称以及 LLM 节点特有的 token 消耗量。这些日志最直接的用途有两个。一是看流程的“时间黑洞”——哪个节点平均耗时最长哪个节点超时率最高一目了然。二是看 token 消耗分布很多 Agent 项目账单爆炸就是因为在某些节点循环调用了多次模型日志能帮你迅速定位。我建议从第一天就接好这个埋点而不是等上线之后再补。因为后续的告警、链路追踪、费用分析都要建立在日志数据之上。日志里加上一个统一的 trace_id和 instance_id 打通排查问题时就能把一次用户请求经过的所有节点串成一条完整的链路。5.5 兜底退路失败时回归人工无论怎么设计Agent 一定会遇到处理不了的情况。轻量 Workflow 的失败兜底策略我坚持的是一条原则流程可以失败但用户不能没人管。具体做法是在 workflow 定义里预留一个统一的on_error节点。无论哪个节点最终无法恢复流程都会进入这个节点。这个节点可以是转人工工单创建、推荐其他服务、发道歉短信等。它的存在让整条流程永远有一个“软着陆”出口不会出现一个异常直接抛到用户脸上。我最开始做 Agent 项目时没有兜底结果因为一次模型崩溃几百个用户收到了半截回复。后来加了 on_error 节点专门处理“无法理解、无法处理、服务降级”三类情况用户体验立刻稳定了不少。轻量不代表简陋恰恰因为流程轻我们才更应该把兜底和降级做得扎实。最后聊一点个人的体会。轻量 Workflow 的核心不是“少写代码”而是“克制”。克制住引入重型概念的冲动克制住把复杂度转移到流程定义文件的冲动克制住让工具直接写上下文的冲动。真正在设计里留住那些必要的复杂度把不必要的复杂度坚决挡在门外。这套方案在我手头的几个项目里已经稳定跑了大半年如果你也正在为 Agent 的流程编排头疼不妨先从小而美的状态机做起相信我你会喜欢这种通透的感觉。