Spring AI和Spring AI Alibaba最火技术2.0

目录
🤖 Multi-Agent 到底是啥?(别再把“多次调用 LLM”当多智能体了)
🔥 把一个复杂任务拆成多个“各司其职”的智能体,让它们分工合作完成任务。
👨⚖️ Supervisor / Coordinator(监工 / 裁判)
🆚 Multi-Agent vs 单 Agent Workflow
前言
如果你看完上一篇还没来得及亲自上手体验 Spring AI / Spring AI Alibaba,那恭喜你——现在入场正是好时候 😎
接下来这一波内容,我们不光是“了解”,而是要一起把这些新技术玩明白、用起来!
这次给你安排的都是“进阶大招”:
👉 Multi-Agent(多智能体协作,像组队打副本)
👉 A2A(Agent to Agent,对话不止人与AI)
👉 Structured Output(让AI说人话,也说“结构化的话”)
👉 Tool Calling 2.0(工具调用升级版,缩小Tool使用范围)
👉 Playbook / Skillbook(AI的“技能树”和“作战手册”)
简单说一句:这一趟,我们不只是用AI,而是在教AI怎么更像一个靠谱的同事 🚀
一.Structured Output

本质上就是将AI返回内容按你规定的格式老老实实输出,而不需要你自己后面进行json字符串的解析转化

converter.getFormat() 的意思就是:
- 读取这个 Java 类的结构
- 读取字段名、字段类型、枚举值、描述信息
- 生成一段 prompt 可以直接使用的格式约束文本
@JsonPropertyDescription的作用是描述这个字段的作用,方便LLM理解
最后将最终的结果拼接到LLM提示词中,使得LLM最终输出符合预期结果
二.Tool Calling 2.0
Tool Calling 2.0:不是让模型会用工具,而是让平台把工具“管起来”
很多人一提 Tool Calling,还停留在:
👉 “让模型学会调函数”
但在 Spring AI / Spring AI Alibaba 这一套新范式里,事情已经完全变味了——
Tool Calling 2.0 的核心,不是模型更聪明,而是平台更专业。
说人话就是:
👉 不再是“AI随便挑工具”,而是“平台帮AI挑工具、管工具、盯工具”。
🧩 拆开看,本质就是 5 件事
1️⃣ 工具不再是“函数”,而是“有身份证的能力”
以前:
getWeather(city)
现在:
👉 一个工具 ≠ 一个方法
👉 而是一个“带完整档案的能力包”
包括:
- name(我是谁)
- description(我能干啥)
- schema(怎么用我)
- domain(我属于哪一类)
- keywords(别人怎么找到我)
- 权限 / 版本 / 状态(我能不能用、谁能用)
一句话总结:
工具,从“函数”进化成了“服务资产”。
/**
* 平台内部统一管理的工具定义。
* 这里把工具回调、描述、schema、关键词和领域放到一起,形成一个“最小治理单元”。
*/
public record LabToolDefinition(
String name,
String domain,
String description,
String inputSchema,
List<String> keywords,
ToolCallback callback
) {
}
注释理解:
•
name: 工具名,相当于工具身份证上的名字
•
domain: 工具所属领域,方便分类治理
•
description: 工具能力说明,给 discovery 和模型理解用
•
inputSchema: 工具参数规范,告诉平台和模型“怎么调用”
•
keywords: discovery 阶段的检索提示词
•
callback: 最终真正执行工具的方法入口
注册工具
private void register(
String name,
String domain,
String description,
String inputSchema,
List<String> keywords,
ToolCallback[] callbacks) {
ToolCallback callback = Arrays.stream(callbacks)
.filter(toolCallback -> name.equals(toolCallback.getToolDefinition().name()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("未找到工具回调: " + name));
toolMap.put(name, new LabToolDefinition(name, domain, description, inputSchema, keywords, callback));
}
register(
"query_department",
"department",
"查询部门信息,例如负责人、部门职责、组织范围。",
"...schema...",
List.of("部门", "组织", "负责人", "leader", "职责", "团队"),
ToolCallbacks.from(departmentToolService)
);
@Component
public class PolicyToolService {
private final LabKnowledgeBase labKnowledgeBase;
private final LabObservationService labObservationService;
public PolicyToolService(LabKnowledgeBase labKnowledgeBase, LabObservationService labObservationService) {
this.labKnowledgeBase = labKnowledgeBase;
this.labObservationService = labObservationService;
}
@Tool(name = "search_policy", description = "搜索报销、请假、出差等正式政策信息")
public String searchPolicy(@ToolParam(description = "政策关键词,例如报销、请假、出差") String keyword) {
return labObservationService.observeTool("search_policy", keyword, () -> searchFromMap(labKnowledgeBase.policies(), keyword, "政策"));
}
private String searchFromMap(Map<String, String> source, String keyword, String category) {
return source.entrySet().stream()
.filter(entry -> entry.getKey().contains(keyword) || keyword.contains(entry.getKey()))
.findFirst()
.map(entry -> category + "命中: " + entry.getKey() + " -> " + entry.getValue())
.orElseThrow(() -> new IllegalArgumentException("未找到匹配的" + category + "信息,关键词: " + keyword));
}
}
2️⃣ 不是一股脑全给模型,而是先“筛工具”
以前的做法很粗暴:
👉 把一堆工具全丢给模型,让它自己选
结果就是:
- 模型懵 😵
- Token 爆 💸
- 命中率还不高
现在变成:
👉 先 discovery(发现) → 再缩圈 → 再给模型
流程像这样:
- 从工具库里找候选
- 按 domain / 关键词过滤
- 只保留“可能相关”的一小撮
- 再交给模型判断
一句话:
别让模型大海捞针,先帮它把水抽干。
public List<DiscoveryCandidate> discover(String question, List<LabToolDefinition> allTools, int topK) {
if (allTools.isEmpty()) {
return List.of();
}
ChatModel chatModel = chatModelProvider.getIfAvailable();
if (chatModel == null) {
labObservationService.recordEvent("discovery-strategy", "未检测到 ChatModel,discovery 回退到规则模式");
return ruleBasedToolDiscoveryService.discover(question, allTools, topK);
}
try {
labObservationService.recordEvent("discovery-strategy", "使用 LLM discovery 从全部工具中筛选候选工具");
return discoverWithLlm(chatModel, question, allTools, topK);
}
catch (RuntimeException ex) {
labObservationService.recordEvent("discovery-fallback", "LLM discovery 失败,回退到规则模式。原因: " + ex.getMessage());
return ruleBasedToolDiscoveryService.discover(question, allTools, topK);
}
}
private List<DiscoveryCandidate> discoverWithLlm(ChatModel chatModel, String question, List<LabToolDefinition> allTools, int topK) {
String toolCatalogJson = buildToolCatalogJson(allTools);
String rawResponse = ChatClient.create(chatModel)
.prompt()
.system("""
你是企业内部 AI 工具平台的 discovery 模块。
你的任务不是直接回答用户问题,而是从给定工具目录中挑选最相关的候选工具。
你必须严格遵守下面规则:
1. 只能从给定工具目录中选择工具,不能编造不存在的工具名。
2. 最多返回 topK 个工具。
3. 综合参考工具的 name、domain、description、inputSchema、keywords。
4. reason 必须说明“为什么这个工具适合这个问题”。
5. 只返回 JSON,不要 Markdown,不要解释性前后文。
6. JSON 结构必须是:
{"candidates":[{"toolName":"query_employee","score":9,"reasons":["原因1","原因2"]}]}
""")
.user("""
用户问题:
%s
topK:
%d
工具目录:
%s
""".formatted(question, topK, toolCatalogJson))
.call()
.content();
String sanitizedResponse = sanitizeJson(rawResponse);
labObservationService.recordEvent("discovery-llm-raw", "LLM discovery 原始输出: " + abbreviate(sanitizedResponse));
LlmDiscoveryResult parsedResult = readDiscoveryResult(sanitizedResponse);
List<DiscoveryCandidate> candidates = mapToCandidates(question, parsedResult, allTools, topK);
if (candidates.isEmpty()) {
throw new IllegalStateException("LLM discovery 没有返回有效候选工具");
}
return candidates;
}
3️⃣ 工具调用 = 平台编排问题(不是模型一句话的事)
早期是:
👉 模型说:“调这个函数” → OK 结束
现在是:
👉 一整套 编排
- 谁做 discovery?
- 谁决定最终选哪个工具?
- 参数谁来构造?
- 调用失败怎么办?
- 要不要 fallback?
你会发现一个关键变化:
“调不调用工具”,从模型问题,变成系统设计问题。
// LLM_TOOL_CALL 模式处理逻辑:平台先筛候选,再让模型只在候选工具中做选择。
private AskResponse handleLlmToolCall(AskRequest request, String requestId, List<DiscoveryCandidate> candidates) {
// 记录事件,强调这里不是把所有工具都暴露给模型,而是只暴露 discovery 后的候选工具。
labObservationService.recordEvent(
// 当前事件阶段名。
"tool-selected",
// 当前事件内容:打印出暴露给模型的候选工具名。
"LLM_TOOL_CALL 模式将只向模型暴露候选工具: " + candidates.stream().map(candidate -> candidate.tool().name()).toList()
);
// 对 LLM 工具调用阶段做统一观测,并得到模型最终回答。
String finalAnswer = labObservationService.observe(
// 观测名称。
"lab.llm.tool-call",
// 阶段名称。
"llm-tool-call",
// 真正执行 LLM 工具调用:模型只能在候选工具范围内做决策。
() -> llmToolCallingService.callWithTools(request.question(), candidates.stream().map(DiscoveryCandidate::tool).toList())
);
// 从当前 trace 中读取第一条工具调用记录里的工具名。
String selectedTool = RequestTraceHolder.current()
// 如果 trace 存在,就从工具调用记录里取第一条并读取工具名。
.flatMap(trace -> trace.toolCalls().stream().findFirst().map(ToolCallRecord::toolName))
// 如果模型没有调用任何工具,则返回兜底提示。
.orElse("模型未调用工具");
// 从当前 trace 中读取第一条工具调用记录里的结果摘要。
String toolResult = RequestTraceHolder.current()
// 如果 trace 存在,就从工具调用记录里取第一条并读取结果摘要。
.flatMap(trace -> trace.toolCalls().stream().findFirst().map(ToolCallRecord::resultSummary))
// 如果没有记录到工具结果,则返回兜底提示。
.orElse("未获取到工具结果");
// 记录最终答案已生成。
labObservationService.recordEvent("final-answer", "LLM 最终答案已生成");
// 统一组装响应对象并返回。这里工具参数暂时返回空 Map,因为参数是模型内部决定的。
return buildResponse(requestId, request.question(), ToolInvocationMode.LLM_TOOL_CALL, candidates, selectedTool, Map.of(), toolResult, finalAnswer);
}
4️⃣ 全链路必须“看得见”(可观测性)
以前:
👉 只看最终回答
现在:
👉 不好意思,每一步都要能查账
你得能看到:
- 用户问了啥
- 筛出了哪些工具
- 模型选了哪个
- 调用耗时多少
- 返回了什么
- 哪一步炸了 💥
一句话:
AI 也要上监控,不然出事就是玄学。
5️⃣ 工具越多,重点越变成“治理”
当工具数量从 10 个 → 100 个 → 1000 个之后
问题就变了:
不是:
👉 “能不能调?”
而是:
- 怎么注册?
- 怎么发现?
- 怎么控制暴露范围?
- 怎么做权限隔离?
- 怎么审计调用?
- 怎么维护版本?
一句话总结:
工具多了之后,拼的不是能力,是管理能力。
private void register(
String name,
String domain,
String description,
String inputSchema,
List<String> keywords,
ToolCallback[] callbacks) {
ToolCallback callback = Arrays.stream(callbacks)
.filter(toolCallback -> name.equals(toolCallback.getToolDefinition().name()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("未找到工具回调: " + name));
toolMap.put(name, new LabToolDefinition(name, domain, description, inputSchema, keywords, callback));
}
⚔️ 和早期 Function Calling 的区别
可以用一句非常形象的话总结👇
🧒 Function Calling(1.0)
- 模型看到一堆函数
- 自己选一个
- 调完收工
👉 像一个刚会用工具的小孩
🧑💼 Tool Calling 2.0
- 平台先维护“工具目录”
- 先 discovery(筛一轮)
- 再缩小暴露范围
- 再执行调用
- 全程监控 + 可追踪 + 可治理
👉 像一个有流程、有监控、有权限系统的公司
三.Playbook / Skillbook
🎯 什么是 Skill?
先说结论:
Skill = 一个可复用的“做事套路”
它不是简单的:
- ❌ 一个工具调用
- ❌ 一段 prompt
而是把一类问题的处理方式彻底固化下来。
一个完整的 Skill,通常会包含:
- 负责解决什么问题(职责边界)
- 什么时候该触发(触发条件)
- 可以用哪些 Tool(工具集合)
- 固定执行步骤(流程)
- fallback 策略(兜底方案)
- 输出结构(必须长啥样)
- 上下文策略(能看啥、不能看啥)
一句话总结:
Skill = 面向某类任务的“标准解法模板”
📖 Playbook 是什么?
如果说 Skill 是“技能”,
那 Playbook 就是:
这项技能的操作说明书
它关注的不是:
👉 能做什么
而是:
👉 遇到问题时,应该按什么顺序做
🧩 举个例子(奖学金查询 Skill)
一个 Playbook 可能长这样:
- 识别奖学金类型
- 查询制度库
- 提取申请流程和材料
- 按固定模板输出
- 如果识别失败 → 走通用 fallback
你会发现:
👉 这已经不是“生成答案”
👉 而是在执行流程
一句话:
Playbook = Skill 的“操作步骤说明书”
📚 Skillbook 又是啥?
如果继续类比:
- Skill = 一本技能书
- Playbook = 这本书里的操作步骤
- Skillbook = 一整排书架
👉 所以:
Skillbook = 系统的“能力地图”
它回答的是:
- 系统有哪些稳定能力?
- 每种能力怎么被触发?
- 各自怎么执行?
🔗 三者关系,一句话记住
Skill 是能力单元,Playbook 是执行步骤,Skillbook 是整体能力体系。
四.Multi-Agent
🤖 Multi-Agent 到底是啥?(别再把“多次调用 LLM”当多智能体了)
很多人一听到 Multi-Agent 就以为:
👉 “我多调几次 LLM,不就是多智能体了吗?”
不好意思——
那顶多算你在“多刷几次 API”,不是 Multi-Agent。😅
🧠 一句话先给你打个底
Multi-Agent ≠ 多步骤 ≠ 多次调用 LLM
它真正的意思是:
🔥 把一个复杂任务拆成多个“各司其职”的智能体,让它们分工合作完成任务。
🧩 Multi-Agent 到底在强调什么?
它关注的不是“调用了几次模型”,而是:
- ✅ 有没有多个独立角色
- ✅ 有没有明确分工
- ✅ 有没有Agent 之间协作
- ✅ 有没有中间结果传递
- ✅ 有没有检查 / 补充 / 裁决机制
👉 总结一下就是:
不是“多干活”,而是“分工干活”
🧠 一个标准 Multi-Agent 都在干嘛?
一个典型配置长这样:
🧭 RouterAgent(路由官)
👉 负责:
- 理解问题
- 判断任务类型
- 决定该谁上场
👉 就像:
“这题是数学的,叫数学老师来。”
🧑🔬 Specialist Agent(专家团)
👉 每个人负责一个领域:
- 有人管检索(RAG)
- 有人管结构化数据
- 有人管推理
- 有人管工具调用
👉 就像:
“术业有专攻,别什么都让我一个人干。”
👨⚖️ Supervisor / Coordinator(监工 / 裁判)
👉 负责:
- 检查结果够不够好
- 要不要补一轮
- 最终给答案收口
👉 就像:
“这答案不行,再写一版。”
🔧 Multi-Agent 是怎么跑起来的?
其实可以拆成两层👇
🧱 第一层:执行机制(底座)
也就是“系统怎么跑”:
- 🧩 graph(节点 + 边)
- 🔁 状态机(state machine)
- 📬 消息循环(event loop)
👉 本质:
控制流程怎么走
🧠 第二层:协作语义(灵魂)
也就是“大家怎么配合”:
- 🎭 role(角色)
- 📦 protocol(通信协议)
- 🔁 delegation(任务委派)
- 👨⚖️ supervision(监督裁决)
👉 本质:
谁干什么 + 怎么交接
🆚 Multi-Agent vs 单 Agent Workflow
这个是面试高频,必须说清👇
🧍 单 Agent workflow
一个大脑:
→ 分类
→ 检索
→ 生成
→ 汇总
👉 特点:
- 所有事情都自己干
- 再复杂也只是“多几步”
👉 本质:
🧠 一个人干完所有活(全能打工人)
🤝 Multi-Agent
Router
→ 专家A
→ 专家B
→ Supervisor
→ 不够再补
→ 最终裁决
👉 特点:
- 多个主体
- 分工明确
- 可以互相协作 / 补充
👉 本质:
🤝 一个团队干活(分工协作)
🧪 一个“像样”的 Multi-Agent 应该长啥样?
如果你写的是 Multi-Agent,至少要满足这些👇
- ✅ 有多个独立 Agent 类(不是换个 prompt 名字)
- ✅ 每个 Agent 有清晰职责边界
- ✅ Agent 之间传递的是结构化数据(不是纯文本乱拼)
- ✅ Agent 可以决定是否调用其他 Agent
- ✅ 有一个专门负责汇总 / 裁决的角色
-
✅ 支持:
不够 → 再调用 → 再补充 → 再收敛
1. 多 Agent 的统一接口 主题:每个 Agent 都不是普通方法节点,而是一个能接收消息、返回结果和下一跳消息的独立角色。
/**
* 所有 Agent 的统一接口。
* 每个 Agent 接收结构化消息,并返回本轮执行结果以及下一跳消息。
*/
public interface CampusAgent {
/**
* 当前 Agent 的角色身份。
*/
AgentRole role();
/**
* 处理一条 AgentMessage,并基于当前上下文产出执行结果。
*/
AgentExecution handleMessage(AgentMessage message, AgentContext context);
}
2. Agent 角色定义 主题:Multi-Agent 不是多个 prompt,而是多个有角色边界的主体
/**
* Agent 角色枚举。
*/
public enum AgentRole {
ROUTER_AGENT("RouterAgent"),
POLICY_AGENT("PolicyAgent"),
LIFE_AGENT("LifeAgent"),
SUPERVISOR_AGENT("SupervisorAgent");
private final String displayName;
AgentRole(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return this.displayName;
}
}
3. Agent 间通信协议 主题:Agent 之间不是传裸字符串,而是传结构化消息
/**
* Agent 之间的结构化消息。
*/
public record AgentMessage(
/** 当前消息自身的唯一标识,用于追踪一次 Agent-to-Agent 通信。 */
String messageId,
/** 这条消息由哪个 Agent 发出。 */
AgentRole fromAgent,
/** 这条消息准备投递给哪个 Agent。 */
AgentRole toAgent,
/** 这条消息的意图,例如路由、回答、审阅、补充。 */
AgentIntent intent,
/** 这条消息随附的结构化请求体。 */
AgentRequest request
) {
}
4. Agent 输入结构 主题:每个 Agent 收到的不是一句话,而是一份带上下文的结构化请求。
/**
* Agent 统一输入结构。
*/
public record AgentRequest(
/** 当前请求链路的唯一追踪 ID。 */
String traceId,
/** 用户提交的原始问题。 */
String originalQuery,
/** 当前请求是由哪个 Agent 发起的。 */
AgentRole requestedBy,
/** 当前希望处理的目标领域。 */
List<CampusDomain> targetDomains,
/** 本轮需要重点关注的补充点。 */
List<String> focusPoints,
/** 多 Agent 共享的结构化事实。 */
Map<String, String> sharedFacts,
/** 当前是第几轮处理。 */
int round
) {
}
5. Agent 输出结构 主题:每个 Agent 都要产出标准化结果,而不是随便返回一段文本
/**
* Agent 统一输出结构。
*/
public record AgentResult(
/** 当前结果由哪个 Agent 产出。 */
AgentRole agentRole,
/** 当前 Agent 的处理状态。 */
AgentStatus status,
/** 对本次处理结果的简短摘要。 */
String summary,
/** 结构化要点列表。 */
List<String> bulletPoints,
/** 下一步建议继续处理的领域。 */
List<CampusDomain> nextDomains,
/** 当前 Agent 已覆盖的主题列表。 */
List<String> coveredTopics,
/** 当前 Agent 认为仍然缺失的主题列表。 */
List<String> missingTopics,
/** 当前 Agent 产出的共享事实。 */
Map<String, String> sharedFacts
) {
}
6.Agent 单次执行结果 主题:一次 Agent 执行,不只返回答案,还返回“下一步要发给谁”
/**
* Agent 单次执行结果,包含本轮结果和下一跳消息。
*/
public record AgentExecution(
/** 当前 Agent 处理完消息后产出的结构化结果。 */
AgentResult result,
/** 当前 Agent 希望继续发出的后续消息列表。 */
List<AgentMessage> outboundMessages,
/** 当前这次执行是否已经可以视为终局并停止协作。 */
boolean terminal
) {
}
7.执行底座:消息循环 主题:底层执行机制不是固定流程,而是 event loop。
Deque<AgentMessage> queue = new ArrayDeque<>();
queue.add(AgentMessage.of(
AgentRole.ROUTER_AGENT,
AgentRole.ROUTER_AGENT,
AgentIntent.ROUTE,
initialRequest
));
while (!queue.isEmpty() && context.canDispatchMore()) {
AgentMessage message = queue.removeFirst();
context.appendMessage(message);
CampusAgent targetAgent = this.agentRegistry.get(message.toAgent());
AgentExecution execution = targetAgent.handleMessage(message, context);
if (execution.result() != null) {
context.appendResult(execution.result());
}
for (AgentMessage outboundMessage : execution.outboundMessages()) {
if (context.markDispatched(outboundMessage)) {
queue.addLast(outboundMessage);
}
}
}
这段代码说明系统不是写死 A -> B -> C,而是通过消息队列驱动。每个 Agent 执行完后可以产生新消息,新消息继续进入队列。这个机制让 Agent 协作具备动态性。
8.RouterAgent:负责让谁先上场 主题:Router 只做初始路由,不直接回答用户问题。
@Override
public AgentExecution handleMessage(AgentMessage message, AgentContext context) {
AgentRequest request = message.request();
AgentResult routeResult = this.llmProperties.useDashScope()
? llmRoute(request, context)
: mockRoute(request);
List<CampusDomain> routedDomains = routeResult.nextDomains();
CampusDomain primaryDomain = routedDomains.get(0);
AgentRole primaryAgent = toSpecialist(primaryDomain);
AgentRequest specialistRequest = request.forAgent(
role(),
routedDomains,
parseFocusPoints(routeResult.sharedFacts().get("router.focusPoints")),
routeResult.sharedFacts(),
request.round()
);
AgentMessage outbound = AgentMessage.of(
role(),
primaryAgent,
AgentIntent.ANSWER,
specialistRequest
);
return AgentExecution.of(routeResult, List.of(outbound), false);
}
Router 的任务不是回答,而是判断问题类型,并把问题交给合适的 Specialist
9.Router 的 LLM 决策 主题:LLM 在 Router 中用于“分类和路由”,不是用于最终回答。
private AgentResult llmRoute(AgentRequest request, AgentContext context) {
RouterDecision decision = this.llmJsonClient.generateJson(
this.promptFactory.routerSystemPrompt(),
this.promptFactory.routerUserPrompt(request, context),
RouterDecision.class
);
List<CampusDomain> routedDomains = LlmDecisionSupport.parseDomains(
decision.targetDomains(),
List.of(CampusDomain.POLICY)
);
Map<String, String> facts =
new LinkedHashMap<>(LlmDecisionSupport.normalizeFacts(decision.sharedFacts()));
facts.put("router.route", routedDomains.toString());
if (!decision.focusPoints().isEmpty()) {
facts.put("router.focusPoints", String.join(" | ", decision.focusPoints()));
}
return new AgentResult(
role(),
AgentStatus.COMPLETED,
fallback(decision.summary(), "RouterAgent 已完成初始路由。"),
decision.reasons(),
routedDomains,
routedDomains.stream().map(CampusDomain::label).toList(),
List.of(),
facts
);
}
10.Specialist Agent:做本领域判断并决定是否委派 主题:Specialist 不是什么都做,只处理自己的领域;如果发现缺别的领域,就发消息给对应 Agent。
@Override
public AgentExecution handleMessage(AgentMessage message, AgentContext context) {
AgentRequest request = message.request();
AgentResult result = this.llmProperties.useDashScope()
? llmHandle(request, context)
: mockHandle(request, context);
List<AgentMessage> outbound = new ArrayList<>();
maybeDelegateToCounterpart(message, context, result)
.ifPresent(outbound::add);
outbound.add(reviewMessage(request, result));
return AgentExecution.of(result, outbound, false);
}
Specialist 的一次执行会做三件事:先产出本领域结果;如果需要其他领域,就委派给其他 Specialist;无论是否委派,都会把结果发给 Supervisor 审阅。
11. Delegation:任务委派 主题:Agent 发现自己不够时,可以主动委派给其他 Agent
private Optional<AgentMessage> maybeDelegateToCounterpart(
AgentMessage message,
AgentContext context,
AgentResult result
) {
if (!result.nextDomains().contains(CampusDomain.LIFE)) {
return Optional.empty();
}
if (context.hasConsulted(AgentRole.LIFE_AGENT)
|| message.fromAgent() == AgentRole.LIFE_AGENT) {
return Optional.empty();
}
AgentRequest delegateRequest = message.request().forAgent(
role(),
List.of(CampusDomain.LIFE),
focusPoints,
result.sharedFacts(),
message.request().round()
);
return Optional.of(AgentMessage.of(
role(),
AgentRole.LIFE_AGENT,
AgentIntent.ANSWER,
delegateRequest
));
}
这就是 delegation。Specialist 不会硬答自己不擅长的内容,而是根据 nextDomains 决定是否把任务交给另一个 Agent。
12. Supervisor:审阅、补充、裁决 主题:Supervisor 不是领域专家,而是质量控制和最终裁决者
@Override
public AgentExecution handleMessage(AgentMessage message, AgentContext context) {
AgentRequest request = message.request();
AgentResult result = this.llmProperties.useDashScope()
? llmHandle(request, context)
: mockHandle(request, context);
List<AgentMessage> outbound = new ArrayList<>();
if (result.status() == AgentStatus.NEEDS_SUPPLEMENT
&& context.canSupplement()) {
for (CampusDomain domain : result.nextDomains()) {
AgentRole targetRole = toSpecialist(domain);
AgentRequest followUpRequest = request.forAgent(
role(),
List.of(domain),
result.missingTopics(),
result.sharedFacts(),
request.round() + 1
);
outbound.add(AgentMessage.of(
role(),
targetRole,
AgentIntent.SUPPLEMENT,
followUpRequest
));
}
context.markSupplementTriggered();
}
boolean terminal =
result.status() == AgentStatus.COMPLETED && outbound.isEmpty();
return AgentExecution.of(result, outbound, terminal);
}
13. 共享上下文和防失控机制 主题:Multi-Agent 需要记忆、去重和终止条件,否则容易无限互相调用。
private final List<AgentResult> resultHistory = new ArrayList<>();
private final List<AgentMessage> messageHistory = new ArrayList<>();
private final Map<String, String> sharedFacts = new LinkedHashMap<>();
private final Set<String> dispatchedKeys = new LinkedHashSet<>();
private final Set<String> supplementKeys = new LinkedHashSet<>();
private final int maxSupplementRounds = 2;
private final int maxHopCount = 12;
public void appendResult(AgentResult result) {
this.resultHistory.add(result);
this.sharedFacts.putAll(result.sharedFacts());
this.consultedAgents.add(result.agentRole());
}
public boolean markDispatched(AgentMessage message) {
return this.dispatchedKeys.add(message.dispatchKey());
}
public boolean canDispatchMore() {
return this.messageHistory.size() < this.maxHopCount;
}
真实 Multi-Agent 必须有边界。这里用 messageHistory 记录通信轨迹,用 dispatchedKeys 防重复消息,用 maxHopCount 防无限循环,用 maxSupplementRounds 限制补充轮次。
14.用一条请求说明 Multi-Agent 不是固定 workflow。
示例问题:
奖学金申请材料有哪些,晚上食堂营业到几点?
典型链路:
RouterAgent -> RouterAgent [ROUTE][round=0]
RouterAgent -> SpecialistAgentA [ANSWER][round=0]
SpecialistAgentA -> SpecialistAgentB [ANSWER][round=0]
SpecialistAgentA -> SupervisorAgent [REVIEW][round=0]
SpecialistAgentB -> SupervisorAgent [REVIEW][round=0]
SupervisorAgent -> SpecialistAgentA [SUPPLEMENT][round=1]
SpecialistAgentA -> SupervisorAgent [REVIEW][round=1]
SupervisorAgent -> final
这条链路里同时出现了路由、专家处理、专家间委派、监督审阅、补充调用和最终裁决。它体现的不是“多次调用 LLM”,而是多个有角色边界的 Agent 通过结构化消息协作完成任务。
所以,在这个实现里,Multi-Agent 并不是简单地多调几次 LLM。
真正关键的是:
1. 有多个独立角色;
2. 每个角色有明确职责;
3. Agent 之间通过结构化协议通信;
4. Agent 可以根据结果动态委派;
5. Supervisor 负责审阅、补充和最终裁决;
6. 整个系统通过消息循环驱动,而不是写死固定顺序。
五.Agent to Agent
1. 先理解现在为什么要加 A2A
原来项目里 Agent 通信是
AgentMessage -> Orchestrator 队列 -> AgentRegistry -> 本地 Agent 对象
这只能在一个 Java 进程里玩。
现在加 A2A 后,目标是变成
AgentMessage -> AgentTransport -> 本地 Agent 或远程 A2A Agent
也就是说:先不推翻你原来的 Multi-Agent,只是在边界上加一层远程协议能力。
2.先看入口:Agent Card
GET /.well-known/agent.json

作用是让外部系统知道:
这个 Agent 服务
- 叫什么
- 能做什么
- A2A 调用地址在哪
- 支持什么能力
你可以把 Agent Card 理解成:
Agent 的简历 / 名片 / 能力说明书

🎯 metadata 里的 3 个字段是干嘛的?
先说结论:
👉 这 3 个字段不是 A2A 强制要求的
👉 是我们自己加的“工程说明书”
就像给 Agent 挂了个小牌子:
“你好,我是谁,我怎么被调用,我现在啥工作模式”
🧩 原代码长这样:
Map.of(
"runtime", "spring-boot",
"transport", "json-rpc-over-http",
"mode", "sync-message-send"
)
🧠 逐个解释(带点人话版👇)
🟢 1. runtime = spring-boot
👉 我是谁,我用什么写的
翻译一下:
“我是一个 Spring Boot 打工人 👨💻”
现实意义:
如果公司有一堆 Agent:
- Java 的 👉 spring-boot
- Python 的 👉 fastapi
- Node 的 👉 nodejs
那这个字段就是:
👉 给运维/平台看的身份证
🔵 2. transport = json-rpc-over-http
👉 你要怎么跟我说话
翻译一下:
“别打电话,发 HTTP + JSON-RPC 给我 📦”
具体就是:
http
POST /a2a
Content-Type: application/json
body:
{
"method": "message/send",
"params": {...}
}
🟡 3. mode = sync-message-send
👉 我工作的时候,是慢悠悠还是秒回型
翻译一下:
“我收到消息 → 处理 → 当场给你结果 ⚡”
也就是:
- 同步模式(sync)
- 一次请求 → 一次响应
对比一下你未来会遇到的:
| 模式 | 描述 |
|---|---|
| sync-message-send | 立即返回(你现在这个) |
| async-task | 先接单,晚点给结果 |
| stream | 边想边说(流式) |
3. 再看 A2A 请求入口

它暴露:
POST /a2a
当前只支持:
message/send
流程是:
JSON-RPC 请求
-> A2aSendMessageParams
-> AgentMessage
-> 找到目标 Agent
-> handleMessage(...)
-> AgentExecution
-> A2A JSON-RPC 响应
A2A 不是替代你的 Agent,而是把远程请求翻译成你内部 Agent 能理解的消息。
4. 然后看协议对象






5. 重点看适配器
它做两类转换:
A2A -> 内部协议
A2aSendMessageParams -> AgentMessage
内部协议 -> A2A
AgentExecution -> A2aSendMessageResult
6. 再看 Transport 抽象
public interface AgentTransport {
AgentExecution deliver(AgentMessage message, AgentContext context);
}


7. 看远程调用骨架
AgentMessage
-> A2aSendMessageParams
-> JSON-RPC 请求
-> HTTP POST 到远程 /a2a
-> A2aJsonRpcResponse
-> AgentExecution
理论这么多,脑子都要转糊了,下面举个例子带你理解一下
1. Controller 接收问题
@GetMapping("/ask")
public AskResponse ask(@RequestParam String query) {
return this.orchestrator.ask(query);
}
2. Orchestrator 初始化上下文和第一条消息
public AskResponse ask(String query) {
if (!StringUtils.hasText(query)) {
throw new IllegalArgumentException("query 不能为空");
}
String traceId = UUID.randomUUID().toString();
AgentContext context = new AgentContext(traceId, query);
AgentRequest initialRequest = AgentRequest.initial(traceId, query);
Deque<AgentMessage> queue = new ArrayDeque<>();
queue.add(AgentMessage.of(AgentRole.ROUTER_AGENT, AgentRole.ROUTER_AGENT, AgentIntent.ROUTE, initialRequest));
创建 traceId
创建 AgentContext
创建第一条消息:RouterAgent -> RouterAgent [ROUTE]
3. Orchestrator 消费消息队列
AgentResult finalResult = null;
while (!queue.isEmpty() && context.canDispatchMore()) {
AgentMessage message = queue.removeFirst();
context.appendMessage(message);
AgentExecution execution = this.agentTransport.deliver(message, context);
if (execution.result() != null) {
context.appendResult(execution.result());
if (execution.result().agentRole() == AgentRole.ROUTER_AGENT) {
context.registerRoutedDomains(execution.result().nextDomains());
}
if (execution.terminal()) {
finalResult = execution.result();
break;
}
}
for (AgentMessage outboundMessage : execution.outboundMessages()) {
if (context.markDispatched(outboundMessage)) {
queue.addLast(outboundMessage);
}
}
}
4. Transport 决定本地调用还是远程 A2A
@Override
public AgentExecution deliver(AgentMessage message, AgentContext context) {
if (this.a2aProperties.hasRemote(message.toAgent())) {
return this.a2aAgentClient.send(message);
}
CampusAgent targetAgent = this.agentRegistry.get(message.toAgent());
return targetAgent.handleMessage(message, context);
}
如果配置了远程 Agent,就走 A2aAgentClient.send。否则走本地 AgentRegistry,找到对应 Agent,调用它的 handleMessage。

5. RouterAgent 判断问题属于哪些领域
@Override
public AgentExecution handleMessage(AgentMessage message, AgentContext context) {
AgentRequest request = message.request();
AgentResult routeResult = this.llmProperties.useDashScope() ? llmRoute(request, context) : mockRoute(request);
List<CampusDomain> routedDomains = routeResult.nextDomains();
CampusDomain primaryDomain = routedDomains.get(0);
AgentRole primaryAgent = toSpecialist(primaryDomain);
AgentRequest specialistRequest = request.forAgent(
role(),
routedDomains,
parseFocusPoints(routeResult.sharedFacts().get("router.focusPoints")),
routeResult.sharedFacts(),
request.round()
);
AgentMessage outbound = AgentMessage.of(role(), primaryAgent, AgentIntent.ANSWER, specialistRequest);
return AgentExecution.of(routeResult, List.of(outbound), false);
}
对于“奖学金 + 食堂”这种混合问题

6.Orchestrator 把下一跳放回队列
for (AgentMessage outboundMessage : execution.outboundMessages()) {
if (context.markDispatched(outboundMessage)) {
queue.addLast(outboundMessage);
}
}
7.再次投递时命中远程 A2A
这次目标是 POLICY_AGENT。

8.本地把内部 AgentMessage 转成 A2A 请求

真正的转换
public A2aSendMessageParams toSendMessageParams(AgentMessage message) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("traceId", message.request().traceId());
metadata.put("fromAgent", message.fromAgent().name());
metadata.put("toAgent", message.toAgent().name());
metadata.put("intent", message.intent().name());
metadata.put("round", message.request().round());
metadata.put("targetDomains", message.request().targetDomains().stream().map(Enum::name).toList());
metadata.put("focusPoints", message.request().focusPoints());
metadata.put("sharedFacts", message.request().sharedFacts());
A2aMessage a2aMessage = new A2aMessage(
message.messageId(),
"user",
List.of(A2aPart.text(message.request().originalQuery())),
metadata
);
return new A2aSendMessageParams(a2aMessage);
}
被转成 A2A JSON-RPC:
{
"jsonrpc": "2.0",
"id": "随机UUID",
"method": "message/send",
"params": {
"message": {
"messageId": "内部messageId",
"role": "user",
"parts": [
{
"kind": "text",
"text": "奖学金申请材料有哪些,晚上食堂营业到几点?"
}
],
"metadata": {
"traceId": "同一个traceId",
"fromAgent": "ROUTER_AGENT",
"toAgent": "POLICY_AGENT",
"intent": "ANSWER",
"round": 0,
"targetDomains": ["POLICY", "LIFE"],
"focusPoints": ["需要跨制度与生活协同回答"],
"sharedFacts": {
"router.route": "[POLICY, LIFE]",
"router.focusPoints": "需要跨制度与生活协同回答"
}
}
}
}
}
9.本地真正 POST 到远程 A2A
A2aJsonRpcResponse response = this.restClient.post()
.uri(remote.getUrl())
.contentType(MediaType.APPLICATION_JSON)
.headers(headers -> {
if (remote.getApiKey() != null && !remote.getApiKey().isBlank()) {
headers.setBearerAuth(remote.getApiKey());
}
})
.body(request)
.retrieve()
.body(A2aJsonRpcResponse.class);

10.远程服务的 /a2a 接住请求
远程服务也有同样的入口:
@PostMapping("/a2a")
public A2aJsonRpcResponse handle(@RequestBody A2aJsonRpcRequest request) {
if (!"2.0".equals(request.jsonrpc())) {
return A2aJsonRpcResponse.error(request.id(), -32600, "jsonrpc must be 2.0");
}
if (!"message/send".equals(request.method())) {
return A2aJsonRpcResponse.error(request.id(), -32601, "Unsupported method: " + request.method());
}
A2aSendMessageParams params = this.objectMapper.convertValue(request.params(), A2aSendMessageParams.class);
AgentMessage message = this.adapter.toAgentMessage(params);
11.远程 A2A message 转内部 AgentMessage
public AgentMessage toAgentMessage(A2aSendMessageParams params) {
A2aMessage message = params.message();
Map<String, Object> metadata = message.metadata();
String query = textOf(message);
String traceId = stringValue(metadata.get("traceId"), UUID.randomUUID().toString());
AgentRole fromAgent = roleValue(metadata.get("fromAgent"), AgentRole.ROUTER_AGENT);
AgentRole toAgent = roleValue(metadata.get("toAgent"), AgentRole.ROUTER_AGENT);
AgentIntent intent = intentValue(metadata.get("intent"), AgentIntent.ROUTE);
int round = intValue(metadata.get("round"), 0);
AgentRequest request = new AgentRequest(
traceId,
query,
fromAgent,
domainList(metadata.get("targetDomains")),
stringList(metadata.get("focusPoints")),
stringMap(metadata.get("sharedFacts")),
round
);
return AgentMessage.of(fromAgent, toAgent, intent, request);
}
12.远程服务调用自己的 PolicyAgent
CampusAgent agent = this.agentRegistry.get(message.toAgent());
AgentExecution execution = agent.handleMessage(message, context);
if (execution.result() != null) {
context.appendResult(execution.result());
}
13.远程 PolicyAgent 生成结果
@Override
public AgentExecution handleMessage(AgentMessage message, AgentContext context) {
AgentRequest request = message.request();
AgentResult result = this.llmProperties.useDashScope() ? llmHandle(request, context) : mockHandle(request, context);
List<AgentMessage> outbound = new ArrayList<>();
maybeDelegateToCounterpart(message, context, result).ifPresent(outbound::add);
outbound.add(reviewMessage(request, result));
return AgentExecution.of(result, outbound, false);
}

远程 /a2a 当前不会继续执行这些 outbound messages,只会把它们作为结果 metadata 返回
14.远程把 AgentExecution 转成 A2A 响应
return A2aJsonRpcResponse.success(request.id(), this.adapter.toSendMessageResult(execution));
public A2aSendMessageResult toSendMessageResult(AgentExecution execution) {
AgentResult result = execution.result();
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("agentRole", result.agentRole().name());
metadata.put("status", result.status().name());
metadata.put("summary", result.summary());
metadata.put("bulletPoints", result.bulletPoints());
metadata.put("nextDomains", result.nextDomains().stream().map(Enum::name).toList());
metadata.put("coveredTopics", result.coveredTopics());
metadata.put("missingTopics", result.missingTopics());
metadata.put("sharedFacts", result.sharedFacts());
metadata.put("terminal", execution.terminal());
metadata.put("outboundMessages", execution.outboundMessages().stream().map(AgentMessage::dispatchKey).toList());
A2aMessage responseMessage = new A2aMessage(
UUID.randomUUID().toString(),
"agent",
List.of(A2aPart.text(String.join("\n", result.bulletPoints()))),
metadata
);
return new A2aSendMessageResult(
UUID.randomUUID().toString(),
"task",
new A2aTaskStatus(stateOf(result.status()), responseMessage),
metadata
);
}
返回结果大概是:
{
"jsonrpc": "2.0",
"id": "本地请求id",
"result": {
"id": "task-id",
"kind": "task",
"status": {
"state": "working",
"message": {
"role": "agent",
"parts": [
{
"kind": "text",
"text": "制度侧回答内容..."
}
]
}
},
"metadata": {
"agentRole": "POLICY_AGENT",
"status": "NEEDS_SUPPLEMENT",
"summary": "PolicyAgent 已完成制度部分,但建议再补生活侧与制度细节。",
"bulletPoints": ["..."],
"nextDomains": ["LIFE"],
"missingTopics": ["..."],
"sharedFacts": {
"policy.round": "0"
},
"terminal": false,
"outboundMessages": [
"POLICY_AGENT|LIFE_AGENT|ANSWER|0|..."
]
}
}
}
15.本地收到远程响应并转回 AgentExecution

public AgentExecution toAgentExecution(A2aSendMessageResult result) {
Map<String, Object> metadata = result.metadata();
AgentRole role = roleValue(metadata.get("agentRole"), AgentRole.SUPERVISOR_AGENT);
AgentStatus status = statusValue(metadata.get("status"), AgentStatus.COMPLETED);
AgentResult agentResult = new AgentResult(
role,
status,
stringValue(metadata.get("summary"), ""),
stringList(metadata.get("bulletPoints")),
domainList(metadata.get("nextDomains")),
stringList(metadata.get("coveredTopics")),
stringList(metadata.get("missingTopics")),
stringMap(metadata.get("sharedFacts"))
);
return AgentExecution.of(agentResult, List.of(), booleanValue(metadata.get("terminal"), false));
}

16.结果回到 Orchestrator

由于目前这个是demo,为了学习,所以只是把router和其他agent分开,所以可以一次性解决,但是如果后续把每个服务agent都拆开的话,就需要让agent把outboundMessag返回,作为下一跳,例如:
总结一些:我们这次学习了下列技术
👉 Multi-Agent(多智能体协作,像组队打副本)
👉 A2A(Agent to Agent,对话不止人与AI)
👉 Structured Output(让AI说人话,也说“结构化的话”)
👉 Tool Calling 2.0(工具调用升级版,缩小Tool使用范围)
👉 Playbook / Skillbook(AI的“技能树”和“作战手册”).
沉淀与分享,共同进步
更多推荐




所有评论(0)