大模型应用开发课程 ------ 项目 09~12 完整教程
项目 09:Workflow 工作流编排
9.1 理论讲解
什么是 Workflow 工作流?
Workflow(工作流)是一种确定性编排模式------把一组按特定顺序执行、可复用的"步骤"(节点)组装成一条确定的执行链,由引擎负责调度,每个节点只做自己那一件事。
与第八章的 Agent 模式对比:
- Agent:模型自由决策,每次调用的流程可能不同(灵活但不可控)
- Workflow:流程是确定的、可预期的(可控、可审计)
为什么需要 Workflow?
- 可控性:固定业务流程(如客服分流)用 Workflow,流程透明、可审计
- 可复用性:节点是独立的组件,可在不同工作流中复用
- 可测试性:每个节点可以独立测试,流程可以端到端测试
- 可维护性:修改某个节点不会影响其他节点
核心设计原则
本项目展示了一个核心设计原则:"分类用模型、路由用代码"
- 语义理解(如意图分类)是大模型的强项 → 交给 LLM
- 确定性路由(如分类结果走哪条处理线)是代码的强项 → 交给代码
工作流引擎的两种编排原语
- 顺序节点(SequenceNode):按顺序依次执行子节点
- 条件分支节点(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 收到的"员工工作汇报"
三个关键配置
defaultTools(specialistAgents):把专家团队注册为 Manager 可调用的工具- ToolCallingAdvisor:开启工具调用能力(Spring AI 2.0 默认启用)
- 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 按量计费 + 噪声干扰输出"三连问题的工程手艺。
为什么需要上下文工程?
上下文窗口是有限的。多轮对话中历史消息不断累积,很快会:
- 超出窗口被模型截断(丢失早期信息)
- 即使不超窗,也白花 token(变贵、变慢)
- 噪声干扰输出(无关历史降低回复质量)
三种上下文管理策略
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 全量保留 | 所有历史都传给模型 | 信息最完整 | 线性增长,最终撑爆窗口 |
| 滑动窗口 | 只保留最近 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页):







