
当第二个 Agent 出现在你的系统里,复杂度不是 +1,而是 ×N。本文基于 ooder-platform 上真实落地的分布式 A2A 架构(跨节点寻址、流式中继、跨节点人工门、三档身份鉴权),拆解企业 Agent 基础架构为什么"不得不复杂",以及怎样把复杂度收敛成可治理的几条正交主线。
日期 2026-09-28
代码基线 ooder-a2a / scene-engine / ooder-pro / ooder-biz-finance
证据 克隆测试台 fin 8017 ↔ llm 8018 实测
大多数"多 Agent 框架"的 Demo 之所以看起来简单,是因为它们运行在一个进程、一个事件循环、一个内存字典里。一旦进入企业真实环境,三条物理边界会同时出现,每条都会把"函数调用"撕成"分布式协议":
边界 | 它强制你面对的问题 | 原本隐含的假设被打破 |
|---|---|---|
进程边界 | Agent 由不同团队、不同语言、不同发布节奏交付;不能共享内存,不能共享事务 | "把上下文对象传过去" |
浏览器边界 | 流式输出(SSE / WebSocket)只存在于人连接的那个节点;执行体节点没有浏览器 | "边生成边推给前端" |
组织边界 | Agent 归属不同域(财务/办公/研发),有授权边界、审计边界、数据出域边界 | "谁都能调谁" |

图 1 复杂度增长曲线。总线并不减少语义,它把"N² 条双边契约"重构为"N 个单边适配器 + 1 份共享信封规范"——这正是 A2A 这类协议的经济学来源。

图 2 分布式 A2A 全景分层。上方是编排层(谁决定要干),中间是执行体节点(谁真的在干),下方是协议层与传输层(怎么找到对方、怎么证明是对方、怎么留下证据)。注意浏览器只挂在最右侧的编排节点上——这个细节决定了第 6 节全部设计。
2024 年底到 2026 年,Agent 互操作领域出现了三条彼此正交、正在快速标准化的主线。理解它们,才知道自己造的每一块砖该放哪。
主线 | 解决的问题 | 行业走向 | 本文架构中的落点 |
|---|---|---|---|
工具面MCP | Agent 怎么用工具(垂直集成) | 2024 年底提出后迅速成为事实标准,工具/资源/提示的服务化描述 | ooder-a2a中的MCPAgent / MCPMessage / MCPMessageConverter,与 A2A 并列但不混用 |
协作面A2A | Agent 怎么找 Agent(水平集成) | Agent Card 能力声明 + Task 生命周期 + 多模态 Part;2025 年捐入 Linux Foundation,向中立治理演进 | AgentDescriptor(等价 Agent Card)+A2AMessage信封 +AgentDirectoryPort目录 +conversationId任务归属 |
治理面身份与审计 | 怎么证明"你是你"、谁来负责 | 可验证 Agent 身份(DID / 可验证凭证思路)、Agent Mesh / 去中心化目录(如 NANDA 方向)、逐跳审计留痕成为合规刚需 | A2aAgentIdentity的 HMAC 三档放行 + per-agent 密钥 +a2a_message全量留痕 |
关键判断
MCP 与 A2A 不是竞争关系,而是垂直与水平的关系:MCP 让 Agent 长出手,A2A 让 Agent 找到彼此。企业落地时最容易犯的错,是把"工具调用"的 RPC 心智模型直接套到"Agent 协作"上——于是就长出了 N² 条点对点 HTTP 调用网,每条都要自己处理超时、重试、幂等、鉴权、审计。这正是我们需要一套独立协作总线的现实理由。
值得强调的是:协议标准化不等于基础设施消失。A2A 规范定义了信封与语义,但"信封怎么到达对端"、"到达不了怎么办"、"流式怎么跨节点"、"人在环上的门怎么回投"这些问题,规范留白,必须由企业基础架构补齐。下面四节讲的就是这四块留白。
分布式系统里 90% 的诡异 bug,最后都能追到"标识语义含混"。我们在 A2A 上做的第一件事,是把混沌的"目标"拆成四个正交标识:
标识 | 回答的问题 | 载体 | 示例 | 误区 |
|---|---|---|---|---|
agentId | 谁干(业务身份,可授权) | fromAgentId/toAgentId | fin.tax/llm.tax | 拿它当网络地址用 |
nodeId | 发给谁(物理投递地址) | targetNodeId/Descriptor.nodeId | clone-t-split-llmagent | 认为它等于 agentId 前缀 |
sceneGroupId | 组播分组 + 隔离域 | sceneGroupId(topic 第二段) | finance/FLOW-RELAY | 拿它当权限边界 |
conversationId | 这次协作的归属 | conversationId | conv_9f3a… | 与流程实例 ID 混为一谈 |
agentId 的第一段是命名空间,用来区分"同一个能力由哪种角色承载":
// FinanceA2aAgentRegistrar:能力清单是单一来源,agentId 由命名空间派生
public static final String KEY_NAMESPACE = "ooder.a2a.agent.namespace"; // fin | llm
public static String agentIdFor(String capability){ return activeNamespace + "." + capability; }
// 关键:sceneIdFor() 按「能力段」解析 → fin.tax 与 llm.tax 映射到同一流程定义
// 因此切换命名空间不需要第二份场景映射表这样做的好处是部署形态与业务语义解耦:同一个"税务负担分析"能力,开发期跑在宿主进程(fin.tax),生产期拆成独立智能体进程(llm.tax),流程定义、场景绑定、前端呈现全都不用改。
实测教训
命名空间必须是静态隔离 + 运行时隔离双保险:执行端 canHandle 只认本实例命名空间前缀,绝不跨命名空间代收。否则"投错了也被好心执行",比投不出去更危险——你会得到一个看起来成功、实际上跑错流程的结果。切换命名空间时还必须轮转名册文件 {dataDir}/a2a-agents.json,否则启动期 seeder 会恢复旧条目,导致目录里同时存在两套执行体。

图 3 四标识寻址与定向投递决策。agentId与nodeId
分离是整套架构的基石;解析链条每一级都必须是可判定的失败,而不是"尽力而为"。
请求-响应模型在分布式环境里有个反直觉的坑:响应的目标节点,必须由请求方自己声明。因为对端收到的 fromAgentId 是业务身份,不是可投递地址。
// FinanceTaskRequestHandler:回填时必须读原始请求的 replyNodeId
Object replyNode = original.getHeader("replyNodeId");
if (replyNode != null && !String.valueOf(replyNode).trim().isEmpty()) {
resp.setTargetNodeId(String.valueOf(replyNode).trim());
} else {
log.warn("[A2A-Fin] 请求未声明 headers.replyNodeId —— TASK_RESPONSE 将无法跨节点定向回投");
}真实缺陷 D14
早期版本回填时只设了 toAgentId(回显请求方的 fromAgentId,而那是个节点名不是执行体),没有设 targetNodeId。结果:对端执行成功、日志一切正常,但响应 UNDELIVERABLE,请求方永远收不到结果。"执行成功但没人知道"比"执行失败"更难排查——这也是为什么我们坚持"缺失即 WARN,不静默"。
我们在架构上做了一个明确的切分:北向协议(HTTP)管生命周期,A2A(MQTT)管协作。这两条通道不是冗余,而是语义不同、不可互换。
维度 | 北向协议(HTTP) | A2A(MQTT) |
|---|---|---|
方向 | 南北向:编排者 → 执行体 | 东西向:执行体 ↔ 执行体 |
契约 | AgentTask/TaskStatus/TaskResult | A2AMessage(messageType枚举) |
语义 | 任务生命周期(提交/轮询/取结果/取消) | 协作语义(委派/取数/转交/等待) |
同步性 | 半同步(提交即返回 taskId,结果靠查) | 异步(requestId关联 + future 超时) |
落库 | agent_task(服务端权威) | a2a_message(本地观测 + 状态机) |
鉴权 | X-OODER-Source+X-OODER-Token白名单 | broker 鉴权 + HMAC 信封签名 |
何时用 | 流程节点要执行一个 Agent 任务 | Agent 执行中需要另一个 Agent 帮忙 |
为什么不能合并
用北向 HTTP 做 Agent 间协作,会退化成 RPC 网状(N² 条边),且每个调用方都要自己实现超时/重试/幂等;用 A2A 做任务下发,则丢失任务生命周期与幂等键——你无法回答"这个任务现在是 RUNNING 还是 TIMEOUT",也无法取消它。两条通道各自守住一半语义,才是可治理的。
关键在于任务语义单源:A2A 入站复用与北向同一个 AgentTaskService——同一套状态机、同一套幂等键、同一个超时看门狗、同一个执行内核。南北向与东西向只是接入方式不同,谁都不另起一套任务状态。这条设计省掉了后来几乎所有"两边行为不一致"的 bug。
// 入站 TASK_REQUEST → 复用北向任务服务(taskId = requestId,天然幂等)
NorthboundTaskRequest req = new NorthboundTaskRequest(input, metadata, taskContext, timeout);
req.setTaskId(requestId); // ★ 重复投递不新建、不重跑
TaskResult r = agentTaskService.submit(req); // 与 HTTP 北向完全同一条路径
// 回填:to = 原 fromAgentId;targetNodeId = 原 headers.replyNodeId;headers.requestId 原样回显分布式消息系统最常见的腐败方式是假装成功:发不出去就记个日志,或者更糟——退回广播"碰碰运气"。我们的做法是给每条出站消息一个可判定的终态。
出站(direction = SEND)SENT本节点有消费者目标在远端目标=本节点LOCAL_CONSUMED本地 handler 已处理(不经 MQTT)PUBLISHED已发布(定向 / 组播)定向解析失败UNDELIVERABLEWARN + error 写明原因FAILED处理过程抛异常对端 RECV入站不推进状态TASK_RESPONSE 回投依赖 headers.replyNodeId可靠性拼图QoS1 重投 → 入站台账去重(24h TTL)幂等键 = requestId(任务侧)sendRequest future 超时(默认 30s)自环过滤(只比 originNodeId / fromAgentId

图 4 出站状态机。UNDELIVERABLE与FAILED
是两个刻意分开的终态:前者是"我知道没送到",后者是"送的过程中炸了"——排障时这两者的处置路径完全不同。
a2a_message 表是每节点本地库(SQLite,按 studio.instance.data.dir 隔离),不是跨节点消息中心。这个取舍很明确:我们不做全局消息视图,因为一旦做,它就变成新的单点与新的权威——而各节点保留自己的"我发出去了什么、收到了什么"的账本,恰恰是事后对账最可靠的证据链。
这是整套架构里唯一一个由物理约束倒逼出来的设计。
流程在 8018(执行体节点)上跑,用户在 8017(编排节点)的浏览器前看。token 流、thinking 流、步骤事件都在 8018 生成,但 SSE emitter 只存在于 8017——8018 上根本没有连接。于是 SseEventPushService.deliverFlowEvent 返回 dropped=true:本机没有订阅者。
传统做法是让执行体把事件写库、前端轮询。但那样就失去了流式体验,且要处理"轮询与推送并存"的双源乱序。我们的做法是在丢弃点上挂一个中继:

图 5 FLOW-RELAY 时序。中继挂在SSE 本机丢弃点上,而不是挂在事件源上——这个"触发点"的选择是关键:它意味着本地有人看就不走网络,本地没人看才跨节点,单机部署零开销,分布式部署才付成本。
如果说流式是"执行体 → 人"的单向通道,人工门就是"人 → 执行体"的反向通道,而且必须精确落到正确的流程实例上。这是分布式 Agent 系统里最容易被低估的一环:人在 A 节点点"确认",流程卡在 B 节点的某个活动上。

图 6 跨节点人工门回投。agentId
在这里第二次发挥关键作用:它既是"门属于谁"的判据,也是"决策投给谁"的地址。
设计取舍
门的状态存在编排节点(人有 UI 的那一侧),而流程实例在执行体节点。这个不对称是刻意的:门是"人机交互"的产物,天然属于人的一侧;恢复动作则是"流程语义",必须在持有实例的那一侧执行。想把它们合并到一侧,都会引入跨节点事务——那是更大的复杂度。
Agent 网络上最早的安全模型是"声称即信任":报文里写 fromAgentId = fin.tax,对端就信了。这在单进程框架里无所谓,在跨节点、跨组织边界的环境里是致命的。
// 出站签名:只在两个唯一出口(publishGroup / publishTargeted)计算,保证无缝隙
static final String HDR_SIG = "x-a2a-sig"; // HMAC-SHA256
static final String HDR_KID = "x-a2a-kid"; // 密钥 ID(per-agent 优先)
static final String HDR_NODE = "x-a2a-node"; // 来源节点
签名串 = messageId | fromAgentId | messageType | timestamp
密钥 = agentKeys[kid] 存在则用之,否则回落到平面 token(ooder.a2a.auth-token)
图 7 三档放行矩阵。audit
档是这套设计里最有用的一档:它允许你在不中断业务的前提下把身份体系铺开——先看见,再收紧。日志一律打
[A2A-AUTH] 身份识别(放行留痕)。
配套的是 per-agent 密钥:-Dooder.a2a.agent-keys="fin.tax:secret1,llm.tax:secret2"。平面 token 一旦泄露影响全网格,per-agent 密钥把爆炸半径收敛到单个 agent,也天然支持"按 agent 轮转"。
下面每一条都来自真实的联调记录,不是理论推演。把它们按复杂度来源归类,你会看到一个规律:寻址、时序、身份、可观测这四类各有若干坑,但工程类(构建/部署/取证)的坑数量同样多,而且最耗时间。
类别 | 缺陷 | 现象 | 根因 / 修复 |
|---|---|---|---|
寻址 | D11 / D14 | 对端执行成功但请求方永远收不到结果 | 回填未设targetNodeId;约定headers.replyNodeId,缺失即 WARN |
GA-1 | Studio/dispatch不透传headers,replyNodeId无通道 | 新增 headers 透传 + 顶层replyNodeId便捷字段,响应回显 | |
跨命名空间错投 | llm.tax被fin节点代收 | canHandle严格前缀 + 切命名空间必须轮转名册 | |
时序 | D15 | 流程已归档却被回写成 PAUSED,面板显示"暂停中" | 异步节点暂停分支加两段式终态守卫(入口 +setStatus前二次校验) |
组播回声行 | 发布方自己多一条 RECV,被误判为重复消费 | 非缺陷:订阅ooder/event/A2A/#导致回环;自环过滤阻止分发但不阻止落库 | |
身份 | D13 | 消息到达但不执行、无回填,协议栈只报handler error | 见下方"泛型陷阱":异常发生在try之前,被协议栈吞成一行 WARN |
handler 异常无堆栈 | 排障只能靠文案猜 | 协议栈log.warn(msg)不带异常对象(已登记待改进) | |
可观测 | D12 | 两个实例查到同一批消息行 | 消息库路径硬编码 → 四级解析链 +studio.instance.data.dir隔离 |
自环过滤误杀心跳 | 远端心跳被当成自己的丢弃 | HEARTBEAT 只比headers.originNodeId,其余比fromAgentId | |
工程 | D10 | 启动失败Circular placeholder reference | yml 写了自引用占位符${ooder.a2a.mqtt.enabled:false};改用-D |
构建陷阱 | 改了scene-engine却"没生效",据此得出错误结论并重复返工 | mvn -pl ooder-pro package不会带上未 install 的依赖模块;被依赖模块必须先install |
D13 · Java 泛型重载地雷(本轮最隐蔽的一个)
A2AMessage 提供 <T> T getHeader(String key)。下面这行会静默推断出错误的重载:
// ✗ 错误:T 被推断为 char[],选中 String.valueOf(char[]) 重载
// 运行期抛 ClassCastException: String cannot be cast to [C
String rid = String.valueOf(message.getHeader("requestId"));
// ✓ 正确:先落到 Object,重载解析回 valueOf(Object)
Object v = message.getHeader("requestId");
String rid = v != null ? String.valueOf(v) : null;更糟的是:该行位于 try 之前,异常逃逸到协议栈,被 catch 成一行 [A2A-MQTT] handler error——表现为"消息到达但不执行、无回填、无堆栈"。分布式系统的可观测性缺口,往往会放大一个纯语言层面的小错。
通用教训
只要改动落地在被依赖模块(scene-engine、ooder-a2a、bpm-server),验证前必须先 mvn -pl <module> install;只打包宿主模块会静默取用本地仓库旧 jar,表现为"改了没生效"。这一条在本轮让我们走了两次弯路,包括一次基于假阴性得出的错误结论修正。
回头看,这整套东西能不能不做?能不能就用"一个 HTTP 接口 + 一个任务表"顶住?我的判断是不能。下面六项不是"最佳实践",而是一旦跨进程就绕不过去的必需品:
# | 不可省略的能力 | 不做会怎样 | 最小实现 |
|---|---|---|---|
1 | 命名与目录(谁/在哪) | 调用方硬编码地址,任一节点迁移即全网格失效 | agentId+nodeId双标识 + 一个可查的目录端口 |
2 | 统一信封 | N² 个自定义 JSON,无法统一鉴权/追踪/审计 | 一个消息类 + 枚举messageType+headers控制面 |
3 | 可判定失败的投递语义 | "静默重试/退回广播"导致重复执行与幽灵成功 | 出站状态机含UNDELIVERABLE,解析不到就落库告警 |
4 | 幂等与去重 | QoS1 重投 / 网络抖动把一个任务跑两遍(在财务场景这是事故) | requestId即幂等键 + 落盘入站台账 |
5 | 跨节点流式与人机回路 | 执行体节点上的过程对用户完全不可见;人工门静默卡死 | 丢弃点中继 + seq 排序 + 门的全量回读与反向决策回投 |
6 | 身份与审计留痕 | 任何进程都能冒充fin.tax;出事无法溯源 | HMAC 签名 + per-agent 密钥 + 三档放行 + 全量消息落库 |
一句话总结
企业 Agent 基础架构的复杂度,本质上来自"智能"是分布式的,而"责任"是集中的。执行可以散落在 N 个进程、M 个团队、K 个域里,但审计、授权、可观测、用户体验必须能被集中回答。A2A 协议给出了信封与语义,而把"责任集中"这件事落到地上,靠的正是上面这六项工程。
# —— 传输与寻址(跨节点必开)——
-Dooder.a2a.mqtt.enabled=true
-Dooder.a2a.mqtt.broker-url=ws://host:9890/mqtt # 或 tcp://host:1883
-Dooder.a2a.mqtt.client-id=<全局唯一>
-Dooder.a2a.node-id=<本节点唯一 ID>
-Dscene.a2a.enabled=true
# —— 身份 ——
-Dooder.a2a.auth-mode=strict|loose|audit # 默认 loose
-Dooder.a2a.auth-token=<平面令牌>
-Dooder.a2a.agent-keys="fin.tax:s1,llm.tax:s2" # per-agent 密钥
# —— 命名空间(fin=宿主本机执行体 / llm=独立智能体进程)——
-Dooder.a2a.agent.namespace=llm
-Dooder.agent.executors=llm.tax,llm.reimburse,llm.invoice-recon,llm.bank-statement
# —— 跨节点流式 FLOW-RELAY(仅执行体节点开启)——
-Dooder.a2a.flow-relay.enabled=true
-Dooder.a2a.flow-relay.agent-id=llm.tax
-Dooder.a2a.flow-relay.agent-name=税务智能体
-Dooder.a2a.flow-relay.thinking-interval-ms=150 # 合帧窗口
-Dooder.a2a.flow-relay.thinking-max-bytes=4096
# —— 幂等与存储 ——
-Dooder.a2a.dedup.ttl-ms=86400000
-Dstudio.instance.data.dir=data/<实例私有目录>[AgentRoster] 本节点执行体: ids=[…], nodeId=<节点ID>, domain=<域>
[A2AMessageStore] 就绪: jdbc:sqlite:…/a2a-message.db (dirSource=studio.instance.data.dir)
[A2A-MQTT] connected: broker=…, clientId=…, localNodeId=<节点ID>, subscribed node(<节点ID>) + event/A2A
[A2aRegistrar] A2A 执行端注册完成: N / N 个执行体(nodeId=<节点ID>)本文所有结论均来自下列实现与实测记录(克隆测试台 fin 8017 ↔ llm 8018,broker 1883):
ooder-a2a/src/main/java/net/ooder/a2a/A2AMessage.java · A2AMessageType.java · A2AProtocolServiceImpl.java · A2aAgentIdentity.java · spi/AgentDirectoryPort.java · store/A2AMessageStore.java · config/A2AAutoConfiguration.java ooder-pro/src/main/java/net/ooder/studio/chat/a2a/FlowEventA2aRelay.java · FlowRelayInboundHandler.java · A2aParallelCoordinator.java · A2aInboundLedger.java · SseA2AEventPort.java ooder-pro/src/main/java/net/ooder/studio/chat/conversation/db/HumanGateStore.java · chat/controller/StudioChatSseController.java · chat/engine/SseEventPushService.java ooder-biz-finance/src/main/java/net/ooder/studio/chat/a2a/FinanceA2aAgentRegistrar.java · FinanceTaskRequestHandler.java ooder-pro/src/main/resources/static/shared/chatnext-base/StreamDomain.js · App.js · ui/MessageList.js docs/distributed-a2a-design-and-config-20260918.md · docs/distributed-a2a-next-steps-20260926.md
实测汇总:XVERIFY_DONE ALL=PASS(跨节点定向投递 / 对端真实消费 / 跨节点回填 / 跨命名空间双向错投)、MCVERIFY_DONE ALL=PASS(组播 G1–G6)。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。