ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

agency-agents 多智能体协同框架:设计逻辑、实现与避坑指南

agency-agents 多智能体协同框架:设计逻辑、实现与避坑指南

1. 从“agency-agents”这个名字说起:它到底在解决什么问题

第一次看到agency-agents这个命名,我的直觉是:这大概率是一个围绕“代理(agent)”概念构建的框架或工具集,而且前缀agency暗示了它和“代理机构”“中介层”“任务委托”这类语义强相关。在软件工程语境里,agent 这个词已经被用得很泛了——它可以是设计模式里的代理对象,可以是分布式系统里的执行节点,也可以是当下热门的智能体(intelligent agent)。但agency-agents这个组合词,把“代理能力”和“代理实体”两个层面叠在了一起,指向的应该是一套让多个 agent 协同完成任务的机制。

我之所以对这个方向感兴趣,是因为在实际项目里,我们经常遇到一类尴尬场景:单个模块能跑通,但一旦要把多个独立逻辑串成一条完整链路,代码就开始变得又臭又长。比如一个数据处理流程,需要先拉取、再清洗、再校验、再入库,每一步其实都可以独立存在,但硬写在一个函数里就变成了几百行的面条代码。agency-agents这类思路的价值,就是把这些独立步骤抽象成一个个可复用的 agent,让它们各自负责一块,再通过一个调度层把它们组织起来。

从关键词和摘要描述为空这一点来看,这个项目可能还处于早期阶段,或者它的定位本身就是“概念先行”——名字即文档。这种情况下,我更需要从命名本身去反推它的设计意图。agency在英文里除了“代理机构”,还有“能动性”“作用力”的意思。把这两个词放在一起,我倾向于理解为:这是一个赋予 agent 自主行动能力的框架,或者说是一个管理 agent 行为的中介层。

适合谁来参考这篇内容?如果你正在做多步骤任务编排、需要把零散逻辑模块化、或者对 agent 协同机制感兴趣,那接下来的拆解会对你有帮助。如果你只是想知道一个具体 API 怎么调,那可能需要等这个项目更成熟之后再看。我下面要做的,是基于这个命名和常见工程实践,把这类项目最可能涉及的核心设计、实操要点和踩坑经验梳理清楚。

2. 拆解 agency-agents 的核心设计逻辑

2.1 为什么是“agency”而不是“manager”或“orchestrator”

很多人做任务编排时,第一反应是起名叫TaskManager或WorkflowOrchestrator。这两个名字都没问题,但它们隐含了一种“中心化控制”的语义——有一个总控在发号施令。而agency这个词的选择,暗示了设计者可能更倾向于“赋权”而非“控制”。换句话说,每个 agent 不是被动等待调用的函数,而是拥有一定自主决策能力的实体。

这个区别在实际编码中会体现得很明显。如果是 manager 模式,你可能会写:

manager = TaskManager() manager.register("fetch", fetch_func) manager.register("clean", clean_func) manager.run("fetch") manager.run("clean")

而 agency 模式下,更可能是:

fetch_agent = FetchAgent() clean_agent = CleanAgent() fetch_agent.delegate_to(clean_agent) fetch_agent.act()

前者是你告诉系统“先做这个再做那个”,后者是你告诉 agent“你做完之后把结果交给下一个”。这个差异看似微小,但在复杂链路里,agency 模式能让每个 agent 自己决定下一步走向,而不是把所有路由逻辑都堆在中心调度器里。

我个人的经验是,当任务步骤超过 5 个、且存在条件分支时,agency 模式的可维护性会明显优于中心化编排。因为每个 agent 只需要关心自己的输入和输出,以及“什么条件下该把控制权交给谁”,而不需要知道全局流程。

2.2 agent 之间的通信契约怎么定

不管框架怎么设计,agent 之间要能协作,就必须有一个通信契约。这个契约通常包含三部分:数据格式、调用协议、错误传递方式。

数据格式方面,最常见的是字典或 JSON 对象。每个 agent 接收一个上下文对象,处理后返回一个修改过的上下文对象。这样做的好处是扩展性强——新增一个字段不会破坏已有 agent 的接口。但缺点是类型不安全,容易在运行时才发现字段缺失。

调用协议方面,同步调用最简单,但容易阻塞;异步调用更灵活,但需要处理并发和顺序问题。agency-agents如果定位是轻量级框架,我猜测它会先支持同步调用,再逐步引入异步能力。

错误传递方面,我见过两种做法:一种是抛异常,让上层捕获;另一种是把错误信息也放进上下文对象里,让 agent 自己决定怎么处理。前者适合快速失败场景,后者适合需要容错和重试的场景。从 agency 的“自主性”语义来看,后者可能更契合——每个 agent 应该有能力判断“上一步出错了,我是该重试、跳过还是终止”。

提示:如果你自己在设计类似机制,建议在上下文对象里固定一个_error字段,约定所有 agent 在出错时把错误信息写进去,而不是直接抛异常。这样调用方可以统一处理,也方便做链路追踪。

2.3 生命周期管理:agent 从创建到销毁

一个容易被忽略但很关键的点是 agent 的生命周期。如果每个任务都新建一个 agent 实例,开销可能不大,但如果 agent 需要持有连接池、缓存或其他资源,频繁创建销毁就会成为瓶颈。

常见的做法是引入一个 agent 池或注册中心。agent 在启动时注册自己,运行时从池里获取,用完归还。这样既能复用资源,又能控制并发数量。但这也带来了新问题:agent 的状态怎么管理?如果 agent 是无状态的,池化很简单;如果 agent 需要记住上次执行的结果,池化就需要额外的状态隔离机制。

我倾向于建议:在 agency 模式里,尽量让 agent 保持无状态,所有状态都放在上下文对象里传递。这样 agent 本身可以随意复用,逻辑也更清晰。如果确实需要状态,就把它显式地抽出来,作为一个独立的 state store,而不是藏在 agent 内部。

3. 如果我要动手实现一个 agency-agents 原型

3.1 最小可行骨架的搭建步骤

假设从零开始,我会先定义一个基类BaseAgent,约定所有 agent 必须实现act(context)方法。然后定义一个Agency类,负责注册 agent 和启动流程。代码大概长这样:

class BaseAgent: def act(self, context): raise NotImplementedError class Agency: def __init__(self): self.agents = {} def register(self, name, agent): self.agents[name] = agent def start(self, entry_name, context=None): context = context or {} current = entry_name while current: agent = self.agents[current] result = agent.act(context) current = result.get("next") return context

这个骨架非常简陋,但它把核心逻辑说清楚了:每个 agent 处理完上下文后,返回一个next字段,告诉 agency 下一步该找谁。如果没有next,流程就结束。

接下来我会实现几个具体的 agent 来验证这个骨架。比如一个FetchAgent负责往上下文里塞数据,一个CleanAgent负责清洗数据,一个SaveAgent负责持久化。每个 agent 只做一件事,做完后指定下一个 agent 的名字。

3.2 上下文对象的设计细节

上下文对象是整个流程的血液。我建议至少包含这几个字段:

字段名类型说明
datadict当前流程处理的数据
_errorstr错误信息,为空表示正常
_tracelist执行轨迹,记录经过了哪些 agent
_metadict元信息,如超时时间、重试次数

_trace字段特别有用。当流程出问题时,你可以直接看这个列表,就知道是在哪个 agent 那里卡住的。我试过在复杂链路里不加 trace,结果排查一个问题花了两个小时,后来加上之后,定位时间缩短到几分钟。

_meta字段则用来传递一些控制参数。比如某个 agent 可能需要知道“最多重试几次”,这个信息不应该硬编码在 agent 里,而应该由调用方通过上下文传入。

3.3 条件分支和循环怎么处理

最简单的流程是线性的:A 做完做 B,B 做完做 C。但真实场景里经常需要条件分支:如果数据校验通过就走保存,不通过就走告警。这时候next字段就不能只是一个字符串,而应该支持函数或条件表达式。

一种做法是让 agent 返回一个可调用对象:

def act(self, context): if context["data"]["valid"]: return {"next": "save_agent"} else: return {"next": "alert_agent"}

另一种做法是引入一个路由表,把条件判断抽出来:

agency.add_route("validate_agent", { "valid": "save_agent", "invalid": "alert_agent" })

两种方式各有优劣。前者把路由逻辑放在 agent 内部,agent 更自主,但路由规则分散;后者把路由逻辑集中管理,更清晰,但 agent 的自主性被削弱。我个人的偏好是混合使用:简单的二选一放在 agent 里,复杂的多路分支用路由表。

循环的处理更微妙。如果 A 做完发现需要重做,就指向自己;如果发现需要回到上一步,就指向上一个 agent。但这样容易写出死循环。所以必须在上下文里加一个计数器,限制最大循环次数。

def act(self, context): context["_meta"]["retry_count"] = context["_meta"].get("retry_count", 0) + 1 if context["_meta"]["retry_count"] > 3: return {"next": "fail_agent"} if need_retry(context): return {"next": "self"} return {"next": "next_agent"}

注意:循环控制是 agency 模式里最容易出问题的地方。我踩过的坑是忘了在成功路径上重置计数器,导致一个正常流程跑了三次之后被误判为超限。后来我改成每次成功进入新 agent 时都重置计数器,问题才解决。

4. 实际跑起来之后才会遇到的坑

4.1 agent 之间的数据依赖变成隐式耦合

刚开始用 agency 模式时,我觉得很爽:每个 agent 只关心自己的输入输出,看起来解耦得很干净。但跑了一段时间后发现,agent A 往上下文里塞了一个字段temp_result,agent C 恰好需要这个字段,但 agent B 完全不知道这个字段的存在。结果就是,如果你单独测试 agent C,它永远跑不通,因为它依赖一个上游某个 agent 塞进来的字段。

这种隐式耦合在 agent 数量少的时候不明显,一旦超过十个,就变成了一团乱麻。你根本说不清某个字段是谁塞的、谁改的、谁删的。

我的解决方案是引入一个“字段契约”文档,或者更激进一点,用类型化的上下文对象。每个 agent 声明自己需要哪些字段、会产出哪些字段,agency 在启动前做一次校验,发现缺失就提前报错,而不是等到运行时才崩。

4.2 错误处理策略不统一导致流程卡死

另一个常见问题是错误处理。有的 agent 出错后抛异常,有的 agent 把错误写进上下文继续往下走,有的 agent 直接静默返回。这三种策略混在一起,agency 根本不知道该不该继续。

我后来强制约定:所有 agent 不允许抛异常,必须把错误信息写进context["_error"]。agency 在每次调用 agent 之后检查这个字段,如果有错误,就根据预设策略决定是重试、跳过还是终止。这个约定虽然看起来有点笨,但它让错误处理变得可预测。

result = agent.act(context) if context.get("_error"): if context["_meta"].get("on_error") == "retry": return {"next": current} elif context["_meta"].get("on_error") == "skip": context["_error"] = None return {"next": result.get("next")} else: return {"next": None}

4.3 性能瓶颈往往不在 agent 本身

很多人以为 agency 模式的性能开销在于 agent 之间的调用,但实际上,大部分时间花在 agent 内部的实际业务逻辑上。我做过一次 profiling,发现 agent 调度本身只占了不到 5% 的时间,剩下 95% 都在等数据库、等网络、等文件 IO。

所以优化重点不应该放在“怎么让 agent 调用更快”,而应该放在“怎么让 agent 内部的 IO 更高效”。比如把多个小查询合并成一个大查询,把同步 IO 改成异步 IO,把重复计算的结果缓存起来。

但这里有个陷阱:如果你为了性能把多个 agent 合并成一个,那就破坏了 agency 模式的初衷。我的建议是保持 agent 的粒度不变,但在 agent 内部做优化。比如一个FetchAgent内部可以用连接池、可以批量拉取,但它对外仍然是一个独立的 agent。

5. 从 agency-agents 延伸出去的设计思考

5.1 和消息队列、工作流引擎的边界在哪里

有人可能会问:这东西和消息队列有什么区别?和 Airflow、Prefect 这类工作流引擎有什么区别?

我的理解是,消息队列解决的是“解耦”和“缓冲”问题,它不关心消息被消费后产生什么结果。工作流引擎解决的是“依赖管理”和“调度”问题,它通常有一个中心化的 DAG 定义。而 agency-agents 更强调 agent 的自主性——每个 agent 自己决定下一步走向,而不是由一个外部 DAG 决定。

这个区别在简单场景里不明显,但在需要动态决策的场景里就很关键。比如一个 agent 根据中间结果决定是走快速路径还是慢速路径,这种决策在工作流引擎里需要写条件分支,而在 agency 模式里就是 agent 自己返回不同的next。

当然,agency 模式也有代价:流程不透明。你没法一眼看出整个流程长什么样,因为流程是分散在各个 agent 里的。所以它更适合那些流程本身就不固定、需要根据运行时情况动态调整的场景。

5.2 什么时候不该用 agency 模式

不是所有任务编排都适合 agency 模式。如果你的流程是固定的、线性的、不需要动态决策的,那用简单的函数调用链就够了,引入 agency 只会增加复杂度。

我总结了一个简单的判断标准:

场景特征适合 agency适合简单调用链
步骤数量5 个以上5 个以下
分支条件多且动态少且固定
复用需求高低
团队规模多人协作单人维护
调试需求需要追踪每一步直接看日志即可

如果你的场景落在右边那一列,那就别折腾 agency 了,老老实实写函数调用链,简单直接。

5.3 未来可能的演进方向

从agency-agents这个命名来看,它还有很大的演进空间。我猜测后续可能会往这几个方向走:

一是引入更丰富的 agent 类型,比如支持并行执行的 agent、支持超时控制的 agent、支持优先级调度的 agent。二是增强可观测性,比如内置 trace 收集、性能指标上报、可视化流程展示。三是支持分布式部署,让 agent 可以跑在不同的进程或机器上,通过消息传递来协作。

但这些演进都需要在“自主性”和“可控性”之间做权衡。agent 越自主,流程越灵活,但也越难预测和调试。这是一个永恒的张力,没有标准答案,只能根据具体场景来取舍。

6. 我在实际使用中积累的几个小技巧

第一个技巧是关于 agent 命名的。我建议用“动词+名词”的形式,比如FetchDataAgent、ValidateSchemaAgent、SaveRecordAgent。这样一看名字就知道它做什么,而且按字母排序时相关 agent 会聚在一起。避免用AgentA、AgentB这种命名,过两天你自己都忘了谁是谁。

第二个技巧是关于上下文清理的。每个 agent 处理完后,尽量把中间产物从上下文里删掉,只保留最终需要传递的字段。否则上下文会越来越大,不仅占内存,还会让调试变得困难。我一般会在 agent 的最后一步加一句context["data"].pop("temp_field", None)。

第三个技巧是关于测试的。每个 agent 都应该能独立测试,不依赖其他 agent。这意味着你需要为每个 agent 准备完整的输入上下文,而不是只准备它“应该”用到的那部分。这样做虽然麻烦,但能提前发现隐式依赖问题。

第四个技巧是关于日志的。在 agency 的调度层统一打日志,记录每个 agent 的开始时间、结束时间、输入输出摘要。这样出问题时,你只需要看调度日志就能还原整个流程,而不需要去翻每个 agent 自己的日志。

最后一个技巧是关于版本管理的。agent 的接口一旦确定,就不要轻易改。如果确实需要改,就新增一个 agent,而不是修改已有的。因为修改已有 agent 的接口,可能会影响所有依赖它的流程,而新增 agent 则不会破坏现有链路。这个原则在多人协作时尤其重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进