💡第十四篇写完五种编排模式之后,我在自己的 Demo 上试了"再拆一个 Agent 出来":调度 Agent 负责分派,专业 Agent 负责查、算、写。结果单测跑通了,链路一拉长就三件事同时冒出来——上下文预算炸了(一个 Agent 挂 30 个工具,光 schema 就吃掉大半窗口)、并行任务的超时和取消没法统一、失败之后不知道从哪一步重跑。
更现实的一个问题是协议:社区里"多 Agent 协作"讲得最多的是 A2A,我按惯例去找 Spring AI 的 A2A 支持,结果是——2.0.1 的 BOM 里 169 个 artifact 没有一个和 A2A 有关,文档里搜a2a零命中。它管的是 MCP(能力跨进程复用),不管 Agent 之间怎么说话。
这一篇就按这个事实往前推:拆 Agent 的判据是什么、工具箱规模化 Spring AI 在 2.0.1 给了什么新部件(tool-search全家桶,1.1.2 完全没有)、跨进程那一层该用 MCP 还是等 A2A、以及 Java 侧并行、补偿、链路追踪具体怎么写。
1. 什么时候才真的需要拆
三个判据满足一个再动手,一个都不满足就别拆。
第十四篇 已经把 Chain / Parallelization / Routing / Orchestrator-Workers / Evaluator-Optimizer 五种模式各给了一段能跑的 Java。那一层全在同一个进程、同一个ChatClient里,本质是"多轮模型调用怎么组织",跟"多 Agent"没什么关系。要不要真的拆开,我的判据只有三条:
| 判据 | 含义 | 拆开的收益 | 不拆的代价 |
|---|---|---|---|
| 上下文预算 | 一个 Agent 能挂的工具 + 检索片段把窗口占掉一半以上,模型开始"看不见"系统提示 | 每个子 Agent 只装自己那 5~8 个工具,抽取质量立刻回来 | 工具选错、指令被忽略,第十三篇的 trace 里能看到但改不动 |
| 权限隔离 | 不同能力对应不同信任级别(只读 vs 能写 vs 能出款) | 子 Agent 是不同服务账号 / 不同进程,越权在 OS 层就被挡住 | 只能靠第八篇、第十八篇那套应用层过滤,一处漏了就全漏 |
| 独立伸缩与发布 | 某一步是重计算或第三方慢调用,需要单独扩缩 / 单独回滚 | 拆成服务后按自己节奏部署 | 一次慢调用把主流程的线程池拖住 |
反过来,这三条一条都不沾,只是想"看起来更智能",那多 Agent 只会带来三个新问题:链路线性叠加延迟、错误在多跳之间放大、没有一份完整上下文可供归因。
笔者的实际做法:先用"单 Agent + 动态工具集"撑到撑不住(第 3 节有具体部件),撑不住再拆进程。因为拆进程之后所有原本一次函数调用的事都要变成网络调用 + 超时策略 + 补偿逻辑。
Q1:拆成多个 ChatClient 实例算不算多 Agent?
不算。多ChatClientbean 只是配置隔离(不同模型、不同 Advisor 链、不同系统提示),它们仍然共享线程池、共享进程、共享故障域。真正"多 Agent"的分界线是进程边界:跨了进程,才有独立的部署、独立的权限和必须显式传递的上下文。
2. 跨进程的三种拓扑,成本完全不一样
中心化编排 Coordinator --> [Worker A] [Worker B] [Worker C] 上下文由 Coordinator 汇总 │ 一份决策,多份执行;失败重试最容易做 │ 代价:Coordinator 是瓶颈,且它必须理解所有子任务 流水线 A --> B --> C 每跳只处理自己那段 │ 上下文最小、最好测;单跳可换实现 │ 代价:早期丢的信息后面拿不回来,需要显式契约(结构化 DTO) 点对点 A <--> B <--> C(共享黑板 / 消息总线) 灵活 代价:没有中心决策,收敛性靠 prompt 保证,最难调试| 拓扑 | 上下文传递 | 失败处理 | 可观测 | 我的适用判断 |
|---|---|---|---|---|
| 中心化 | Coordinator 持有全量,分派时截断 | 一处重试,幂等键集中在它手里 | 一个 traceId 串全场 | 默认选它,业务型任务几乎都够用 |
| 流水线 | 跳与跳之间是结构化 DTO(第七篇的 entity 输出) | 每跳独立重试 + 补偿 | 每跳一个 span,天然好查 | 步骤固定、可预先定义契约时用 |
| 点对点 | 无共享,靠消息内容 | 分布式事务级的问题留给你 | 需要先建 correlationId 体系 | 除非真有动态协作需求,否则别碰 |
一个具体的反例:我做过"解析 → 校验 → 生成回复"三段流水线,把第一段做成独立 Agent 后,它在解析阶段丢掉了"用户的口语化时间表达",校验段就再也判不出日期歧义。流水线要求每一跳的输出是完备的,而模型输出天然不完备——所以流水线拓扑里,跳与跳之间应该传结构化 DTO + 原始输入留档,而不是传"上一跳的理解"。
3. 工具箱规模化:tool-search是 2.0.1 的新答案
这是本篇最实用的一节。多 Agent 场景里第一个撞墙的通常是"工具太多"。
3.1 问题定量:30 个工具的 schema 有多大
一个中等复杂度的内部系统,工具集很容易到 20~40 个。每个工具的 input schema(DeepSeek、通义这些对 schema 长度同样敏感)按 200~400 token 估,光"告诉模型你能干什么"就要吃掉 6k~16k token。后果不是费用,是选择质量:工具越多、描述越相似,模型选错率越高(第八篇痛点 1)。
1.x 时代只能自己写"工具目录 + 检索 + 动态挂载"。2.0.1 直接把这套做成了模块——我在 BOM diff 里确认这六个 artifact 在 1.1.2 中完全不存在:
spring-ai-tool-search-tool 核心:ToolIndex / ToolSearchTool / 索引实现 spring-ai-tool-search-advisor ToolSearchToolCallingAdvisor spring-ai-tool-search-tool-advisor 桥接 spring-ai-tool-search-tool-lucene LuceneToolIndex spring-ai-tool-search-tool-vectorstore VectorToolIndex spring-ai-starter-tool-search-advisor starter(另有RegexToolIndex,在 tool 模块的index/regex包里——正则版最适合起步和排查。)
3.2 机制:让模型自己去搜工具
ToolSearchToolCallingAdvisor直接继承ToolCallingAdvisor:
publicclassToolSearchToolCallingAdvisorextendsToolCallingAdvisor{publicstaticBuilder<?>builder();publicvoidevictSession(StringsessionId);// 会话结束要主动清索引}// Builder 在 ToolCallingAdvisor.Builder 之上多出的方法TtoolIndex(ToolIndex)// 用哪个索引实现TmaxResults(Integer)TsessionIdKeyName(String)TevictionStrategy(ToolIndexEvictionStrategy)// Ttl / Lru / Composite / AlwaysEvictTsystemMessageSuffix(String)// 追加到 system:教模型怎么用这个工具TreferenceToolNameAccumulation(boolean)索引层就三个方法(ToolIndex):
publicinterfaceToolIndex{voidindexTool(StringsessionId,ToolReferencereference);defaultvoidindexTools(StringsessionId,List<ToolReference>references);ToolSearchResponsesearch(ToolSearchRequestrequest);// sessionId/query/maxResults/categoryFiltervoidclearIndex(StringsessionId);}publicrecordToolReference(StringtoolName,DoublerelevanceScore,Stringsummary){}运行时行为是:模型先调tool_search这个元工具(ToolSearchTool.toolSearchTool(query, maxResults, categoryFilter, ToolContext)),拿到一批工具名字,Advisor 再把这些名字对应的ToolCallback加进后续轮次。也就是"按需装配",而不是开局全量塞。
ChatClientagent=ChatClient.builder(chatModel).defaultAdvisors(ToolSearchToolCallingAdvisor.builder().toolCallingManager(this.toolCallingManager)// 仍是你自己的 manager(含 limits).toolIndex(newRegexToolIndex())// 起步用正则版,够用且零依赖.maxResults(5).evictionStrategy(newTtlEvictionStrategy(Duration.ofMinutes(10))).build()).defaultTools(ToolCallbacks.from(this.hugeToolbox))// 全量注册进索引,不进 prompt.build();两个必须注意的点:
- 它
extends ToolCallingAdvisor,所以它就是链路上那个唯一的ToolAdvisor。不要再手动加ToolCallingAdvisor,否则撞上第十八篇 5.3 那条At most one ToolAdvisor is allowed in the advisor chain。 - 权限模型不会因为"搜索"而自动收紧。检索只是决定模型这轮"看得见哪些工具",索引里全量工具仍在你的进程里。第 4 节那层按角色过滤还是得做——否则一次成功的
tool_search就能把危险工具捞进可见集。多 Agent 场景这条尤其重要:子 Agent 的工具集应当是父 Agent 权限的子集,不是并集。
3.3 拆 Agent 之后,工具箱怎么分
| 角色 | 工具集规模 | 索引选型 | 备注 |
|---|---|---|---|
| Coordinator | 3~8(分派、汇总、请求确认) | 不需要 | 描述要极清晰,它选错一次整条链路偏 |
| 查询型 Worker | 10~20 个只读工具 | RegexToolIndex起步,量大换VectorToolIndex | 只读 → 可自由重试 |
| 写入型 Worker | 3~6 个,全部要确认 | 不需要 | 权限最小化,见第十八篇第 4 节 |
| 评审型(Evaluator-Optimizer) | 0~2 | 不需要 | 只吃上下文不吃工具,第十四篇模式 5 |
4. A2A:现状说清楚,别再照着过时说法写
A2A 是最容易被写成"框架已支持"的一块,我把它按核到的事实摆一遍。
| 事项 | 核到的事实 | 依据 |
|---|---|---|
| 治理归属 | 已不是"Google 的协议":Linux Foundation 官发新闻稿宣布托管 Agent2Agent Protocol 项目,表述为“an open source project under the Linux Foundation, contributed by Google” | linuxfoundation.org 新闻页 |
| 规范版本 | 官方规范仓库a2aproject/A2A最新 release 为v1.0.1(2026-05-28) | GitHub Releases API |
| Spring AI 支持 | 没有官方 A2A 模块:spring-ai-bom:2.0.1169 个 artifact 无 a2a;2.0 与 2.1 文档搜a2a零命中;org:spring-projects下按 a2a 搜仓库 count 0 | Maven Central BOM + docs + GitHub search |
| Java 生态成熟度 | 社区实现a2a-java的integrations目录目前只有microprofile-config一项(没有 Spring 集成) | GitHub contents API |
所以结论很直白:Spring 侧要用 A2A,只能自己写适配层,或者接第三方 SDK 再手工桥接到ChatClient。这就带来一个判断题:现在到底需不需要 A2A?
我的答案是暂时不需要,因为 A2A 要解决的问题,MCP 已经解决了一大半,而两者层次不同:
| MCP(第九篇) | A2A | |
|---|---|---|
| 抽象对象 | 能力:tool / resource / prompt | Agent:有身份、有任务生命周期、会主动推进 |
| 通信语义 | 请求-响应(工具调用) | 任务提交 / 流式状态更新 / 产物(artifact)/ 取消 |
| 发现机制 | 工具列表 | Agent Card(声明能力、端点、认证方式) |
| 适合 | 把接口和能力标准化复用 | 跨组织、跨框架的 Agent 互操作 |
| Spring AI 2.0.1 | 官方支持(starter + 注解 + SDK 2.0.0) | 无 |
绝大多数内部多 Agent 系统的真实需求是"能力复用 + 鉴权边界",那是 MCP 的地盘。只有当你要把 Agent 暴露给别人写的、异构的智能体(或者反过来要调别人的 Agent),A2A 的 Agent Card 与任务生命周期才真正派上用场。
现在就能落: 你的 Java Agent --> MCP client --> 别人的 MCP Server(能力) 需要 A2A 时: 你的 Java Agent --> A2A client --> 别人的 Agent(任务 + 状态 + 产物) └── 适配层自己写,Spring AI 不给你 ──┘真要接的话,我会把 A2A 的 Agent Card 映射成自己的AgentRegistry(服务发现走 Nacos / Eureka),任务生命周期映射到 Spring 的状态机 +CompletableFuture,认证沿用 Spring Security 的 token 透传——本质上就是"自己实现一遍 A2A 的子集",好处是链路里少一个不受控的黑盒。
5. Java 侧的并行、超时与补偿
这一节全是 Spring 本体,没有任何 AI 魔法;但多 Agent 系统的稳定性 90% 由它决定。
5.1 并行分派:超时必须逐跳可控
@ServicepublicclassFanOutCoordinator{privatefinalMap<String,WorkerAgent>workers;// 按能力名注册privatefinalExecutorServicepool=Executors.newVirtualThreadPerTaskExecutor();// JDK 21publicMap<String,WorkerResult>dispatch(StringtraceId,TaskPlanplan,Durationbudget){Instantdeadline=Instant.now().plus(budget);Map<String,CompletableFuture<WorkerResult>>futures=plan.subTasks().stream().collect(toMap(SubTask::name,t->CompletableFuture.supplyAsync(()->this.workers.get(t.name()).run(traceId,t),this.pool).orTimeout(Duration.between(Instant.now(),deadline).toMillis(),MILLISECONDS).exceptionally(ex->WorkerResult.failed(t.name(),rootCauseOf(ex)))));CompletableFuture.allOf(futures.values().toArray(CompletableFuture[]::new)).join();returnfutures.entrySet().stream().collect(toMap(Entry::getKey,e->e.getValue().join()));}}三个决定:
- 虚拟线程(JDK 21+)适合"大量阻塞式模型调用"这种 IO 密集场景;但如果 Worker 内部走 WebFlux,就别再套一层
supplyAsync,直接组合Flux。混用只会让排查看到两套线程模型。 - 预算从入口传下来(
budget参数),而不是每个 Worker 各设各的 30 秒。多跳链路里"每跳都合理"的超时,加起来一定超过用户耐心。 - 失败要变成结果,不是异常。
.exceptionally(...)把失败收集成WorkerResult.failed,Coordinator 才能判断"这一步可跳过"还是"整单终止"。让异常穿透到最外层,等于放弃决策权。
5.2 补偿:把重试粒度定义在"步骤"上
模型调用的不确定性会让同一份输入产生不同输出,因此重试必须幂等——这比传统 RPC 的幂等要求更严:
| 步骤类型 | 重试策略 | 前提 |
|---|---|---|
| 只读(查询、抽取、分类) | 直接重试,最多 2 次,可换模型 | 无副作用 |
| 写入(改签、下单) | 不重试:失败即挂起,人工或上游决定 | 见第十八篇 5.2 的确认闸门 |
| 有外部副作用但可幂等 | 带idempotencyKey重试 | Key 必须由上游生成并随任务传递,不能在执行侧生成 |
| 评审型(打分、反思) | 可重试,但要把上一轮结论一起给 | 否则第二轮可能推翻第一轮,链路振荡 |
idempotencyKey我建议就是(traceId, stepName, attempt=0)的哈希——attempt 固定为 0,因为"重跑同一步"在语义上必须复用同一个键。
5.3 链路上下文:Runnable里的 ThreadLocal 会丢
ChatMemory靠.advisors(a -> a.param(CONVERSATION_ID, id))显式传,这点在并行 Worker 下没问题。丢的是另一些东西:
// 错误:SecurityContext / MDC 都是 ThreadLocal,supplyAsync 之后是空的CompletableFuture.supplyAsync(()->assistant.answer(q),pool);// 正确:显式捕获后传参,别指望上下文自动穿越线程边界varctx=SecurityContextHolder.getContext();varmdc=MDC.getCopyOfContextMap();CompletableFuture.supplyAsync(()->{SecurityContextHolder.setContext(ctx);if(mdc!=null)MDC.setContextMap(mdc);try{returnassistant.answer(q);}finally{SecurityContextHolder.clearContext();MDC.clear();}},pool);(这是 Spring 老问题,DelegatingSecurityContextExecutorService也能做。但在多 Agent 场景里我更倾向于显式传参:谁把 userId 带进了这个 Worker,看调用点就知道,不靠框架隐式搬运。第十八篇 3.1 的ToolContext同理。)
6. 一条多 Agent 链路怎么追
没有这一步,前面所有设计都只是"看起来合理"。
关键就一件事:traceId与sessionId必须跨进程传,且不能只靠 HTTP header。
用户请求 ──traceId──> Coordinator ──┬──> Worker A(本地 span,child of traceId) ├──> Worker B(跨进程:W3C traceparent header) └──> MCP Server(跨进程:MCP 请求里自己带 sessionId)| 层 | 怎么串 | 记什么 |
|---|---|---|
| 模型调用 | Spring AI 的 Micrometer observation(第十三篇) | 每个 Worker 的 usage / 延迟 / 模型名 |
| 工具执行 | ToolCallingObservationDocumentation.TOOL_CALL | TOOL_DEFINITION_NAME(低基数,安全);参数与结果只在include-content=true时才进 span——多 Agent 下别打开,第十八篇 6.3 |
| 跨进程 | W3Ctraceparent自动透传;MCP 侧自己带 | 目标 Agent 名 + 任务 ID |
| 决策 | 自建agent_decision表 | traceId、agent、candidateTools、chosenTool、argumentsHash、decision、tokens |
sessionId与traceId不是一回事:一次用户会话(sessionId)可能包含多条链路(traceId),而ToolSearchToolCallingAdvisor的索引是按 sessionId 组织的——会话结束一定要evictSession(...),否则长期跑下来索引会积累一堆用不到的工具引用(我用的 Ttl 策略就是防这个,但别只靠 TTL)。
成本归因也变得必须:多 Agent 的 Token 消耗是"乘法"(N 个 Worker × M 轮),单看总账只会得出"变贵了",看不到是谁花的。第十三篇那套按用户 / 功能维度归因,在这里再加一维agentName就够用。
7. 自检清单
| # | 检查项 | 不过关的表征 |
|---|---|---|
| 1 | 拆分能对应到三条判据之一(写进设计文档) | “为了看起来像多 Agent” |
| 2 | 每个 Worker 的工具集 ≤ 8,或已接tool-search | 光 schema 就吃掉半窗,模型忽略 system |
| 3 | 子 Agent 工具集是父权限的子集 | 一次 tool_search 把危险工具捞进可见集 |
| 4 | ChatClient链路上只有一个ToolAdvisor | At most one ToolAdvisor is allowed异常 |
| 5 | 超时预算自入口下发 | 单跳合理,端到端 3 分钟 |
| 6 | 写操作步骤不自动重试 | 重复改签、重复出款 |
| 7 | idempotencyKey由上游生成并跨跳传递 | 重跑生成新键,下游重复执行 |
| 8 | 上下文穿越线程边界是显式传参 | Worker 里Authentication为 null |
| 9 | traceId/sessionId/agentName三者在 trace 与决策表里都齐 | 出事只能猜是哪个 Agent 干的 |
| 10 | evictSession在会话结束被调用 | 长跑后工具索引虚胖,检索结果变差 |
| 11 | 跨进程协议选型明确(MCP 做能力 / 自研做任务) | 文档里写"A2A",代码里是 HTTP POST |
最后总结
- 拆 Agent 只有三条正当理由:上下文预算、权限隔离、独立伸缩。都不沾就别拆——"多 Agent"在多数内部系统里是个成本项,不是能力项。
- 拓扑上我默认选中心化编排,其次是契约清晰的流水线;点对点除非真有动态协作,否则收敛性和可观测都太难。
- 工具箱规模化的正解是 2.0.1 新增的
tool-search全家桶(ToolSearchToolCallingAdvisor+Regex / Lucene / VectorToolIndex+ TTL/LRU 驱逐):让模型自己去搜工具,而不是开局把所有 schema 塞进 prompt。1.1.2 没有这套,需要自研。 - A2A 在 Spring AI 2.0.1 里没有官方支持(BOM 无相关 artifact、文档零命中),协议本身已进 Linux Foundation(Google 贡献,规范最新 v1.0.1),Java 侧社区实现还很薄。当前阶段能力复用交给 MCP 就够,A2A 留到真要跨组织互操作时自研适配层。
- 稳定性靠 Spring 本体的三件事:预算自入口下发、失败变成结果而不是异常、幂等键由上游生成并跨跳传递。
- 对于后端 / 架构开发者,我个人更关注决策表:
agentName / candidateTools / chosenTool / decision / tokens。多 Agent 系统里唯一能回答"当时为什么这么干"的,就是这张表,框架不会替你写它。
参考资料 & 致谢
[1] Spring AI Reference(2.0.1)
[2] Spring AI - MCP Overview
[3] Spring AI - Tools
[4] Linux Foundation 托管 A2A 项目的官方新闻稿
[5] A2A Protocol 规范
[6] a2aproject/A2A - GitHub
[7] spring-projects/spring-ai - GitHub
[8] Spring AI 第八篇:Tools 七大痛点
[9] Spring AI 第九篇:MCP 实现、原理与鉴权
[10] Spring AI 第十三篇:可观测性与 Token 归因
[11] Spring AI 第十四篇:Agent 五种模式在 2.0 里怎么写