ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

多Agent协作实战:A2A架构设计与记忆管理避坑指南

多Agent协作实战:A2A架构设计与记忆管理避坑指南

1. 从一个真实请求说起:为什么单Agent不够用了

假设你对着一个智能助手说:“帮我查一下上季度华东区的销售数据,做个同比分析,然后生成一份PPT,顺便把关键结论发邮件给相关负责人。”这句话里其实藏了至少四个独立任务:数据查询、统计分析、文档生成、消息发送。如果只用一个Agent去扛,它得同时具备数据库查询能力、数据分析能力、PPT排版能力、邮件协议对接能力,还要在一条推理链里把所有事串起来。这种“全能型Agent”在Demo阶段看着很美好,一旦落到真实业务里,维护成本会指数级上升。

我最早接触多Agent协作是在一个内部知识管理项目里。当时的需求是让系统自动处理用户提交的各种格式文档,提取关键信息,分类归档,再根据内容生成摘要推送给不同团队。一开始我们写了一个巨大的单体Agent,提示词塞了三千多字,工具挂了十几个。结果就是:它经常在“该调哪个工具”这一步就犯迷糊,偶尔还会把归档操作和推送操作搞混。后来拆成三个各司其职的Agent——一个负责解析文档,一个负责分类决策,一个负责通知分发——问题立刻少了一大半。

这就是A2A(Agent to Agent)要解决的核心问题:当任务复杂度超过单个Agent的合理承载范围时,如何让多个Agent各司其职、高效协作。A2A不是某个具体的框架或协议,而是一套设计思路,涵盖Agent怎么选、怎么分工、怎么通信、状态和记忆放在哪里。它适合那些正在从“玩具级Agent”向“生产级Agent系统”过渡的开发者,也适合想理解多Agent协作底层逻辑的技术管理者。

这篇文章我会沿着一条完整的请求链路,把A2A涉及的几个关键决策点逐一拆开讲。每个决策点我都会说清楚:当时面临什么选择、为什么选了这个方案、实际跑下来效果如何、踩过哪些坑。你不需要认同我的所有选择,但至少能拿到一套可参考的决策框架。

2. Agent选型:什么活该交给什么Agent

2.1 先搞清楚Agent的能力边界在哪里

选Agent的第一步不是看市面上有哪些框架,而是先把自己的任务拆清楚。我习惯用一个很简单的方法:把整个请求拆成原子任务,然后给每个原子任务打两个标签——确定性和交互复杂度。

确定性指的是这个任务的输出是否可预期。比如“从数据库查一条记录”确定性很高,“判断一段文本的情感倾向”确定性中等,“根据模糊需求生成一份报告”确定性很低。交互复杂度指的是这个任务需要和外部系统打交道的次数和深度。查一次数据库算低,需要多轮API调用加结果校验算高。

把任务按这两个维度分完,你大概能得到四类:

类型确定性交互复杂度适合的Agent形态
工具型高低函数调用型Agent,甚至直接写死逻辑
流程型高高带状态机的Workflow Agent
推理型低低纯LLM推理Agent,不需要挂太多工具
协作型低高需要多Agent协商的复杂Agent

这个分类不是绝对的,但能帮你快速判断:哪些任务根本不需要“智能”,直接写代码就行;哪些任务需要LLM的推理能力;哪些任务需要多个Agent来回商量。

2.2 工具型Agent:能写死就别让LLM猜

我见过很多项目犯的一个通病:明明是一个固定的数据查询逻辑,非要用LLM去理解用户意图再生成SQL。结果就是用户换个说法,LLM就生成一条错误的SQL,然后整个链路崩掉。

工具型Agent的核心原则是:LLM只负责参数提取,不负责逻辑决策。比如用户说“查一下上个月华东区的销售数据”,LLM只需要提取出时间范围=上个月、区域=华东、指标=销售数据这三个参数,然后调用一个预先写好的查询函数。查询逻辑本身是确定的,不需要LLM参与。

这样做的好处很明显:稳定、可测试、可监控。你可以给这个查询函数写单元测试,可以加日志,可以在参数提取失败时给出明确的错误提示。如果让LLM直接生成SQL,你连测试用例都不好写。

实操心得:工具型Agent的提示词要尽可能短,只保留参数提取的指令和输出格式要求。提示词越长,LLM越容易“自由发挥”,反而降低稳定性。

2.3 推理型Agent:给它思考的空间,但别让它碰执行

推理型Agent适合处理那些没有标准答案、需要结合上下文判断的任务。比如“判断这份合同里有哪些风险条款”,或者“根据用户的历史行为推荐下一步操作”。这类任务的特点是:输入是非结构化的,输出也是非结构化的,中间需要LLM的推理能力。

但推理型Agent有一个很大的风险:它容易“想太多”。如果你给它挂了工具,它可能会在推理过程中突然决定去调用某个工具,然后基于工具返回的结果继续推理,最后得出一个你完全没预期的结论。这种不确定性在Demo里看着很智能,在生产环境里就是灾难。

我的做法是:推理型Agent只负责输出结构化的推理结果,不直接执行任何操作。比如让它输出一个JSON,里面包含风险等级、风险描述、建议操作,然后由下游的流程型Agent去决定要不要执行、怎么执行。这样既保留了LLM的推理能力,又把执行权收回到可控的流程里。

2.4 协作型Agent:什么时候真的需要多个Agent商量

协作型Agent是最复杂的一类,也是A2A真正发挥价值的地方。但我要先泼一盆冷水:大部分场景下你不需要协作型Agent。很多开发者一上来就搞三四个Agent互相聊天,结果发现它们要么陷入无限循环,要么互相推诿,要么给出互相矛盾的结论。

真正需要协作型Agent的场景通常满足两个条件:第一,任务没有唯一的正确路径,需要根据中间结果动态调整策略;第二,不同子任务之间需要共享上下文,且共享的内容会随着推理过程变化。

举个例子:一个智能客服系统,用户的问题可能涉及订单查询、退换货政策、物流跟踪三个领域。如果用户的问题很明确,直接路由到对应的工具型Agent就行。但如果用户的问题是“我上周买的那个东西到现在还没到,我想退了重新买一个”,这就涉及订单查询、物流跟踪、退换货政策、重新下单四个环节,而且中间可能需要根据物流状态决定是否真的要走退换货流程。这种场景下,一个协调者Agent加几个执行者Agent的协作模式就比较合适。

3. 协作机制:Agent之间怎么说话、怎么分工

3.1 通信协议:别让Agent用自然语言互相聊天

我见过最离谱的多Agent实现,是让两个Agent用自然语言互相发消息,然后另一个Agent去解析这些消息。这种做法的稳定性极差,因为自然语言有歧义,LLM每次生成的表述可能都不一样,下游Agent解析起来很容易出错。

更可靠的做法是:Agent之间的通信使用结构化消息。消息体至少包含以下几个字段:

{ "sender": "agent_a", "receiver": "agent_b", "task_id": "task_001", "action": "request_data", "payload": { "query": "华东区上季度销售数据", "format": "json" }, "timestamp": "2025-01-15T10:30:00Z" }

这样做的好处是:消息可校验、可日志、可重放。你可以给消息定义严格的Schema,任何不符合Schema的消息直接拒绝,避免下游Agent收到乱七八糟的输入。同时,结构化消息也方便你做链路追踪,出问题时能快速定位是哪个环节出了错。

注意事项:结构化消息的Schema不要设计得太复杂。我见过一个项目定义了二十多个字段,结果每个Agent都要花大量时间在填充和校验消息上,反而降低了效率。通常五到八个核心字段就够了。

3.2 分工模式:协调者模式和流水线模式

多Agent协作的分工模式主要有两种:协调者模式和流水线模式。

协调者模式是一个中心化的Agent负责拆解任务、分配任务、汇总结果,其他Agent只负责执行。这种模式的好处是控制力强,协调者可以根据中间结果动态调整任务分配。缺点是协调者容易成为瓶颈,而且协调者本身的提示词设计很关键,它需要理解全局任务,还要知道每个执行者Agent的能力边界。

流水线模式是任务按固定顺序依次经过多个Agent,每个Agent处理完自己的部分就传给下一个。这种模式的好处是简单、可预测、容易调试。缺点是灵活性差,如果中间某个环节的输出不符合预期,整个流水线可能就卡住了。

我的经验是:先用流水线模式跑通,再根据实际需求决定是否引入协调者。很多任务其实用流水线就能解决,强行上协调者反而增加了复杂度和不确定性。

3.3 任务分配:怎么决定谁干什么

任务分配的核心问题是:协调者怎么知道该把任务分配给谁?有两种常见做法。

一种是能力注册:每个执行者Agent在启动时向协调者注册自己的能力描述,协调者根据能力描述来匹配任务。这种做法的好处是动态性强,新增Agent不需要改协调者的代码。缺点是能力描述本身可能不准确,而且协调者需要额外的逻辑来判断“这个任务到底需要什么能力”。

另一种是硬编码路由:协调者里直接写死什么任务交给什么Agent。这种做法简单直接,适合Agent数量少、任务类型固定的场景。缺点是扩展性差,新增Agent需要改协调者的代码。

我目前更倾向于一种混合做法:用能力注册做粗筛,用硬编码路由做精筛。协调者先根据能力描述筛选出候选Agent,然后根据预定义的路由规则决定最终分配给谁。这样既保留了一定的动态性,又避免了完全依赖能力描述带来的不确定性。

3.4 冲突处理:当两个Agent给出不同结论时怎么办

多Agent协作中一个很现实的问题是:两个Agent对同一件事给出了不同的结论。比如一个Agent说“这个订单可以退”,另一个Agent说“这个订单不符合退货条件”。这时候怎么办?

我的做法是引入一个仲裁机制。仲裁者可以是一个独立的Agent,也可以是一段确定性代码。如果是确定性代码,就预先定义好优先级规则,比如“政策类Agent的结论优先于推荐类Agent的结论”。如果是仲裁Agent,就让它根据两个结论的推理过程和依据来做判断。

但我要提醒一点:仲裁机制不要设计得太复杂。我见过一个项目搞了三层仲裁,结果仲裁本身成了最大的不确定性来源。大多数情况下,简单的优先级规则就够了。如果两个Agent的结论经常冲突,那说明任务拆分本身有问题,应该回头去调整分工,而不是在仲裁上堆逻辑。

4. 记忆管理:状态放在哪里、怎么共享

4.1 记忆的分类:短期记忆、长期记忆、共享记忆

在多Agent系统里,记忆管理是最容易被忽视、但出问题最多的环节。我习惯把记忆分成三类:

短期记忆是单个Agent在一次任务执行过程中的上下文。比如用户说了什么、Agent调用了哪些工具、工具返回了什么结果。这类记忆通常放在Agent的上下文窗口里,任务结束就丢弃。

长期记忆是跨任务持久化的信息。比如用户的历史偏好、之前处理过的类似案例、领域知识库。这类记忆需要外部存储,Agent在需要时主动查询。

共享记忆是多个Agent之间共享的上下文。比如协调者拆解任务时产生的任务描述、执行者Agent的中间结果、最终汇总的输出。这类记忆需要放在一个所有Agent都能访问的地方,同时要处理好并发读写的问题。

4.2 短期记忆:上下文窗口不是越大越好

很多开发者有一个误区:既然LLM的上下文窗口越来越大,那就把所有东西都塞进去。实际跑下来你会发现,上下文越长,LLM的注意力越容易分散,关键信息反而被淹没了。

我的做法是:短期记忆只保留当前任务必需的信息。具体来说,每个Agent的上下文里只放三样东西:当前任务的描述、最近一次工具调用的结果、以及一个精简的历史摘要。历史摘要由Agent自己生成,只保留关键决策点和结论,不保留完整的对话记录。

实操心得:我通常会给每个Agent设置一个上下文预算,比如4000个token。当上下文接近预算时,触发一次摘要压缩,把之前的对话压缩成一段简短的摘要。这样既能保留关键信息,又不会让上下文无限膨胀。

4.3 长期记忆:向量数据库不是唯一选择

提到长期记忆,很多人第一反应是向量数据库。向量数据库确实适合存储非结构化的文本记忆,但它不是唯一选择,也不一定是最优选择。

对于结构化的长期记忆,比如用户ID、订单号、历史操作记录,用传统的关系型数据库或者键值存储反而更合适。查询速度快、一致性有保障、运维成本低。我见过一个项目把所有记忆都塞进向量数据库,结果查一个订单状态还要做语义检索,延迟高得离谱。

我的建议是:根据记忆的类型选择存储方案。结构化数据用关系型数据库,非结构化文本用向量数据库,频繁访问的热数据加一层缓存。不要为了技术上的“统一”而牺牲实际性能。

4.4 共享记忆:并发读写怎么保证一致性

共享记忆最大的挑战是并发读写。多个Agent可能同时读取同一份共享记忆,也可能同时写入。如果不加控制,就会出现数据不一致的问题。

我的做法是引入一个记忆管理器,所有对共享记忆的读写都通过它来进行。记忆管理器负责加锁、版本控制、冲突检测。具体来说:

  • 读操作:记忆管理器返回当前版本的数据,并记录读取者。
  • 写操作:记忆管理器检查版本号,如果版本号与读取时不一致,拒绝写入并通知Agent重新读取。
  • 冲突处理:如果两个Agent同时写入,后写入的Agent需要先合并前一个Agent的修改,再提交。

这套机制听起来简单,但实际实现时要注意锁的粒度。锁太粗会影响并发性能,锁太细会增加死锁风险。我通常按任务ID来加锁,同一个任务内的共享记忆用同一把锁,不同任务之间互不影响。

4.5 记忆的生命周期管理

记忆不是越多越好。随着系统运行时间增长,记忆会不断累积,如果不做清理,不仅存储成本上升,检索效率也会下降。

我通常会给记忆设置一个生命周期策略:

  • 短期记忆:任务结束后立即清除。
  • 共享记忆:任务结束后保留一段时间(比如24小时),用于问题排查和审计,然后归档或删除。
  • 长期记忆:根据业务需求设置保留期限,比如用户偏好保留一年,操作日志保留三个月。

同时,我会定期对长期记忆做一次“记忆整理”,把相似的记忆合并,把过期的记忆标记为失效,把高频访问的记忆提升到更快的存储层。这个过程可以离线做,不影响在线服务。

5. 完整链路拆解:一次请求的A2A之旅

5.1 请求入口:意图识别与任务拆解

用户发来请求后,第一个接触请求的Agent是入口Agent。它的职责很单一:判断用户意图,把请求拆解成结构化的任务列表。

比如用户说“帮我查一下上季度华东区的销售数据,做个同比分析,然后生成一份PPT,顺便把关键结论发邮件给相关负责人”,入口Agent的输出应该是这样的:

{ "intent": "sales_report", "tasks": [ {"id": "t1", "type": "data_query", "params": {"region": "华东", "period": "上季度", "metric": "销售数据"}}, {"id": "t2", "type": "data_analysis", "params": {"analysis_type": "同比", "depends_on": ["t1"]}}, {"id": "t3", "type": "document_generation", "params": {"format": "ppt", "depends_on": ["t2"]}}, {"id": "t4", "type": "notification", "params": {"channel": "email", "recipient": "相关负责人", "depends_on": ["t2"]}} ] }

入口Agent的提示词要尽可能聚焦在意图识别和任务拆解上,不要让它去执行任何具体任务。我见过一些实现让入口Agent直接去查数据,结果它查完数据就忘了后面还要生成PPT。

5.2 任务调度:谁来决定执行顺序

任务拆解完成后,调度Agent接手。调度Agent根据任务之间的依赖关系决定执行顺序。上面的例子中,t1没有依赖,可以立即执行;t2依赖t1,必须等t1完成;t3和t4都依赖t2,可以并行执行。

调度Agent还需要处理异常情况。比如t1执行失败了,t2、t3、t4都应该被标记为“阻塞”,而不是继续执行。调度Agent要能识别这种依赖失败,并决定是重试t1还是直接终止整个流程。

注意事项:调度Agent的重试策略要谨慎设计。我通常只对幂等操作做自动重试,比如数据查询。对于非幂等操作,比如发送邮件,重试前必须确认上一次是否真的失败了,否则可能发出两封重复邮件。

5.3 执行阶段:各Agent如何配合

执行阶段是A2A系统最热闹的时候。以t1为例,调度Agent把任务分配给数据查询Agent。数据查询Agent从共享记忆里读取任务参数,调用数据库查询工具,拿到结果后写入共享记忆,然后通知调度Agent“t1完成”。

t2依赖t1,调度Agent收到t1完成的通知后,把t2分配给数据分析Agent。数据分析Agent从共享记忆里读取t1的结果,执行同比分析,把分析结果写入共享记忆。

t3和t4可以并行。调度Agent同时把t3分配给文档生成Agent,把t4分配给通知Agent。文档生成Agent从共享记忆里读取t2的分析结果,生成PPT文件,把文件路径写入共享记忆。通知Agent从共享记忆里读取t2的分析结果,生成邮件内容,调用邮件发送工具。

整个过程中,共享记忆是各Agent之间传递数据的唯一通道。Agent之间不直接通信,所有数据交换都通过共享记忆完成。这样做的好处是解耦彻底,每个Agent只需要关心自己的输入和输出,不需要知道上游是谁、下游是谁。

5.4 结果汇总:谁来生成最终回复

所有任务完成后,汇总Agent负责生成最终回复。汇总Agent从共享记忆里读取所有任务的执行结果,整理成用户可读的格式,返回给用户。

汇总Agent的提示词要强调“简洁”和“重点突出”。用户不需要知道中间过程发生了什么,只需要知道最终结果是什么。比如上面的例子,汇总Agent的回复可能是:“上季度华东区销售数据已完成同比分析,PPT已生成并保存,关键结论已发送至相关负责人邮箱。”

如果中间有任务失败,汇总Agent要如实告知用户,并给出建议。比如“数据查询失败,可能是数据库连接问题,建议稍后重试”。

6. 常见问题与排查技巧实录

6.1 Agent之间消息丢失或重复

现象:调度Agent发出任务后,执行Agent没有收到,或者收到了两次。

排查思路:首先检查消息队列的配置。如果用的是内存队列,进程重启会导致消息丢失。如果用的是持久化队列,检查消费者的确认机制是否正确。重复消息通常是确认机制的问题,比如消费者处理完消息后没有及时确认,导致消息被重新投递。

解决方法:使用持久化消息队列,确保消息不丢失。消费者端实现幂等处理,即使收到重复消息也不会重复执行。给每条消息加唯一ID,消费者记录已处理的消息ID,遇到重复ID直接跳过。

6.2 共享记忆读写冲突

现象:两个Agent同时读写共享记忆,导致数据不一致。

排查思路:检查共享记忆的读写是否加了锁。如果没有加锁,或者锁的粒度不对,就会出现冲突。另外检查版本控制机制是否生效,写入时是否检查了版本号。

解决方法:引入记忆管理器统一管理读写,按任务ID加锁,实现乐观锁的版本控制。写入时如果版本号不匹配,拒绝写入并通知Agent重新读取。

6.3 Agent陷入无限循环

现象:两个Agent互相发消息,谁也不肯结束。

排查思路:检查Agent的终止条件是否明确。如果Agent的提示词里没有明确的“任务完成”判断逻辑,它可能会一直等待下游的反馈。另外检查消息的跳数限制,如果没有限制,两个Agent可能无限循环。

解决方法:给每个任务设置最大跳数限制,比如10跳。超过限制后强制终止,并记录错误日志。Agent的提示词里要明确“当你完成XX操作后,任务即告完成,不需要等待进一步指令”。

6.4 上下文膨胀导致响应变慢

现象:随着任务执行,Agent的响应时间越来越长。

排查思路:检查Agent的上下文窗口是否被塞满了。如果每次工具调用的结果都完整保留在上下文里,上下文会迅速膨胀。

解决方法:实现上下文压缩机制,当上下文接近预算时触发摘要。工具调用的结果只保留关键字段,不要保留完整响应。历史对话压缩成简短摘要,只保留关键决策点和结论。

6.5 某个Agent频繁失败

现象:某个Agent的执行成功率明显低于其他Agent。

排查思路:先看这个Agent的提示词是否过于复杂。提示词越长,LLM越容易出错。再看这个Agent挂载的工具是否稳定,工具的超时设置是否合理。最后看这个Agent的输入是否规范,上游Agent是否传入了不符合Schema的数据。

解决方法:简化提示词,只保留必要的指令。给工具调用设置合理的超时和重试策略。在Agent入口处加数据校验,拒绝不符合Schema的输入。

问题类型典型现象排查方向解决手段
消息丢失任务卡住不执行消息队列配置持久化队列+幂等消费
读写冲突数据不一致锁机制和版本控制记忆管理器+乐观锁
无限循环任务永不结束终止条件和跳数限制最大跳数+明确终止条件
上下文膨胀响应变慢上下文窗口大小摘要压缩+精简工具结果
Agent频繁失败成功率低提示词和工具稳定性简化提示词+超时重试

6.6 独家避坑技巧

技巧一:给每个Agent起一个有意义的名字。不要用agent1、agent2这种命名,用data_query_agent、analysis_agent这种描述性名字。出问题时看日志能快速定位是哪个环节。

技巧二:在共享记忆里记录每个任务的执行耗时。这样你能快速发现哪个环节是瓶颈。我通常会在任务完成后记录开始时间、结束时间、耗时,汇总时按耗时排序,一眼就能看出哪个Agent最慢。

技巧三:给Agent的提示词加一个“自我检查”步骤。让Agent在输出结果前先检查一遍:参数是否完整、格式是否符合要求、是否有明显错误。这个简单的步骤能减少很多低级错误。

技巧四:不要迷信“全自动”。有些关键决策点,比如发送邮件、修改数据,最好加一个人工确认环节。我见过一个项目因为Agent误判,把内部测试数据发给了客户,造成了很尴尬的局面。

技巧五:定期做“混沌测试”。故意让某个Agent失败、让消息延迟、让共享记忆不可用,观察系统的表现。这样能在真实故障发生前发现薄弱环节。

7. 我个人的一些实践体会

跑过几个A2A项目之后,我最大的体会是:多Agent系统的复杂度不是线性增长的,而是指数增长的。两个Agent协作的复杂度可能是单Agent的三倍,三个Agent可能是九倍。每增加一个Agent,你就要考虑它和其他所有Agent的交互、它和共享记忆的交互、它在异常情况下的行为。

所以我的建议是:能用单Agent解决的就不要用多Agent,能用两个Agent解决的就不要用三个。多Agent的价值在于分工带来的专业化和可维护性,但如果任务本身不复杂,强行拆分反而得不偿失。

另一个体会是:记忆管理比Agent本身更重要。我见过太多项目把精力花在Agent的提示词调优上,结果共享记忆一团糟,Agent之间传的数据格式不统一、版本不一致、过期数据没清理。最后Agent再聪明也跑不起来。把记忆管理做好,Agent的提示词反而可以写得很简单。

最后分享一个小技巧:在开发阶段给每个Agent加一个“调试模式”。开启调试模式时,Agent会把完整的输入、输出、中间推理过程都写到日志里。这样出问题时你能看到Agent到底“想”了什么,而不是只看到一个失败的结果。这个技巧帮我省了很多排查时间。

这个方向后续还可以往几个方向扩展:一是引入更细粒度的权限控制,不同Agent只能访问自己权限范围内的记忆;二是做Agent的性能画像,根据历史执行数据动态调整任务分配;三是探索跨系统的A2A协作,让不同团队开发的Agent能够互相发现和调用。这些方向我还在摸索中,有新的进展再和大家分享。

RELATED READING

延伸阅读

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