目录

前言

一.Structured Output

二.Tool Calling 2.0

1️⃣ 工具不再是“函数”,而是“有身份证的能力”

2️⃣ 不是一股脑全给模型,而是先“筛工具”

3️⃣ 工具调用 = 平台编排问题(不是模型一句话的事)

4️⃣ 全链路必须“看得见”(可观测性)

5️⃣ 工具越多,重点越变成“治理”

⚔️ 和早期 Function Calling 的区别

🧒 Function Calling(1.0)

🧑‍💼 Tool Calling 2.0

三.Playbook / Skillbook

🎯 什么是 Skill?

📖 Playbook 是什么?

🧩 举个例子(奖学金查询 Skill)

📚 Skillbook 又是啥?

四.Multi-Agent

🤖 Multi-Agent 到底是啥?(别再把“多次调用 LLM”当多智能体了)

🔥 把一个复杂任务拆成多个“各司其职”的智能体,让它们分工合作完成任务。

🧩 Multi-Agent 到底在强调什么?

🧠 一个标准 Multi-Agent 都在干嘛?

🧭 RouterAgent(路由官)

🧑‍🔬 Specialist Agent(专家团)

👨‍⚖️ Supervisor / Coordinator(监工 / 裁判)

🔧 Multi-Agent 是怎么跑起来的?

🧱 第一层:执行机制(底座)

🧠 第二层:协作语义(灵魂)

🆚 Multi-Agent vs 单 Agent Workflow

🧍 单 Agent workflow

🤝 Multi-Agent

🧪 一个“像样”的 Multi-Agent 应该长啥样?

五.Agent to Agent


前言

如果你看完上一篇还没来得及亲自上手体验 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(发现) → 再缩圈 → 再给模型

流程像这样:

  1. 从工具库里找候选
  2. 按 domain / 关键词过滤
  3. 只保留“可能相关”的一小撮
  4. 再交给模型判断

一句话:

别让模型大海捞针,先帮它把水抽干。

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 可能长这样:

  1. 识别奖学金类型
  2. 查询制度库
  3. 提取申请流程和材料
  4. 按固定模板输出
  5. 如果识别失败 → 走通用 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的“技能树”和“作战手册”).

沉淀与分享,共同进步

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐