最近在搭一套多Agent协同调度模块时,被一件事反复折磨:Agent的任务链路越来越长,中途要调工具、要等人审、要并行跑子任务,代码里if-else堆到没法维护。我意识到,缺的不是模型能力,而是一个能把这些步骤“编排”起来的东西。
网上搜了一轮,市面上成熟的Workflow引擎基本都偏重——部署一套要起好几个服务,配置模型复杂,光是理解它的抽象概念就得花一周。对Agent场景来说,这套东西太重了,杀鸡用牛刀还给鸡添负担。最后我决定自己设计一个面向Agent的轻量Workflow设计,目标很明确:核心逻辑能在一千行内讲清楚,部署时只要一个进程,Agent只需按约定上报状态,剩下的编排、重试、恢复全部交给Workflow层。
这篇文章就记录我整个的设计思路、取舍过程和落地实测。如果你是正在做Agent应用、被任务编排搞得头疼的开发者,或者想理解轻量工作流内部到底怎么回事,这篇应该能给你一个可以直接抄作业的参考。
1. 起因:传统流程引擎在Agent场景下的别扭
1.1 我的实际痛点是什么
我们最初用的是任务列表手动推进的方式。每个Agent执行完一步,往数据库写一条记录,下一步的触发靠定时任务扫描。听起来简单,但Agent场景有个特点:任务不是固定流水线,而是高度动态的。
比如一个“生成市场分析报告”的任务,可能要先判断数据源是否可用,可用就并行拉取三个渠道的数据,不可用就要进入人工确认分支。人工确认完成后,还要重新评估分支条件。这种动态拓扑,用固定状态字段根本描述不了。我代码里不得不维护一张“当前任务类型”的表,每种类型配一套推进逻辑,加一个新任务类型就要动核心代码。
更痛苦的是失败处理。Agent调外部API超时了怎么办?重试几次?重试间隔多久?如果Agent进程崩溃重启,当时跑到哪个步骤了?这些逻辑散落在各个Agent实现里,每个Agent写的还不一样。有的Agent重试三次,有的重试一次就放弃,排查线上问题时根本不知道某个任务卡住是因为什么。
1.2 为什么不用现成的Workflow引擎
我评估过两个方向。一个是重型工作流引擎,它能做复杂状态机、分布式事务、可视化编排,但部署成本太高。在我这个场景里,Agent本身就够复杂了,再引入一套需要独立维护的编排系统,运维负担直接翻倍。另一个是云厂商提供的Serverless编排服务,功能倒是全,但任务是跑在我们自建的Agent容器里,跨网络调用的延迟和费用都吃不消。
我也认真想过另一个思路:完全不用Workflow,让Agent自己负责所有步骤。这个方案确实最轻,但它把Agent搞成了一个上帝类,既要懂业务又要懂调度。而且一旦任务涉及多个Agent协作,比如A做完B做,B的结果要回传给A,没有中间层根本没法管理。我得有一个能记录“当前谁在做什么、做到哪一步”的公共视图。
1.3 我定义的“轻量”边界在哪
基于这些痛点,我给自己定了几条硬约束:
- 整体代码量控制在两千行以内,核心执行器不超过八百行
- 不依赖独立服务,嵌入现有Agent进程即可
- 用数据库一张表存Workflow实例状态,不引入额外的存储系统
- 支持顺序执行、并行执行、条件分支、失败重试、人工审批五种基本能力
- 任何操作都要能在五分钟内向新人讲清楚
轻量的核心不是功能少,而是剥离一切“在这个场景下不常用”的能力。我不需要可视化拖拽编排,不需要高可用集群,不需要分布式事务。我需要的是:任务能跑起来、失败了能恢复、出问题能定位。这三件事做到,就已经覆盖了Agent编排90%的诉求。
2. 核心模型设计:一张表加一个循环就够了
2.1 任务节点的数据结构
设计这个Workflow时,我把自己代入一个Agent的视角去思考:Agent接到的指令应该是什么?它只需要一个节点描述对象。最终我定义的数据结构是这样的:
@dataclass class TaskNode: node_id: str # 节点唯一ID,比如 "step_1" agent_type: str # 要调度哪个Agent,比如 "data_fetcher" payload: dict # 传给Agent的具体参数 depends_on: list[str] # 依赖哪些节点,空表示无依赖 next_on_success: str # 成功后进入哪个节点 next_on_failure: str # 失败后进入哪个节点 retry_count: int = 3 # 最大重试次数 retry_interval: int = 30 # 重试间隔,单位秒 timeout: int = 300 # 单次执行超时时间depends_on这一个字段解决了并行问题。我把依赖关系显式声明出来,执行器只需要检查“我的依赖都完成了吗”,就能判断自己是否可以启动。这样避免了在代码里写死调度顺序,拓扑结构完全由数据驱动。
next_on_success和next_on_failure则解决了分支问题。传统Workflow引擎用专门的分支节点类型,我觉得没必要。普通节点的成功或失败本身就是天然的布尔分支条件。只要在节点定义里写明成功往哪走、失败往哪走,分支能力自然就有了。
2.2 状态机的流转规则
有了节点定义,我还需要一个表示整体执行进度的状态机。我用了最直白的六状态模型:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| PENDING | 待执行 | 节点创建,等待依赖完成 |
| RUNNING | 执行中 | 依赖已完成,Agent领取任务 |
| SUCCEEDED | 已成功 | Agent返回成功结果 |
| FAILED | 已失败 | Agent返回失败结果 |
| RETRYING | 等待重试 | 失败但未超过重试次数 |
| IN_REVIEW | 待人工审批 | 进入审批节点,等待人审结果 |
这六种状态的流转规则我写成了一个纯函数,方便单元测试和排查:
def build_mermaid(instance_id): print(mermaid_code)不对,方法想岔了,实际我是写成状态转移表来校验合法转移。比如 RUNNING 只能变成 SUCCEEDED、FAILED 或 RETRYING,绝对不允许直接跳到 PENDING。这些规则保证状态机不会出现“死循环”或“跳变”的诡异情况。
状态的归属粒度是“节点”而非“整个工作流”。整个Workflow实例也有一个总状态,但它是根据节点状态实时计算出来的:只要存在RUNNING节点就是RUNNING,只要有一个FAILED节点就是FAILED,全部SUCCEEDED就是完成。这样省去了一堆状态同步的逻辑。
2.3 执行循环:心跳式调度
我设计执行器的核心是一个死循环加一个“可运行节点查询”:
def scheduler_loop(): while True: runnable = db.query( "SELECT * FROM nodes WHERE status = 'PENDING' " "AND EXISTS (SELECT 1 FROM nodes AS deps " "WHERE deps.node_id = ANY(nodes.depends_on) " "AND deps.status = 'SUCCEEDED')" ) for node in runnable: assign_to_agent(node) time.sleep(SCHEDULE_INTERVAL)这个查询表达的核心逻辑是:找所有依赖都已完成且自身还没启动的节点。之所以用“轮询”而不是“事件驱动”,是为了简化恢复逻辑。如果某个Agent领取任务后崩溃了,节点状态会一直停在RUNNING,直到超时。轮询模式下,超时检测是顺手的事,而事件驱动的模式里,丢失的事件会留下永久的挂起节点。
执行器采用单线程循环。并行能力不靠执行器的多线程,而是靠“一次调度多个节点分配给多个Agent”。每个Agent本身就是独立进程,天然并行,调度器只管分发和回收,不需要自己处理并发竞争。
3. 编排能力的低成本实现:顺序、并行、分支、重试与人审
3.1 顺序执行与条件分支的约定
顺序执行其实是最简单的。只要在定义工作流时让每个节点的depends_on指向前一个节点,执行器自然按顺序跑。我习惯把整条链的next_on_success也串联起来,这样即使依赖断了,成功路径还是清晰的。
条件分支我分成了两种实现方式,取决于分支条件的位置。
一种是在调度层判断:前置节点成功后,调度器根据返回值决定把哪个后续节点置为可运行。比如“数据源可用性检查”节点返回{"available": true},调度器就把“拉取数据”节点置为可运行;返回{"available": false},就把“人工确认”节点置为可运行。这个逻辑我放在一个叫decide_next的钩子函数里:
def decide_next(node_result, workflow_definition): if node_result.get("available") is True: return "fetch_data_node" return "human_review_node"另一种是在Agent内部判断:Agent自己根据业务逻辑返回不同的成功标记,比如{"success": true, "action": "retry"}或{"success": true, "action": "giveup"}。调度器根据action字段决定走next_on_success还是直接跳到终态。这种方式适合“业务判断比较复杂、但路径固定”的场景,把决策留给Agent自己做。
3.2 并行分支与聚合
并行是这套设计里最讨巧的部分。前面说了,depends_on字段天然描述并行关系。如果三个节点都只依赖同一个父节点,那么父节点成功后,三个节点会同时被调度器发现并分配给三个Agent。
但并行之后往往要聚合——等三个任务都完成,才能进入下一步。这时我引入了一个虚拟聚合节点。它的Agent类型是aggregator,depends_on填写那三个并行子节点:
{ "node_id": "aggregate_1", "agent_type": "aggregator", "payload": {"mode": "join", "sources": ["child_1", "child_2", "child_3"]}, "depends_on": ["child_1", "child_2", "child_3"] }聚合节点本身不调用外部Agent,而是由调度器内置的聚合逻辑处理。它会等待所有依赖节点返回SUCCEEDED,收集它们的输出结果组装成一个聚合结果,然后传递给下游节点。这里有个坑我必须提醒:千万不要在并行子节点里写数据库或做有副作用的操作,因为并行出错重试时,副作用可能叠加。我踩过这个坑,后面专开一节讲。
3.3 重试、超时与人工审批的实现位
重试和超时理解起来不复杂,但实现位置的选择会影响整个系统的行为。
我把重试逻辑交给调度器统一处理。节点执行失败时,调度器不会立刻把节点置为FAILED,而是检查重试次数。如果还有剩余次数,就把状态置为RETRYING,并记录下次重试的绝对时间戳;调度循环扫描到当前时间超过时间戳时,再次把节点分配给Agent。
超时检测则利用了状态时间戳。每个节点记录started_at,调度循环里每个周期检查一次:当前时间减去started_at如果超过节点的timeout,直接判断为执行失败。这个逻辑很朴素,但足够可靠——Agent崩溃、容器被杀、网络分区导致消息丢失,最终都会表现为“超时”,超时机制把它们统一收编了。
人工审批的实现则是一个特殊的Agent类型。节点进入RUNNING时,调度器不是去调外部模型,而是给审批人推一条待办通知,然后进入等待。审批人在管理后台点了“通过”或“拒绝”,接口会直接修改节点状态。为了让流程不卡死,我给审批节点也加了超时——超时后默认拒绝,并触发失败分支。实际上线后发现,没有超时上限的话,一个流程可能被人为晾一周,下游全部堵塞,所以超时这个设计保留了。
4. 持久化与断点恢复:如何做到崩溃后接着跑
4.1 状态持久化只落两个时机
为了做到进程崩溃后能恢复,持久化是躲不开的。但追高频的持久化会拖垮性能。我定的策略是:只有两个时机强制写数据库。
第一个时机是节点状态发生转移时。比如从PENDING变成RUNNING,从RUNNING变成SUCCEEDED,这些状态变更必须同步落库,确保“当前进度”不会丢。第二个时机是Agent领取任务时。调度器把任务派发出去后,会立即把节点状态置为RUNNING并写入当前执行的Agent实例ID。这样做的好处是:崩溃后恢复时,我知道这个节点的任务被谁领走了,可以去查它的最终状态。
Agent执行过程中的中间日志不落库。比如Agent每一步日志、临时计算结果,这些丢了我认,因为它们是可重算的。只有“节点状态”这种不可丢失的进展信息才需要持久化。这个取舍遵循一个原则:状态是硬数据,过程是软数据,硬数据必须保,软数据丢了重跑就行。
4.2 恢复流程与幂等设计
进程重启后的恢复逻辑,比我预想的要简单。核心就三步:
def recover_after_restart(): # 1. 找到所有 RUNNING 且超时的节点 hanging = db.query( "SELECT * FROM nodes WHERE status = 'RUNNING' " "AND started_at < now() - interval '5 minutes'" ) for node in hanging: # 2. 标记为执行失败(走重试逻辑) mark_failed(node, reason="agent crash, no heartbeat") # 3. 其余状态不变,调度循环自动继续所有处于PENDING、RETRYING状态的节点,恢复后天然会被调度循环重新发现。RUNNING节点如果还在正常执行,会正常返回;如果Agent已经死了,等超时后被置为失败并触发重试。这里最精妙的一点是:恢复逻辑不需要遍历所有节点,只需要处理“悬挂”的少数节点。
但“重新跑”是有风险的,所以我给所有会触发外部副作用的节点设计了幂等策略。核心方法是给每个节点分配一个全局唯一的execution_id,这个ID会被写进外部API请求的幂等键里。比如Agent调支付接口、写文件、发消息时,带上这个ID,外部系统看到同样的ID就认为是重试,直接返回上一次的结果,避免重复扣款和重复发消息。
5. 实测:某跨平台系统接入这套Workflow的真实表现
5.1 从领任务到结果回传的完整链路
我拿一个实际业务来验证整个设计:某跨平台系统的“多平台内容同步发布”服务,这个任务链路包含数据收集、内容审核、多平台发布、结果汇总四个大步骤。
数据收集阶段并行调用了三个平台的接口,每个平台单独一个Agent。三个平台数据都回来后,进入审核Agent,判断内容是否符合各平台规范。审核通过后进入发布阶段,发布这个动作我拆成了三个并行节点,分别发布到三个平台。全部发布成功后,汇总节点收集三个平台的返回结果并写入最终报表。整个流程共8个节点。
我把这套流程定义成JSON放在数据库里,5分钟完成配置。第一次跑的时候,三个并行的数据收集Agent同时启动,确实做到了并行。其中一个平台的接口当时因限流返回502,数据收集节点走了两次重试后成功,整个过程没有人工介入。完整链路跑完,从开始到汇总报告生成花了约40秒,比之前手动拼接流程快了将近3倍。
5.2 并发、容错和性能实测数据
我关心三个指标:调度器本身的吞吐、节点状态变更的延迟、以及容错场景下的恢复时间。
调度器单循环间隔我设的是1秒,一次轮询最多拉取50个可运行节点。实测中50个节点全部分配出去,从“符合调度条件”到“所有Agent领取完毕”耗时约1.2秒,瓶颈在数据库查询,不在调度器本身。对Agent场景来说这个吞吐完全够了。
容错场景我用了一个粗暴但有效的测试方法:在聚合节点执行前直接kill掉调度器进程,等了10秒再重启。重启后恢复逻辑扫描到有3个已完成的节点和1个悬挂的RUNNING节点。悬挂节点被标记为失败并触发重试,Agent重新执行后成功。整个恢复过程耗时约3秒,没有人工干预。后来我在代码注释里写了一句评价:最满意的就是这里,崩溃恢复在软件层面几乎没成本。
性能方面,状态变更落库一次的平均耗时约8毫秒。对单节点多次重试、多分支并行这类常见情况,状态变更的总次数通常不超过20次,数据库压力完全可以忽略。我没有做一万并发这种压力测试,因为这不是轻量方案的目标场景,硬去够那种规模,只会把架构重新逼回复杂。
6. 踩过的坑和必须提前给后来者说的话
6.1 三个印象最深的坑
第一个坑是并行节点的幂等处理。最初设计时我完全没考虑重试带来的副作用叠加。某次数据收集节点在重试时,向内部消息队列重复推送了两个完全相同的数据包,下游统计直接翻倍。排查时我看到节点重试日志才反应过来,问题出在重新执行任务时没有使用幂等键。后来给所有写入操作的外呼都固定带上execution_id,前后对比才正常。
第二个坑是恢复逻辑只处理了RUNNING节点,没处理“PENDING但依赖已完成”的节点。事情是这样的:调度器把节点A分发给Agent后,Agent执行完也返回了,但调度器在写库前崩溃了。恢复后节点A还是RUNNING状态,它不是悬挂状态,但永远不会被重新调度。那段时间我有几个任务卡在“执行中”一周没动。解决办法是恢复时把所有RUNNING且没有心跳超时的节点也强制重跑一遍,代价是偶发重复执行,但在幂等键保护下可以接受。
第三个坑是审批节点的超时策略。最初我把审批节点的超时设成无限大,结果一个需要人工确认的任务被审批人晾了两个星期,下游整条链路瘫痪。后来加了一个总量超时——整个工作流的执行期限,超过直接失败。审批确实可能耗时,但系统必须有兜底。我现在建议给每个审批节点单独设置一个合理的超时上限,而不是用全局默认值。
6.2 如果让我重来,哪些设计我会保留
重新回看这个方案,有三件事我做得比较对。
第一是坚持用“数据驱动而非代码驱动”的编排。所有流程定义都是JSON,新增一个流程不需要改代码、不需要发布版本,运营人员在后台填一张表就能上线。这让迭代速度提高非常多。
第二是状态的“级联简化”。我一开始也想设计复杂的子任务关系、任务组,后来一刀切:每个节点只有六种状态,节点之间只有依赖关系,整个工作流的总状态从节点状态推导。简单模型带来的直接好处是排查问题方便,看到节点状态就知道卡在哪,不需要一层层展开去看父子关系。
第三是“写完执行器再写恢复器”。很多设计文档喜欢把恢复机制当成附加模块,我这次是先把正常执行路径的代码写完,紧接着就把恢复逻辑写成同等级核心组件,并且每周做一次kill进程恢复测试。恢复能力不是上线前临时加的保险,而是与主流程并列的基础设施。
如果你也在给Agent场景设计Workflow,我的建议是:先把需要的能力压缩到五个以内,用最直接的数据结构表达清楚,然后让执行器足够“笨”,笨到把所有聪明都留给数据定义。这种方向下做出来的东西,维护成本会低到超出你的直觉预期。