多代理编排架构实战:agency-agents 调度层与执行层分离设计
1. 从"agency-agents"这个名字说起:它到底想解决什么问题
第一次看到"agency-agents"这个项目名,我脑子里冒出来的第一个念头是:这大概率不是一个单纯的工具库,而是一套"代理的代理"体系。拆开来看,"agency"在软件语境里通常指"代理机构、中介层、调度中枢",而"agents"则是"执行单元、智能体、任务代理"。把这两个词叠在一起,指向的其实是一个非常具体的工程问题——当系统里存在多个可独立执行任务的代理单元时,谁来编排它们、谁来决定谁先谁后、谁来兜底失败重试。
这个问题在早期的小型项目里根本不算问题。一个脚本干一件事,跑完就结束,没有编排的必要。但只要业务稍微复杂一点,比如一个流程里要同时调用数据抓取、格式清洗、结果校验、通知推送这四类动作,而且每一类动作可能对应不同的执行策略、不同的超时阈值、不同的失败处理方式,那么"谁来管这些执行单元"就会立刻变成一个绕不开的架构决策。agency-agents 这个标题,本质上就是在回答这个决策问题:它把"调度编排"和"具体执行"拆成了两层,agency 负责前者,agents 负责后者。
我之所以对这个方向特别有感触,是因为过去几年里我见过太多项目在这件事上反复横跳。一开始大家喜欢把所有逻辑塞进一个大函数,后来发现维护不动了,就开始拆模块;拆完模块发现模块之间调用关系乱成一团,于是引入消息队列;引入队列之后又发现状态追踪变得极其困难,最后不得不回头重新设计一套代理调度层。agency-agents 这类命名的项目,往往就是在这个"回头重新设计"的阶段被提炼出来的产物——它不是凭空造概念,而是被真实的复杂度逼出来的。
那么它适合谁来参考?我的判断是三类人。第一类是正在做多步骤自动化流程的开发者,比如需要把若干个独立任务串成一条流水线的人;第二类是做智能体编排、任务分发相关方向的工程师,需要一套清晰的"调度层 vs 执行层"职责划分思路;第三类是想理解"代理模式"在真实工程里怎么落地的人——不是设计模式课本里那个静态代理,而是动态的、带状态、带失败恢复的运行时代理。这三类人读这个标题的诉求不一样,但底层关心的都是同一件事:如何让多个执行单元协同工作,而不至于让调度逻辑本身变成新的混乱源头。
接下来的内容,我会围绕这个核心问题,把 agency-agents 这类项目在真实落地时会遇到的选型、设计、踩坑、调优几个层面拆开讲。需要提前说明的是,原始项目正文和关键词都是空的,所以下面涉及的具体实现细节,是我基于"一个合格的后端/自动化方向从业者在做这类系统时最可能采用的合理方案"进行的补全,属于常见工程实践的还原,不是对某个特定代码库的逐行解读。这一点先讲清楚,免得读者误以为我在描述某个确定的开源实现。
2. 调度层与执行层为什么要分开:agency 和 agents 的职责边界
2.1 把"决定做什么"和"实际去做"混在一起,是混乱的起点
我先讲一个几乎每个做自动化流程的人都会踩的坑。假设你要做一个"每天定时汇总多个数据源、生成报告、发出去"的流程。最直觉的写法是这样的:一个主函数里,先循环调用各个数据源的抓取函数,抓完统一清洗,清洗完拼报告,拼完发送。这个写法在数据源只有两三个、格式统一的时候完全没问题,代码也短。
但真实情况往往是:数据源 A 返回的是 JSON,数据源 B 返回的是 CSV,数据源 C 偶尔会超时,数据源 D 需要先鉴权再拉数据。这时候你会发现主函数里开始出现大量的 if-else:如果超时怎么办、如果格式不对怎么办、如果鉴权失败要不要重试。这些逻辑本身没错,问题在于它们和"流程该怎么走"这件事搅在了一起。你改一个数据源的超时策略,可能会不小心影响到整个流程的控制流。
agency-agents 这种分层思路的价值就在这里。agency 层只关心"这个流程有哪几个步骤、步骤之间的依赖关系是什么、某一步失败了整体该怎么走";agents 层只关心"我这个执行单元拿到输入之后,怎么把这件事干完、干完之后返回什么"。两层之间通过一个明确的契约通信——通常是任务描述进、结果或错误出。这样一来,超时策略、重试策略属于 agent 自己的事,而"要不要因为这一步失败而中止整个流程"属于 agency 的事。职责一分开,改动的爆炸半径就小了很多。
我个人的经验是,判断一个项目该不该做这种分层,看一个信号就够了:当你发现控制流代码(if/else、try/catch、循环)和业务动作代码(真正干活的那几行)的比例超过 1:1 时,就该考虑分层了。如果控制流比业务动作还多,说明调度逻辑已经喧宾夺主,再不分层,后面每加一个执行单元都是在给主函数增加负担。
2.2 agency 层真正要管的四件事
很多人以为 agency 层就是个"按顺序调用"的调度器,其实远不止。一个能扛住真实业务的调度层,至少要管四件事,我按重要性排一下。
第一件是依赖解析。流程里的步骤不是永远线性的,经常有"B 和 C 都依赖 A 的结果,但 B 和 C 之间互不依赖"这种结构。agency 层要能识别出哪些步骤可以并行、哪些必须串行。这件事听起来简单,但一旦步骤数量上去,手工维护依赖关系很容易出错,所以成熟的做法是用有向无环图(DAG)来描述依赖,让调度器自己去算执行顺序。
第二件是状态管理。一个流程跑到一半挂了,重启之后是从头再来还是从断点继续?这取决于 agency 层有没有记录每个步骤的执行状态。我见过不少项目为了省事不做状态持久化,结果流程一长,失败重跑的成本高得离谱。状态管理不一定要上数据库,写本地文件也行,但一定要有。
第三件是失败策略。某一步失败了,是立即中止、跳过继续、还是重试若干次?这个策略不应该写死在 agent 里,而应该由 agency 层根据流程的整体语义来决定。比如"发送通知"这一步失败了,可能重试三次就够了;但"扣款"这一步失败了,绝对不能盲目重试,得走人工介入。这种差异只有 agency 层才看得全。
第四件是结果聚合。多个 agent 跑完之后,结果怎么汇总、以什么格式返回给调用方,这也是 agency 层的活。agent 只管产出自己的那份结果,聚合逻辑放在调度层,才能保证对外接口的稳定性。
2.3 agents 层应该保持"无知"
和 agency 层要"什么都知道"相反,agents 层最好的状态是"什么都不知道"。一个 agent 不应该知道自己在整个流程里排第几,不应该知道上游是谁、下游是谁,甚至不应该知道这次执行是第几次重试。它只需要知道:给我输入,我按约定处理,返回结果或抛出错误。
这种"无知"设计的好处,我在实际项目里体会特别深。早期我做的一个流程里,某个 agent 为了"优化",自己判断了一下上游数据是否为空,如果为空就跳过处理直接返回成功。结果后来上游逻辑改了,空数据变成了合法输入,这个 agent 的"自作聪明"反而导致数据被静默丢弃,排查了半天才找到。从那以后我就坚持一个原则:agent 只对输入负责,不对流程负责。输入合法就处理,输入非法就报错,至于这个错误要不要导致整个流程失败,交给 agency 层判断。
这种边界一旦立住,agent 就变成了高度可复用的单元。同一个"数据清洗 agent",可以放在流程 A 里,也可以放在流程 B 里,因为它不依赖任何流程上下文。这也是 agency-agents 这类架构最实际的收益之一:执行单元的可复用性大幅提升。
3. 代理之间怎么通信:契约设计比实现更关键
3.1 输入输出的数据结构,决定了整个系统的扩展上限
agency 和 agents 之间的通信契约,是整个系统里最不该随便设计的东西。我见过太多项目在这里图省事,agent 的输入直接用一个字典或者 map 传,字段名靠约定,结果跑着跑着就出现"某个 agent 期望的字段名和上游给的对不上"这种低级问题,而且因为字典是弱类型的,编译期根本发现不了。
比较稳妥的做法是给 agent 的输入输出定义明确的结构。不一定要用很重的 schema 框架,哪怕是一个带字段校验的类,也比裸字典强。核心是要做到三点:字段名固定、类型明确、必填可选区分清楚。这三点做到了,agent 之间的对接就从"靠记忆和文档"变成了"靠类型系统",出错概率会低一个数量级。
我用一个具体的对比来说明。假设一个 agent 的输入是"待处理的数据 + 处理配置",弱契约的写法可能是{"data": ..., "config": ...},强契约的写法会明确成TaskInput(data: List[Record], config: ProcessConfig),其中ProcessConfig又明确定义了timeout、retry、mode这些字段的类型和默认值。后者写起来麻烦一点,但当流程里有十几个 agent 时,这点麻烦会以"少排查无数次字段错位"的形式加倍还回来。
3.2 错误传递:别让异常在层与层之间"变形"
错误处理是代理通信里最容易出问题的地方。一个 agent 内部可能抛出各种异常——网络超时、数据格式错误、权限不足。这些异常如果原样往上抛,agency 层就得认识所有 agent 的所有异常类型,耦合度极高;如果全部吞掉转成一个笼统的"执行失败",agency 层又拿不到足够信息做决策。
我的做法是在契约里定义一个统一的错误结构,包含错误码、错误类别、是否可重试、原始信息四个字段。agent 内部无论抛什么异常,都在边界处转换成这个统一结构。这样 agency 层只需要认识这一种错误结构,就能做出"重试/跳过/中止"的决策。错误类别用来区分是暂时性故障还是永久性故障,是否可重试直接告诉调度层这个错误值不值得再试一次。
这里有个细节值得单独说:原始信息一定要保留。我踩过的坑是,早期为了"统一",把原始异常信息丢掉了,只留了一个错误码。结果线上出问题时,错误码对应的场景有好几种可能,根本定位不到具体原因。后来改成统一结构里带一个raw字段存原始异常,排查效率立刻上来了。统一是为了让调度层好处理,保留原始信息是为了让人好排查,这两件事不矛盾。
3.3 同步还是异步:根据 agent 的实际耗时来定
代理之间是同步调用还是异步调用,这个选择不能拍脑袋,得看 agent 的实际耗时分布。如果所有 agent 都是毫秒级的轻量操作,同步调用最简单,也最好调试。但只要有一个 agent 涉及网络请求或者重计算,耗时到了秒级甚至分钟级,同步调用就会把整个流程卡住。
我的经验判断线是这样的:单个 agent 的 P95 耗时如果超过 500 毫秒,就值得考虑异步化。异步化不一定要上复杂的消息队列,用线程池或者协程就能解决大部分场景。关键是要让 agency 层能够"发起任务后不等结果,先去做别的,等结果回来了再处理"。这样多个互不依赖的 agent 就能真正并行起来。
不过异步化会带来一个新的复杂度:结果回来的顺序和发起的顺序不一致。这时候 agency 层就需要一套机制来匹配"哪个结果对应哪个任务",通常用任务 ID 来关联。这个 ID 在任务发起时生成,随任务一起传给 agent,agent 返回结果时带上。看起来是个小设计,但没有它,异步流程根本没法正确聚合结果。
4. 落地时最容易翻车的几个地方
4.1 重试不是万能药:区分幂等和非幂等操作
重试是调度层最常用的失败处理手段,但它也是最容易被滥用的。我见过一个流程,所有步骤失败都无脑重试三次,结果其中有个"创建订单"的步骤,因为第一次其实已经成功只是响应超时,重试之后创建了两个订单。这种问题在测试环境很难发现,因为测试环境的超时不像线上那么频繁。
核心判断标准是幂等性:这个操作执行一次和执行多次,结果是否一样。查询类操作通常幂等,重试安全;创建、扣款、发送这类操作通常不幂等,重试要格外小心。对于不幂等的操作,要么在业务层做去重(比如用唯一请求 ID),要么干脆不自动重试,失败就交给人工或补偿流程。
我在设计 agent 的时候,会强制要求每个 agent 声明自己是否幂等。这个声明会写进 agent 的元信息里,agency 层在做重试决策时读取这个标记。不幂等的 agent,即使配置了重试次数,调度层也会忽略,直接走失败分支。这个约束一开始团队里有人觉得麻烦,但出过一次重复扣款的事故之后,所有人都认可了这个设计的价值。
4.2 超时设置:全局超时和单步超时要分开
超时设置有个常见的错误做法:只设一个全局超时,比如整个流程 30 分钟。这个设置的问题是,当流程卡住时,你只知道"整体超时了",但不知道卡在哪一步。排查的时候只能靠日志一点点翻,效率极低。
正确的做法是两层超时:每个 agent 有自己的单步超时,agency 层有整体超时。单步超时到了,agency 层能立刻知道是哪个 agent 卡住了,可以针对性地处理(重试、跳过或中止)。整体超时作为兜底,防止某个 agent 的单步超时设置得过长导致整体失控。
单步超时设多少合适?我的经验是取该 agent 正常耗时的 P99 再乘以 1.5 到 2 倍。设得太紧,正常波动就会触发超时;设得太松,卡住的时候要等很久才发现。这个值最好能通过配置调整,而不是硬编码,因为线上流量变化时可能需要临时调大。
4.3 日志和追踪:没有任务 ID,排查就是大海捞针
多代理流程的日志如果不好好设计,排查问题会非常痛苦。因为多个 agent 可能并行执行,日志交错在一起,你根本分不清哪几行属于同一次任务执行。
解决办法是给每次流程执行分配一个全局唯一的 trace ID,这个 ID 会贯穿整个流程,所有 agent 的日志都带上它。这样排查时只要 grep 这个 ID,就能把一次完整执行的所有日志串起来。如果条件允许,再给每个 agent 的执行分配一个 span ID,就能看出每个 agent 的耗时分布,定位性能瓶颈。
这个设计我在项目里是强制要求的,agent 的日志接口必须接受 trace ID 参数。一开始有人嫌传参麻烦,但当线上出问题、别人还在翻日志的时候,你能靠一个 ID 五分钟定位到根因,就会觉得这点麻烦完全值得。
5. 性能与可维护性的平衡:几个实战取舍
5.1 并行度不是越高越好
agency 层支持并行执行之后,很容易产生一种"能并行就并行"的冲动。但实际上并行度太高会带来几个问题:下游服务可能被打爆、资源竞争导致单个任务变慢、失败时的清理逻辑变复杂。我一般会把并行度控制在一个合理范围,比如同时最多跑 4 到 8 个 agent,具体数字根据下游服务的承载能力来定。
这个上限最好做成可配置的,并且配合一个简单的限流机制。当并行任务数达到上限时,新的任务排队等待,而不是无限制地开新线程。这样既能利用并行带来的吞吐提升,又不会因为瞬时压力把下游打挂。
5.2 agent 的粒度:太粗和太细都不好
agent 拆得多细,是个需要权衡的问题。拆得太粗,一个 agent 干太多事,复用性差,而且失败时不好定位是哪部分出的问题;拆得太细,agent 数量爆炸,调度开销和通信开销都会上升,而且流程配置会变得非常冗长。
我的经验法则是:一个 agent 对应一个"语义上完整且可独立测试"的动作。比如"从数据源拉取原始数据"是一个 agent,"把原始数据清洗成标准格式"是另一个 agent,这两个动作各自完整、可以单独测试,就适合拆开。但"清洗数据"内部如果又分成"去重""补全""格式化"三个子步骤,除非这三个子步骤有独立的复用需求,否则没必要拆成三个 agent,放在一个 agent 内部作为私有方法就够了。
5.3 配置化还是代码化:流程定义放哪里
流程的定义(有哪些步骤、依赖关系如何、失败策略是什么)放在代码里还是配置文件里,这也是个常见纠结。放代码里,类型安全、调试方便,但改流程要重新部署;放配置里,改流程不用动代码,但配置本身容易写错,而且复杂流程的配置可读性很差。
我目前的倾向是混合:流程的骨架用代码定义,具体的参数用配置。比如"先抓取、再清洗、再校验、最后发送"这个骨架写在代码里,而每个步骤的超时、重试次数、并发数这些参数从配置文件读。这样既保证了流程结构的清晰和类型安全,又保留了运行时调整参数的灵活性。纯配置驱动的流程引擎看起来很美好,但实际用起来,复杂流程的配置文件往往会变成另一种形式的"代码",而且是没有类型检查、没有 IDE 支持的那种,维护成本反而更高。
6. 我在实际项目里踩过的几个具体坑
6.1 状态持久化的时机:别等流程结束才写
早期我做状态管理时,习惯在流程全部结束后统一写一次状态。这个做法在流程顺利时没问题,但一旦流程中途崩溃,状态就完全丢失了,重启后只能从头再来。后来改成每个步骤执行完就立刻持久化一次状态,虽然写操作变多了,但换来的是"任何时刻崩溃都能从断点恢复"的能力。
写频率上去了,性能会不会受影响?实测下来,只要不是极端高频的流程,每步写一次的开销完全可以接受。如果确实有性能压力,可以做一个折中:状态先写内存,定期批量刷盘,同时保证关键节点(比如不可逆操作之前)强制刷盘。这样在性能和可靠性之间取一个平衡。
6.2 agent 的初始化开销:别在每次执行时重复初始化
有些 agent 在执行前需要做一些初始化工作,比如建立连接、加载模型、读取配置。如果每次执行都重新初始化,开销会很大。我踩过的坑是一个涉及模型加载的 agent,每次执行都重新加载一遍,单次执行时间从几百毫秒涨到了好几秒。
解决办法是把 agent 设计成可以复用的实例,初始化只做一次,之后的执行共享这个实例。这要求 agent 内部不能保存和单次执行相关的状态,所有执行相关的数据都通过输入参数传入。这个约束和前面说的"agent 应该保持无知"是一致的,本质上都是为了让 agent 无状态化,从而可以安全复用。
6.3 依赖版本:agent 之间的隐性耦合
当 agent 数量多了之后,一个容易被忽视的问题是 agent 之间的隐性依赖。比如 agent A 和 agent B 都依赖某个公共库,如果这个库升级了不兼容的版本,可能只有其中一个 agent 出问题,而且报错信息往往指向库内部,很难联想到是版本问题。
我的做法是给每个 agent 明确声明自己的依赖,并且在部署时尽量做到依赖隔离。如果做不到完全隔离,至少要在升级公共依赖时,把所有依赖它的 agent 都跑一遍回归测试。这个成本不低,但比线上出问题时临时排查要划算得多。
7. 关于这套架构,我最后想说的几点体会
agency-agents 这类分层架构,最大的价值不在于它用了多先进的技术,而在于它把"调度"和"执行"这两件本质不同的事分开了。分开之后,调度层可以专注于流程的正确性和健壮性,执行层可以专注于单个动作的可靠性,两边的复杂度都被控制在各自能驾驭的范围内。
但分层不是免费的。它引入了额外的抽象、额外的通信开销、额外的调试复杂度。所以我的建议是:不要为了分层而分层。如果你的流程只有三五个步骤,而且短期内不会增长,那一个大函数可能就是最合适的选择。只有当流程的复杂度真的到了"控制流和业务逻辑互相纠缠、改一处动全身"的程度,分层才划算。
另外一点体会是,这套架构的成败很大程度上取决于契约设计得好不好。契约清晰,agent 就能像积木一样自由组合;契约模糊,分层反而会让问题更难定位,因为错误可能在层与层之间被"消化"掉,等你发现的时候已经离源头很远了。所以如果你打算做这类系统,我建议把最多的精力花在定义输入输出结构和错误结构上,这部分做扎实了,后面的实现都是水到渠成的事。
最后分享一个我一直在用的小技巧:在开发阶段,给 agency 层加一个"干跑"模式,只走调度逻辑不真正执行 agent,把每一步的输入输出和决策过程打印出来。这个模式在调试复杂流程时特别好用,能让你在不产生任何副作用的情况下,看清楚整个流程是怎么走的。等流程逻辑确认无误了,再关掉干跑模式真正执行。这个习惯帮我省下了无数次"因为流程配置写错而误触发真实操作"的麻烦。