大模型应用开发课程 —— 项目 09~12 完整教程

大模型应用开发课程 ------ 项目 09~12 完整教程


项目 09:Workflow 工作流编排

9.1 理论讲解

什么是 Workflow 工作流?

Workflow(工作流)是一种确定性编排模式------把一组按特定顺序执行、可复用的"步骤"(节点)组装成一条确定的执行链,由引擎负责调度,每个节点只做自己那一件事。

与第八章的 Agent 模式对比:

  • Agent:模型自由决策,每次调用的流程可能不同(灵活但不可控)
  • Workflow:流程是确定的、可预期的(可控、可审计)

为什么需要 Workflow?

  1. 可控性:固定业务流程(如客服分流)用 Workflow,流程透明、可审计
  2. 可复用性:节点是独立的组件,可在不同工作流中复用
  3. 可测试性:每个节点可以独立测试,流程可以端到端测试
  4. 可维护性:修改某个节点不会影响其他节点
核心设计原则

本项目展示了一个核心设计原则:"分类用模型、路由用代码"

  • 语义理解(如意图分类)是大模型的强项 → 交给 LLM
  • 确定性路由(如分类结果走哪条处理线)是代码的强项 → 交给代码
工作流引擎的两种编排原语
  1. 顺序节点(SequenceNode):按顺序依次执行子节点
  2. 条件分支节点(BranchNode):根据上下文中的某个值路由到不同子节点

两者组合起来就可以表达任意业务流程。

本项目流程设计
复制代码
用户问题
   │
   ▼
[意图分类节点] ---- 模型把问题分类为 after_sale / consultation / complaint
   │
   ▼
[条件分支节点] ---- 按分类路由
   ├── after_sale    → [售后处理节点](先查订单工具,再生成回复)
   ├── consultation  → [产品咨询节点]
   └── complaint     → [投诉处理节点]

9.2 项目结构

复制代码
09-workflow/
├── pom.xml
└── src/main/
    ├── resources/
    │   └── application.yml
    └── java/com/example/workflow/
        ├── WorkflowApplication.java
        ├── controller/
        │   └── WorkflowController.java
        ├── service/
        │   └── CustomerServiceWorkflow.java
        ├── workflow/
        │   ├── Node.java
        │   ├── WorkflowContext.java
        │   ├── SequenceNode.java
        │   └── BranchNode.java
        ├── nodes/
        │   ├── ClassifyNode.java
        │   ├── AfterSaleNode.java
        │   ├── ConsultationNode.java
        │   ├── ComplaintNode.java
        │   └── LlmHandlerNode.java
        └── tools/
            └── OrderTools.java

9.3 完整代码

文件:pom.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>09-workflow</artifactId>
    <version>1.0.0</version>
    <name>09-workflow</name>
    <description>第九章 Workflow 工作流编排</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>25</java.version>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <spring-ai.version>2.0.0</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
文件:application.yml
yaml 复制代码
server:
  port: 9009
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  application:
    name: 09-workflow
  jackson:
    encoding: UTF-8

# 大模型配置(OpenAI 兼容协议,经 opencode 网关调用 DeepSeek)
# Spring AI 2.0:chat 参数扁平化(chat.options.* 已废弃)
spring.ai.openai:
  api-key: ${YOUR_OPENCODE_API_KEY}
  base-url: https://opencode.ai/zen/go/v1
  chat:
    # Workflow 多步意图分流+工具调用场景,回退到 V4-Pro 提升分类稳定性
    # Official version: DeepSeek-V4-Pro-0813 (2026/08/13 GA, strongest Agent / multi-step planning)
    model: deepseek-v4-pro
    # temperature=0.2  Workflow intent classification: low temperature keeps routing stable
    temperature: 0.2

logging:
  level:
    root: INFO
    com.example.workflow: DEBUG
文件:WorkflowApplication.java
java 复制代码
package com.example.workflow;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第九章 Workflow 工作流编排 ------ 应用启动类(WorkflowApplication)
 * <p>
 * 【理论知识讲解】
 * <ul>
 *   <li>@SpringBootApplication 是 Spring Boot 的组合注解(等价于 @Configuration + @EnableAutoConfiguration
 *       + @ComponentScan),它同时开启:自动配置(根据 classpath 上的依赖自动装配 Bean,
 *       例如 spring-ai-starter-model-openai 会自动配置 ChatClient 相关的 Bean)、
 *       组件扫描(扫描本类所在包及其子包,把 @Service/@Component/@RestController 等注册为 Bean);</li>
 *   <li>由于本类位于 com.example.workflow 包,而所有业务类都在 com.example.workflow 的子包
 *       (nodes / workflow / service / controller / tools)下,因此无需手动指定扫描路径;</li>
 *   <li>main 方法交给 {@link SpringApplication#run}:启动内嵌 Web 服务器(默认端口见
 *       application.yml 中 server.port=9009),并完成整个 Spring 容器与工作流节点图的装配。</li>
 * </ul>
 */
@SpringBootApplication
public class WorkflowApplication {

    /**
     * 应用入口:启动 Spring Boot 容器
     *
     * @param args 命令行参数(可覆盖 application.yml 中的配置,例如 --server.port=9009)
     */
    public static void main(String[] args) {
        SpringApplication.run(WorkflowApplication.class, args);
    }
}
文件:WorkflowController.java
java 复制代码
package com.example.workflow.controller;

import com.example.workflow.service.CustomerServiceWorkflow;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * 工作流演示接口(WorkflowController)
 * <p>
 * 【理论知识讲解】
 * 对外暴露 HTTP 入口:把"客服工单智能分流"工作流包装成一个 REST 接口,
 * 用户提交工单文本,返回工作流执行结果(含意图分类与最终回复)。
 * 工程上的分层职责:
 * <ul>
 *   <li>Controller 只负责"收参数、调服务、回 JSON",不包含任何业务逻辑;
 *       真正的流程编排在 {@link CustomerServiceWorkflow}(Service 层);</li>
 *   <li>@RestController + @RequestMapping("/api/v1/workflow") 定义接口前缀,
 *       GET /api/v1/workflow/process 携带参数 q 触发整个工作流;</li>
 *   <li>@NotBlank 校验参数非空(配合 spring-boot-starter-validation),
 *       参数不合法时由 Spring 自动返回 400 错误。</li>
 * </ul>
 */
@RestController
@Validated
@RequestMapping("/api/v1/workflow")
public class WorkflowController {

    /** 客服工单工作流服务(Spring 注入) */
    private final CustomerServiceWorkflow workflow;

    /**
     * 构造控制器
     *
     * @param workflow 客服工单工作流服务(Spring 自动注入)
     */
    public WorkflowController(CustomerServiceWorkflow workflow) {
        this.workflow = workflow;
    }

    /**
     * 提交客服工单,由工作流自动处理(分类 + 分流 + 生成回复)
     *
     * @param q 用户提交的工单内容,非空校验,例如"我的订单SO20260814001什么时候能到?能退货吗"
     * @return 工作流执行结果 Map,含 category(意图分类)、toolResult(售后订单查询结果,
     *         仅售后分支出现)、finalAnswer(分支生成的最终回复)等字段
     */
    @GetMapping("/process")
    public Map<String, Object> process(@RequestParam @NotBlank String q) {
        return workflow.process(q);
    }
}
文件:CustomerServiceWorkflow.java
java 复制代码
package com.example.workflow.service;

import com.example.workflow.nodes.AfterSaleNode;
import com.example.workflow.nodes.ClassifyNode;
import com.example.workflow.nodes.ComplaintNode;
import com.example.workflow.nodes.ConsultationNode;
import com.example.workflow.tools.OrderTools;
import com.example.workflow.workflow.BranchNode;
import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.SequenceNode;
import com.example.workflow.workflow.WorkflowContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

/**
 * 客服工单智能分流工作流(CustomerServiceWorkflow)
 * <p>
 * 【理论知识讲解】
 * 这是本章的"装配 + 执行"入口:把工作流引擎的编排原语(顺序节点 / 分支节点)
 * 与业务节点组装成一条完整的业务流水线。
 * <p>
 * 流程设计(确定性编排 + 条件分支):
 * <pre>
 * 用户问题
 *    │
 *    ▼
 * [意图分类节点] ---- 模型把问题分类为 after_sale / consultation / complaint
 *    │
 *    ▼
 * [条件分支节点] ---- 按分类路由
 *    ├── after_sale    → [售后处理节点](先查订单工具,再生成回复)
 *    ├── consultation  → [产品咨询节点]
 *    └── complaint     → [投诉处理节点]
 * </pre>
 * 对比第八章 Agent:这里流程是确定的、可预期的;Agent 是模型自由决策的。
 * 固定业务流程用 Workflow(可控、可审计),开放性问题用 Agent(灵活)。
 * 为什么先分类再路由?------"用户的问题属于哪一类"是语义理解(模型强项),
 * "分类结果走哪条处理线"是确定性映射(代码强项),二者天然分层。
 */
@Service
public class CustomerServiceWorkflow {

    private static final Logger log = LoggerFactory.getLogger(CustomerServiceWorkflow.class);

    /** 工作流入口节点:引擎从这里开始循环执行 */
    private final Node startNode;

    /**
     * 构造工作流:装配节点图(Spring 容器启动时执行一次)
     *
     * @param builder      Spring AI 的 ChatClient.Builder,用于构建各节点共用的 ChatClient
     * @param orderTools   订单查询工具(Spring 注入,供售后节点使用)
     */
    public CustomerServiceWorkflow(ChatClient.Builder builder, OrderTools orderTools) {
        // Java 10+ var:局部变量类型推断(ChatClient 类型可从 builder.build() 返回类型推断)
        var chatClient = builder.build();

        // 叶子节点:三个业务处理分支(售后带工具,咨询/投诉纯 LLM)
        Node afterSale = new AfterSaleNode(chatClient, orderTools);
        Node consultation = new ConsultationNode(chatClient);
        Node complaint = new ComplaintNode(chatClient);

        // 条件分支:根据意图分类结果路由(key 取自上下文 category 字段)
        Node branch = new BranchNode(
                "意图分流",
                ctx -> ctx.getString("category"),
                Map.of(
                        "after_sale", afterSale,
                        "consultation", consultation,
                        "complaint", complaint));

        // 顺序编排:先分类、再分流 ------ 组装出完整工作流
        this.startNode = new SequenceNode(
                "客服工单工作流",
                List.of(new ClassifyNode(chatClient), branch));
    }

    /**
     * 执行工作流并返回最终结果
     *
     * @param userInput 用户提交的客服工单内容(例如"我的订单SO20260814001什么时候能到?能退货吗")
     * @return 工作流执行完后的上下文快照,包含 userInput、category(分类结果)、
     *         toolResult(售后分支的订单查询结果)、finalAnswer(分支生成的最终回复)等字段
     */
    public Map<String, Object> process(String userInput) {
        // 新建独立上下文:一次请求一套状态,互不干扰(var:WorkflowContext 类型可从 new 构造器推断)
        var ctx = new WorkflowContext();
        ctx.put("userInput", userInput);

        log.info("========== 工作流开始执行 ==========");
        // 引擎主循环:从入口节点开始,顺着 execute 返回值一直推进,直到返回 null(var:Node 类型可从 startNode 推断)
        var current = startNode;
        while (current != null) {
            log.info("[Workflow] 执行节点: {}", current.name());
            current = current.execute(ctx);
        }
        log.info("========== 工作流执行完毕 ==========");

        // 返回上下文快照(含分类结果与最终回复),由 Controller 层序列化为 JSON
        return ctx.snapshot();
    }
}
文件:Node.java
java 复制代码
package com.example.workflow.workflow;

/**
 * 工作流节点抽象(Node 接口)
 * <p>
 * 【理论知识讲解】
 * 什么是工作流引擎?------把一组按特定顺序执行、可复用的"步骤"(节点)组装成一条确定的执行链,
 * 由引擎负责调度,每个节点只做自己那一件事。本章手写一个极简工作流引擎,
 * 核心抽象就是本接口 {@link Node}:
 * <ul>
 *   <li>Node 是引擎的"最小组成单元",无论是调用大模型、调用工具、还是编排子节点,统统实现 Node;</li>
 *   <li>引擎通过 {@link #execute(WorkflowContext)} 的返回值决定下一步:返回下一个节点则继续执行,
 *       返回 null 表示当前分支执行完毕------这是整个引擎唯一的"跳转机制";</li>
 *   <li>设计上刻意保持极简:节点之间不直接通信,一切状态都放在共享的
 *       {@link WorkflowContext} 上下文中,从而让每个节点可以独立开发、独立测试。</li>
 * </ul>
 * 为什么用接口而不是抽象类?------接口只约定"能做什么"(能力契约),
 * 让 LLM 节点、工具节点、顺序节点、分支节点这四类差异巨大的实现可以统一被引擎调度,
 * 这就是面向接口编程(依赖抽象而非实现)。
 */
public interface Node {

    /** 节点名称(用于日志与调试),例如"意图分类节点""售后处理节点" */
    String name();

    /**
     * 执行节点逻辑
     *
     * @param ctx 工作流上下文(WorkflowContext),节点间共享状态的载体;
     *            节点从其中读取入参(如用户输入 userInput),并把产出写回其中
     *            (如分类结果 category、工具结果 toolResult、最终回复 finalAnswer)
     * @return 下一个要执行的节点(引擎据此继续调度);
     *         返回 null 表示当前分支执行结束,引擎停止循环
     */
    Node execute(WorkflowContext ctx);
}
文件:WorkflowContext.java
java 复制代码
package com.example.workflow.workflow;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 工作流上下文(WorkflowContext)
 * <p>
 * 【理论知识讲解】
 * 什么是"上下文"?------工作流中的节点之间需要传递数据:分类节点要读取用户输入、把分类结果写出去;
 * 售后节点要读取分类结果、把工具查询结果写出去。节点之间如果直接互相持有引用,耦合会非常重。
 * 所以工作流引擎引入一个"共享黑板(Blackboard)":所有节点读写同一个上下文对象,节点之间完全解耦。
 * <ul>
 *   <li>为什么用 Map 而不是强类型字段?------工作流的节点是"组件化"的,事先无法穷举所有要传递的字段,
 *       Map 提供了最大灵活性;这也是大多数工作流引擎(如 LangChain、Temporal)内部传递数据的通用做法;</li>
 *   <li>为什么用 LinkedHashMap?------保证插入顺序,配合 {@link #snapshot()} 输出执行日志时,
 *       字段顺序即写入顺序,便于观察"谁先写了什么";</li>
 *   <li>约定俗成的 key 命名:userInput(用户输入)、category(意图分类)、toolResult(工具结果)、
 *       finalAnswer(最终回复)------本项目的四个约定键。</li>
 * </ul>
 */
public class WorkflowContext {

    /** 内部数据容器:key 为字符串键名,value 为任意对象(节点间共享的全部状态) */
    private final Map<String, Object> data = new LinkedHashMap<>();

    /**
     * 写入数据(节点把执行结果放进上下文,供后续节点读取)
     *
     * @param key   数据的键名,例如 "category"、"toolResult"
     * @param value 数据的值,例如分类结果字符串、工具查询结果字符串
     */
    public void put(String key, Object value) {
        data.put(key, value);
    }

    /**
     * 读取数据(节点从上下文取出自己需要的入参)
     *
     * @param key 数据的键名
     * @return 键对应的值;若该键尚不存在,返回 null
     */
    public Object get(String key) {
        return data.get(key);
    }

    /**
     * 读取字符串数据(对 {@link #get(String)} 的便捷封装,避免到处强转)
     *
     * @param key 数据的键名
     * @return 键对应的值的字符串形式;若值为 null 则返回 null
     */
    public String getString(String key) {
        // Java 10+ var:Object 类型可从 Map.get() 返回类型推断
        var value = data.get(key);
        return value == null ? null : value.toString();
    }

    /**
     * 获取全部数据的快照(返回一份拷贝,不会影响上下文内部状态)
     *
     * @return 当前上下文中所有键值对的副本,常用于接口返回执行日志、调试输出
     */
    public Map<String, Object> snapshot() {
        return new LinkedHashMap<>(data);
    }
}
文件:SequenceNode.java
java 复制代码
package com.example.workflow.workflow;

import java.util.List;

/**
 * 顺序节点(SequenceNode):按顺序依次执行子节点
 * <p>
 * 【理论知识讲解】
 * 顺序编排是工作流的第一种"编排原语(Orchestration Primitive)"。
 * 大多数业务流程本质上都是"先做 A、再做 B、再做 C"的线性步骤,
 * 本节点把一组子节点包装成一个整体,依次执行:
 * <ul>
 *   <li>执行规则:从头到尾遍历子节点列表,每个子节点执行后若返回了"下一个节点"(非 null),
 *       则立即跳转到该节点(支持自定义跳转,例如提前进入某个分支);否则继续执行下一个子节点;</li>
 *   <li>返回规则:所有子节点执行完毕仍没有指定跳转,则返回 null,代表整个顺序段执行完成;</li>
 *   <li>与 {@link BranchNode} 的关系:顺序节点解决"先做什么后做什么",分支节点解决"根据条件走哪条路",
 *       两者组合起来(顺序 + 分支)就可以表达任意业务流程,这正是工作流引擎"原语组合"思想。</li>
 * </ul>
 */
public class SequenceNode implements Node {

    private final String name;
    /** 按执行顺序排列的子节点列表 */
    private final List<Node> children;

    /**
     * 构造顺序节点
     *
     * @param name     节点名称(用于日志与调试),例如"客服工单工作流"
     * @param children 按执行顺序排列的子节点列表,例如 List.of(分类节点, 分支节点)
     */
    public SequenceNode(String name, List<Node> children) {
        this.name = name;
        this.children = children;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public Node execute(WorkflowContext ctx) {
        // 依次执行每一个子节点
        for (Node child : children) {
            // Java 10+ var:Node 类型可从 execute() 返回类型推断
            var next = child.execute(ctx);
            if (next != null) {
                // 子节点显式指定了下一个节点,跳转执行
                return next;
            }
        }
        // 所有子节点都执行完毕且没有指定跳转,顺序段结束
        return null;
    }
}
文件:BranchNode.java
java 复制代码
package com.example.workflow.workflow;

import java.util.Map;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * 条件分支节点(BranchNode):根据上下文中的某个值路由到不同子节点
 * <p>
 * 【理论知识讲解】
 * 条件分支是工作流的第二种"编排原语",也是工作流"确定性决策"能力的核心:
 * <ul>
 *   <li>它的本质是一张"路由表":router 函数从上下文中提取一个路由键(key),
 *       branches 是"键 → 分支节点"的映射,取到键后直接查表返回对应的分支节点;</li>
 *   <li>为什么这样设计?------把"判断"拆成两步:<b>如何算键</b>(router,可替换)与
 *       <b>键到分支的映射</b>(branches,可扩展)。新增一个分类只需往 Map 里加一个键值对,
 *       无需改动分支节点本身的代码;</li>
 *   <li>在 AI 工作流中的典型用法:分类这种"语义理解"交给大模型产出固定枚举值
 *       (如 after_sale / consultation / complaint),再交给本节点做"确定性查表路由"------
 *       即"分类用模型、路由用代码"的核心设计原则;</li>
 *   <li>注意:若路由键在映射表中不存在,branches.get(key) 会返回 null,
 *       引擎会认为该分支结束。因此模型输出的分类词必须严格限定为约定枚举值,
 *       这是"让模型输出结构化结果"的意义所在。</li>
 * </ul>
 */
public class BranchNode implements Node {

    private static final Logger log = LoggerFactory.getLogger(BranchNode.class);

    private final String name;
    /** 从上下文提取路由键(如用户意图分类结果 category 字段) */
    private final Function<WorkflowContext, String> router;
    /** 路由键 -> 分支节点(路由表) */
    private final Map<String, Node> branches;

    /**
     * 构造条件分支节点
     *
     * @param name     节点名称(用于日志与调试),例如"意图分流"
     * @param router   路由函数:接收工作流上下文,返回路由键字符串(例如 ctx.getString("category"))
     * @param branches 路由表:路由键到分支节点的映射(例如 after_sale → 售后处理节点)
     */
    public BranchNode(String name, Function<WorkflowContext, String> router, Map<String, Node> branches) {
        this.name = name;
        this.router = router;
        this.branches = branches;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public Node execute(WorkflowContext ctx) {
        // 第一步:从上下文中提取路由键(例如 LLM 分类节点写入的 category 值)
        // Java 10+ var:String 类型可从 Function<WorkflowContext, String>.apply() 推断
        var key = router.apply(ctx);
        log.info("[Workflow] {} 路由到分支: {}", name(), key);
        // 第二步:查路由表返回对应分支节点;查不到返回 null(该分支结束)
        return branches.get(key);
    }
}
文件:ClassifyNode.java
java 复制代码
package com.example.workflow.nodes;

import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.WorkflowContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;

import java.util.UUID;

/**
 * LLM 意图分类节点(ClassifyNode):感知用户问题并归类
 * <p>
 * 【理论知识讲解】
 * 本节点演示工作流中"如何把不确定性收敛到模型擅长的环节":
 * <ul>
 *   <li>意图分类属于"语义理解",是大模型的强项,所以交给 LLM;而分类之后的路由是确定性逻辑,
 *       交给 {@link com.example.workflow.workflow.BranchNode} 用代码完成------这就是
 *       <b>"分类用模型、路由用代码"</b>的设计原则,也是本工作流最关键的分工;</li>
 *   <li>让模型输出"固定枚举值"(after_sale / consultation / complaint)是关键技巧:
 *       System Prompt 明确限定输出格式("只输出一个分类词"),
 *       这样下游分支节点才能用精确匹配做路由,避免模型自由发挥破坏流程;</li>
 *   <li>为什么每个节点都生成独立的 conversationId?------工作流是多节点接力,每个 LLM 调用都是一次
 *       独立的"对话",不需要复用历史会话(也没有轮次上下文),独立 ID 让每次调用互不干扰;</li>
 *   <li>本节点是纯"感知"节点:只写分类结果到上下文,不直接调用下游节点,所以 execute 返回 null,
 *       由外层 SequenceNode 继续执行下一个节点。</li>
 * </ul>
 */
public class ClassifyNode implements Node {

    private static final Logger log = LoggerFactory.getLogger(ClassifyNode.class);

    /** Spring AI 的 ChatClient:面向大模型的声明式客户端 */
    private final ChatClient chatClient;

    /**
     * 构造意图分类节点
     *
     * @param chatClient 用于调用大模型的 ChatClient(由 Spring AI 注入构建)
     */
    public ClassifyNode(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @Override
    public String name() {
        return "意图分类节点";
    }

    @Override
    public Node execute(WorkflowContext ctx) {
        // 从共享上下文中读取用户输入(工作流入口写入的 userInput)
        // Java 10+ var:局部变量类型推断(String 类型可从 getString() 推断)
        var userInput = ctx.getString("userInput");

        // 调用大模型完成意图分类:System 限定角色与输出格式,User 传入用户问题
        // var:String 类型可从 prompt() 链式调用的 content() 推断
        var category = chatClient.prompt()
                .system("""
                        你是客服工单分类器。把用户问题分类为三类之一:
                        - after_sale:涉及订单、退货、维修、退款等售后问题
                        - consultation:咨询产品信息、价格、活动等
                        - complaint:投诉、不满、要求投诉处理
                        只输出一个分类词,不要输出其他内容。
                        """)
                .user(userInput)
                // 每条分类请求使用独立会话 ID,避免相互污染历史
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                .call()
                .content()
                .trim();

        // 把分类结果写入上下文,供 BranchNode 读取并路由
        ctx.put("category", category);
        log.info("[Workflow] 意图分类结果: {}", category);
        // 纯感知节点:不指定下一个节点,由 SequenceNode 继续驱动
        return null;
    }
}
文件:AfterSaleNode.java
java 复制代码
package com.example.workflow.nodes;

import com.example.workflow.tools.OrderTools;
import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.WorkflowContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;

/**
 * 售后处理节点(AfterSaleNode):先调用订单工具查询真实数据,再生成回复
 * <p>
 * 【理论知识讲解】
 * 本节点演示工作流中"工具节点 + LLM 节点"的组合------最典型的 AI 应用形态:
 * <ul>
 *   <li><b>先工具、后模型</b>:回复要基于"真实业务数据"(订单状态),不能靠模型编造。
 *       所以先在 {@link #execute(WorkflowContext)} 中用正则从用户问题里提取订单号、
 *       调用订单查询工具拿真实状态写入上下文,再调用基类的模板方法让模型基于该数据生成回复;</li>
 *   <li>为什么用正则而不是让模型提取订单号?------"从文本里找 SO 开头的编号"是确定性规则,
 *       用代码做又快又准又免费;模型提取则慢、贵且可能出错。这再次体现
 *       <b>"能确定的用代码,不能确定的交给模型"</b>的设计原则;</li>
 *   <li>工具封装:真实系统里这里可能是查数据库、调 ERP 接口,本章用内存 Map 模拟订单库,
 *       封装在 {@link OrderTools} 中,节点只面向工具接口编程。</li>
 * </ul>
 */
public class AfterSaleNode extends LlmHandlerNode {

    private static final Logger log = LoggerFactory.getLogger(AfterSaleNode.class);

    /** 订单查询工具(封装订单数据的真实来源) */
    private final OrderTools orderTools;

    /**
     * 构造售后处理节点
     *
     * @param chatClient 用于调用大模型的 ChatClient(传递给父类)
     * @param orderTools 订单查询工具(由 Spring 注入,用于查询真实订单状态)
     */
    public AfterSaleNode(ChatClient chatClient, OrderTools orderTools) {
        super(chatClient);
        this.orderTools = orderTools;
    }

    @Override
    public String name() {
        return "售后处理节点";
    }

    @Override
    public Node execute(WorkflowContext ctx) {
        String userInput = ctx.getString("userInput");

        // 工具节点:从用户问题中提取订单号(简单正则 SO 开头+数字),查询真实订单状态
        // Java 10+ var:局部变量类型推断(Matcher 类型可从 Pattern.matcher() 推断)
        var m = java.util.regex.Pattern
                .compile("SO\\d+")
                .matcher(userInput);
        // 命中订单号才查询:把真实订单状态写入上下文,供后续 LLM 生成回复时引用
        if (m.find()) {
            // var:orderNo / result 的类型(String)可从 m.group() / queryOrder() 返回类型推断
            var orderNo = m.group();
            var result = orderTools.queryOrder(orderNo);
            ctx.put("toolResult", result);
            log.info("[Workflow] 调用订单工具: {}", result);
        }

        // LLM 节点:基于工具结果生成售后回复(复用父类模板方法)
        return super.execute(ctx);
    }

    @Override
    protected String systemPrompt() {
        return """
                你是电商售后客服。基于已查到的订单数据回答用户:
                1. 如实告知订单状态,不要编造;
                2. 若是"已发货",引导用户查看物流或申请退换货;
                3. 若是"待付款",提醒及时支付;
                4. 语气耐心、专业。
                """;
    }
}
文件:ConsultationNode.java
java 复制代码
package com.example.workflow.nodes;

import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.WorkflowContext;
import org.springframework.ai.chat.client.ChatClient;

/**
 * 产品咨询处理节点(ConsultationNode)
 * <p>
 * 【理论知识讲解】
 * 分支节点之一,面向"咨询产品信息、价格、活动"类工单。
 * 它继承了 {@link LlmHandlerNode} 基类,本身不写任何调用逻辑,
 * 只通过实现 {@link #systemPrompt()} 定义"产品顾问"这一角色的人设与应答规范:
 * <ul>
 *   <li>这就是"模板方法模式"的价值:同样的调用流程,换一个 System Prompt 就是另一个业务节点;
 *       咨询类问题不涉及真实业务数据,因此不需要工具节点参与,直接由模型作答;</li>
 *   <li>提示词设计要点:明确角色(产品顾问)、明确任务边界(只答产品/价格/活动)、
 *       明确行为约束(不知道就如实说明、不要过度推销),保证回复质量可控。</li>
 * </ul>
 */
public class ConsultationNode extends LlmHandlerNode {

    /**
     * 构造产品咨询处理节点
     *
     * @param chatClient 用于调用大模型的 ChatClient(传递给父类)
     */
    public ConsultationNode(ChatClient chatClient) {
        super(chatClient);
    }

    @Override
    public String name() {
        return "产品咨询节点";
    }

    @Override
    protected String systemPrompt() {
        return """
                你是产品顾问。回答用户关于产品、价格、活动的咨询:
                1. 热情、专业地介绍产品卖点;
                2. 不知道的信息如实说明,可以引导去官网查看;
                3. 适度推荐,但不要过度推销。
                """;
    }
}
文件:ComplaintNode.java
java 复制代码
package com.example.workflow.nodes;

import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.WorkflowContext;
import org.springframework.ai.chat.client.ChatClient;

/**
 * 投诉处理节点(ComplaintNode)
 * <p>
 * 【理论知识讲解】
 * 分支节点之一,面向"投诉、不满、要求投诉处理"类工单。
 * 与咨询节点一样继承 {@link LlmHandlerNode} 基类,只通过 {@link #systemPrompt()}
 * 定义"投诉处理专员"的角色规范:
 * <ul>
 *   <li>投诉场景的提示词设计特别强调<b>情绪安抚</b>与<b>风险边界</b>:
 *       先道歉共情(稳住情绪),再说明跟进机制(转人工、48 小时内回复),
 *       同时<b>不轻易承诺具体赔偿</b>(赔偿涉及利益承诺,超出 AI 权限,交由人工处理)------
 *       这是把"高风险决策"从模型输出中隔离出去的典型做法;</li>
 *   <li>业务上这里通常还会接"升级人工"的工单流转逻辑,本章聚焦分支路由演示,保持最小实现。</li>
 * </ul>
 */
public class ComplaintNode extends LlmHandlerNode {

    /**
     * 构造投诉处理节点
     *
     * @param chatClient 用于调用大模型的 ChatClient(传递给父类)
     */
    public ComplaintNode(ChatClient chatClient) {
        super(chatClient);
    }

    @Override
    public String name() {
        return "投诉处理节点";
    }

    @Override
    protected String systemPrompt() {
        return """
                你是投诉处理专员。处理用户投诉:
                1. 先诚恳道歉,表达对用户不满的理解;
                2. 说明我们会如何跟进(转交人工客服、48小时内回复);
                3. 不轻易承诺具体赔偿,交由人工处理。
                """;
    }
}
文件:LlmHandlerNode.java
java 复制代码
package com.example.workflow.nodes;

import com.example.workflow.workflow.Node;
import com.example.workflow.workflow.WorkflowContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;

import java.util.UUID;

/**
 * LLM 处理节点基类(LlmHandlerNode):不同分支共用一套"读取上下文 -> 调用模型 -> 写回结果"逻辑
 * <p>
 * 【理论知识讲解】
 * 本章的"模板方法模式(Template Method Pattern)"应用:
 * <ul>
 *   <li>三个分支节点(售后 / 咨询 / 投诉)都要做同一件事:读用户输入 → 组织提示词 → 调用模型 → 写回回复,
 *       差别只有"这个角色怎么处理"(即系统提示词)。于是把公共流程抽到本基类的
 *       {@link #execute(WorkflowContext)} 中固化下来(模板方法),
 *       把差异部分({@link #systemPrompt()})留给子类实现(可变步骤);</li>
 *   <li>为什么要这样做?------消除重复代码、保证三个分支的调用方式完全一致,
 *       新增一个分支只需继承本类并实现 systemPrompt,几乎零成本;</li>
 *   <li>上下文约定:读取 userInput(用户输入)与 toolResult(可选的工具查询结果,
 *       由售后节点在调用本方法前写入),把模型回复写入 finalAnswer(最终回复),
 *       后续 Controller 层直接返回整个上下文快照。</li>
 * </ul>
 */
public abstract class LlmHandlerNode implements Node {

    private static final Logger log = LoggerFactory.getLogger(LlmHandlerNode.class);

    /** Spring AI 的 ChatClient:面向大模型的声明式客户端(protected 供子类间接使用) */
    protected final ChatClient chatClient;

    /**
     * 构造 LLM 处理节点
     *
     * @param chatClient 用于调用大模型的 ChatClient(由 Spring AI 注入构建)
     */
    public LlmHandlerNode(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    /** 该分支的系统提示词(子类实现:定义这个"角色"如何处理业务) */
    protected abstract String systemPrompt();

    @Override
    public Node execute(WorkflowContext ctx) {
        // 从共享上下文读取用户输入(var:String 类型可从 getString() 推断)
        var userInput = ctx.getString("userInput");
        // 读取可选的工具查询结果(售后节点先调用订单工具后写入;其他分支为 null)
        var toolResult = ctx.getString("toolResult");

        // 调用大模型:System 为子类提供的角色提示词,User 附带用户问题与已查到的业务数据
        // Java 15+ 文本块 + formatted():用占位符把"用户问题 + 工具数据"拼成多行提示词
        var answer = chatClient.prompt()
                .system(systemPrompt())
                .user(toolResult == null
                        ? "用户问题:" + userInput
                        : """
                          用户问题:%s
                          
                          已查到的业务数据:%s
                          """.formatted(userInput, toolResult))
                // 每次处理使用独立会话 ID,避免多轮历史干扰
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                .call()
                .content();

        // 把最终回复写回上下文(Controller 返回的 finalAnswer 字段)
        ctx.put("finalAnswer", answer);
        log.info("[Workflow] {} 已生成回复", name());
        // 处理完成:不指定下一个节点,该分支结束
        return null;
    }
}
文件:OrderTools.java
java 复制代码
package com.example.workflow.tools;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.util.Map;

/**
 * 订单查询工具(OrderTools):售后分支调用真实数据
 * <p>
 * 【理论知识讲解】
 * 工作流中的"工具节点"------把系统能力(查数据库、调 ERP、发通知)封装为可被确定性调用的方法:
 * <ul>
 *   <li>为什么工作流需要工具?------大模型没有真实数据,不知道订单到底发没发货。
 *       把"查询订单状态"这类确定性操作封装成工具,由工作流代码主动调用,
 *       再把结果塞进上下文供模型参考,回复才不会瞎编;</li>
 *   <li>@Tool 注解是 Spring AI 2.0 的"工具暴露"机制:本类被 Spring 管理为 Bean(@Component),
 *       方法上标注 @Tool 后,Spring AI 会自动把方法签名、描述、参数说明构造成
 *       "OpenAI Function Calling"格式的工具描述,模型就能在需要时调用它;</li>
 *   <li>@ToolParam(description=...) 是给模型看的参数说明------模型据此知道该传什么;
 *       本章工作流是"代码主动调用"工具(不走模型决策),因此这两个注解更多是演示用途,
 *       真正由模型自主调用的场景见第八章与第十章。</li>
 * </ul>
 */
@Component
public class OrderTools {

    /** 内存订单库(模拟真实订单系统):订单号 -> 订单状态描述 */
    private static final Map<String, String> ORDER_DB = Map.of(
            "SO20260814001", "iPhone 16 Pro,已发货",
            "SO20260814002", "MacBook Air,待付款",
            "SO20260813005", "AirPods Pro,已完成");

    /**
     * 根据订单号查询订单状态
     *
     * @param orderNo 订单号,例如 "SO20260814001"(@ToolParam 描述供模型理解参数含义)
     * @return 订单状态描述字符串,例如 "订单 SO20260814001:iPhone 16 Pro,已发货";
     *         订单号不存在时返回 "订单 xxx:不存在"
     */
    @Tool(name = "query_order", description = "根据订单号查询订单状态")
    public String queryOrder(@ToolParam(description = "订单号") String orderNo) {
        return "订单 " + orderNo + ":" + ORDER_DB.getOrDefault(orderNo, "不存在");
    }
}

运行效果截图

演示页面:

PPT 讲读页:

PPT 讲读页(完整8页):


项目 10:多智能体协作(Multi-Agent)

10.1 理论讲解

什么是多智能体协作?

多智能体协作(Multi-Agent Collaboration)是 Agent 范式的进阶------多个专业 Agent 各司其职、协同完成复杂任务。本章实现 AgentScope 的 Manager-Worker(主管-员工)模式

Manager-Worker 模式
复制代码
用户提交任务
  → Manager(主管)分析需求、拆解子任务
  → 调用工具(ask_copywriter / ask_technician / ask_finance)派活
  → 各专家(Worker)返回"工作汇报"(字符串)
  → Manager 汇总成一份结构完整的最终方案
为什么用工具调用实现 Agent 间通信?

工具调用的结果是"字符串"(模型能读懂的语言),恰好天然就是 Agent 间的通信通道:

  • Manager 一次工具调用 = 一次任务分配 + 一次专家工作
  • 专家返回的字符串就是 Manager 收到的"员工工作汇报"
三个关键配置
  1. defaultTools(specialistAgents):把专家团队注册为 Manager 可调用的工具
  2. ToolCallingAdvisor:开启工具调用能力(Spring AI 2.0 默认启用)
  3. MessageChatMemoryAdvisor + MessageWindowChatMemory:保留对话历史,Manager 才能记住"谁已经干完活了"
多智能体 vs 单 Agent 的优势
  • 上下文隔离:每个专家拥有独立的角色 Prompt 与上下文,领域知识互不干扰
  • 专业深度:每个专家只聚焦自己的领域,回复质量更高
  • 可扩展性:新增专家只需添加一个方法,无需修改主管逻辑

10.2 项目结构

复制代码
10-multi-agent/
├── pom.xml
└── src/main/
    ├── resources/
    │   └── application.yml
    └── java/com/example/multiagent/
        ├── MultiAgentApplication.java
        ├── controller/
        │   └── MultiAgentController.java
        ├── service/
        │   └── MultiAgentService.java
        └── agents/
            └── SpecialistAgents.java

10.3 完整代码

文件:pom.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>10-multi-agent</artifactId>
    <version>1.0.0</version>
    <name>10-multi-agent</name>
    <description>第十章 多智能体协作(AgentScope 模式:Manager-Worker)</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>25</java.version>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <spring-ai.version>2.0.0</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-anthropic</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
文件:application.yml
yaml 复制代码
# ========== 服务器配置 ==========
server:
  # 应用监听端口:本章固定使用 9010(与 09 章 9009 区分开)
  port: 9010
  servlet:
    encoding:
      # 强制请求/响应统一使用 UTF-8,避免中文接口返回乱码
      charset: UTF-8
      enabled: true
      force: true

# ========== Spring 应用配置 ==========
spring:
  application:
    # 应用名称,用于日志、监控等场景标识当前服务
    name: 10-multi-agent
  jackson:
    # Jackson 序列化 JSON 时使用 UTF-8 编码
    encoding: UTF-8

# ========== 大模型配置(Anthropic Messages 协议,直连 DeepSeek 原生 Anthropic 端点) ==========
spring.ai.anthropic:
  # DeepSeek 官方 API Key(platform.deepseek.com 申请)
  api-key: ${YOUR_DEEPSEEK_API_KEY}
  # DeepSeek 原生 Anthropic 兼容端点:https://api.deepseek.com/anthropic/v1/messages
  base-url: https://api.deepseek.com/anthropic
  chat:
    # 多智能体协作场景,Manager 与专家反复调用、对响应速度与成本敏感,
    # 使用 DeepSeek-V4-Flash(Anthropic 协议直连官方)
    # Official version: DeepSeek-V4-Flash-0731 (2026/07/31 GA, fast, high concurrency, low cost)
    model: deepseek-v4-flash
    # temperature=0.5  Manager-Worker collaboration: mid temperature for expert personality without divergence
    temperature: 0.5
    # max-tokens:Anthropic 协议必填字段(模型单次生成的最大 token 数)
    max-tokens: 4096
    # 说明:DeepSeek 原生 Anthropic 端点默认返回 thinking 块,已在代码中通过
    # AnthropicChatOptions.thinkingDisabled() 显式禁用,保证专家输出干净

# ========== 日志配置 ==========
logging:
  level:
    # 根日志级别为 INFO
    root: INFO
    # 本工程包名下的日志级别为 DEBUG:方便开发期查看细节日志
    com.example.multiagent: DEBUG
文件:MultiAgentApplication.java
java 复制代码
package com.example.multiagent;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第十章 多智能体协作(AgentScope 模式)------ 应用启动类(MultiAgentApplication)
 * <p>
 * 【理论知识讲解】
 * Spring Boot 应用入口:@SpringBootApplication 是一个组合注解,等价于
 * @Configuration(声明配置类)+ @EnableAutoConfiguration(自动装配)
 * + @ComponentScan(扫描本包及子包下的 @Component/@Service/@Controller)。
 * <p>
 * 启动后自动装配:ChatClient(Spring AI)、SpecialistAgents 专家团队、
 * MultiAgentService 主管服务、MultiAgentController 接口层。
 */
@SpringBootApplication
public class MultiAgentApplication {

    /**
     * 应用主入口
     *
     * @param args 命令行参数(一般无需传入;可通过 --server.port 覆盖端口)
     */
    public static void main(String[] args) {
        // run:启动内嵌 Tomcat,监听 application.yml 配置的 9010 端口
        SpringApplication.run(MultiAgentApplication.class, args);
    }
}
文件:MultiAgentController.java
java 复制代码
package com.example.multiagent.controller;

import com.example.multiagent.service.MultiAgentService;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * 多智能体协作演示接口(MultiAgentController)
 * <p>
 * 【理论知识讲解】
 * 对外暴露多智能体协作的 HTTP 入口:用户提交一个任务文本,
 * 由主管 Agent 自动拆解并调度专业 Agent 团队协作完成,返回最终汇总方案。
 * 工程分层职责:
 * <ul>
 *   <li>Controller 只负责参数接收与 JSON 返回,不承载任何 Agent 编排逻辑;
 *       真正的多智能体协作在 {@link MultiAgentService}(Service 层);</li>
 *   <li>返回结构固定为 {"answer": "..."}:answer 即主管 Agent 汇总的完整方案,
 *       便于前端直接展示;</li>
 *   <li>@NotBlank 校验任务非空(配合 spring-boot-starter-validation),
 *       参数缺失或为空时 Spring 自动返回 400。</li>
 * </ul>
 */
@RestController
@Validated
@RequestMapping("/api/v1/multi-agent")
public class MultiAgentController {

    /** 多智能体协作服务(主管 Agent,Spring 注入) */
    private final MultiAgentService multiAgentService;

    /**
     * 构造控制器
     *
     * @param multiAgentService 多智能体协作服务(Spring 自动注入)
     */
    public MultiAgentController(MultiAgentService multiAgentService) {
        this.multiAgentService = multiAgentService;
    }

    /**
     * 把任务交给多智能体团队(主管拆解 → 专家协作 → 汇总方案)
     *
     * @param task 用户提交的任务描述,非空校验,例如"新饮品'青柠莓气泡水'需要一句广告语,并估算定价范围"
     * @return 包含 answer 键的 Map,answer 为主管 Agent 汇总后的完整方案文本
     */
    @GetMapping("/collaborate")
    public Map<String, String> collaborate(@RequestParam @NotBlank String task) {
        // Map.of:JDK 9+ 不可变 Map 工厂,构造单键值对响应
        return Map.of("answer", multiAgentService.collaborate(task));
    }
}
文件:MultiAgentService.java
java 复制代码
package com.example.multiagent.service;

import com.example.multiagent.agents.SpecialistAgents;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 主管 Agent 多智能体协作服务(MultiAgentService)
 * <p>
 * 【理论知识讲解】
 * 这是本章的核心:实现 AgentScope 的 Manager-Worker(主管-员工)模式------
 * 一个主管 Agent 把整个专家团队当作"工具"调用,像团队一样分工完成复杂任务。
 * <p>
 * 三个关键配置缺一不可:
 * <ol>
 *   <li><b>{@code defaultTools(specialistAgents)}</b>:把专家团队注册为 Manager
 *       可调用的工具(相当于"下属花名册",模型能看到三个专家方法的"工具说明书");</li>
 *   <li><b>ToolCallingAdvisor</b>:开启工具调用能力------Manager 才能"拨号"给专家;
 *       (Spring AI 2.0 中 ChatClient 默认启用,这里保留显式说明)</li>
 *   <li><b>MessageChatMemoryAdvisor + MessageWindowChatMemory</b>:保留对话历史,
 *       Manager 才能记住"谁已经干完活了",最后一次性汇总所有专家的结论。</li>
 * </ol>
 * <p>
 * 协作流程(完整时序见 README 1.7 节 mermaid 时序图):
 * <pre>
 * 用户提交任务
 *   → Manager 分析需求、拆解子任务
 *   → 调用 ask_copywriter / ask_technician / ask_finance 派活
 *   → 各专家返回"工作汇报"(字符串)
 *   → Manager 汇总成一份结构完整的最终方案
 * </pre>
 */
@Service
public class MultiAgentService {

    /** 主管 Agent 的 ChatClient(带工具注册 + 记忆) */
    private final ChatClient managerClient;

    /**
     * 构造多智能体服务:装配"主管 + 专家团队 + 记忆"三要素
     *
     * @param builder           Spring AI 的 ChatClient.Builder
     * @param specialistAgents  专业 Agent 团队(Spring 注入,注册为 Manager 的工具)
     */
    public MultiAgentService(ChatClient.Builder builder, SpecialistAgents specialistAgents) {
        // Java 10+ var:局部变量类型推断(MessageWindowChatMemory 类型可从 build() 返回推断)
        var chatMemory = MessageWindowChatMemory.builder()
                .maxMessages(20)  // 窗口保留最近 20 条消息:够 Manager 记住各专家结论,又不爆上下文
                .build();

        // 记忆顾问:自动把历史对话拼进每次请求(Manager 的"会议纪要")
        // Spring AI 2.0:MessageChatMemoryAdvisor 通过 Builder 构建,传入内存记忆实现
        // Java 10+ var:Advisor 类型可从 build() 返回类型推断
        var memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();

        // 主管 ChatClient:注册专家团队为工具 + 挂载记忆顾问
        this.managerClient = builder
                // 显式禁用思考块:DeepSeek Anthropic 端点默认返回 thinking 内容,
                // 关闭后 Manager 汇总输出干净(避免思考过程混入最终方案)
                .defaultOptions(AnthropicChatOptions.builder().thinkingDisabled())
                .defaultSystem("""
                        你是团队主管(Manager Agent)。用户会给你一个复杂任务,
                        你需要:
                        1. 分析任务,判断需要哪些专业能力(文案 / 技术 / 财务);
                        2. 通过调用工具 ask_copywriter / ask_technician / ask_finance
                           把子任务分派给对应的专家 Agent;
                        3. 汇总各专家返回的结论,整理成一份结构清晰、可直接交付的完整方案。

                        输出要求:先给出"任务拆解",再给出"分派记录",最后是"汇总结论"。
                        """)
                .defaultTools(specialistAgents)
                .defaultAdvisors(memoryAdvisor)
                .build();
    }

    /**
     * 把任务交给多智能体团队协作完成
     *
     * @param task 用户提交的复杂任务,例如"新饮品'青柠莓气泡水'需要一句广告语,并估算定价范围"
     * @return 主管 Agent 汇总后的完整方案文本
     */
    public String collaborate(String task) {
        // 主管 Agent 自主完成"拆任务 → 派活 → 汇总"全流程,返回最终方案
        // advisors 指定会话 ID:记忆顾问据此存取历史(同 ID 多次调用共享 Manager 上下文)
        return managerClient.prompt()
                .user(task)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "manager-session"))
                .call()
                .content();
    }
}
文件:SpecialistAgents.java
java 复制代码
package com.example.multiagent.agents;

import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

/**
 * 专业 Agent 团队(SpecialistAgents)------"员工花名册"
 * <p>
 * 【理论知识讲解】
 * 本章实现 AgentScope 最经典的 Manager-Worker 模式:一个主管 Agent(Manager)把
 * 多个专业 Agent(Worker)当作"工具"调用。本类就是 Worker 团队------三个专家各司其职:
 * <ul>
 *   <li>askCopywriter 文案专家:负责广告语、营销文案等创意内容;</li>
 *   <li>askTechnician 技术专家:负责技术实现、方案可行性评估;</li>
 *   <li>askFinance 财务专家:负责成本核算、定价建议。</li>
 * </ul>
 * 为什么用工具调用实现 Agent 间通信?------工具调用的结果是"字符串"(模型能读懂的语言),
 * 恰好天然就是 Agent 间的通信通道:Manager 一次工具调用 = 一次任务分配 + 一次专家工作,
 * 专家返回的字符串就是 Manager 收到的"员工工作汇报"。
 * <p>
 * 每个专家方法内部是一次独立的 {@code ChatClient.prompt()} 调用------这意味着每个专家
 * 拥有自己独立的角色 Prompt 与上下文,领域知识互不干扰(这正是多智能体相对单 Agent
 * "上下文隔离"的价值)。
 */
@Component
public class SpecialistAgents {

    /** 各专家共用的 ChatClient 构造器(Spring 注入;专家内部各自 build 独立实例) */
    private final ChatClient.Builder builder;

    /**
     * 构造专家团队
     *
     * @param builder Spring AI 的 ChatClient.Builder,专家方法内部通过它构建独立 ChatClient
     */
    public SpecialistAgents(ChatClient.Builder builder) {
        this.builder = builder;
    }

    /**
     * 文案专家:为产品生成广告语与营销文案
     *
     * @param task 文案任务描述,例如"为新饮品青柠莓气泡水写一句广告语"
     * @return 专家生成的文案结果(Manager 收到的"工作汇报")
     */
    @Tool(name = "ask_copywriter", description = "调用文案专家:为产品生成广告语、营销文案等创意内容")
    public String askCopywriter(@ToolParam(description = "文案创作任务描述") String task) {
        // Java 25 文本块:多行系统提示词,无需 + 拼接与 \n 转义
        String system = """
                你是资深品牌文案专家。请基于用户需求创作高质量、可直接交付的文案。
                要求:简洁有力、突出卖点、朗朗上口;如无特殊要求直接给主推方案。
                """;
        // 每个专家独立 ChatClient:拥有独立角色上下文,与其他专家互不干扰
        // 显式禁用思考块:DeepSeek Anthropic 端点默认返回 thinking 内容,关闭后专家输出干净
        return builder.defaultOptions(AnthropicChatOptions.builder().thinkingDisabled()).build()
                .prompt()
                .system(system)
                .user(task)
                .call()
                .content();
    }

    /**
     * 技术专家:评估技术实现方案与可行性
     *
     * @param task 技术任务描述,例如"评估智能客服系统接入多轮对话的技术方案"
     * @return 专家给出的技术结论(Manager 收到的"工作汇报")
     */
    @Tool(name = "ask_technician", description = "调用技术专家:评估技术实现方案、可行性、技术选型")
    public String askTechnician(@ToolParam(description = "技术评估任务描述") String task) {
        // Java 25 文本块:多行系统提示词
        String system = """
                你是资深软件架构师。请对用户给出的技术方案进行评估,输出:
                1. 技术可行性结论
                2. 推荐的技术选型(含理由)
                3. 关键风险与注意事项
                """;
        return builder.defaultOptions(AnthropicChatOptions.builder().thinkingDisabled()).build()
                .prompt()
                .system(system)
                .user(task)
                .call()
                .content();
    }

    /**
     * 财务专家:进行成本核算与定价建议
     *
     * @param task 财务任务描述,例如"估算新产品单瓶成本2元的合理定价区间"
     * @return 专家给出的财务分析与定价建议(Manager 收到的"工作汇报")
     */
    @Tool(name = "ask_finance", description = "调用财务专家:成本核算、定价建议、财务可行性分析")
    public String askFinance(@ToolParam(description = "财务分析任务描述") String task) {
        // Java 25 文本块:多行系统提示词(var:String 类型可从右侧推断)
        var system = """
                你是资深财务分析师。请基于用户提供的成本与市场信息,输出:
                1. 成本结构分析
                2. 建议的定价区间(含理由)
                3. 毛利预估
                """;
        return builder.defaultOptions(AnthropicChatOptions.builder().thinkingDisabled()).build()
                .prompt()
                .system(system)
                .user(task)
                .call()
                .content();
    }
}

运行效果截图

演示页面:

PPT 讲读页:

PPT 讲读页(完整8页):


项目 11:上下文工程(Context Engineering)

11.1 理论讲解

什么是上下文工程?

上下文工程 = 有意识地控制"每次调用模型时,把什么内容放进去、放多少",是治理"上下文窗口有限 + token 按量计费 + 噪声干扰输出"三连问题的工程手艺。

为什么需要上下文工程?

上下文窗口是有限的。多轮对话中历史消息不断累积,很快会:

  1. 超出窗口被模型截断(丢失早期信息)
  2. 即使不超窗,也白花 token(变贵、变慢)
  3. 噪声干扰输出(无关历史降低回复质量)
三种上下文管理策略
策略 原理 优点 缺点
全量保留 所有历史都传给模型 信息最完整 线性增长,最终撑爆窗口
滑动窗口 只保留最近 N 条消息 简单有效,token 恒定 丢弃早期信息
摘要压缩 用 LLM 把旧历史压成一段摘要 极致省 token,保住主干 有损压缩,需要额外模型调用
Token 估算

Token(词元)是大模型处理文本的最小单位,模型按 token 计费、上下文窗口也按 token 计数。本项目使用启发式估算:

  • 汉字:约 1.2 token/字(中文信息密度高)
  • 其他字符:约 4 字符/token
  • 每条消息固定 +8 token(消息格式开销)

11.2 项目结构

复制代码
11-context-engineering/
├── pom.xml
└── src/main/
    ├── resources/
    │   └── application.yml
    └── java/com/example/context/
        ├── ContextEngineeringApplication.java
        ├── controller/
        │   └── ContextController.java
        ├── service/
        │   └── ContextManager.java
        └── util/
            └── TokenEstimator.java

11.3 完整代码

文件:pom.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>11-context-engineering</artifactId>
    <version>1.0.0</version>
    <name>11-context-engineering</name>
    <description>第十一章 上下文工程</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>25</java.version>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <spring-ai.version>2.0.0</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
文件:application.yml
yaml 复制代码
server:
  port: 9011
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  application:
    name: 11-context-engineering
  jackson:
    encoding: UTF-8

# 大模型配置(OpenAI 兼容协议,经 opencode 网关调用 DeepSeek)
# Spring AI 2.0:chat 参数扁平化(chat.options.* 已废弃)
spring.ai.openai:
  api-key: ${YOUR_OPENCODE_API_KEY}
  base-url: https://opencode.ai/zen/go/v1
  chat:
    # Official version: DeepSeek-V4-Flash-0731 (2026/07/31 GA, fast, high concurrency, low cost, ideal for teaching)
    model: deepseek-v4-flash
    # temperature=0.4  Context strategy comparison: balanced for stable yet natural summary/window answers
    temperature: 0.4

logging:
  level:
    root: INFO
    com.example.context: DEBUG
文件:ContextEngineeringApplication.java
java 复制代码
package com.example.context;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第十一章 上下文工程 ------ 应用启动类(Spring Boot 入口)
 * <p>
 * 【理论知识讲解】
 * 技术是什么:Spring Boot 是"约定优于配置"的 Java Web 框架,
 * @SpringBootApplication 一个注解同时启用 @Configuration(配置类)、
 * @EnableAutoConfiguration(自动装配)、@ComponentScan(组件扫描)。
 * 为什么用:本项目是一个对外提供 REST 接口的 Web 服务,需要内嵌 Tomcat、
 * 并自动装配 Spring AI 的 ChatClient 等 Bean,Spring Boot 可零配置拉起整个应用。
 * 核心概念:main 方法是唯一入口,SpringApplication.run() 会创建并刷新
 * Spring 容器(实例化所有单例 Bean)、启动内嵌 Web 服务器、阻塞监听端口。
 * 原理:run() 内部依次经历「准备环境 → 创建应用上下文 → 刷新容器
 * (扫描 @Service / @RestController 并完成依赖注入)→ 启动内嵌服务器 → 注册关闭钩子」,
 * 期间读取 application.yml 完成端口、模型等配置。
 */
@SpringBootApplication
public class ContextEngineeringApplication {

    /**
     * 应用入口方法
     *
     * @param args 命令行参数(如 --server.port=9011 可覆盖 yml 中的端口配置),一般留空
     * @return 无返回值;进程启动后阻塞监听 HTTP 端口,直到被停止
     */
    public static void main(String[] args) {
        // 启动 Spring Boot 应用:自动装配、组件扫描完成后阻塞监听 9011 端口
        SpringApplication.run(ContextEngineeringApplication.class, args);
    }
}
文件:ContextController.java
java 复制代码
package com.example.context.controller;

import com.example.context.service.ContextManager;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * 上下文工程演示接口
 * <p>
 * 同一个 sessionId 连续调用,观察三种策略下上下文(historyTokens)的变化:
 * - 全量保留:线性增长,最终撑爆窗口
 * - 滑动窗口:恒定在窗口大小
 * - 摘要压缩:被 LLM 压成一段摘要,几乎不增长
 * <p>
 * 【理论知识讲解】
 * 技术是什么:REST Controller 是 Spring MVC 的 HTTP 入口,把"对话"这种业务动作
 * 暴露成 /api/v1/context 下的 GET 接口。@RequestParam 负责绑定查询参数,
 * @NotBlank 用 Bean Validation 做非空校验(参数缺失/为空时返回 400)。
 * 为什么用:三个接口对应三种策略(full / window / summary),用 curl 连续调用
 * 就能逐轮对比返回的 token 统计字段,直观看到策略差异,无需写前端页面。
 * 核心概念:sessionId 是"会话维度"的隔离键------同一会话累积历史,不同会话互不干扰;
 * 控制器只做参数接收与转发,真正的策略逻辑全部封装在 ContextManager。
 * 原理:请求进入 → 参数绑定与校验 → 调用 ContextManager 对应方法 →
 * 模型生成回复并统计 token → 以 Map 序列化为 JSON 返回给调用方。
 */
@RestController
@Validated
@RequestMapping("/api/v1/context")
public class ContextController {

    /** 上下文管理器(通过构造器由 Spring 注入,负责三种策略的实现) */
    private final ContextManager contextManager;

    /**
     * 构造器注入上下文管理器
     *
     * @param contextManager 上下文管理器 Bean(Spring 自动注入)
     */
    public ContextController(ContextManager contextManager) {
        this.contextManager = contextManager;
    }

    /**
     * 策略1:全量保留------把全部历史逐字发给模型(基线,观察上下文如何膨胀)
     *
     * @param sessionId 会话 ID,同一会话累积历史、不同会话互不干扰
     * @param message   用户本次发言内容
     * @return 含 strategy / historySize / historyTokens / userTokens / answerTokens / answer 的统计结果
     */
    @GetMapping("/full")
    public Map<String, Object> full(@RequestParam @NotBlank String sessionId,
                                    @RequestParam @NotBlank String message) {
        // 转发给上下文管理器,返回含 historyTokens 的统计结果
        return contextManager.chatFull(sessionId, message);
    }

    /**
     * 策略2:滑动窗口------只保留最近 N 条消息,更早的历史直接丢弃
     *
     * @param sessionId 会话 ID,同一会话累积历史、不同会话互不干扰
     * @param message   用户本次发言内容
     * @return 含 windowSize / windowTokens / discardedTokens 等字段的统计结果
     */
    @GetMapping("/window")
    public Map<String, Object> window(@RequestParam @NotBlank String sessionId,
                                      @RequestParam @NotBlank String message) {
        // 转发给上下文管理器,返回含 windowTokens / discardedTokens 的统计结果
        return contextManager.chatWindow(sessionId, message);
    }

    /**
     * 策略3:摘要压缩------每攒够几轮,用 LLM 把旧历史压成一段摘要
     *
     * @param sessionId 会话 ID,同一会话累积历史、不同会话互不干扰
     * @param message   用户本次发言内容
     * @return 含 summaryExists / summaryTokens / recentSize / recentTokens 等字段的统计结果
     */
    @GetMapping("/summary")
    public Map<String, Object> summary(@RequestParam @NotBlank String sessionId,
                                       @RequestParam @NotBlank String message) {
        // 转发给上下文管理器,返回含 summaryTokens / recentTokens 的统计结果
        return contextManager.chatSummary(sessionId, message);
    }
}
文件:ContextManager.java
java 复制代码
package com.example.context.service;

import com.example.context.util.TokenEstimator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 上下文工程演示:三种上下文管理策略对比
 * <p>
 * 背景问题:上下文窗口是有限的。多轮对话中历史消息不断累积,
 * 很快会:(1) 超出窗口被模型截断(丢失早期信息);
 * (2) 即使不超窗,也白花 token(变贵、变慢)。
 * <p>
 * 三种策略:
 * - chatFull    全量保留:所有历史都传给模型(基线,验证"为什么会爆")
 * - chatWindow  滑动窗口:只保留最近 N 条消息(简单有效)
 * - chatSummary 摘要压缩:用 LLM 把旧历史压成一段摘要(极致省 token)
 * <p>
 * 【理论知识讲解】
 * 技术是什么:上下文工程 = 有意识地控制"每次调用模型时,把什么内容放进去、
 * 放多少",是治理"上下文窗口有限 + token 按量计费 + 噪声干扰输出"
 * 三连问题的工程手艺。本类用三种策略演示同一问题的不同解法。
 * 为什么用:多轮对话如果不加治理,历史消息线性累积,最终必然超出窗口
 * (早期信息被截断,对话"失忆"),或白白浪费 token(变贵变慢)。
 * 核心概念:
 * - 消息结构:发给模型的上下文是一组 Message------user 是用户发言、
 *   assistant 是模型回复、system 是全局指令。三种策略的差异只在
 *   "构造 Message 列表的方式",调模型的代码完全一样,即上下文管理
 *   本质是"发什么"的问题;
 * - 截断(滑动窗口):直接丢弃超窗历史,简单但丢信息;
 * - 压缩(摘要):LLM 提炼旧历史为一段摘要,有损但保住主干;
 * - 结构化(用户画像):把历史沉淀成事实字段,最省 token(第十二章实现)。
 * 原理:每轮对话 = 取历史 → 追加当前消息 → 按策略裁剪或压缩 →
 * 交给 ChatClient 调用模型 → 把回复追加回历史 → 返回附带 token 统计的结果。
 */
@Service
public class ContextManager {

    /** 日志记录器(SLF4J) */
    private static final Logger log = LoggerFactory.getLogger(ContextManager.class);

    /** 单轮消息:role = user / assistant */
    public record Turn(String role, String content) {
    }

    /** 摘要会话:已压缩的历史 + 最近未压缩的消息 */
    private static class SummarySession {
        String summary = "";
        List<Turn> recent = new ArrayList<>();
    }

    /** 策略1:全量历史会话 */
    private final Map<String, List<Turn>> fullSessions = new ConcurrentHashMap<>();
    /** 策略2:滑动窗口会话 */
    private final Map<String, List<Turn>> windowSessions = new ConcurrentHashMap<>();
    /** 策略3:摘要压缩会话 */
    private final Map<String, SummarySession> summarySessions = new ConcurrentHashMap<>();

    /** 窗口大小与压缩阈值(教学演示用小值) */
    private static final int WINDOW_SIZE = 6;
    private static final int SUMMARY_TRIGGER = 4;

    /** Spring AI 的链式调用客户端(封装 HTTP 请求与响应解析) */
    private final ChatClient chatClient;

    /**
     * 构造器注入:由 Spring 注入 ChatClient.Builder 并构建客户端
     *
     * @param builder ChatClient 构造器(Spring AI 自动配置,模型等参数来自 application.yml)
     */
    public ContextManager(ChatClient.Builder builder) {
        // 用默认配置构建 ChatClient(模型、温度等来自 application.yml)
        this.chatClient = builder.build();
    }

    /**
     * 策略1:全量历史------多轮后上下文无限膨胀
     *
     * @param sessionId 会话 ID,用于定位该会话累积的历史消息
     * @param userMsg   用户本次发言内容
     * @return 含 strategy / historySize / historyTokens / userTokens / answerTokens / answer 的统计结果
     */
    public Map<String, Object> chatFull(String sessionId, String userMsg) {
        // 按 sessionId 取出该会话的历史(首次访问时创建空列表)(var:List<Turn> 类型可从 computeIfAbsent 推断)
        var history = fullSessions.computeIfAbsent(sessionId, k -> new ArrayList<>());
        // 把用户消息追加进历史
        history.add(new Turn("user", userMsg));

        // 全量策略:不裁剪,把整段历史转成 Message 发给模型
        // Java 10+ var:List<Message> / String 类型可从方法返回类型推断
        var messages = toMessages(history);
        var answer = call(messages);

        // 把模型回复追加回历史,下一轮继续全量传给模型
        history.add(new Turn("assistant", answer));
        // limited=false 表示未做任何限制,用作"不治理就会爆"的基线对照
        return result("全量保留", history, userMsg, answer, false);
    }

    /**
     * 策略2:滑动窗口------只保留最近 WINDOW_SIZE 条消息
     *
     * @param sessionId 会话 ID,用于定位该会话累积的历史消息
     * @param userMsg   用户本次发言内容
     * @return 含 windowSize / windowTokens / discardedTokens 等字段的统计结果
     */
    public Map<String, Object> chatWindow(String sessionId, String userMsg) {
        // 按 sessionId 取出该会话的历史(首次访问时创建空列表)(var:List<Turn> 类型可从右侧推断)
        var history = windowSessions.computeIfAbsent(sessionId, k -> new ArrayList<>());
        history.add(new Turn("user", userMsg));

        // 核心:只取最后 N 条,更早的历史直接丢弃(var:List<Turn> 类型可从三元表达式推断)
        var window = history.size() > WINDOW_SIZE
                ? history.subList(history.size() - WINDOW_SIZE, history.size())
                : history;
        // 在修改历史前统计"实际传给模型"的窗口 token(var:long 类型可从 mapToLong().sum() 推断)
        var windowTokens = window.stream().mapToLong(t -> TokenEstimator.estimate(t.content())).sum();

        // 只把窗口内的消息发给模型(更早的历史已被丢弃,模型看不到)
        var answer = call(toMessages(window));

        // 回复仍追加进完整历史,用于计算"累计 vs 被丢弃"的对比
        history.add(new Turn("assistant", answer));
        var totalTokens = history.stream().mapToLong(t -> TokenEstimator.estimate(t.content())).sum();

        // Java 10+ var:Map<String, Object> 类型可从右侧 LinkedHashMap 推断
        var map = new LinkedHashMap<String, Object>();
        map.put("strategy", "滑动窗口");
        map.put("limited", true);
        map.put("historySize", history.size());          // 累计总条数(会被截断丢弃)
        map.put("windowSize", window.size());            // 真正传给模型的条数(恒定 ≤ WINDOW_SIZE)
        map.put("windowTokens", windowTokens);           // 真正传给模型的 token(恒定)
        map.put("discardedTokens", totalTokens - windowTokens); // 被丢弃的 token(省下来的)
        map.put("userTokens", TokenEstimator.estimate(userMsg));
        map.put("answerTokens", TokenEstimator.estimate(answer));
        map.put("answer", answer);
        return map;
    }

    /**
     * 策略3:摘要压缩------每 N 轮把旧历史压成一段摘要
     *
     * @param sessionId 会话 ID,用于定位该会话的摘要状态
     * @param userMsg   用户本次发言内容
     * @return 含 summaryExists / summaryTokens / recentSize / recentTokens 等字段的统计结果
     */
    public Map<String, Object> chatSummary(String sessionId, String userMsg) {
        // 按 sessionId 取出该会话的摘要状态(首次访问时创建)(var:SummarySession 类型可从 computeIfAbsent 推断)
        var session = summarySessions.computeIfAbsent(sessionId, k -> new SummarySession());
        session.recent.add(new Turn("user", userMsg));

        // 达到触发轮数 → 把【旧摘要 + 最近消息】用 LLM 压缩成新摘要
        if (session.recent.size() >= SUMMARY_TRIGGER) {
            // 组装压缩输入:已有摘要 + 本轮新增的最近消息
            // Java 15+ 文本块 + formatted():用占位符拼出"已有摘要 + 新增对话"的压缩输入
            var text = """
                    已有摘要:%s
                    
                    新增对话:%s
                    """.formatted(session.summary, render(session.recent));
            // 用 system 指令约束摘要器:保留关键事实、只输出摘要(注意用随机会话 ID 隔离,避免内置记忆干扰)
            session.summary = chatClient.prompt()
                    .system("你是对话摘要器。把下面「已有摘要+新增对话」压缩成不超过200字的摘要,保留关键事实(人名、数字、约定、结论),不要丢失任何重要信息。只输出摘要。")
                    .user(text)
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                    .call()
                    .content();
            // 压缩完成,清空最近消息(它们已被吸收进摘要)
            session.recent = new ArrayList<>();
            log.info("[Context] >>> 第 {} 轮触发摘要压缩,旧历史已合并 <<<", SUMMARY_TRIGGER);
        }

        // 组装上下文:摘要(system 级)+ 最近消息(var:List<Message> 类型可从右侧推断)
        var messages = new ArrayList<Message>();
        if (!session.summary.isBlank()) {
            // 摘要放 System 消息:告诉模型"这段是你的长期记忆",优先级最高、始终在上下文中
            // Java 15+ 文本块:System 消息以换行拼接摘要文本
            messages.add(new org.springframework.ai.chat.messages.SystemMessage("""
                    历史对话摘要(来自之前的对话):
                    %s
                    """.formatted(session.summary)));
        }
        // 最近消息按 user/assistant 结构追加
        messages.addAll(toMessages(session.recent));

        var answer = call(messages);
        // 回复仍计入最近消息,供下一轮压缩时使用
        session.recent.add(new Turn("assistant", answer));

        return summaryResult(session, userMsg, answer);
    }

    // ==================== 私有工具方法 ====================

    /**
     * 调用模型并返回回复
     *
     * @param messages 要发给模型的完整消息列表(已按策略裁剪或压缩)
     * @return 模型生成的文本回复
     */
    private String call(List<Message> messages) {
        // 每次调用使用随机会话 ID,避免 Spring AI 内置记忆把历史偷偷叠加进来,干扰策略对比
        return chatClient.prompt()
                .messages(messages)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                .call()
                .content();
    }

    /**
     * 把内部 Turn 转成 Spring AI Message
     *
     * @param turns 内部消息列表(role + content)
     * @return Spring AI 的 Message 列表(user 消息映射为 UserMessage,assistant 消息映射为 AssistantMessage)
     */
    private List<Message> toMessages(List<Turn> turns) {
        // Java 10+ var:List<Message> 类型可从 new ArrayList<Message>() 推断
        var messages = new ArrayList<Message>();
        for (Turn turn : turns) {
            // 按角色映射:user → UserMessage,assistant → AssistantMessage
            messages.add("user".equals(turn.role())
                    ? new UserMessage(turn.content())
                    : new AssistantMessage(turn.content()));
        }
        return messages;
    }

    /**
     * 把消息列表渲染成可读纯文本(用于喂给摘要器)
     *
     * @param turns 内部消息列表
     * @return 形如 "user: ...\nassistant: ...\n" 的纯文本
     */
    private String render(List<Turn> turns) {
        // Java 10+ var:StringBuilder 类型可从 new 构造器推断
        var sb = new StringBuilder();
        for (Turn turn : turns) {
            sb.append(turn.role()).append(": ").append(turn.content()).append("\n");
        }
        return sb.toString();
    }

    /**
     * 组装返回结果(附带上下文规模统计,便于对比三种策略)
     *
     * @param strategy 策略名称(如"全量保留")
     * @param history  完整历史(含本轮消息)
     * @param userMsg  用户本次发言
     * @param answer   模型回复
     * @param limited  是否对上下文做了限制(false 表示全量、无治理)
     * @return 含 historySize / historyTokens 等统计字段的结果 Map
     */
    private Map<String, Object> result(String strategy, List<Turn> history, String userMsg, String answer, boolean limited) {
        // 统计完整历史的 token 总量(观察它如何随轮次线性增长)(var:long 类型可从 sum() 推断)
        var histTokens = history.stream().mapToLong(t -> TokenEstimator.estimate(t.content())).sum();
        // Java 10+ var:Map<String, Object> 类型可从右侧 LinkedHashMap 推断
        var map = new LinkedHashMap<String, Object>();
        map.put("strategy", strategy);
        map.put("limited", limited);
        map.put("historySize", history.size());
        map.put("historyTokens", histTokens);
        map.put("userTokens", TokenEstimator.estimate(userMsg));
        map.put("answerTokens", TokenEstimator.estimate(answer));
        map.put("answer", answer);
        return map;
    }

    /**
     * 摘要策略的返回结果
     *
     * @param session 摘要会话(含已压缩摘要与最近未压缩消息)
     * @param userMsg 用户本次发言
     * @param answer  模型回复
     * @return 含 summaryExists / summaryTokens / recentSize / recentTokens 等统计字段的结果 Map
     */
    private Map<String, Object> summaryResult(SummarySession session, String userMsg, String answer) {
        // 统计最近未压缩消息的 token 总量(观察压缩后上下文几乎不增长)(var:long 类型可从 sum() 推断)
        var recentTokens = session.recent.stream().mapToLong(t -> TokenEstimator.estimate(t.content())).sum();
        // Java 10+ var:Map<String, Object> 类型可从右侧 LinkedHashMap 推断
        var map = new LinkedHashMap<String, Object>();
        map.put("strategy", "摘要压缩");
        map.put("limited", true);
        map.put("summaryExists", !session.summary.isBlank());
        map.put("summaryTokens", TokenEstimator.estimate(session.summary));
        map.put("recentSize", session.recent.size());
        map.put("recentTokens", recentTokens);
        map.put("userTokens", TokenEstimator.estimate(userMsg));
        map.put("answerTokens", TokenEstimator.estimate(answer));
        map.put("answer", answer);
        return map;
    }
}
文件:TokenEstimator.java
java 复制代码
package com.example.context.util;

/**
 * 简易 Token 估算器
 * <p>
 * 上下文工程的第一个基本功:能量化"上下文有多大"。
 * 精确 token 数由模型分词器决定,这里用启发式估算(误差可接受):
 * - 汉字:约 1.2 token/字(中文信息密度高)
 * - 其他字符:约 4 字符/token(英文单词平均约 1.3 token)
 * - 每条消息固定 +8 token(消息格式开销)
 * <p>
 * 【理论知识讲解】
 * 技术是什么:Token(词元)是大模型处理文本的最小单位,模型按 token 计费、
 * 上下文窗口也按 token 计数。TokenEstimator 用统计经验近似真实分词结果,
 * 让开发者对"上下文有多大"心里有数。
 * 为什么用:真实分词器(如 tiktoken / BPE)需要引入额外依赖且与具体模型强绑定,
 * 教学演示用启发式估算即可对比三种策略的规模差异。
 * 核心概念:中文按"字"计数(约 1.2 token/字),非中文按"4 字符折 1 token"估算,
 * 每条消息再加 8 token 的固定结构开销。
 * 原理:遍历字符串统计汉字个数(Unicode CJK 区间 0x4E00~0x9FFF),
 * 其余字符数 = 总长度 - 汉字数,按加权公式计算后四舍五入,最后补 8 token
 * 模拟消息包装成本。
 */
public final class TokenEstimator {

    /** 私有构造器:工具类禁止实例化(仅提供静态方法) */
    private TokenEstimator() {
    }

    /**
     * 估算一段文本的 token 数量
     *
     * @param text 待估算的文本(允许为 null 或空串)
     * @return 估算出的 token 数;text 为 null 或空串时返回 0
     */
    public static long estimate(String text) {
        if (text == null || text.isEmpty()) {
            // 空文本不需要消耗任何 token
            return 0;
        }
        // 统计汉字个数(Unicode 区间 0x4E00~0x9FFF 为 CJK 统一表意文字)
        // Java 10+ var:long 类型可从 count() 返回类型推断
        var chinese = text.chars().filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
        // 其余字符个数 = 文本总长度 - 汉字数
        long others = text.length() - chinese;
        // 汉字按 1.2 token/字、其他字符按 4 字符/token 估算,最后加 8 token 消息开销
        return Math.round(chinese * 1.2 + others / 4.0) + 8;
    }
}

运行效果截图

演示页面:

PPT 讲读页:

PPT 讲读页(完整8页):


项目 12:长期记忆机制(Long-Term Memory)

12.1 理论讲解

什么是长期记忆?

短期记忆(如第三章的 MessageWindowChatMemory)只活在当前会话,用户换个会话就"不认人"。长期记忆 则是把对话中关于用户的持久事实提炼成结构化画像,存到磁盘/数据库,让模型在"新会话、重启后"依然记得用户是谁。

记忆闭环:读-用-写
复制代码
1. 读记忆:会话开始前,从磁盘加载该用户的画像(UserProfile)
2. 用记忆:把画像注入 System 消息,模型回答时参考
3. 写记忆:回答后,用 LLM 从对话中提取"新的持久事实",合并写回磁盘
核心设计原则
  • 按 userId 隔离:每个用户一个 JSON 文件,记忆互不串扰
  • 只存事实不存原文:大幅节省 token,并提升回答质量(模型基于干净画像作答,而非噪声原文)
  • 失败宽容:读写失败、解析失败只记日志,绝不打断对话主链路(记忆是"锦上添花",不是主链路)
  • JSON 输出约束:记忆提取要求模型"只输出 JSON 字符串数组",便于程序解析
长期记忆 vs 短期记忆
特性 短期记忆(窗口消息) 长期记忆(用户画像)
存储位置 内存 磁盘/数据库
生命周期 随会话结束即消失 跨会话、跨重启
内容 对话原文 提炼后的持久事实
Token 消耗 线性增长 恒定(画像大小有限)
适用场景 单轮对话上下文 跨会话用户理解

12.2 项目结构

复制代码
12-long-term-memory/
├── pom.xml
└── src/main/
    ├── resources/
    │   └── application.yml
    └── java/com/example/memory/
        ├── LongTermMemoryApplication.java
        ├── controller/
        │   └── LongTermMemoryController.java
        └── service/
            ├── LongTermMemoryService.java
            └── UserProfile.java

12.3 完整代码

文件:pom.xml
xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>12-long-term-memory</artifactId>
    <version>1.0.0</version>
    <name>12-long-term-memory</name>
    <description>第十二章 长期记忆机制</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>25</java.version>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <spring-ai.version>2.0.0</spring-ai.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
文件:application.yml
yaml 复制代码
server:
  port: 9012
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  application:
    name: 12-long-term-memory
  jackson:
    encoding: UTF-8

# 大模型配置(OpenAI 兼容协议,经 opencode 网关调用 DeepSeek)
# Spring AI 2.0:chat 参数扁平化(chat.options.* 已废弃)
spring.ai.openai:
  api-key: ${YOUR_OPENCODE_API_KEY}
  base-url: https://opencode.ai/zen/go/v1
  chat:
    # Official version: DeepSeek-V4-Flash-0731 (2026/07/31 GA, fast, high concurrency, low cost, ideal for teaching)
    model: deepseek-v4-flash
    # temperature=0.4  Long-term profile generation: balanced for accurate yet natural profile text
    temperature: 0.4

logging:
  level:
    root: INFO
    com.example.memory: DEBUG
文件:LongTermMemoryApplication.java
java 复制代码
package com.example.memory;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第十二章 长期记忆机制 ------ 应用启动类(Spring Boot 入口)
 * <p>
 * 【理论知识讲解】
 * 技术是什么:Spring Boot 是"约定优于配置"的 Java Web 框架,
 * @SpringBootApplication 一个注解同时启用 @Configuration(配置类)、
 * @EnableAutoConfiguration(自动装配)、@ComponentScan(组件扫描)。
 * 为什么用:本项目是一个对外提供 REST 接口的 Web 服务,需要内嵌 Tomcat、
 * 并自动装配 Spring AI 的 ChatClient 等 Bean,Spring Boot 可零配置拉起整个应用。
 * 核心概念:main 方法是唯一入口,SpringApplication.run() 会创建并刷新
 * Spring 容器(实例化所有单例 Bean)、启动内嵌 Web 服务器、阻塞监听端口。
 * 原理:run() 内部依次经历「准备环境 → 创建应用上下文 → 刷新容器
 * (扫描 @Service / @RestController 并完成依赖注入)→ 启动内嵌服务器 → 注册关闭钩子」,
 * 期间读取 application.yml 完成端口、模型等配置。
 */
@SpringBootApplication
public class LongTermMemoryApplication {

    /**
     * 应用入口方法
     *
     * @param args 命令行参数(如 --server.port=9012 可覆盖 yml 中的端口配置),一般留空
     * @return 无返回值;进程启动后阻塞监听 HTTP 端口,直到被停止
     */
    public static void main(String[] args) {
        // 启动 Spring Boot 应用:自动装配、组件扫描完成后阻塞监听 9012 端口
        SpringApplication.run(LongTermMemoryApplication.class, args);
    }
}
文件:LongTermMemoryController.java
java 复制代码
package com.example.memory.controller;

import com.example.memory.service.LongTermMemoryService;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * 长期记忆演示接口
 * <p>
 * 用同一个 userId 连续对话,模型会逐步记住用户信息;
 * 换一个 userId(模拟"换会话/重启后新用户"),记忆互不干扰。
 * 记忆文件保存在 target/memory/{userId}.json,重启后依然有效。
 * <p>
 * 【理论知识讲解】
 * 技术是什么:REST Controller 是 Spring MVC 的 HTTP 入口,把"带记忆的对话"
 * 暴露成 GET /api/v1/memory/chat。userId 是记忆的"隔离维度",message 是本次输入。
 * 为什么用:curl 一条命令即可演示------同一 userId 多轮对话观察画像逐轮累积;
 * 换 userId 观察记忆隔离;重启进程后再问,记忆仍在(磁盘持久化的价值)。
 * 核心概念:控制器只做参数接收、校验与转发;"读-用-写"闭环全部封装在
 * LongTermMemoryService 里,职责单一。
 * 原理:请求进入 → 参数绑定与 @NotBlank 校验 → 调用 memoryService.chat(userId, message)
 * → 服务层执行完整记忆闭环 → 返回 { userId, answer, profile } JSON。
 */
@RestController
@Validated
@RequestMapping("/api/v1/memory")
public class LongTermMemoryController {

    /** 长期记忆服务(通过构造器由 Spring 注入) */
    private final LongTermMemoryService memoryService;

    /**
     * 构造器注入长期记忆服务
     *
     * @param memoryService 长期记忆服务 Bean(Spring 自动注入)
     */
    public LongTermMemoryController(LongTermMemoryService memoryService) {
        this.memoryService = memoryService;
    }

    /**
     * 带长期记忆的对话接口
     *
     * @param userId  用户标识(每个用户一份独立画像,记忆按 userId 隔离)
     * @param message 用户本次发言
     * @return 含 userId / answer / profile 的结果 Map(profile 为最新画像文本)
     */
    @GetMapping("/chat")
    public Map<String, Object> chat(@RequestParam @NotBlank String userId,
                                    @RequestParam @NotBlank String message) {
        // 转发给服务层:内部完成"读记忆 → 用记忆 → 写记忆"的完整闭环
        return memoryService.chat(userId, message);
    }
}
文件:LongTermMemoryService.java
java 复制代码
package com.example.memory.service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * 长期记忆服务(跨会话、跨重启)
 * <p>
 * 记忆闭环 = 三步:
 * 1. 读记忆:会话开始前,从磁盘加载该用户的画像(UserProfile);
 * 2. 用记忆:把画像注入 System 消息,模型回答时参考;
 * 3. 写记忆:回答后,用 LLM 从对话中提取"新的持久事实",合并写回磁盘。
 * <p>
 * 与短期记忆(第三章的 MessageWindowChatMemory,只活在本会话)的区别:
 * 长期记忆落在磁盘/数据库上,换会话、重启进程都还在。
 * <p>
 * 【理论知识讲解】
 * 技术是什么:长期记忆 = 把对话中关于用户的持久事实提炼成结构化画像,
 * 存到磁盘/数据库,让模型在"新会话、重启后"依然记得用户是谁。
 * 为什么用:短期记忆(窗口消息)随会话结束即消失,用户换个会话就"不认人";
 * 长期记忆按 userId 持久化,解决体验断层;同时只存事实不存原文,
 * 大幅节省 token,并提升回答质量(模型基于干净画像作答,而非噪声原文)。
 * 核心概念:
 * - 读-用-写闭环:加载画像 → 注入 System → 模型回答 → LLM 提取新事实 → 合并写回;
 * - 按 userId 隔离:每个用户一个 JSON 文件,记忆互不串扰;
 * - JSON 输出约束:记忆提取要求模型"只输出 JSON 字符串数组",便于程序解析;
 * - 失败宽容:读写失败、解析失败只记日志,绝不打断对话主链路
 *   (记忆是"锦上添花",不是主链路)。
 * 原理:chat() 每次调用都执行完整的读-用-写三步;extractNewFacts() 让模型
 * 以 system 指令扮演"记忆提取器",从画像 + 对话中筛出持久事实;
 * 结果经 extractJsonArray() 截取 [ ] 片段后,用 Jackson 解析为 List<String>,
 * 再经 UserProfile.merge() 去重合并,最后 save() 写回磁盘。
 */
@Service
public class LongTermMemoryService {

    /** 日志记录器(SLF4J) */
    private static final Logger log = LoggerFactory.getLogger(LongTermMemoryService.class);

    /** 记忆文件目录(演示用本地文件代替数据库) */
    private static final Path MEMORY_DIR = Paths.get("target", "memory");

    /** Spring AI 客户端(承担"对话回答"与"记忆提取"两次模型调用) */
    private final ChatClient chatClient;
    /** Jackson JSON 解析器(画像读写、提取结果解析共用) */
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 构造器注入:由 Spring 注入 ChatClient.Builder 并构建客户端
     *
     * @param builder ChatClient 构造器(Spring AI 自动配置,模型等参数来自 application.yml)
     */
    public LongTermMemoryService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    /**
     * 带长期记忆的对话(完整执行"读记忆 → 用记忆 → 写记忆"闭环)
     *
     * @param userId  用户标识(每个用户一份独立画像,记忆按 userId 隔离)
     * @param message 用户本次发言
     * @return 含 userId / answer / profile(最新画像文本)的结果 Map
     */
    public Map<String, Object> chat(String userId, String message) {
        // 1. 读记忆:从磁盘加载该用户画像(首次访问返回空画像)(var:UserProfile 类型可从 load() 推断)
        var profile = load(userId);
        log.info("[Memory] 用户 {} 已有记忆条数: {}", userId, profile.facts().size());

        // 2. 用记忆:画像注入 System,模型回答时参考(System 消息优先级最高)
        // Java 15+ 文本块 + formatted():占位符 %s 渲染画像,保持"前缀 + 换行 + 画像"原语义
        var answer = chatClient.prompt()
                .system("""
                        你是用户的贴心助手。以下是你对这个用户的长期记忆(用户画像),回答时请参考:
                        %s
                        """.formatted(profile.render()))
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                .call()
                .content();

        // 3. 写记忆:LLM 从"画像 + 本次对话"中提取新的持久事实(var:List<String> 类型可从方法返回类型推断)
        var newFacts = extractNewFacts(profile, message, answer);
        if (!newFacts.isEmpty()) {
            // 有新事实才合并写盘(去重由 UserProfile.merge 保证;merge 返回新实例,需重新赋值)
            profile = profile.merge(newFacts);
            save(userId, profile);
            log.info("[Memory] 新增记忆: {}", newFacts);
        }

        // 返回答案 + 最新画像,便于观察记忆的逐轮累积过程
        // Java 10+ var:Map<String, Object> 类型可从右侧 LinkedHashMap 推断
        var result = new LinkedHashMap<String, Object>();
        result.put("userId", userId);
        result.put("answer", answer);
        result.put("profile", profile.render());
        return result;
    }

    /**
     * 让 LLM 从"用户画像 + 本次对话"中提取新的持久事实,返回 JSON 字符串数组
     *
     * @param profile 当前用户画像(供模型判断哪些信息已知,避免重复提取)
     * @param message 用户本次发言
     * @param answer  模型刚才的回复(新事实常常藏在问答内容里)
     * @return 提取到的新事实列表;没有新事实或解析失败时返回空列表
     */
    private List<String> extractNewFacts(UserProfile profile, String message, String answer) {
        // 用 system 指令约束输出格式:只输出 JSON 字符串数组,便于程序解析(var:String 类型可从链式调用推断)
        var json = chatClient.prompt()
                .system("""
                        你是记忆提取器。根据「用户画像」和「本次对话」,提取关于用户的新的持久事实。
                        持久事实指长期有效的信息:姓名、年龄、职业、喜好、家庭、计划、偏好等;
                        临时信息(如"今天天气不错")不要提取。
                        如果本次对话没有新事实,输出空数组 []。
                        只输出一个 JSON 字符串数组,例如:["用户喜欢打篮球", "用户下周去北京出差"],不要输出其他内容。
                        """)
                // Java 15+ 文本块 + formatted():用占位符拼出"画像 + 消息 + 回复"多行输入
                .user("""
                        用户画像:%s
                        
                        用户本次消息:%s
                        
                        你的回复:%s
                        """.formatted(profile.render(), message, answer))
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, UUID.randomUUID().toString()))
                .call()
                .content();

        // 解析 JSON 数组,解析失败则忽略(不让记忆流程被异常打断)
        try {
            return objectMapper.readValue(extractJsonArray(json), new TypeReference<List<String>>() {
            });
        } catch (Exception e) {
            // 失败宽容:只打日志并返回空列表,对话照常继续
            log.warn("[Memory] 记忆提取解析失败,跳过", e);
            return Collections.emptyList();
        }
    }

    /**
     * 从模型输出中截取第一个 [...] 片段(模型偶尔会夹带解释文字)
     *
     * @param text 模型输出的原始文本
     * @return 截取到的 JSON 数组片段;找不到合法片段时返回 "[]"
     */
    private String extractJsonArray(String text) {
        // 取第一个 '[' 到最后一个 ']' 之间的内容,容错模型夹带的说明文字
        // Java 10+ var:int 类型可从 indexOf / lastIndexOf 返回类型推断
        var start = text.indexOf('[');
        var end = text.lastIndexOf(']');
        return (start >= 0 && end > start) ? text.substring(start, end + 1) : "[]";
    }

    /**
     * 从磁盘读取用户画像(不存在则返回空画像)
     *
     * @param userId 用户标识,对应磁盘文件 target/memory/{userId}.json
     * @return 该用户的画像;文件不存在或读取失败时返回空画像
     */
    private UserProfile load(String userId) {
        // 每个用户一个文件:target/memory/{userId}.json(var:Path 类型可从 resolve() 返回类型推断)
        var file = MEMORY_DIR.resolve(userId + ".json");
        if (!Files.exists(file)) {
            // 首次访问:还没有记忆文件,返回空画像
            return new UserProfile(List.of());
        }
        try {
            // 用 Jackson 把 JSON 反序列化为 UserProfile(UTF-8 保证中文不乱码)
            return objectMapper.readValue(Files.readString(file, StandardCharsets.UTF_8), UserProfile.class);
        } catch (IOException e) {
            // 文件损坏等异常:返回空画像并打日志,不中断对话
            log.warn("[Memory] 读取记忆文件失败", e);
            return new UserProfile(List.of());
        }
    }

    /**
     * 把用户画像写入磁盘(UTF-8,带缩进便于查看)
     *
     * @param userId  用户标识
     * @param profile 要持久化的用户画像
     */
    private void save(String userId, UserProfile profile) {
        try {
            // 目录不存在时先创建(首次写入会自动建目录)
            Files.createDirectories(MEMORY_DIR);
            // 带缩进的 JSON,方便直接打开文件查看记忆内容
            // Java 10+ var:String 类型可从 writeValueAsString 返回类型推断
            var json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(profile);
            Files.writeString(MEMORY_DIR.resolve(userId + ".json"), json, StandardCharsets.UTF_8);
        } catch (IOException e) {
            // 写入失败只打日志(记忆是锦上添花,不阻塞主流程)
            log.warn("[Memory] 写入记忆文件失败", e);
        }
    }
}
文件:UserProfile.java
java 复制代码
package com.example.memory.service;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;

/**
 * 用户画像:长期记忆的数据模型
 * <p>
 * 长期记忆 ≠ 逐字保留对话,而是把对话中"关于用户的持久事实"
 * (姓名、年龄、喜好、职业、约定......)提炼成结构化画像保存。
 * 下次任何新会话开始时,把画像注入 System 消息,模型"就记得"用户了。
 * <p>
 * 【理论知识讲解】
 * 技术是什么:UserProfile 是"用户级记忆"的载体------一个 Java record,
 * 内部只有一条 facts 组件(持久事实列表),借助 Jackson 可一键序列化为
 * JSON 文件落到磁盘,也可从 JSON 反序列化回来。
 * 为什么用:相比把对话原文塞进上下文,画像体积小(省 token)、价值高
 * (只留持久事实)、可检索(字段化)。这正是上下文工程"结构化"手段的落地,
 * 也是第十一章"摘要压缩"的进一步升华------从"压缩文本"升级为"提取事实"。
 * 核心概念:
 * - 事实(fact):一句关于用户的、长期有效的信息,如"用户叫小明,25岁";
 * - 渲染(render):把事实列表变成注入 System 消息的编号文本;
 * - 合并(merge):新事实与旧事实取并集并去重,同一事实只存一次。
 * 原理:render() 用"序号 + 内容"排版,方便模型逐条引用;
 * merge() 借助 LinkedHashSet 去重(既去重又保持插入顺序),
 * 并过滤 null / 空白串、统一 trim,避免脏数据污染画像;
 * record 是不可变数据载体,merge() 返回合并后的新实例(不原地修改)。
 */
public record UserProfile(List<String> facts) {

    /**
     * 渲染成 System 消息里的记忆文本
     *
     * @return 形如 "1. 用户叫小明,25岁\n2. 用户喜欢打篮球" 的编号文本;
     *         画像为空时返回"(暂无该用户的长期记忆)"
     */
    public String render() {
        // 访问器是 facts():record 自动生成的组件访问方法
        if (facts().isEmpty()) {
            // 无记忆时给出明确占位,避免 System 消息出现空内容
            return "(暂无该用户的长期记忆)";
        }
        // Java 10+ var:StringBuilder 类型可从 new 构造器推断
        var sb = new StringBuilder();
        // Java 5+ for-each + 计数器:逐条编号,方便模型逐条引用记忆(避免手写索引 i)
        int no = 1;
        for (String fact : facts()) {
            sb.append(no++).append(". ").append(fact).append("\n");
        }
        // 去掉末尾换行,避免 System 消息末尾出现多余空行
        return sb.toString().trim();
    }

    /**
     * 合并新事实(去重,用 Set 保证同一事实只存一次)
     * <p>
     * record 不可变:不修改当前实例,而是返回合并后的新画像实例
     *
     * @param newFacts 本次提取到的新持久事实列表(可能包含 null 或空白串,会被过滤)
     * @return 合并去重后的新 UserProfile 实例(含新旧全部事实)
     */
    public UserProfile merge(List<String> newFacts) {
        // LinkedHashSet:既去重又保持原有插入顺序(var:Set<String> 类型可从 new LinkedHashSet<>() 推断)
        var merged = new LinkedHashSet<>(facts());
        for (String fact : newFacts) {
            // 过滤 null 与空白串,统一 trim 后再存入,避免脏数据
            if (fact != null && !fact.isBlank()) {
                merged.add(fact.trim());
            }
        }
        // 返回新实例:去重后的集合作为新画像的 facts
        return new UserProfile(new ArrayList<>(merged));
    }
}

运行效果截图

演示页面:

PPT 讲读页:

PPT 讲读页(完整8页):

相关推荐
xierui1231231 小时前
AI Agent 隐私架构:本地化重点为什么是登录态与执行权限
java·人工智能·网络安全·架构
u0103055271 小时前
昇腾AI赋能安卓智能助手
人工智能·笔记
回眸&啤酒鸭1 小时前
【回眸】Grok 4.6 核心能力落地与实战应用指南
大数据·人工智能
冬奇Lab1 小时前
Code Agent 解剖(07):外部工具怎么接进来?MCP 集成是怎么做的?
人工智能
日月新著1 小时前
微软2万人调查:天天用AI的你,为什么还是原地踏步?
人工智能·microsoft
cfm_29141 小时前
Spring AI Tool 调用架构全解
java·人工智能·spring
东离与糖宝1 小时前
告别人工瞎筛!4步搭建AI简历初筛系统,精准避坑零误差
人工智能
冬奇Lab1 小时前
开源项目第194期:deepseek-harness — DeepSeek 出品的 AI Agent 开发框架,万物皆插件
人工智能·开源·deepseek
qiyongwork1 小时前
大模型自动化测试生成:SmartSE的实践与启示
人工智能·项目管理·需求管理