这年头聊AI协同的不少,但真正把多个AI代理(Agent)替多个用户跑事落地成一套可维护系统的,并不多见。我最近把内部的一套原型重构成了"多人多AI协同"架构,核心思路是让每个AI代理(Agent)通过统一的交互层代表用户和其他AI发起对话、完成任务,而不是让用户在各AI之间手工搬运上下文。这篇文章把系统架构的关键设计、通信协议、状态同步、异构模型接入和踩坑过程整理出来,适合正在做多智能体系统、AI工作流编排,或者被"一堆AI各管一摊、没法协作"折磨过的人参考。
先打个预防针:这篇里说的"代理"都是Agent智能体,不是网络转发那种"代理"服务。我们讨论的是AI怎么替人跑事、AI之间怎么互相配合,不涉及任何网络层面的转发概念。
1. 多AI协同难在哪:先讲清楚"代为交互"解决的真实问题
1.1 用户在不同AI之间手工搬运上下文,是我重构的直接原因
我最早做这个项目,起因很朴素:团队里每个人手头少则两三个、多则五六个AI工具,写方案、查资料、生成图表、审代码各用各的。AI互相之间根本不认识,协作方式停留在人肉中转——从对话A复制结论,粘贴到对话B继续问,再把结果转发给同事。遇到两三个人的小组任务,搬运成本直接指数上升,经常是"A问了一遍、B又问了一遍、C把A和B的结论再喂一遍"。
后来我意识到,真正缺的不是"更多更强的AI",而是让AI代理们彼此能说上话的系统。所谓"代为交互",就是把"人带着上下文跑腿"这件事抽象掉:用户只负责定义目标、约束和验收条件,剩下的事由各个AI代理通过统一的交互协议去接力完成。这个转变不只是省掉复制粘贴,而是把协同的主体从"人"换成了"代理"。
1.2 交互主体变了:从"人围着AI转"变成"代理围着任务转"
传统使用方式里,交互主体是"人-单个AI"。人或主动发起请求,或被动接收结果。而"代为交互"模式下,交互主体有三个层次:
第一个层次是"用户-代理":用户向某个代理下达意图并持续对话,这个代理成为用户侧的会话代言人,负责理解用户、拆解任务、维护这一段对话的目标。
第二个层次是"代理-代理":用户侧代理发现任务需要另一个专业能力,比如画架构图、跑数据、审代码,它会把子任务以格式化消息发出去,其他代理按自身能力应答。这里的关键是,代理之间交互的不再是"聊天记录",而是带明确语义的任务、参数和约束。
第三个层次是"代理-工具/系统":代理调用检索、计算、存储、消息通知等基础能力,把模型输出和真实世界连接起来。
这很像项目会。如果一个需求非得靠参会人全部口头同步才能推进,一旦有人缺席会议就卡死;但如果有个秘书把待办、结论、负责人写进工单,会后每个人照着工单走,推进会顺畅很多。"代为交互"的架构,本质就是给AI协同配上这个工单系统。
1.3 我给自己定的三个设计目标
动手前我列了三条硬性设计目标,后面所有取舍都围绕这三条:
- 透明可观测:任何一条消息,都必须能追溯是哪个用户、经由哪个代理、为什么这样决策。没有可观测性,协同系统排错会是一场灾难。
- 横向可扩展:新接入一个AI模型或新代理,不应该改动其他代理的逻辑,只做注册和配置。
- 有边界的数据隔离:代理代表用户交互时,必须有清晰的数据边界。多用户场景下,A用户会话里的数据不能被B用户随便读到。
这三条目标直接决定了架构的形态。你会发现,后面每个组件几乎都能对应到这三点中的某一条。
2. 总体架构设计:控制面与数据面分离的代理网格
2.1 分层:接入、协同、代理、模型、基础设施
我把整个系统划分成五层。这个分层不是拍脑袋定的,而是按照"谁在跟谁说话"来切:
- 接入层:统一的会话网关,对外提供WebSocket/HTTP接口,处理用户连接、身份认证、会话创建。
- 协同层:包含协同编排器、代理注册中心、路由调度、仲裁组件,是整个系统的核心控制面。
- 代理层:一组常驻的AI代理进程。每个代理有自己的角色描述、任务队列、上下文窗口和工具权限。
- 模型层:统一模型网关,屏蔽后端不同模型的差异,不管是本地私有模型还是云端大模型都走同一套接口。
- 基础设施层:消息总线、状态存储、对象存储、监控链路。
2.2 为什么叫它"代理网格",而不是直连
"网格"不是营销词汇。早期我做的是N对N直连——每个代理持有其他代理的地址,遇到任务直接调用。跑了一个月后问题全暴露了:地址散落、权限无法统一、排错困难、新增代理要改所有人的配置。
后来我把交互关系收敛成"代理网格",核心变化是:
- 代理之间不直接知道对方地址,只通过协同层路由来找到"谁有能力处理这个消息"。
- 每个代理只在注册中心暴露"能力声明",而不是暴露内部实现。
- 所有跨代理消息都走统一的事件总线,控制面和数据面彻底分开。
改完之后,新增一个代理时只要完成注册和配置,其他代理无需感知,这正是"横向可扩展"目标落地的关键。
2.3 控制面与数据面分离,相当于给系统装了交通调度和车道
控制面与数据面分离这个设计,很多人听过但未必理解为什么对AI协同特别重要。我直接用对比表说明:
| 维度 | 控制面 | 数据面 |
|---|---|---|
| 职责 | 路由决策、会话状态、权限仲裁、任务调度 | 消息内容、模型输出、文件、临时结果 |
| 存储 | 尽量无状态,状态放Redis/数据库 | 按需持久化到对象存储或消息队列 |
| 扩展方式 | 横向加实例即可 | 关注吞吐和延迟,可独立扩容 |
| 故障影响 | 控制面故障影响新路由和新任务 | 数据面故障影响消息传输,但不影响控制面逻辑 |
这样设计有个直观代价:控制面会多一跳,初期看起来"绕路"。但换来两个非常实在的好处。
第一,控制面逻辑可以做到幂等。协同层是无状态的,请求打到任意实例结果都一样,横向扩缩容不需要迁移会话状态。
第二,消息风暴不会直接把控制面打挂。即使某个代理在疯狂产出消息,数据面队列把消息兜住,控制面按规则限流和调度,而不是被拖进同一个进程一起死。
这个思路和分布式交换机非常像——控制协议和数据帧分离,交换机才能在转发层全速跑,路由策略又可以随时下发调整。AI协同系统的控制面,承担的就是交换机里"控制角色"那部分工作。
2.4 组件清单与选型
下面是我在实际项目里用的组件清单。选型不是唯一解,我给每项都标了理由,仅供参考:
| 组件 | 选择 | 为什么这样选 |
|---|---|---|
| 会话接入 | WebSocket + 短轮询兜底 | 双向消息推送需要长连接,短轮询只做降级 |
| 消息总线 | NATS JetStream(或 Redis Streams) | 支持持久化、消费者组、消息回放,轻量够用 |
| 状态存储 | Redis + PostgreSQL | Redis放会话短状态,PG放审计和最终一致数据 |
| 代理运行时 | 独立的容器进程 | 隔离资源、独立扩缩容、便于单独更新模型 |
| 可观测 | OpenTelemetry + Prometheus | 统一trace和metrics,跨代理链路肉眼可查 |
3. 代理之间的通信协议:事件总线加能力路由
3.1 消息类型先分清楚,协议才不会写成四不像
我在设计通信协议时,先把协同场景里出现的消息按性质分成了四类:
- 指令型消息:代理A要求代理B执行某个动作,例如"生成架构图""执行这段SQL"。这类消息必须确认且可重试。
- 状态型消息:某个代理广播自己的工作进展,例如"代码审查完成,结果在附件里"。可以接受到达但不强一致。
- 查询型消息:请求事实判断或上下文信息,例如"用户最新确认的设计约束是什么"。需要快速响应。
- 治理型消息:心跳、注册、能力变更通知、仲裁请求。短小且高优先级。
这四类的可靠性和超时特征完全不同。我最初把指令和状态混在同一个主题里推进,结果踩了不少坑。后来强制按消息分类规划主题,整个链路清爽了很多。
3.2 统一消息信封的设计
所有消息都用一个统一信封包装,字段如下(JSON示例):
{ "event_id": "evt_01J0XYZ...", "event_type": "task.request", "session_id": "sess_9f3a...", "version": 43, "from": { "type": "agent", "id": "agent.user.alice", "abilities": ["planner"] }, "to": { "type": "skill", "id": "skill.arch_diagram" }, "payload": { "task_id": "task_8831", "input": "根据上面的方案生成部署架构图", "constraints": {"format": "png", "style": "clean"} }, "trace_id": "tr_8d2f...", "reply_topic": "session.sess_9f3a.reply" }这个信封里有几个字段是我后来才补上去的,但对系统稳定至关重要:
version:会话状态版本号,用于乐观锁冲突检查。trace_id:贯穿所有代理调用链路。没有它,排错只能靠猜。reply_topic:异步响应地址,避免代理之间必须互知对方地址。to:可以是代理ID,也可以是能力ID。我强烈建议优先用能力ID,由协同层做路由。
3.3 为什么不用纯REST,而选带确认的事件总线
很多人问:代理之间为什么不用HTTP REST调用?我的回答是:REST是"请求-响应"的强同步模型,适合控制类操作;但AI代理之间的任务,绝大多数是长耗时、多参与方、需要持续跟进的过程。
举个例子:代理A请代理B生成一份数据分析报告,B可能要先调模型、再查数据、再生成图表,整个过程几十秒甚至几分钟。如果A用HTTP同步等B,那A的所有后续工作都会被阻塞;如果用异步回调,那其实已经是在实现轮询和回调队列了——不如一开始就用事件总线。
事件总线的另一个好处是支持多接收方和订阅。一个代理完成子任务后发布消息,关心这件事的多个代理,比如用户侧代理、监督代理、审计模块,都能收到,而不是像REST那样强制一对一。
带确认的语义是:消费方处理完成后要给总线一个ack;处理失败时,总线按重试策略重新投递。我们用的是NATS JetStream的consumer group模式,读消息需手动ack,超时自动重投。但要注意,重投会导致重复任务,所以下游处理必须做成幂等的,这个我在踩坑章节详细讲。
3.4 能力注册与路由决策
每个代理启动后,会向注册中心上报自己的能力声明:
agent: id: agent.arch.designer display_name: 架构设计助手 abilities: - arch_diagram - tech_selection model_affinity: local_70B max_concurrent_tasks: 4 trust_level: internal协同层的路由调度器维护一张"技能-代理"路由表。当消息的to填的是skill.xxx时,调度器找到当前可用的、可信等级匹配的代理实例,然后把消息转给它。如果多个代理具备同一技能,调度器按负载、模型偏好、延迟三个因子打分选路。
这个机制的收益很大:代理升级、替换、缩容都不会影响消息发送方,消息只认能力,不认具体实例。
4. 多人会话与多代理协同:状态同步到底怎么做
4.1 先把三种状态分清楚
多人多AI场景下,"状态"这个词太容易混淆了。我心里必须先分清三种:
- 会话状态:整个协同会话的公共上下文,比如目标、约束、已确认的决策、参与者列表。它是所有代理共享的。
- 任务状态:某个子任务在哪个代理手里、进展如何、依赖哪些上游任务。它是局部的但需要上报。
- 代理记忆:每个代理自己的上下文窗口、偏好和缓存。它属于代理私有,但受控时可以导出共享。
三种状态混用很容易出问题。我见过一种典型事故:代理A在私有记忆里维护了一份"用户需求清单",代理B也在自己的记忆里维护一份,两人对"需求是否变更"产生了分歧,后续输出全偏了。所以我的原则是:凡是会被多个代理用于决策的公共事实,一律进共享事实表,不允许存在代理私有的副本里。
4.2 共享事实表加版本化乐观锁
共享事实表的实现并不复杂,关键是写入规则:
- 每个事实记录带版本号。
- 代理要修改某条事实时,必须带上自己读到的版本号发起修改。
- 版本落后时,拒绝写入并返回冲突信息。
- 追加型操作,比如添加一条备注,不做冲突拒绝,直接追加。
用Redis存JSON,配合Lua脚本做原子乐观锁:
local cur = redis.call('GET', KEYS[1]) if cur == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]) return 1 else return 0 endARGV[1]是发起方携带的base_version,ARGV[2]是新的状态JSON。冲突时调用方收到0,就进入冲突仲裁流程。
4.3 冲突消解:先追加、再仲裁、最后人工
我把冲突处理设计成三级:
- 追加型更新:直接合并,不需要仲裁。例如新的聊天记录、新的附件索引。
- 覆盖型更新:进入仲裁。仲裁规则依赖场景——可以配置"最新写入优先"、"优先级高的代理优先",或者"需要多数代理同意"。
- 高风险更新:例如删除已确认的决策、修改用户身份标识,直接挂起,通知用户侧代理,由人类确认后放行。
这个策略一开始就实现了,但初版很粗糙,只用了"最新写入优先",结果两个代理交替覆盖同一个字段,陷入"你写我覆盖、我写你覆盖"的循环。后来给仲裁增加了"同一字段变更冷却窗口":一个字段被某代理更新后,两秒内其他代理的覆盖请求会被拒绝并排队等待。效果立竿见影。
4.4 上下文压缩:别让协同变成上下文大杂烩
多代理共享一个会话时,token消耗增长是线性的,但上下文混乱度增长接近指数。我试过直接把所有代理消息都塞进同一个上下文窗口,结果模型很快被大量历史"淹死",回答质量越来越差。解决方案是三段式上下文管理:
- 滑动窗口:每个代理只保留最近N条与自己相关的消息。
- 定期摘要:每K条公共消息后,由一个摘要代理生成会话摘要,追加到共享事实表的
summary字段。 - 关键事实库:从消息中抽取实体和已确认决策,单独入库。代理做决策时优先读取事实库,而不是翻整个聊天记录。
这个机制上线后,同样任务量下模型输出质量明显回升,token成本也压低了将近一半。
5. 异构模型接入:本地模型与云端大模型的统一网关
5.1 代理层不应该直接绑死单一模型
大部分多AI协同方案的默认前提是接入同一个大模型,但实际项目里往往是"一个AI助手代理加本地模型"的组合:核心业务相关的对话和数据处理必须放在本地,防止数据出域;通用创意、长文本润色、快速检索这些任务可以交给云端大模型。两种模型能力不一样、协议不一样、成本不一样,如果让每个代理自己对接,那么每换一个模型,所有代理全要改。
我在架构里加了一个模型网关,让所有代理只面对一组统一接口:
ChatComplete(request):通用对话补全,参数标准化为输入、系统提示、工具定义。Embedding(text):向量化,供检索和记忆模块使用。ToolCallExecution(spec):部分模型无法原生调用工具时,由网关做工具调度和结果回填。
5.2 本地模型的资源隔离:IOMMU与显存分配
本地模型是异构接入里最折腾的部分。我曾在同一台GPU服务器上同时跑一个7B模型和一个70B模型,结果两个模型都在抢显存,70B频繁OOM,7B响应也变慢。后来我做了两件事。
第一,通过容器化隔离进程。每个模型实例分配独立的显存配额,超配直接拒绝,而不是运行时才OOM。
第二,用IOMMU对DMA访问做隔离。这类硬件能力在虚拟化环境里很关键:每个设备或设备实例绑定独立的IOMMU domain,让模型进程无法越权访问其他设备或内存。对我实际部署来说,最重要的价值是能把"模型A打爆CUDA上下文导致模型B一起挂"这类故障隔离开。
研究Linux内核IOMMU软件架构时,我发现把IOMMU当成"地址映射加权限闸门"来理解,比当成特殊硬件更容易上手。它承担的就是隔离和故障域收敛的角色,跟我们系统控制面与数据面分离是同一个思想。
5.3 路由策略:什么时候走本地,什么时候走云端
模型网关的核心职责之一就是路由。我的路由配置大概长这样:
routes: - name: private_data_analysis match: data_class: ["private", "internal"] task_type: ["analysis", "sql"] target: local_70B priority: 100 - name: code_generation match: task_type: ["coding"] target: cloud_code_model priority: 90 - name: creative_writing match: task_type: ["drafting", "polish"] target: cloud_llm priority: 80 - name: fallback match: "*" target: local_7B基本逻辑是:涉及私有数据的任务强制走本地,需要特定领域能力的任务优先指定模型,最后是兜底路由。每条路由都配了熔断开关:当目标模型的错误率超过阈值,网关会把流量降级到次优目标,并通知协同层标记该模型不健康。
5.4 统一网关带来的工程收益
网关上线后,代理层从"识别十种模型API"缩减到"只对接一种内部API"。新增模型时,只需要按协议做一个适配器,再到路由表加规则。我们后面新接一个模型,单点耗时从一两天降到两小时左右,主要时间都花在测试输出质量上,而不是写协议转换。
6. 落地坑位实录:三个真实事故与排查链路
6.1 事故一:代理之间无限循环对话,模型资源被跑空
现象:某天晚上消息量突然暴涨,两小时后多个模型实例开始返回超时。查看监控发现,负载主要来自事件总线,消息几乎都在task.request和task.review两个topic之间反复横跳。
排查链路:
- 先按
trace_id捞了一条完整消息链路,发现代理A发了一条"请B评审方案",B评审后又发"请A修改",A修改完又发"请B评审",无限循环。 - 检查业务规则:评审变更确实没有定义终止条件。A和B的指令都合法,但组合到一起形成了环。
- 根因定位:任务消息缺少"循环深度"的上下文,代理无法判断自己收到的任务,是不是自己曾经发起过的任务的派生任务。
解决方案是三管齐下:
- 任务消息增加
task_chain字段,记录祖先任务ID和深度。 - 当深度超过阈值(默认5层)时,路由调度器自动拒绝转投。
- 增加循环检测器:统计同一
(from, to)组合在单位时间内的消息数,超过阈值则触发告警并暂停后续路由。
这也是我强调可观测性的重要原因。如果没有trace_id贯穿链路,这种循环问题在业务日志里极难定位。
6.2 事故二:共享事实表覆盖循环,代理各自为政
现象:多代理协同做一份技术方案时,方案里的"部署架构"字段在两个代理之间来回变,最终结果和两个代理各自记忆里的版本都对不上。
排查链路:
- 从共享事实表的更新日志里发现,代理A和B在20秒内交替提交了7次覆盖更新。
- 检查各自上下文,A看到的是自己上一轮的输出,B看到的是自己上一轮的输出,而二者都认为自己拥有最终决定权。
- 根因定位:覆盖型更新没有仲裁机制,简单的"最新写入优先"会在高并发下放大竞争。
解决方案:
- 对覆盖型更新引入仲裁规则,默认"负责人代理优先"。
- 给同一字段增加冷却时间窗。
- 变更日志里增加
conflict_resolved_by字段,任何仲裁结果都可追溯。
6.3 事故三:消息重投导致任务重复执行
现象:某代理在处理"生成报表"任务时,任务执行成功但ack确认失败,消息被总线重投,同一个报表生成了两份,且其中一份使用的参数是旧版本。
排查链路:
- 查看消费者日志,发现第一次处理其实已经成功写库,但在ack前进程发生了GC停顿,导致ack超时。
- 总线按策略重投,第二次处理读取的是旧版本快照,结果覆盖了新版本。
针对这类问题,我把所有下游处理都改成了"幂等任务"模式:
- 每个任务都带有
task_id + source_version。 - 任务执行前先查任务状态表,如果已经是
completed状态则直接返回已有结果。 - 写操作必须在事务里同时更新任务状态和数据,避免结果写了但状态没更新的窗口。
6.4 实测性能轮廓
最后给一组压测参考数据。你们环境不同,重点看趋势:
| 场景 | 并发用户 | AI代理数 | 消息吞吐(条/秒) | P95协同链路时延 | 控制面CPU占比 |
|---|---|---|---|---|---|
| 2人2AI多轮协作 | 2 | 2 | 38 | 2.4s | 12% |
| 5人5AI方案评审 | 5 | 5 | 210 | 5.8s | 23% |
| 10人8AI大规模协同 | 10 | 8 | 680 | 9.7s | 31% |
前两个场景中,时延大头在模型推理;到了10人8AI场景,控制面的调度、路由和状态同步开始占据明显比例。如果继续往上扩,我的计划是先把控制面实例扩容到3副本,再把会话状态按session分片。
7. 后续演进:记忆层、代理团队与审计能力
7.1 从会话级协同走向长期记忆协同
当前架构的记忆还停留在会话级别。会话一结束,代理对合作方的理解就归零。下一步我打算把代理记忆抽象成独立记忆层,按实体、用户、项目、偏好存储,代理之间可以有条件地共享部分记忆索引。这里的关键是"有条件":不应该让一个代理的私有历史直接暴露给所有代理,而是提供可检索但受权限约束的接口。
7.2 代理团队自组织:能力注册之后再加团队契约
目前代理之间的协作仍由协同编排器统一调度。下一步准备引入"团队契约":一组代理在承接大型任务前,先协商各自角色、上下文共享范围和验收标准,形成契约后写在共享事实表里。这相当于从"临时拼凑"走向"项目制组织",适合更复杂的任务。
7.3 审计与治理
多人多AI协同一旦进入生产,审计能力不是可选项。我们正在完善四个维度的审计:谁授权的任务、哪个代理执行了什么动作、调用外部模型时输入了哪些数据、冲突仲裁的决策记录。这里纯粹从工程治理角度讲:只有把这些事件全部落库,后续优化和事故复盘才有依据。
最后再说一点个人体会:这类系统的核心复杂度不在单个AI能力上,而在控制面。控制面设计得清爽、可观测、可仲裁,多AI协同就能跑得稳;控制面偷懒图省事,后面一定会在某个凌晨的故障里加倍还回来。如果你也在做类似架构,建议先把状态同步和消息重放的功课做扎实,再回头调模型效果。