JB3-9-SpringAI(一)

Java道经第3卷 - 第9阶 - SpringAI(一)


传送门:JB3-9-SpringAI(一)
传送门:JB3-9-SpringAI(二)

心法:本章使用 Maven 父子结构项目进行练习。

练习项目结构如下

|_ v3-9-ssm-ai
	|_ 13901 springai-chat
	|_ 13902 springai-rag
	|_ 13903 springai-tool-calling
	|_ 13904 springai-mcp-server
	|_ 13905 springai-mcp-client
	|_ 13906 springai-graph
	|_ 13907 springai-agent-chat
	|_ 13908 springai-agent-skills
	|_ 13909 springai-agent-supervisor
	|_ 13910 springai-agent-parallel
	|_ 13911 springai-agent-routing
	|_ 13999 springai-web

武技:搭建练习项目结构。

  1. 创建父项目 v3-9-ssm-ai,删除 src 目录。
  2. 在父项目中锁定版本(SpringAI 对 Jackson 版本要求较高,而 SpringBoot3.2.5 提供的 Jackson 版本是 2.15.4,这里手动提高到 2.17.0 版本):
<properties>
	<maven.compiler.source>17</maven.compiler.source>
	<maven.compiler.target>17</maven.compiler.target>
	<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	<junit.version>4.13.2</junit.version>
	<lombok.version>1.18.24</lombok.version>
	<hutool-all.version>5.8.25</hutool-all.version>
	<jackson.version>2.17.0</jackson.version>
	<spring-boot.version>3.2.5</spring-boot.version>
	<spring-ai.version>1.1.2</spring-ai.version>
	<spring-ai-alibaba.version>1.1.2.0</spring-ai-alibaba.version>
	<spring-ai-alibaba-extensions.version>1.1.2.0</spring-ai-alibaba-extensions.version>
	<ST4.version>4.3.4</ST4.version>
	<redisson-spring-boot-starter.version>3.33.0</redisson-spring-boot-starter.version>
	<spring-ai-alibaba-studio.version>1.1.2.2</spring-ai-alibaba-studio.version>
</properties>
  1. 在父项目中配置里程碑仓库:Spring AI Alibaba 的依赖包暂未完全发布到 Maven 中央仓库,而是在 Spring 官方的里程碑仓库(milestone)里:
<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>
  1. 在父项目中管理依赖:
依赖项版本描述
spring-boot-dependencies3.2.5SpringBoot 核心依赖清单
spring-ai-bom1.1.2SpringAI 核心依赖清单
spring-ai-alibaba-bom1.1.2.0SpringAIAlibaba 核心依赖清单
spring-ai-alibaba-extensions-bom1.1.2.0SpringAIAlibaba 扩展依赖清单
<dependencyManagement>
    <dependencies>
        <!--spring-boot-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!--spring-ai-->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!--spring-ai-alibaba-->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-bom</artifactId>
            <version>${spring-ai-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!--spring-ai-alibaba-extensions-->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-extensions-bom</artifactId>
            <version>${spring-ai-alibaba-extensions.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
  1. 在父项目中引入通用依赖:
<dependencies>
	<!--junit-->
	<dependency>
		<groupId>junit</groupId>
		<artifactId>junit</artifactId>
		<version>${junit.version}</version>
		<scope>test</scope>
	</dependency>
	<!--lombok-->
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>${lombok.version}</version>
		<scope>provided</scope>
	</dependency>
	<!--hutool-all-->
	<dependency>
		<groupId>cn.hutool</groupId>
		<artifactId>hutool-all</artifactId>
		<version>${hutool-all.version}</version>
	</dependency>
	<!--jackson-databind-->
	<dependency>
		<groupId>com.fasterxml.jackson.core</groupId>
		<artifactId>jackson-databind</artifactId>
		<version>${jackson.version}</version>
	</dependency>
	<!--jackson-core-->
	<dependency>
		<groupId>com.fasterxml.jackson.core</groupId>
		<artifactId>jackson-core</artifactId>
		<version>${jackson.version}</version>
	</dependency>
	<!--jackson-annotations-->
	<dependency>
		<groupId>com.fasterxml.jackson.core</groupId>
		<artifactId>jackson-annotations</artifactId>
		<version>${jackson.version}</version>
	</dependency>
</dependencies>

S01. SpringAI入门

AI 系统能力进化路径

阶段系统定位中文简述(工具指的均为 @Tool 方法)包含的相关组件 / 技术
1Chat基础对话系统提供纯自然语言对话能力,无外部依赖对话模型(ChatModel/ChatClient)
对话选项(ChatOptions)
实体响应(entity)
预设角色(defaultSystem)
对话记忆、模型日志等(advisor)
2MultiModal多模态系统支持图片、音频等多种模态输入输出
属于横向能力,可附加给任意阶段
文生图片模型(ImageModel)
文生音频模型(AudioModel)
文生视频模型(VideoModel)
3RAG-Chat增强型对话系统基于私有知识库检索
提供精准、专业的领域知识问答
检索增强(RAG + ETL)
向量数据库(VectorStore)
4Skill功能型系统具备对话理解能力
可调用工具执行特定业务动作
本地工具调用(FunctionCall/ToolCalling)
外部工具调用(MCP,可选)
5Workflow工作流系统采用预定义的固定执行路径
不依赖大模型自主决策
工具与流程编排均需手动开发
状态图编排(Graph,可选实现方式)
6SingleAgent单智能体系统动态生成执行路径
由大模型自主决策工具调用顺序与入参
仅需开发工具,无需手动编排流程
自主决策智能体(ReactAgent)
状态图编排(Graph,可选实现方式)
7MultiAgent多智能体系统依靠角色分工、任务拆解与结果汇总
多个独立智能体协同完成复杂目标
多智能体编排(AgentScope/AgentGen)

E01. AI语言模型

心法:LLM(Large Language Model)即大型语言模型,依托海量海量文本数据完成预训练,具备自然语言理解、逻辑推理与内容生成能力,是当下主流人工智能核心基座。

1. 生成式大模型

心法:生成式大模型是能依据提示生成文本、图像、音频、视频、软件代码等全新内容的人工智能。

传统的 LLM 和 生成式 LLM 对比如下:

技术原理方面

  • 传统的 AI:常基于规则和算法,通过对大量标注数据的学习来提取特征,实现分类、预测等任务。
  • 生成式 AI:以生成模型为核心,通过对海量数据的无监督或半监督学习,掌握数据内在模式和分布,进而生成新数据。

功能特点方面

  • 传统的 AI:擅长执行特定任务,像语音识别、图像识别、医疗影像诊断、金融风险预测等,专注在已知模式和规则下对输入数据分类、判断和预测,不具备内容创造能力。
  • 生成式 AI:突出特点是创造新内容,涵盖文本、图像、音频、视频等,还能进行代码补全、场景模拟等。例如根据文本描述生成对应图像,或依据简单旋律拓展成完整乐曲。

应用场景方面

  • 传统的 AI:广泛用于需要精准判断和预测的领域,比如在安防监控中识别异常行为,电商推荐系统依据用户行为和偏好推荐商品,工业生产中检测产品缺陷等。
  • 生成式 AI:多用于创意和内容生成领域,比如广告营销生成创意文案和设计,游戏开发自动生成地图、角色和剧情,影视制作生成特效和虚拟场景。

2. 令牌单位Token

心法:Token 是大模型解析、处理、输出文本的最小运算单位,各厂商分词规则略有差异,各家 LLM 厂商都有自己的切字逻辑,但整体来看,大约一个 token 等于 0.5 ~ 1 个汉字。

AI 接口单次请求总消耗 Token 计算公式

# 总 Token 数 = (输入提示词 Token) + (模型回复 Token) + (缓存上下文 Token)

total_tokens = prompt_tokens + completion_tokens + cached_tokens

3. 提示词Prompt

心法:提示词,是你向大模型下发的指令、问题与交互上下文,是控制模型输出效果的核心,一个完整的提示词包含模型 model,用户输入 input 和请求参数 parameters 三部分。

模型 model:主要规定了本次请求使用的大模型:

  • 如深推理模型 qwen-max:深度思考模型。
  • 如纯文本模型 qwen-plus:通用文本模型,默认值。
  • 如多模态模型 qwen3.6-plus:多模态专用模,需手动启用。
  • 其它模型参考 阿里模型列表 即可。

用户输入 input:包含各种角色以及对应的消息内容,目前内置四大交互角色:

  • 系统角色 system:设定模型人设风格,官方不推荐滥用,易干扰逻辑推理
  • 用户角色 user:用于向模型传递问题、指令或上下文等
  • 助手角色 assistant:模型对用户消息的回复
  • 工具角色 tool:函数调用、外部工具返回结果

示例

"input": {
	"messages": [
		{ "role": "user", "content": "50字以内,介绍常用的AI编程工具" }
	]
},

请求参数 parameters:本次调用附加的其它参数配置(均在 "parameters": {} 中进行配置):

配置项简述详述
temperature: 0.8随机采样温度控制模型生成文本的多样性,取值范围 [0, 1]
数值越高,回答越灵活发散
数值越低,回答越严谨固定、重复性越强
qwen-plus 默认 0.8
maxTokens: 59277最大输出长度限制模型单次回复最大 Token 数量
超出则提前终止并返回 finish_reason: length

qwen-turbo 最大 Token 数量 ≤ 1500
qwen-plus 最大 Token 数量 ≤ 2000
qwen-max 最大 Token 数量 ≤ 8192
qwen3.6-plus 最大 Token 数量 ≤ 65536
enable_thinking: true深度思考开关开启深度思考模型的思考模式(其它模型开启无效),参考 深度思考

true: 开启后,思考内容将通过 reasoning_content 字段返回
false:关闭深度思考,精简 Token 消耗、提升响应速度
stream: false流式输出开关是否使用流式回复(底层使用 SSE 推送):

true:流式逐字输出。
false:完整一次性输出,默认值。
multi_model: true多模态能力开关如果使用多模态模型如 qwen3.6-plus,则必须配置此项,纯文本模型无需配置

武技:测试标准请求体格式。

  1. 在父项目中直接新建一个 PromptTest.http 文件,组装提示词并向 阿里云百炼通义大模型的文本生成标准调用接口 发送请求:
POST https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
Authorization: Bearer {{API_KEY}}
Content-Type: application/json

{
    "model": "qwen-plus",
    "input": {
        "messages": [
            {
                "role": "user",
                "content": "50字以内,介绍Ollama工具"
            }
        ]
    },
    "parameters": {
        "temperature": 0.8,
        "max_tokens": 2000,
        "stream": false
    }
}

3. 本地部署Deepseek

心法:Ollama 是一款开源、轻量、跨平台的本地大模型运行管理工具,主打一键在个人电脑或服务器上部署、调度各类开源大语言模型(Deepseek,Llama、Qwen、Mistral、Phi、Llama3、GLM 等),不用复杂配置环境,普通人也能轻松跑本地的大模型。

Ollama 工具常用命令

命令英文说明中文释义
ollamaShow all available commands展示全部可用命令
ollama serveStart ollama启动 Ollama 后台服务
ollama createCreate a model from a Modelfile根据 Modelfile 文件创建自定义模型
ollama showShow information for a model查看指定模型的详细信息
ollama runRun a model运行并加载模型,开启对话交互
ollama stopStop a running model停止正在运行中的模型
ollama pullPull a model from a registry从模型仓库拉取下载模型
ollama pushPush a model to a registry将本地模型上传推送至模型仓库
ollama listList models列出本地已下载的所有模型
ollama psList running models查看当前正在运行的模型
ollama cpCopy a model复制模型(可给模型起新标签别名)
ollama rmRemove a model删除本地存储的模型文件
ollama helpHelp about any command查看任意命令的详细帮助文档

武技:使用 Ollama 工具,本地部署 Deepseek 模型。

  1. 下载 ollama 工具,该工具会在本地服务监听 11434 端口。
  2. 安装 ollama 工具:双击安装默认在 C 盘,推荐使用 CMD 命令行指定位置安装:
# 使用 CMD 指定位置安装 ollama 工具(目录需要自己提前创建)
OllamaSetup.exe /DIR=D:\ollama

# 查看 ollama 工具版本
ollama --version
  1. 配置系统环境变量,指定大模型产品的安装位置(默认安装到 C 盘):

在这里插入图片描述

  1. 原地重启 ollama 工具(右下角退出),否则不生效。
  2. 使用 CMD 命令下载 deepseek-r1:1.5b 模型:
ollama run deepseek-r1:1.5b
  1. 启动模型,测试交互:
cd D:\ollama\bin

# 展示全部下载的大模型
ollama list

# 启动模型
ollama run deepseek-r1:1.5b

4. 通义千问模型Qwen

心法:阿里通义系列大模型是阿里云百炼平台(类似模型商店)主推的商用大模型(商店中的具体产品),覆盖通用对话、深度推理、代码开发、长文本处理等全场景,不同版本定位、性能、上下文长度与适用业务差异明确,可按需选型接入。

为何使用阿里的通义千问模型

  • 原生适配中文生态,对国内开发者使用门槛更低、上手更顺畅。
  • 提供丰富免费资源与试用额度,低成本起步、轻松试水。
  • 国内节点布局完善,网络访问延迟低、运行稳定性强。
  • 与 Spring AI 生态深度适配,项目集成简洁高效,开发效率拉满。

通义千问主流模型分类

特性Qwen-3.6-PlusQwen-MaxQwen-Plus
产品定位全新旗舰高端推理旗舰通用均衡主力
上线时间2026 年 4 月 最新迭代版本早期经典高端版本早期通用主流版本
产品定位代码开发 + 智能体专属模型强逻辑 + 高难度深度推理模型高性价比全场景通用模型
上下文窗口最高 100 万超长 Token常规 8K~32K Token常规 32K Token
优势适用场景全栈代码生成
项目开发
百万级长文档解析
智能体编排
专业医疗研判
金融风控分析
复杂数理推导
高精逻辑运算
智能客服接待
文本归纳摘要
日常问答
普通文案创作
市场表现全球接口调用量稳居前列推理精度高、调用成本偏高受众广、调用成本适中、稳定性强

武技:获取阿里云百炼的 API_KEY,该 KEY 相当于调用其大模型服务的身份通行证,系统通过它来识别你的身份、进行权限管理和费用核算。

  1. 登录 阿里云百炼 页面。
  2. 对自己的账号进行认证,否则后续使用 API-Key 的时候可能会响应 403 权限不足的错误。
  3. 依次点击 右上角设置 - API-Key - 创建 API-KEY,然后将 API KEY 记录下来(SK 开头的),归属业务空间选择默认业务空间即可,描述可省略。

在这里插入图片描述

  1. 将API-KEY设置到环境变量
# 设置系统环境变量
setx DASHSCOPE_API_KEY sk-xxxxxxx

# 检查 API-KEY 的环境变量是否生效
echo %DASHSCOPE_API_KEY%
  1. 可以直接在百炼官网进行 模型调试,如图:

在这里插入图片描述

E02. AI应用框架

心法:AI 应用框架和大模型的交互,就类似于 WEB 模型中的请求和响应,AI 应用给大模型发消息,大模型给应用返结果。

AI 应用和大模型的交互流程 - 图示

  • To There:AI 应用将自身的数据和通过 API 获取的信息发送给大模型,供其处理和利用。
  • To Here:大模型处理完信息后,将结果返回给 AI 应用。

在这里插入图片描述

1. SpringAI

心法:SpringAI 是 Spring 官方 2024 年正式推出的 AI 开发框架,借鉴 LangChain、LlamaIndex 主流 AI 框架设计思想,并非简单复刻移植,专为 Java 生态量身打造,实现像写 SpringBoot 项目一样轻松开发 AI 应用。

SpringAI 设计理念:生成式 AI 应用不再局限于 Python 技术栈,全面向 Java 等主流编程语言下沉,让后端开发者低成本快速落地 AI 业务。

SpringAI 框架特点:堪称大模型领域的通用标准适配器,开发者只需掌握一套统一接口,即可无缝对接市面上各类主流大模型:

  • SpringAI 制定并统一了 Java 生态下 AI 开发的标准与编程范式。
  • SpringAI 统一封装了对话模型、对话客户端、提示词、向量嵌入、向量数据库、MCP 工具调用等核心顶层接口。
  • SpringAI 彻底屏蔽不同厂商模型的调用差异,做到 “一套代码随意切换模型”,无需重复修改业务逻辑。

2. SpringAiAlibaba

心法:SpringAiAlibaba 早期仅作为 SpringAI 对接阿里云模型的适配插件,1.1.2.x 及以上高版本全面升级,成为 Java 端一站式企业级 AI 全栈开发框架,集齐模型调用、智能体开发、流程编排、可视化运维全能力。

SpringAI Alibaba 框架定位:深度对接阿里云百炼 DashScope 服务,极速接入通义千问、DeepSeek、文生图、语音合成、多模态识图等全品类 AI 能力,自动封装密钥鉴权、请求组装、结果解析、全局异常捕获,简化阿里云生态 AI 开发流程。

SpringAI Alibaba 组件如下

组件名称定位通俗功能说明
Spring AI Alibaba Agent智能体框架专为复杂业务打造的智能执行中枢,自带 ReAct 推理思考能力
能自动拆分繁杂任务、统一管理对话上下文,还可自主调用各类工具
也能实现多个智能体配合协作,独立完成整套业务流程
Spring AI Alibaba Graph工作流引擎AI 业务专属流程编排工具,把零散的 AI 功能有序串联起来
支持设置流程走向分支、多任务同步运行
也能随时暂停中断流程,出错自动重试
还能保存执行状态,后续继续接续运行
Spring AI Alibaba Admin可视化管理平台一站式调试运维管理后台,轻松对接企业现有业务平台,大幅简化上线与日常维护工作
可直观调试对话效果、查看调用链路与耗时数据,评测模型输出质量等

SpringAI 对比 SpringAI Alibaba:单纯使用通用大模型调用,只用原生 SpringAI 即可,如需要对接阿里云专属能力、开发智能体业务,必须引入 SpringAI Alibaba 依赖:

  • SpringAI:顶层通用标准,定接口、定规范,是所有 Java AI 框架的基础底座。
  • SpringAI Alibaba:基于 SpringAI 标准做阿里云生态深度落地,在通用能力之上,新增智能体、工作流、可视化平台等企业级高阶能力。

S02. 智能对话

E01. 基础对话模型

心法:通过 ChatClient API,你可以方便地向 AI 聊天模型发送消息,并且接收它回复的消息,就像在和一个真实的人聊天一样。

武技:创建 springai-chat 子项目,并完成初始化。

  1. 添加三方依赖:
<dependencies>
    <!--spring-boot-starter-web-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--spring-ai-alibaba-starter-dashscope-->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
    <!--ST4-->
	<dependency>
		<groupId>org.antlr</groupId>
		<artifactId>ST4</artifactId>
		<version>${ST4.version}</version>
	</dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13901 # 端口号
  servlet:
    encoding:
      charset: utf-8 # 字符集(解决 stream 中文乱码)
      enabled: true # 启用字符编码(解决 stream 中文乱码)
      force: true # 强制使用字符编码(解决 stream 中文乱码)

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY} # 阿里云百炼 API_KEY
      read-timeout: 100000 # 读取超时时间(毫秒)
      chat:
        options:
          model: qwen-plus # 基础对话模型
          max-tokens: 1000 # Token 限制
          temperature: 0.5 # 采样温度
    retry:
      max-attempts: 10      # 最大重试次数(默认3次,调大)
      backoff:
        initial-interval: 10000  # 初始轮询间隔(毫秒)
        max-interval: 20000      # 最大轮询间隔(毫秒)
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class ChatApp {
    public static void main(String[] args) {
        SpringApplication.run(ChatApp.class, args);
    }
}

1. ChatModel

心法:SpringAI 提供了 ChatModel 接口(代表与大模型的对话能力),封装了模型调用的细节,只负责发送请求和获取回复,无任何业务能力,引入模型的 starter 启动器后可以直接从容器中注入使用,该接口支持 “同步” 和 “流式” 两种获取消息的类型。

模型调用过程

步骤简述详述
01发送请求浏览器发送请求,携带 Prompt(包含用户输入 Message 和运行时 ChatOptions)
02合并配置服务器使用 “用户运行时 ChatOptions 配置” 覆盖 “系统启动时 ChatOptions 配置”
03输入转换服务器将参数和合并后的 ChatOptions 配置封装为对应大模型的原生 Request 对象
04模型交互服务器将 Request 发送给 AIModel,模型推理并返回原生 Response 对象
05输出转换服务器将原生 Response 转换为统一的 ChatResponse 格式
06执行响应服务器将 ChatResponse 返回给浏览器

图示如下

在这里插入图片描述

武技:使用 ChatModel 与模型进行交互。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@RequestMapping("/api/v1/chatModel")
@RestController
@CrossOrigin
public class ChatModelController {

    private final ChatModel chatModel;

    public ChatModelController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatModel.call(msg);
    }

    @GetMapping("stream")
    public Flux<String> stream(@RequestParam("msg") String msg) {
        return chatModel.stream(msg);
    }
}
  1. 测试控制器:
### call
GET http://localhost:13901/api/v1/chatModel/call?
    msg=讲个50字左右的笑话

### stream
GET http://localhost:13901/api/v1/chatModel/stream?
    msg=讲个50字左右的笑话

2. ChatOptions

心法:ChatOptions 是 SpringAI 提供的一个用于配置大模型参数的对象,统一封装了 temperature,model,maxTokens 等模型参数。

ChatOptions 分类

  • 启动时配置对象:如果在创建 ChatModel 的时候指定 ChatOptions 对象,那么该对象就是一个启动时配置对象,优先级低(等效于主配中的对应配置)。
  • 运行时配置对象:如果在调用 call() 或 stream() 方法时指定 ChatOptions 对象,那么该对象就是一个运行时配置对象,优先级高,会覆盖启动时配置对象中的对应配置项。

武技:测试 ChatOptions 对象。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.messages.Message;

/** @author 周航宇 */
@RequestMapping("/api/v1/chatOptions")
@RestController
@CrossOrigin
public class ChatOptionsController {

    private final ChatModel chatModel;

    public ChatOptionsController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @GetMapping("options")
    public String options(@RequestParam("msg") String msg) {

        // 构建消息
        List<Message> messages = List.of(
                new SystemMessage("你是一个专业的AI助手。"),
                new UserMessage(msg)
		);

        // 构建配置
        ChatOptions chatOptions = ChatOptions.builder()
                .model("qwen-max")
                .temperature(0.9)
                .maxTokens(20)
                .build();

        // 构建提示词
        Prompt prompt = new Prompt(messages, chatOptions);

        // 交互
        ChatResponse chatResponse = chatModel.call(prompt);
        
        // 查看元数据
        ChatResponseMetadata metadata = chatResponse.getMetadata();
        System.out.println("响应ID:" + metadata.getId());
        System.out.println("输入token:" + metadata.getUsage().getPromptTokens());
        System.out.println("输出token:" + metadata.getUsage().getCompletionTokens());
        System.out.println("总计token:" + metadata.getUsage().getTotalTokens());

        // 响应
        return chatResponse.getResult().getOutput().getText();
    }
}
  1. 测试控制器:
### options
GET http://localhost:13901/api/v1/chatOptions/options?
    msg=讲个笑话

3. ChatClient

心法:ChatClient 是对 ChatModel 的同步对话或流式对话的封装,封装了模型输入(Prompt),解析模型输出(ChatResponse)以及和设置模型参数(ChatOptions) 这三个基本功能,但本身默认也没有提供对话记忆,工具调用,流程控制等业务功能。

ChatClient 构建方式(一):自己创建 ChatClient.Builder 对象:

private final ChatClient chatClient;

public AiChatController(ChatModel chatModel) {
	this.chatClient = ChatClient.builder(chatModel).build();
}

ChatClient 构建方式(二):直接从容器中注入 ChatClient.Builder 对象(推荐):

private final ChatClient chatClient;

public AiChatController(ChatClient.Builder chatClientBuilder) {
	this.chatClient = chatClientBuilder.build();
}

武技:使用 ChatClient 与模型进行对话。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@RequestMapping("/api/v1/chatClient")
@RestController
@CrossOrigin
@SuppressWarnings("all")
public class ChatClientController {

    private final ChatClient chatClient;
    private final ChatClient chatClient02;

    public ChatClientController(ChatClient.Builder chatClientBuilder, ChatModel chatModel) {
        this.chatClient = chatClientBuilder.build();
        this.chatClient02 = ChatClient.builder(chatModel).build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                // 设置用户消息
                .user(msg)
                // 同步发送消息,此时该方法会将响应结果一次性响应给前端
                .call()
                // 获取响应内容
                .content();
    }

    @GetMapping("stream")
    public Flux<String> stream(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                // 设置用户消息
                .user(msg)
                // 流式发送消息,此时该方法会将响应结果以流的形式推送给前端
                .stream()
                // 获取响应内容
                .content()
                // 约定返回一个结束标记,方便前端灵活终止 SSE 推送
                .concatWith(Flux.just("[over]"));
    }
    
	@GetMapping("options")
    public String options(@RequestParam("msg") String msg) {
    
        // 构建配置
        ChatOptions chatOptions = ChatOptions.builder()
                .model("qwen-max")
                .temperature(0.9)
                .maxTokens(20)
                .build();
        
        ChatResponse chatResponse = chatClient.prompt()
                // 设置用户消息
                .user(msg)
                // 设置模型参数
                .options(chatOptions)
                // 同步发送消息,此时该方法会将响应结果一次性响应给前端
                .call()
                // 获取 ChatReponse 类型的响应
                .chatResponse();

        // 查看元数据
        ChatResponseMetadata metadata = chatResponse.getMetadata();
        System.out.println("响应ID:" + metadata.getId());
        System.out.println("输入token:" + metadata.getUsage().getPromptTokens());
        System.out.println("输出token:" + metadata.getUsage().getCompletionTokens());
        System.out.println("总计token:" + metadata.getUsage().getTotalTokens());

        // 返回响应内容
        return chatResponse.getResult().getOutput().getText();
    }
}
  1. 测试控制器:
### call
GET http://localhost:13901/api/v1/chatClient/call?
    msg=讲个50字以内的笑话

### stream
GET http://localhost:13901/api/v1/chatClient/stream?
    msg=讲个50字以内的笑话

### options
GET http://localhost:13901/api/v1/chatClient/options?
    msg=讲个50字以内的笑话

4. 响应实体数据

心法:实际开发中,我们期望模型返回结构化数据以方便前端处理,ChatResponse 原生数据冗余且杂乱,因此需要将响应转换为自定义 Bean 对象,提升数据可读性与操作便捷性。

注意:使用实体类作为返回,只能使用 call() 同步调用,无法使用 stream() 流式调用。

武技:测试将模型响应转为实体类。

  1. 开发实体类:
package com.joezhou.entity;

/** @author 周航宇 */
@Data
public class UserVO implements Serializable {
    private String name;
    private Integer age;
    private Integer gender;
    private String info;
}
  1. 开发控制器:
package com.joezhou.controller;
import com.joezhou.entity.UserVO;

/** @author 周航宇 */
@RequestMapping("/api/v1/chatEntity")
@RestController
@CrossOrigin
public class ChatEntityController {

    private final ChatClient chatClient;

    public ChatEntityController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @GetMapping("generateUser")
    public UserVO generateUser() {
        return chatClient.prompt()
                .user("根据《乡村爱情电视剧》生成1条剧中人物信息,每条包括:姓名、年龄、性别(0女1男2保密)、信息介绍")
                .call()
                .entity(UserVO.class);
    }

    @GetMapping("generateUsers")
    public List<UserVO> generateUsers() {
        return chatClient.prompt()
                .user("根据《乡村爱情电视剧》生成3条剧中人物信息,每条包括:姓名、年龄、性别(0女1男2保密)、信息介绍")
                .call()
                // 大括号里必须置空
                .entity(new ParameterizedTypeReference<List<UserVO>>() {});
    }
}
  1. 测试控制器:
### generateUser
GET http://localhost:13901/api/v1/chatEntity/generateUser

### generateUsers
GET http://localhost:13901/api/v1/chatEntity/generateUsers

5. 预设模型角色

心法:在使用 ChatClient.Builder 构建 ChatClient 时,可以使用 builder.defaultSystem() 方法设置预设的系统信息(字符串),该信息通常会作为初始指令传递给 AI 模型,帮助其理解对话的上下文、角色或任务要求。

注意事项

  • 系统信息通常是纯文本,但某些 API 可能支持 Markdown 或其他格式(需查阅具体文档)。
  • 避免使用特殊字符或格式,除非 API 明确支持。
  • 系统信息会应用于所有对话轮次,除非在单次请求中覆盖它。
  • 某些 API 可能对系统信息的长度有限制(例如 OpenAI 的 GPT 模型建议控制在几百个 token 内)。
  • 不要在系统信息中包含敏感信息(如 API 密钥、用户数据等)。

武技:测试 SpringAI 的预设角色功能。

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/systemRole")
@RestController
@CrossOrigin
public class SystemRoleController {

    private final ChatClient chatClient;

    public SystemRoleController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
                .defaultSystem("""
                        你叫詹姆斯9527,你是一个脾气非常不好的人;
                        你从来不会用 “我” 来指代自己,你只会用 “老子” 来指代自己;
                        今天的日期是 {today};
                        """)
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg.trim())
                .system(e -> e.param("today", LocalDate.now()))
                .call()
                .content();
    }
}
  1. 测试控制器:
### call()
GET http://localhost:13901/api/v1/systemRole/call?
    msg=你是谁啊

6. 提示词动态渲染

心法:StringTemplate4(简称 ST4),来自 org.antlr:ST4 依赖,是一套标准化、高复用、安全稳定的 Prompt 动态渲染工具,相比原生字符串拼接 / 格式化,ST4 可以彻底隔离业务代码与提示词文本,大幅降低超长、复杂结构化提示词的编写、迭代与维护成本,适配企业级 AI 应用提示词工程化落地。

ST4 核心:它只负责安全、灵活地把变量填充进 Prompt 骨架,生成最终发给大模型的文本。

ST4 提示词 对比 原生提示词

对比维度String 代码行提示词String 代码块提示词ST4 模板提示词
可读性
灵活性
用加号拼接,参数越多,代码越乱
可读性差,灵活度高
使用内置的占位符如 %s
可读性高,灵活度低
使用自定义占位符如 <name>
可读性高,灵活度高
安全性拼错只会生成错乱文本,不抛异常
全程无校验,安全性极差
仅参数数量、类型不匹配时抛异常
运行期校验,安全性中等
变量和语法等错误直接抛出异常
渲染期 + 编译期双重校验,最安全
功能性只能纯文本拼接,不支持逻辑处理
功能性低
只能纯文本拼接,不支持逻辑处理
功能性低
原生支持分支、循环、嵌套、函数等
功能性高
复用性每个接口都需要重复写一遍提示词
复用性低
每个接口都需要重复写一遍提示词
复用性低
模板全局复用,一处修改全项目生效
复用性高
耦合度
维护性
提示词硬编码嵌入业务代码
耦合度高,维护性低
提示词硬编码嵌入业务代码
耦合度高,维护性低
模板内容可以抽离成单独的 st 文件
耦合度低,维护性高
扩展性新增内容时所有拼接代码全部改动
扩展性低
新增内容时所有拼接代码全部改动
扩展性低
仅修改模板,业务调用代码无需改动
扩展性高
适用场景超简短单句静态文本拼接短文本、少量变量填充的提示词企业级复杂模板和结构化输出

ST4 转义规则

  • ST 模板内支持单行注释 // 和块注释 /* 内容 */,渲染时自动丢弃注释内容,但禁止使用行尾注释,以免破坏换行排版与解析。
  • ST 模板内的 < > $ " “” \ { } 都是 ST 关键字,直接写会解析崩溃,推荐统一使用单引号或使用转义字符。
  • ST 内联模板可使用 """ 字符块定义,保留换行,空格和缩进,而 ST 文件自带换行排版,无需额外处理。
  • ST 模板中的所有所有变量必须通过 add(key, val) 注入,严禁止字符串拼接,杜绝拼接带来的安全漏洞。
  • ST 模板中出现的所有变量必须通过 add(key, val) 完整赋值,缺失变量渲染直接抛出异常,不会填充空值兜底。

ST4 语法示例

// 定义 ST 模板内容
String stTemplate = """
        你是:<name>
		核心职责:
		<duties:{ duty |<i>、<duty>;
		}>
		回答规则(必须严格遵守):
		<if(showRules)>
		- 每条回复字数不超过 <maxLength> 字。
		<endif>
        """;

// 创建字符串模板对象,默认使用 < 和 > 作为模板变量的前后缀
ST st = new ST(stTemplate);

// 填充模板参数
st.add("name", "办公助手");
st.add("duties", List.of("记录日程", "会议管理", "会议记录"));
st.add("showRules", true);
st.add("maxLength", 20);

// 渲染生成最终系统提示词
log.info("渲染后的系统提示词: \n{}", st.render());

武技:测试使用 ST 模板建造提示词。

  1. 开发提示词 ST 文件:采用 $$ 作为变量分隔符,适配部分场景避免和尖括号冲,文件位置和名称随意:

classpath:prompt/commonPrompt.st

你是:$name$
核心职责:
$duties:{ duty |$i$、$duty$; 
}$
回答规则(必须严格遵守):
$if(showRules)$
- 每条回复字数不超过 $maxLength$ 字。
- 只输出纯中文文本,严禁输出JSON、代码块或其他格式。
- 禁止展示思考过程、推理过程或任何工具调用细节。
- 若无法回答,直接告知用户“无法回答”,不做额外解释。
$endif$
  1. 开发 ST 工具类:
package com.joezhou.util;

/** @author 周航宇 */
@Slf4j
public class StUtil {

    /** 模板分隔符开始字符 */
    private static final char DELIMITER_START_CHAR = '$';
    /** 模板分隔符结束字符 */
    private static final char DELIMITER_STOP_CHAR = '$';
    /** 资源加载器 */
    private static final DefaultResourceLoader RESOURCE_LOADER = new DefaultResourceLoader();

    /**
     * 通过加载 .st 文件模板(使用 $ 分隔符)来构建 ST 实例
     *
     * @param location 资源路径 prompt/xxx.st
     * @return ST 实例
     */
    public static ST loadStFile(String location) {
        try {
            // 加载资源,支持 classpath 下的文件
            Resource resource = RESOURCE_LOADER.getResource(location);
            // 读取文件内容
            Path path = resource.getFile().toPath();
            // 读取文件内容
            String content = Files.readString(path, StandardCharsets.UTF_8);
            // 构建 ST 对象
            return new ST(content, DELIMITER_START_CHAR, DELIMITER_STOP_CHAR);
        } catch (Exception e) {
            throw new RuntimeException("加载 classpath 下 .st 文件模板失败", e);
        }
    }

    /**
     * 构建内联字符串模板(使用 < 和 > 分隔符)
     *
     * @param templateText 模板文本
     * @return ST 实例
     */
    public static ST build(String templateText) {
        return new ST(templateText);
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/promptTemplate")
@RestController
@CrossOrigin
public class PromptTemplateController {

    private final ChatClient chatClient;

    public PromptTemplateController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @SneakyThrows
    @GetMapping("/office")
    public Flux<String> office(@RequestParam("msg") String msg) {
        // 创建 ST 对象,默认使用 < 和 > 作为模板变量的前后缀
        ST st = StUtil.build("""
                你是:<name>
                核心职责:
                <duties:{ duty |<i>、<duty>;
                }>
                回答规则(必须严格遵守):
                <if(showRules)>
                - 每条回复字数不超过 <maxLength> 字。
                - 只输出纯中文文本,严禁输出JSON、代码块或其他格式。
                - 禁止展示思考过程、推理过程或任何工具调用细节。
                - 若无法回答,直接告知用户“无法回答”,不做额外解释。
                <endif>
                """);
        // 设置模板参数
        st.add("name", "办公助手");
        st.add("duties", List.of("记录日程", "会议管理", "会议记录"));
        st.add("showRules", true);
        st.add("maxLength", 20);
        // 渲染模板
        String systemPrompt = st.render();
        log.info("渲染后的系统提示词: \n{}", systemPrompt);
        // 交互
        return chatClient.prompt()
                .user(msg)
                .system(systemPrompt)
                .stream()
                .content()
                .concatWith(Flux.just("[over]"));
    }

    @SneakyThrows
    @GetMapping("/ticket")
    public Flux<String> ticket(@RequestParam("msg") String msg) {
        // 创建 ST 对象,默认使用 $ 作为模板变量的前后缀
        ST st = StUtil.load("classpath:prompt/commonPrompt.st");
        // 设置模板参数
        st.add("name", "电影票助手");
        st.add("duties", List.of("查询电影票", "购买电影票", "取消电影票"));
        st.add("showRules", true);
        st.add("maxLength", 20);
        // 渲染模板
        String systemPrompt = st.render();
        log.info("渲染后的系统提示词: \n{}", systemPrompt);
        // 交互
        return chatClient.prompt()
                .user(msg)
                .system(systemPrompt)
                .stream()
                .content()
                .concatWith(Flux.just("[over]"));
    }
}
  1. 测试控制器:
### office
GET http://localhost:13901/api/v1/promptTemplate/office?
    msg=记录今天下午2点的会议,主题是如何成为阿凡达

### ticket
GET http://localhost:13901/api/v1/promptTemplate/ticket?
    msg=帮我买一张《阿凡达》的电影票,影院,场次和座位等信息都随意选择

E02. 多模态模型

心法:通义体系多模态统一依托 DashScope 对接,Spring AI 提供 ImageModel,AudioModel 和 VideoModel 等标准顶层接口,设计范式完全一致。

多模态模型选择

相关模型模型定位核心特点适用场景
CosyVoice-v3-flash文 → 语音延迟低,实时性高
成本低,性价比高
智能客服,实时语音助手,直播,短视频等
对实时性要求高的交互场景
CosyVoice-v3-plus文 → 语音音质高,情感丰富
支持精细指令控制
有声书,新闻播报,精品课,角色配音等
对音质和表现力要求高的内容场景
wan2.2-t2i-flash文 → 图支持文本生成图像
支持以图搜图、图像问答
支持理解图像中的元素
广告设计,创意配图,图片分析等场景
wan2.2-t2i-flash文 → 视频支持文本生成视频
支持结合图像生成视频
广告创意短片,社交媒体动态内容等场景

1. 文生图ImageModel

心法:ImageModel 是 Spring AI 提供的图像生成模型统一接口,通过它,我们可以使用一行代码来对接阿里云通义万相、Stable Diffusion 等文生图模型,实现根据文字描述来自动生成图片的 AI 能力。

文生图核心流程

步骤描述其它
01创建图像配置对象使用 DashScopeImageOptions 创建配置(会覆盖主配中的对应配置):

模型:推荐 wan2.2-t2i-flash(阿里云通义万相极速模型)
图像尺寸:推荐 1024 * 1024
图像数量:推荐 1,默认 1 张
02构建提示词将用户提示词 + 生成配置封装成 ImagePrompt 对象
03发起模型调用调用 ImageModel 发起文生图请求,得到 ImageResponse 响应结果
04获取图片 URL从响应结果中获取图片 URL(AI 模型一般返回云端临时地址)
05下载图片通过 URL 下载图片到服务端内存
06进行响应通过 HttpServletResponse 将图片流直接返回给前端展示

文生图模型列表参考 文生图模型列表参考

武技:根据用户输入的文本描述,调用阿里云通义万相模型生成图像,并将图像返回给客户端。

  1. 开发配置文件(额外添加文生图相关配置):
spring:
  ai:
    dashscope:
      ...
      image:
        options:
          model: wan2.2-t2i-flash # 文生图模型
          width: 1024 # 图片宽度
          height: 1024 # 图片高度
          n: 1 # 生成数量
  1. 开发控制器:
package com.joezhou.controller;
import java.net.URL;

/** @author 周航宇 */
@RequestMapping("/api/v1/imageModel")
@RestController
@CrossOrigin
public class ImageModelController {

    private final ImageModel imageModel;
    private final DashScopeImageOptions dashScopeImageOptions;

    public ImageModelController(ImageModel imageModel) {
        this.imageModel = imageModel;
        // 构建 DashScopeImageOptions 图片配置对象(会覆盖主配中的配置)
        this.dashScopeImageOptions = DashScopeImageOptions.builder()
                // 设置模型
                .model("wan2.2-t2i-flash")
                // 设置宽度
                .width(1024)
                // 设置高度
                .height(1024)
                // 设置生成图片的数量
                .n(1)
                .build();
    }

    @GetMapping("generate")
    public void generate(@RequestParam("msg") String msg, HttpServletResponse resp) throws IOException {
        // 创建 ImagePrompt 提示词对象:包含文本和配置选项
        ImagePrompt imagePrompt = new ImagePrompt(msg, dashScopeImageOptions);
        // 调用模型生成图片
        ImageResponse imageResponse = imageModel.call(imagePrompt);
        // 从 ImageResponse 对象中获取生成的图片的地址
        String imageUrl = imageResponse.getResult().getOutput().getUrl();
        // 从 URL 下载图片
        BufferedImage bufferedImage = ImageIO.read(new URL(imageUrl));
        // 设置响应类型为图片
        resp.setContentType("image/jpeg");
        // 输出图片流给前端
        try (ServletOutputStream outputStream = resp.getOutputStream()) {
            ImageIO.write(bufferedImage, "jpg", outputStream);
            outputStream.flush();
        }
    }
}
  1. 使用浏览器直接访问控制器 http://localhost:13901/api/v1/imageModel/generate?msg=赛博朋克风格未来城市夜景

2. 文生音频AudioModel

心法:AudioModel 是 Spring AI 统一音频生成顶层接口,标准化对接阿里云语音生成模型(cosyvoice 等),输入文字文案,一键合成人声音频文件,和 ImageModel 代码结构对齐,一套编程范式适配全部生成类多模态能力。

文生音频核心流程

步骤描述其它
01创建音频配置对象使用 DashScopeAudioSpeechOptions 创建配置(会覆盖主配中的对应配置):

模型:推荐 cosyvoice-v3-flash
音色:推荐 longanhuan(女)或 longanyang(男),其余参考 音色列表
格式:推荐 MP3,兼容性最好,Web 实时播放首选
语速:推荐 1.0,范围 0.5(最慢)~ 2.0(最快),默认 1.0
音量:推荐 50,范围 0(静音)~ 100(全开),默认 50
音调:推荐 1.0,范围 0.5(最低)~ 1.5(最高),默认 1.0
采样率:推荐 24000,值越大音质越好,文件也越大:
- 可选 8000Hz(类比低清)
- 可选 16000Hz(类比标清)
- 可选 22050Hz(类比 720p 高清)
- 可选 24000Hz(类比 1080p 高清)
- 可选 48000Hz(类比 4K 超高清)(默认值)
02构建提示词将用户台词 + 生成配置封装成 TextToSpeechPrompt 对象
03发起模型调用调用 AudioSpeechModel 发起文生音频请求
得到 Flux<TextToSpeechResponse> 响应结果
04组装音频数据收集流式返回的音频二进制分片,组装为完整音频字节数组
05响应音频数据将组装好的音频字节数组返回给客户端,前端支持如下处理:
- 使用浏览器直接播放
- 使用 <audio src="接口地址"> 播放
- 使用 new Audio(url).play() 播放

武技:根据用户输入的文本,调用阿里云通义万相模型生成对应文本的语音,并将语音返回给客户端。

  1. 开发配置文件(额外添加文生语音相关配置):
spring:
  ai:
    dashscope:
      ...
	  audio:
        synthesis:
          options:
            model: cosyvoice-v3-flash # 语音合成模型
            voice: longanhuan # 音色
            response-format: mp3 # 音频格式
            sample-rate: 24000 # 采样率
            speed: 1.0 # 语速
            volume: 50 # 音量
            pitch: 1.0 # 音调
  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.http.MediaType;
import java.util.UUID;

/** @author 周航宇 */
@RequestMapping("/api/v1/audioModel")
@RestController
@CrossOrigin
public class AudioModelController {

    private final DashScopeAudioSpeechModel audioSpeechModel;
    private final TextToSpeechOptions textToSpeechOptions;

    public AudioModelController(DashScopeAudioSpeechModel audioSpeechModel) {
        this.audioSpeechModel = audioSpeechModel;
        // 构建 TextToSpeechOptions 音频配置对象(会覆盖主配中的配置)
        this.textToSpeechOptions = DashScopeAudioSpeechOptions.builder()
                // 模型
                .model("cosyvoice-v3-flash")
                // 音色
                .voice("longanhuan")
                // 音频格式
                .responseFormat(DashScopeAudioSpeechApi.ResponseFormat.MP3)
                // 采样率
                .sampleRate(24000)
                // 语速
                .speed(1.0)
                // 音量
                .volume(50)
                // 音调
                .pitch(1.0)
                .build();
    }

    @GetMapping(value = "/generate")
    public ResponseEntity<byte[]> generate(@RequestParam("text") String text) {
        // 创建 TextToSpeechPrompt 提示词对象:包含文本和配置选项
        TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, textToSpeechOptions);
        // 调用模型生成音频流(流式调用,性能更好)
        Flux<TextToSpeechResponse> responseFlux = audioSpeechModel.stream(prompt);
        // 收集音频数据
        byte[] audioData = this.collectAudioData(responseFlux);
        // 创建响应头对象,指定音频格式,数据长度以及内联播放(不下载)
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.parseMediaType("audio/mpeg"));
        headers.setContentLength(audioData.length);
        headers.set(HttpHeaders.CONTENT_DISPOSITION, "inline");
        // 直接返回音频数据,浏览器会播放音频
        return ResponseEntity.ok().headers(headers).body(audioData);
    }

	@GetMapping("generateAndDownload")
    public String generateAndDownload(@RequestParam("text") String text) {
        // 创建 TextToSpeechPrompt 提示词对象:包含文本和配置选项
        TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, textToSpeechOptions);
        // 调用模型生成音频流(流式调用,性能更好)
        Flux<TextToSpeechResponse> responseFlux = audioSpeechModel.stream(prompt);
        // 收集音频数据
        byte[] audioData = this.collectAudioData(responseFlux);
        // 定义本地存储目录,UUID 防止文件名重复冲突
        String saveDir = "D:\\workspace\\java\\v3-9-ssm-ai\\springai-chat\\src\\main\\resources\\audio\\";
        String fileName = UUID.randomUUID() + ".mp3";
        String fullFilePath = saveDir + fileName;
        // 写入本地 MP3 文件
        try (FileOutputStream fos = new FileOutputStream(fullFilePath)) {
            fos.write(audioData);
        } catch (IOException e) {
            throw new RuntimeException("保存音频文件失败: " + e.getMessage(), e);
        }
        // 返回音频文件路径
        return "音频生成成功:" + fullFilePath;
    }

    /**
     * 收集流式返回的音频二进制分片,拼接为完整音频字节数组
     *
     * @param flux 语音合成流式响应Flux流,每一项携带一段音频byte分片
     * @return 拼接完成的完整音频二进制字节数组,无数据时返回空byte数组
     */
    private byte[] collectAudioData(Flux<TextToSpeechResponse> flux) {
        // 获取所有音频分片
        List<byte[]> chunks = flux
                // 过滤空响应、空结果对象
                .filter(response -> response != null && response.getResult() != null)
                // 从响应结果中提取音频二进制分片
                .map(response -> response.getResult().getOutput())
                // 收集为 List 列表
                .collectList()
                // 阻塞等待流式数据全部接收完毕
                .block();

        // 无音频分片时直接返回空字节数组
        if (chunks == null || chunks.isEmpty()) {
            throw new RuntimeException("语音合成失败:未生成音频数据");
        }

        // 计算所有分片总字节长度,初始化最终完整数组
        int totalLength = chunks.stream().mapToInt(arr -> arr.length).sum();
        byte[] result = new byte[totalLength];

        // 记录当前写入位置偏移量
        int offset = 0;

        // 循环把每一段分片拷贝到大数组内,依次拼接
        for (byte[] chunk : chunks) {
            System.arraycopy(chunk, 0, result, offset, chunk.length);
            offset += chunk.length;
        }

        // 返回拼接完整音频字节数组
        return result;
    }
}
  1. 使用浏览器直接访问控制器 http://localhost:13901/api/v1/audioModel/generate?text=你好,欢迎使用语音合成服务
  2. 使用浏览器直接访问控制器 http://localhost:13901/api/v1/audioModel/generateAndDownload?text=你好,欢迎使用语音合成服务

3. 文生视频VideoModel

心法:VideoModel 是 Spring AI 统一视频生成顶层接口,视频生成耗时较长(通常 1 ~ 5 分钟),采用异步任务模式提交任务获取 taskId,轮询等待完成后获取视频 URL,而非像音频那样流式返回。

文生视频核心流程

步骤描述其它
01创建音频配置对象使用 DashScopeVideoOptions 创建配置(会覆盖主配中的对应配置):

模型:推荐 wan2.1-t2v-turbo
分辨率:推荐 720P,比 1080P 性价比高
负提示:推荐 “模糊、变形、低画质、扭曲、画面抖动” 等
智能改写:推荐启用,默认关闭
视频种子:固定种子可复现结果,默认随机种子
时长:默认 5 秒
02构建提示词将用户描述 + 生成配置封装成 VideoPrompt 对象
03发起模型调用调用 VideoModel 发起文生视频请求
得到 VideoResponse 响应结果
04拉取视频数据从 VideoResponse 对象中获取生成的临时视频的地址
拉取完整视频二进制字节
05响应视频数据将组装好的视频字节数组返回给客户端,前端支持如下处理:
- 使用浏览器直接播放
- 使用 <video src="接口地址"> 播放
- 使用 new Video(url).play() 播放

武技:根据用户输入的文本生成视频。

  1. 开发配置文件(额外添加文生视频相关配置):
spring:
  ai:
    dashscope:
      ...
      video:
        options:
          model: wan2.1-t2v-turbo # 文生视频模型
          resolution: 720P # 视频分辨率
          negative-prompt: 模糊、变形、低画质、扭曲、画面抖动 # 负提示词,避免生成人
          promptExtend: true # 开启智能改写,效果更好
          seed: 12345 # 固定种子可复现结果,默认随机种子
  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.http.MediaType;
import java.util.UUID;

/** @author 周航宇 */
@RequestMapping("/api/v1/audioModel")
@RestController
@CrossOrigin
public class AudioModelController {

    private final DashScopeVideoModel videoModel;
    private final DashScopeVideoOptions videoOptions;

    public VideoModelController(DashScopeVideoModel videoModel) {
        this.videoModel = videoModel;
        // 构建 DashScopeVideoOptions 视频配置对象(会覆盖主配中的配置)
        this.videoOptions = DashScopeVideoOptions.builder()
                // 通义万相极速文生视频模型
                .model("wan2.7-t2v-2026-04-25")
                // 分辨率:720P 或 1080P(1080P费用更高)
                .resolution("720P")
                // 负提示词:避免生成的视频包含指定的内容
                .negativePrompt("模糊、变形、低画质、扭曲、画面抖动")
                // 视频时长:2~15秒,整数,默认 5 秒
                .duration(2)
                // 开启智能改写,效果更好
                .promptExtend(true)
                // 固定种子可复现结果,默认随机种子
                .seed(12345L)
                .build();
    }

    @GetMapping("generate")
    public ResponseEntity<byte[]> generate(@RequestParam("text") String text) throws IOException {
        // 创建 VideoPrompt 提示词对象:包含画面描述文本和自定义配置
        VideoPrompt videoPrompt = new VideoPrompt(text, videoOptions);
        // 调用模型生成视频任务
        VideoResponse videoResponse = videoModel.call(videoPrompt);
        // 从 VideoResponse 对象中获取生成的视频的地址(临时的)
        String videoUrl = videoResponse.getResult().getOutput().getVideoUrl();
        // 拉取完整视频二进制字节
        byte[] videoBytes;
        try (BufferedInputStream bis = new BufferedInputStream(new URL(videoUrl).openStream())) {
            videoBytes = bis.readAllBytes();
        }
        // 创建响应头对象,指定视频格式,数据长度以及内联播放(不下载)
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.parseMediaType("video/mp4"));
        headers.setContentLength(videoBytes.length);
        headers.set(HttpHeaders.CONTENT_DISPOSITION, "inline");
        // 直接返回视频数据,浏览器会播放视频
        return ResponseEntity.ok().headers(headers).body(videoBytes);
    }

    @GetMapping("generateAndDownload")
    public String generateSaveLocal(@RequestParam("text") String text) {
        // 创建 VideoPrompt 提示词对象:包含画面描述文本和自定义配置
        VideoPrompt videoPrompt = new VideoPrompt(text, videoOptions);
        // 调用模型生成视频任务
        VideoResponse videoResponse = videoModel.call(videoPrompt);
        // 从 VideoResponse 对象中获取生成的视频的地址(临时的)
        String videoUrl = videoResponse.getResult().getOutput().getVideoUrl();
        // 定义本地存储目录,UUID 防止文件名重复冲突
        String saveDir = "D:\\workspace\\java\\v3-9-ssm-ai\\springai-chat\\src\\main\\resources\\video\\";
        String fileName = UUID.randomUUID() + ".mp4";
        String fullFilePath = saveDir + fileName;
        // 写入本地 MP4 文件
        try (BufferedInputStream bis = new BufferedInputStream(new URL(videoUrl).openStream());
             FileOutputStream fos = new FileOutputStream(fullFilePath)) {
            bis.transferTo(fos);
        } catch (IOException e) {
            throw new RuntimeException("视频文件保存失败: " + e.getMessage(), e);
        }
        // 返回成功信息
        return "视频生成保存成功,文件完整路径:" + fullFilePath;
    }
}
  1. 使用浏览器直接访问控制器 http://localhost:13901/api/v1/videoModel/generate?text=一只在夕阳下散步的猫
  2. 使用浏览器直接访问控制器 http://localhost:13901/api/v1/videoModel/generateAndDownload?text=一只在夕阳下狂奔的狗

E03. 顾问拦截器

心法:Advisors API 顾问拦截器(简称顾问器)是 SpringAI 里一套灵活强大的扩展机制,本质上就是 AI 交互的拦截器,可以在 ChatClient 调用 call()、stream() 时自动切入,对请求和响应做拦截、修改与增强,以满足不同的业务需求和优化应用程序的 AI 功能。

Advisors 执行流程图示

在这里插入图片描述

1. 请求日志顾问器

心法:SimpleLoggerAdvisor 就是 Spring AI 自带的 自动日志打印工具,你不用写任何 System.out.println 或者 log.info(),只要把它加到 AI 调用里,它就会自动帮你打印 “用户发送了什么” 以及 “大模型返回了什么”,而且它 不侵入业务代码,所以特别适合开发时调试 bug,生产环境监控,审计对话内容等场景。

SimpleLoggerAdvisor 核心特性

  • 开箱即用:Spring-AI-Chat-Client-starter 已内置 SimpleLoggerAdvisor,直接使用即可。
  • 自动抓包:AI 请求发出去前、响应回来后,自动全部记录下来,不用你手动打印:
    • 自动抓取请求信息:包含用户消息、系统消息、历史对话等内容。
    • 自动抓取响应信息:包含生成的文本、元数据(像 token 数量、模型信息)等内容。
  • 自定义打印内容:你可以自定义打印请求的哪部分、响应的哪部分,从而满足不同的调试和审计要求。
  • 开关自由:支持根据不同的日志级别(例如 DEBUG、INFO)来开启或关闭日志记录功能,生产环境不会乱打印:
    • SimpleLoggerAdvisor 默认使用 DEBUG 级别的输出日志,必须在配置文件中开启对应包的日志权限。
  • 即插即用:采用 AOP 切面方式实现,不会对现有的业务逻辑造成影响,加进去就生效。

武技:测试 SimpleLoggerAdvisor 日志通知。

  1. 调整 SimpleLoggerAdvisor 日志等级以达到开启 SimpleLoggerAdvisor 日志功能的效果:

application.yml

logging:  
  level:  
    org.springframework.ai.chat.client.advisor: DEBUG # 开启 SimpleLoggerAdvisor 日志功能
  1. 开发控制器:
package com.joezhou.controller;
import java.util.function.Function;

/** @author 周航宇 */
@RequestMapping("/api/v1/loggerAdvisor")
@RestController
@CrossOrigin
public class LoggerAdvisorController {

    private final ChatClient chatClient;

    public LoggerAdvisorController(ChatClient.Builder chatClientBuilder) {

        // 自定义:需要打印的请求日志内容(获取完整提示词文本)
        Function<ChatClientRequest, String> requestLogFunction = req -> req.prompt().getContents();
        // 自定义:需要打印的响应日志内容(获取AI返回的文本结果)
        Function<ChatResponse, String> responseLogFunction = res -> res.getResult().getOutput().getText();
        // 执行优先级:数字越小越先执行(0 = 最高优先级)
        int order = 0;
        // 初始化日志增强器
        SimpleLoggerAdvisor loggerAdvisor = new SimpleLoggerAdvisor(requestLogFunction, responseLogFunction, order);

        // 构建带日志增强的 ChatClient
        this.chatClient = chatClientBuilder
                .defaultAdvisors(loggerAdvisor)
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg)
                .call()
                .content();
    }

    @GetMapping("stream")
    public Flux<String> stream(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg)
                .stream()
                .content()
                .concatWith(Flux.just("[over]"));
    }
}
  1. 测试控制器:发送请求后,观察控制台是否记录了对应的请求和响应的日志:
### call:发送请求后,观察控制台是否记录了对应的请求和响应的日志  
GET http://localhost:13901/api/v1/loggerAdvisor/call?  
    msg=讲个50字以内的笑话  
  
### stream:发送请求后,观察控制台是否记录了对应的请求和响应的日志  
GET http://localhost:13901/api/v1/loggerAdvisor/stream?  
    msg=讲个50字以内的笑话

2. 对话记忆顾问器

心法:MessageChatMemoryAdvisor 是 Spring AI 的 对话记忆 顾问器(可以记住历史对话),配合 MessageWindowChatMemory 实现滑动窗口式上下文记忆,让 AI 能记住历史对话内容。

MessageWindowChatMemory:滑动窗口式对话存储器,用来控制 AI 记忆,你可以设置最多记住几条对话,超过数量自动删除最早的记录,防止上下文太长导致请求失败、耗费 token。

conversationId:对话的唯一标识,会话唯一标识。相同标识的对话会共享上下文记忆,不同标识相互隔离,以此实现多用户、多会话的记忆隔离:

  • 相同 ID:同一个对话,共享记忆。
  • 不同 ID:全新对话,记忆不互通。

武技:测试使用记忆通知功能。

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/memoryAdvisor")
@RestController
@CrossOrigin
public class MemoryAdvisorController {

    private final ChatClient chatClient;
    
    public MemoryAdvisorController(ChatClient.Builder chatClientBuilder) {

        // 创建滑动窗口记忆对象
        MessageWindowChatMemory messageWindowChatMemory = MessageWindowChatMemory.builder()
                // 记忆窗口大小:最多保留 10 条对话(用户 + AI各算一条),防止上下文过长
                .maxMessages(10)
                .build();

        // 创建记忆顾问对象:用于将对话记忆注入到 AI 请求中,实现上下文感知
        MessageChatMemoryAdvisor messageChatMemoryAdvisor = MessageChatMemoryAdvisor.builder(messageWindowChatMemory)
                .build();

        // 构建带记忆的 ChatClient
        this.chatClient = chatClientBuilder
                // 注册记忆顾问(核心:开启对话记忆功能)
                .defaultAdvisors(messageChatMemoryAdvisor)
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg, @RequestParam("conversationId") String conversationId) {
        return chatClient
                .prompt()
                .user(msg)
                // 设置对话ID:相同ID使用同一套记忆
                .advisors(e -> e.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();
    }
}
  1. 测试控制器:
### 测试1:发送个人信息(对话ID:123456)
GET http://localhost:13901/api/v1/memoryAdvisor/call?
    msg=我今年100岁了&
    conversationId=123456

### 测试2:询问年龄(AI 会记住上一条消息,正确回答)
GET http://localhost:13901/api/v1/memoryAdvisor/call?
    msg=我多大了&
    conversationId=123456

### 测试3:更换对话ID(无记忆,AI 无法回答)
GET http://localhost:13901/api/v1/memoryAdvisor/call?
    msg=我多大了&
    conversationId=654321

3. 自定义顾问器

心法:Spring AI 中 BaseAdvisor 的核心本质等同于 AOP 环绕增强,能够在大模型调用的全链路前后,实现自定义逻辑的切入与拓展。

Advisor:AI 对话调用的拦截增强器,作用贯穿大模型请求全流程,支持在调用前后介入业务逻辑处理:

  • before():AI 发起调用前触发执行,适用于请求日志打印、非法请求拦截、提示词动态组装与优化等场景。
  • after():AI 响应返回后触发执行,多用于持久化保存模型回答、更新会话记忆、后置数据统计等场景。

武技:开发自定义助手,用于在 redis 中存储对话记忆。

  1. 使用 Docker 启动 Redis 服务。
  2. 在子项目的 POM 文件中添加 Redission 依赖:
<!--redisson-spring-boot-starter-->
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson-spring-boot-starter</artifactId>
	<version>${redisson-spring-boot-starter.version}</version>
</dependency>
  1. 在主配文件中添加 Redission 相关配置:
spring:
  data:
    redis:
      host: 192.168.40.77 # Redis 主机
      port: 6379 # Redis 端口
  1. 开发通知类:
package com.joezhou.advisor;
import org.springframework.ai.chat.messages.Message;

/** @author 周航宇 */
@Slf4j
@Component
@SuppressWarnings("all")
public class RedisAdvisor implements BaseAdvisor {

    @Resource
    private RedissonClient redissonClient;

    /** Redis Key 前缀 */
    private static final String KEY_PREFIX = "RedisAdvisor:";
    /** 对话 ID 键名 */
    private static final String CONVERSATION_ID = "conversationId";
    /** 滑动窗口最大消息数(用户 + AI 各算1条) */
    private static final int MAX_MESSAGES = 10;
    /** 记忆过期时间(分钟):30分钟无操作自动删除 */
    private static final long EXPIRE = 30;

    /**
     * AI 调用前执行:
     * 1. 读取 Redis 历史记忆
     * 2. 拼接本次用户消息
     * 3. 构建完整上下文提示词
     */
    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {

        // 获取对话ID:相同ID使用同一套记忆
        String conversationId = getConversationId(chatClientRequest);

        // 获取本次用户消息
        List<Message> currentUserMessages = chatClientRequest.prompt().getInstructions();

        // 获取历史消息(用户 + AI):这里 redisMessages 和 promptMessages 均表示历史消息,但作用不同:
        // redisMessages:RList 类型,用于更新 Redis 内容
        // promptMessages:ArrayList 类型,用于构建新提示词
        String key = KEY_PREFIX + conversationId;
        RList<Message> redisHistoryMessages = redissonClient.getList(key);
        List<Message> promptMessages = new ArrayList<>(redisHistoryMessages);

        // 把本次用户消息存入 Redis(更新记忆)
        redisHistoryMessages.addAll(currentUserMessages);
        redisHistoryMessages.expire(EXPIRE, TimeUnit.MINUTES);

        // 构建新提示词(Prompt 是只读对象,必须使用 mutate 构建新对象)
        promptMessages.addAll(currentUserMessages);
        Prompt newPrompt = chatClientRequest.prompt().mutate().messages(promptMessages).build();

        // 构建新请求并返回(请求对象只读,不允许直接修改,只能通过 mutate 方法修改)
        log.info("上下文拼接完成,总消息数量:{}", promptMessages.size());
        return chatClientRequest.mutate().prompt(newPrompt).build();
    }

    /**
     * AI 调用后执行:
     * 1. 获取 AI 回答内容
     * 2. 存入 Redis 记忆
     * 3. 滑动窗口裁剪
     */
    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {

        // 获取对话ID:相同ID使用同一套记忆
        String conversationId = getConversationId(chatClientResponse);
        String key = KEY_PREFIX + conversationId;

        // 获取历史消息(用户 + AI)
        RList<Message> historyMessages = redissonClient.getList(key);

        // 获取 AI 回复消息
        AssistantMessage aiMessage = chatClientResponse.chatResponse().getResult().getOutput();

        // 把 AI 回答消息存入 Redis(更新记忆)
        historyMessages.add(aiMessage);

        // 滑动窗口:只保留最新 MAX_MESSAGES 条,防止上下文过长
        if (historyMessages.size() > MAX_MESSAGES) {
            List<Message> latestMessages = historyMessages.subList(
                    historyMessages.size() - MAX_MESSAGES,
                    historyMessages.size()
            );
            historyMessages.clear();
            historyMessages.addAll(latestMessages);
        }

        // 刷新过期时间
        historyMessages.expire(EXPIRE, TimeUnit.MINUTES);
        log.info("记忆保存成功,当前记忆条数:{}", historyMessages.size());

        // 返回响应
        return chatClientResponse;
    }

    /** Advisor 唯一名称 */
    @Override
    public String getName() {
        return "RedisAdvisor";
    }

    /** 执行顺序:数字越小,越先执行 */
    @Override
    public int getOrder() {
        return 0;
    }

    /**
     * 安全获取对话ID,防止空指针异常
     *
     * @param request 对话请求对象
     * @return 对话ID
     */
    private String getConversationId(ChatClientRequest chatClientRequest) {
        Object id = chatClientRequest.context().get(CONVERSATION_ID);
        if (ObjectUtil.isEmpty(id)) {
            throw new IllegalArgumentException("请传入对话ID:conversationId");
        }
        return id.toString();
    }

    /**
     * 安全获取对话ID,防止空指针异常
     *
     * @param response 对话响应对象
     * @return 对话ID
     */
    private String getConversationId(ChatClientResponse chatClientResponse) {
        Object id = chatClientResponse.context().get(CONVERSATION_ID);
        if (ObjectUtil.isEmpty(id)) {
            throw new IllegalArgumentException("请传入对话ID:conversationId");
        }
        return id.toString();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/redisAdvisor")
@RestController
@CrossOrigin
public class RedisAdvisorController {

    private final ChatClient chatClient;

    public RedisAdvisorController(ChatClient.Builder chatClientBuilder, RedisAdvisor redisAdvisor) {

        // 构建带记忆的 ChatClient
        this.chatClient = chatClientBuilder
                // 注册自定义的顾问:开启对话记忆功能
                .defaultAdvisors(redisAdvisor)
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg, @RequestParam("conversationId") String conversationId) {
        return chatClient
                .prompt()
                .user(msg)
                // 设置对话ID:相同ID使用同一套记忆
                .advisors(e -> e.param("conversationId", conversationId))
                .call()
                .content();
    }
}
  1. 测试控制器:
### 测试1:发送请求后,查看 redis 中是否存储了该条记忆(一共两条,一条用户消息,一条模型消息)
GET http://localhost:13901/api/v1/redisAdvisor/call?
    msg=我今年80岁了&
    conversationId=123456

### 测试2:查看模型是否会记住上一条消息,正确回答
GET http://localhost:13901/api/v1/redisAdvisor/call?
    msg=我多大了&
    conversationId=123456

### 测试3:更换对话ID(无记忆,AI 无法回答)
GET http://localhost:13901/api/v1/redisAdvisor/call?
    msg=我多大了&
    conversationId=654321

S03. 检索增强

心法:RAG 全称 Retrieval Augmented Generation,即检索增强生成,核心作用是给大模型外挂一套可实时更新的外部知识库,从根源上提升 AI 应用回答的可靠性、时效性与专业度。

传统大语言模型,只能依赖预训练阶段固化的内置知识点作答。一旦遇到实时新知、行业专属业务、私有文档这类内容,很容易答非所问、信息滞后,甚至产生 AI 幻觉(输出看似逻辑通顺、实则完全错误的虚假内容)。

而接入 RAG 架构后,用户提问时会先通过检索算法,从外部文档、数据库、知识库等资源中,精准召回和问题高度相关的上下文信息,再把检索到的真实资料作为上下文喂给大模型,让模型基于真实素材整合生成答案。

相当于给大模型配备了随时可更新的专属 “参考教材”,不再只靠预训练的老旧知识硬答,从根本上减少幻觉、提升答案准确性与专业性。

武技:创建 springai-rag 子项目,并完成初始化工作。

  1. 添加三方依赖:
<dependencies>
    <!--spring-boot-starter-web-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--spring-ai-alibaba-starter-dashscope-->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
    <!--spring-ai-advisors-vector-store-->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-advisors-vector-store</artifactId>
    </dependency>
    <!--spring-ai-starter-vector-store-redis-->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-vector-store-redis</artifactId>
    </dependency>
    <!--spring-ai-tika-document-reader-->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-tika-document-reader</artifactId>
    </dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13902 # 端口号
  servlet:
    encoding:
      charset: utf-8 # 字符集(解决 stream 中文乱码)
      enabled: true # 启用字符编码(解决 stream 中文乱码)
      force: true # 强制使用字符编码(解决 stream 中文乱码)

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY} # 阿里云百炼 API_KEY
      read-timeout: 100000 # 读取超时时间(毫秒)
      chat:
        options:
          model: qwen-plus # 基础对话模型
          max-tokens: 1000 # Token 限制
          temperature: 0.5 # 采样温度
      embedding:
        options:
          model: text-embedding-v1 # 向量模型,阿里云百炼默认模型,不能修改
          dimensions: 1536  # 向量维度:阿里云该模型固定 1536,不能修改
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class RagApp {
    public static void main(String[] args) {
        SpringApplication.run(RagApp.class, args);
    }
}
  1. 在 classpath:rag/ 目录下,开发几个用于 RAG 测试的知识库文件:

companyInfo.txt

公司成立于1945年10月1号.
公司法人是赵四.
公司经理是刘能.
公司法务代表是广坤.
公司销售团队包括刘英,苏玉红,李银萍,王云等.

userInfo.txt

{
  "name": "赵四",
  "age": 20,
  "gender": "男",
  "email": "zhangsan@example.com",
  "phone": "13800000000",
  "address": "中国 北京",
  "company": "中国公司",
  "hiredate": "2023-01-01",
  "position": "销售代表",
  "department": "销售部",
  "salary": 5000,
  "friends": ["刘英", "苏玉红", "李银萍", "王云"]
}

bookInfo.txt

        《@水&浒*传》                                            施耐庵 (元末明初)

书籍概要:《@水&浒*传》是中国历史上第一部用白话文写成的章回体长篇小说,也是四大名著之一。            

E01. RAG流程解析

心法:RAG 的完整流程大概分为 离线索引在线问答 两个阶段,推荐使用 ETL framework 进行具体实现。

ETL:Spring AI 官方推荐的文档预处理标准方案,专门为 RAG 服务,它就像一个数据化妆师,先从各类文档中 抽取 有效信息,再 清洗整理 成 AI 能理解的干净格式,最后 加载 进知识库,让 AI 能快速检索并精准回答。

1. 离线索引阶段

心法:离线索引阶段,指的是知识库构建阶段,仅在首次接入或更新文档时执行。

具体流程

  1. 读取(Reading):将 PDF、Word、HTML 等原始文档加载到系统中,并封装为 Document 对象列表。
  2. 清洗(Cleaning):清洗原始文档中的多余空行、多余空格、乱码、格式噪声、水印等脏内容。
  3. 切块(Splitting):将大文档切分为语义连贯的小块,避免过长上下文影响模型理解。
  4. 元数据增强(Enhancing):生成对应文档的摘要、关键词、实体等,存入元数据。
  5. 向量化(Embedding):使用 Embedding 模型生成对应文档的高维向量(文本的数学表示,用于后续相似度计算)。
  6. 存储(Storing):将高维向量、原文本片段和元数据一同存入向量数据库,构建可快速检索的知识库索引。

在这里插入图片描述

2. 用户提问阶段

心法:用户提问阶段,在每次用户发送提问请求时实时执行。

具体流程

  1. 提问(Request):接收用户问题,进行清洗、改写、关键词扩展等预处理,优化检索效果。
  2. 向量化(Embedding):使用 Embedding 模型生成对应用户问题的高维向量(文本的数学表示,用于后续相似度计算)。
  3. 检索(Searching):在向量数据库中,通过相似度算法召回与问题最相关的 Top-N 分块。
  4. 构建提示词(Prompt):将 “用户问题 + 检索到的上下文片段 + 系统指令” 拼接为完整的提示词。
  5. 大模型生成(Generation):发送提示词给大模型,让模型基于检索到的上下文信息生成答案,减少幻觉、提升准确性。
  6. 响应(Response):向用户返回最终回答,并可附带参考来源、原文片段或链接,方便用户溯源验证。

在这里插入图片描述

E02. ETL提取Extract

心法:Extract 是 ETL 中的第 1 个步骤,它负责从各类格式的文件中抽取原始内容,并将其标准化为 RAG 架构通用的 Document 对象列表,为后续的文本转换与向量化奠定坚实基础。

Document 组成结构

核心要素简述详述
ID唯一标识文档在向量库中的主键,未显式指定时,系统通常会自动生成 UUID 以确保数据唯一性
Text核心文本经过初步清洗与切分后的纯文本内容,是承载核心语义与生成向量的基础载体
Metadata业务元数据键值对形式的附加信息(如来源、页码、分类标签等),主要用于后续的精准过滤与溯源

1. 资源绑定Resource

心法:在 ETL 流程启动前,必须通过 org.springframework.core.io.Resource 接口完成文件绑定,统一封装本地、类路径、网络等各类数据源,为文档读取提供标准入口。

资源绑定方式

代码简述返回值
new FileSystemResource("D:\\xxx.md")绑定操作系统本地文件Resource
new ClassPathResource("rag/info.txt")绑定项目 classpath 路径下文件Resource
new UrlResource("https://xxx.html")绑定网络远程资源(网页 / 文件)Resource

武技:测试 3 种常用的资源绑定方案。

  1. 开发单元测试类:
package base;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;

/** @author 周航宇 */
public class ResourceTest {

	@SneakyThrows
    @Test
    public void fileSystemResource() {
        String filePath = "D:\\workspace\\java\\v3-9-ssm-ai\\springai-rag\\src\\main\\resources\\rag\\companyInfo.txt";
        // 绑定本地文件
        Resource resource = new FileSystemResource(filePath);
        // 查看文件内容
        String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        System.out.println("====================");
        System.out.println(content);
        System.out.println("====================");
    }

    @SneakyThrows
    @Test
    public void classPathResource() {
        String filePath = "rag/companyInfo.txt";
        // 绑定项目 classpath 下的文件
        Resource resource = new ClassPathResource(filePath);
        // 查看文件内容
        String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        System.out.println("====================");
        System.out.println(content);
        System.out.println("====================");
    }

    @SneakyThrows
    @Test
    public void urlResource() {
        String filePath = "https://blog.csdn.net/CSDN_JOEZHOU/article/details/148041303";
        // 绑定网络资源
        Resource resource = new UrlResource(filePath);
        // 查看文件内容
        String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        System.out.println("====================");
        System.out.println(content);
        System.out.println("====================");
    }
}

2. 读取器DocumentReader

心法:DocumentReader 是 RAG ETL 流程中的数据入口,用于将各类文件、网页、数据库等异构数据源,统一读取并解析为标准的 Document 文档对象,为后续清洗、切分、增强提供统一数据格式。

DocumentReader 读取器:以下均为 DocumentReader 接口的实现类:

  • 可能需要额外引入 org.springframework.ai(1.0.0)包中的对应依赖。
  • 可能需要额外引入 com.alibaba.cloud.ai(1.0.0.4)包中的对应依赖。
读取器名称支持的文件类型核心备注所需依赖
TextReaderTXT轻量读取纯文本,简单场景专用不需要
JsonReaderJSON自动解析 JSON,支持按字段读取不需要
TikaDocumentReader几乎所有格式生产首选,通用型全能解析器spring-ai-tika-document-reader
PagePdfDocumentReaderPDF按页读取 PDFspring-ai-pdf-document-reader
ParagraphPdfDocumentReaderPDF按段落读取 PDF(RAG 首选)spring-ai-pdf-document-reader
YuQueDocumentReader语雀文档企业知识库常用spring-ai-alibaba-starter-yuque
LarkDocumentReader飞书文档飞书数据接入spring-ai-alibaba-starter-lark
WeChatDocumentReader微信公众号文章提取文章正文spring-ai-alibaba-starter-wechat
DatabaseDocumentReaderMySQL 等数据表直接读取库表数据转文档spring-ai-alibaba-starter-database

武技:测试 3 种常用的读取器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.ai.reader.JsonReader;

@RequestMapping("/api/v1/documentReader")
@RestController
@CrossOrigin
public class DocumentReaderController {

    @GetMapping("textReader")
    public List<Document> textReader() {
        // 绑定项目 classpath 下的文件
        Resource resource = new ClassPathResource("rag/companyInfo.txt");
        // 读取 txt 文件中的所有内容
        DocumentReader documentReader = new TextReader(resource);
        return documentReader.read();
    }

    @GetMapping("jsonReader")
    public List<Document> jsonReader() {
        // 绑定项目 classpath 下的文件
        Resource resource = new ClassPathResource("rag/userInfo.json");
        // 读取 json 文件中的 name, age, gender 字段,缺省是读取所有字段
        DocumentReader documentReader = new JsonReader(resource, "name", "age", "gender");
        return documentReader.read();
    }

    @GetMapping("tikaDocumentReader")
    public List<Document> tikaDocumentReader() {
        // 绑定项目 classpath 下的文件
        Resource resource = new ClassPathResource("rag/companyInfo.txt");
        // 读取 txt 文件中的所有内容
        DocumentReader documentReader = new TikaDocumentReader(resource);
        return documentReader.read();
    }
}
  1. 测试控制器:
### textReader
GET http://localhost:13902/api/v1/documentReader/textReader

### jsonReader
GET http://localhost:13902/api/v1/documentReader/jsonReader

### tikaDocumentReader
GET http://localhost:13902/api/v1/documentReader/tikaDocumentReader

E03. ETL转换Transform

心法:Transform 是 ETL 中的第 2 个步骤,指的是对原始文档进行加工处理,使其更适合 RAG 系统的检索与嵌入,具体可分为清理,分块和增强三个可选步骤。

Transform 具体分类

分类简述
Cleaning内容清理清洗原始文档中的多余空行、多余空格、乱码、格式噪声、水印等脏内容
Splitting内容分块将大文档切分为语义连贯的小块,避免过长上下文影响模型理解
Enhancing元数据增强生成对应文档的摘要、关键词、实体等,存入元数据

1. 清洗Cleaning

心法:Cleaning 指的是在文档切块前,对原始文本做降噪、格式化、去冗余处理,让后续切分、向量化、检索更干净、更准确,核心目标就是 “只保留有效语义,剔除所有干扰信息”,但要注意,清洗不是越干净越好,而是只删干扰、不删语义。

常见的清洗需求如下

  • 去除乱码字符:具体哪些符号属于乱码,由具体业务决定。
  • 去除多余空格:将文本中间的多个连续空格缩减为一个。
  • 去除两端空格:大多数情况下,两端的空格都是没有意义的。
  • 统一换行符号:将 \r\n\r\n 全部统一为 \n,避免切分错乱。
  • 合并多余空行:连续 2 个及以上换行 → 合并为 1 个换行,保持段落清晰。
  • 去除页眉页脚:如 “第 X 页 共 Y 页”、文档标题重复、公司水印、页码、网址、版权声明。
  • 去除冗余符号:连续 ---===***### 等装饰线。
  • 去除表格噪声:剔除残缺表格、乱码表格线、无效单元格。
  • 统一标点符号:中文标点 ,。!? 与英文标点 , . ! ? 混乱的统一规范。
  • 剔除无意义短句:如 “点击查看”、“返回顶部”、“加载中” 等网页噪声。
  • 格式化排版:保证句子完整、段落清晰,不破坏语义结构。

武技:测试使用正则表达式等手段,手动清洗数据。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@RequestMapping("/api/v1/cleaning")
@RestController
@CrossOrigin
@Slf4j
public class CleaningController {

    @GetMapping("clean")
    public List<Document> clean() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/bookInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());
        log.info(">> 开始清洗文档");
        // 清洗数据
        documents = documents.stream().map(document -> {
            // 获取文档内容和元数据
            String text = document.getText();
            Map<String, Object> metadata = document.getMetadata();

            log.info(">> 清理前的元数据:{}", metadata);
            log.info(">> 清理前的内容:\n==========\n{}\n==========\n", text);

            // 去除乱码:假设视 @,& 和 * 为乱码(后续自己根据业务补充黑名单)
            text = text.replaceAll("[@&*]", "");
            log.info(">> 已去除乱码字符:\n==========\n{}\n==========\n", text);

            // 去除多余空格 :将文本中间的多个连续空格缩减为一个
            text = text.replaceAll(" {2,}", " ");
            log.info(">> 已去除多余空格:\n==========\n{}\n==========\n", text);

            // 去除两端空格
            text = text.trim();
            log.info(">> 已去除两端空格:\n==========\n{}\n==========\n", text);

            // 填充元数据:添加作者信息
            metadata.put("author", "JoeZhou");
            metadata.put("datetime", LocalDateTime.now());
            log.info(">> 已添加作者信息和时间戳:{}", metadata);

            // 重新构建文档对象并返回
            return Document.builder().text(text).metadata(metadata).build();
        }).toList();

        log.info(">> 全部文档清洗完成,共清洗 {} 条文档", documents.size());
        return documents;
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }
}
  1. 测试控制器:
### clean  
GET http://localhost:13902/api/v1/cleaning/clean

2. 切分器TokenTextSplitter

心法:TokenTextSplitter 是 DocumentTransformer 接口的实现类,底层按照 token 数量 进行切块,最适合 RAG 操作,保证不超上下文窗口。

TokenTextSplitter 常用配置

配置描述默认值
chunkSize分块最多包含多少个 Token800
maxNumChunks最多切出多少个分块10000
minChunkLengthToEmbed小于等于多少个字符的分块会被丢弃5
keepSeparator切成的分块中,是否保留换行符true
minChunkSizeChars仅当存在边界标点时生效:

若边界标点的位置 < 350:不截断分块
若边界标点的位置 ≥ 350:截断分块
350

TokenTextSplitter 切分流程

首先将原始文本编码为 List<Integer> 类型的 token 列表(记录 token 与原始文本的映射关系),然后循环执行以下步骤,直到 token 列表为空,或分块数量达到 maxNumChunks 值:

步骤简述详述
1粗切分段从原始 token 列表中截取前 chunkSize 个 token 作为当前分块,不足则直接取完
2解码文本将当前分块的 token 列表解码为字符串 chunkText
3跳过空块若当前分块为空块,则直接从原始 token 列表中移除,并开启下一轮循环
4寻找边界在 chunkText 中寻找最后一个英文边界标点的位置 pos(句号、问号、叹号,换行符都算):

若找到了,且 pos > minChunkSizeChars,则进入第 5 步(进行截断)
若未找到,或 pos ≤ minChunkSizeChars,则进入第 6 步(跳过截断)
5截断处理将 chunkText 截断至边界标点的下一个位置(保留边界标点)
6格式处理根据 keepSeparator 配置决定如何处理分块中的换行符:

若为 true:保留换行符,仅执行 trim 操作
若为 false:将换行符替换为空格,再执行 trim 操作
7长度过滤若 chunkText 的长度 > minChunkLengthToEmbed 则将该分块加入结果列表
若 chunkText 的长度 ≤ minChunkLengthToEmbed 则直接丢弃该分块
8移出分块对 chunkText 重新编码得到其 token 列表,再从原始 token 列表中移出它们

循环结束后,若仍有未处理的 token,则将它们视为最后一个分块,并依次执行表格中的 2, 6 和 7 步骤。

武技:测试 TokenTextSplitter 切分器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@RequestMapping("/api/v1/tokenTextSplitter")
@RestController
@CrossOrigin
@Slf4j
public class TokenTextSplitterController {

    @GetMapping("split")
    public List<Document> split() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 创建 TokenTextSplitter 切分器
        DocumentTransformer tokenTextSplitter = TokenTextSplitter.builder()
                .withChunkSize(50)             // 每块最多 50 个 Token
                .withMaxNumChunks(10000)       // 最多分 10000 块
                .withMinChunkSizeChars(10)     // 若边界标点的位置小于 10 则不截断,保留原块
                .withMinChunkLengthToEmbed(5)  // 丢弃字符数不超过 5 个字符的分块
                .withKeepSeparator(true)       // 保留分块中的换行符
                .build();

        // 根据 Token 数量切块
        return tokenTextSplitter.apply(documents);
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }
}
  1. 测试控制器:
### split
GET http://localhost:13902/api/v1/tokenTextSplitter/split

3. 切分器SentenceSplitter

心法:SentenceSplitter 是 DocumentTransformer 接口的实现类,底层按照 句子边界标点 进行切块,语义最完整,但容易超长。

TokenTextSplitter 常用配置

配置描述默认值
chunkSize分块最多包含多少个 Token1024

SentenceSplitter 切分流程

首先使用 OpenNLP 的 SentenceDetectorME 模型将原始文本按英文句号、问号、叹号(不包括换行符)分割为句子数组 texts,然后循环遍历该数组,并依次执行以下步骤,直到所有句子处理完毕:

步骤简述详述
1计算增量计算当前分块已占用的 Token 数和当前句子的 Token 数的总和 totalCount
2溢出判断若 totalCount > chunkSize,则进入第 3 步(换块处理)
若 totalCount ≤ chunkSize,则进入第 4 步(追加处理)
3换块处理1. 将当前分块(不含当前句子)加入结果列表(若第一句就执行换块,则会加入一个空块)
2. 新建一个空的分块作为新的当前分块
3. 将当前句子追加到当前分块的末尾
4追加处理将该句子直接追加到当前分块的末尾
5收尾归档若当前句子为数组中的最后一个句子,直接将当前分块加入结果列表

武技:测试 SentenceSplitter 切分器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@RequestMapping("/api/v1/sentenceSplitter")
@RestController
@CrossOrigin
@Slf4j
public class SentenceSplitterController {

    @GetMapping("split")
    public List<Document> split() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 每块最多 15 个 Token
        int chunkSize = 15;

        // 创建 SentenceSplitter 切分器
        DocumentTransformer sentenceSplitter = new SentenceSplitter(chunkSize);

        // 根据句子切块
        return sentenceSplitter.apply(documents);
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }
}
  1. 测试控制器:
### split
GET http://localhost:13902/api/v1/sentenceSplitter/split

4. 切分器RecursiveCharacterTextSplitter

心法:RecursiveCharacterTextSplitter 是 DocumentTransformer 接口的实现类,底层按照 固定字符长度 进行切块,通用性最强,是 LangChain 标准切分器。

RecursiveCharacterTextSplitter 常用配置

配置描述默认值
chunkSize分块最多包含多少个 Token1024
separators分隔符数组(按优先级排序)\n\n\n?" "

RecursiveCharacterTextSplitter 切分流程

首先将整个文本作为第一个切块,传递给负责切分的函数中,然后递归执行以下步骤,直到切块足够小(字符数小于等于 chunkSize),或分隔符号使用耗尽时结束递归:

步骤简述详述(假设分块的字符长度为 len)
1根据切块长度
判断是否结束递归
(递归出口)
若 len = 0:表示切出了空分块,直接结束递归
若 len ≤ chunkSize:说明分块已经足够小,将分块加入结果列表并结束递归
若 len > chunkSize:说明分块不够小,进入第 2 步骤
2根据分隔符耗尽情况
判断是否结束递归
(递归出口)
若 separators 数组中的全部分割符已用尽(均已被使用了一遍)
则固定从头截取该分块的 chunkSize 个字符(不足则截完),加入结果列表并结束递归
否则进入第 3 步
3获取当前分隔符从 separators 数组中按优先级获取一个分隔符
4分割文本使用当前分隔符把文本切成若干个字符串分块(调用字符串的 split 方法,所以分隔符会被丢弃)
5递归处理遍历每一个分块,判断分块的字符长度 len(和步骤 1 重复,是为了递归健壮性):

若 len > chunkSize:说明分块不够小,使用 separators 数组中的下一个分隔符,继续递归切分
若 len ≤ chunkSize:说明分块已经足够小,直接加入结果列表

所有递归结束后,所有分块均满足小于等于 chunkSize,返回最终结果。

武技:测试 RecursiveCharacterTextSplitter 切分器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@RequestMapping("/api/v1/recursiveCharacterTextSplitter")
@RestController
@CrossOrigin
@Slf4j
public class RecursiveCharacterTextSplitterController {

    @GetMapping("split")
    public List<Document> split() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 每块最多 15 个字符
        int chunkSize = 15;

        // 创建 RecursiveCharacterTextSplitter 切分器
        DocumentTransformer recursiveCharacterTextSplitter = new RecursiveCharacterTextSplitter(chunkSize);

        // 根据字符切块
        return recursiveCharacterTextSplitter.apply(documents);
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }
}
  1. 测试控制器:
### split
GET http://localhost:13902/api/v1/recursiveCharacterTextSplitter/split

5. 增强器SummaryMetadataEnricher

心法:SummaryMetadataEnricher 是 DocumentTransformer 接口的实现类(实例化时需要传入 chatModel 对象),可以为文档生成摘要 summary 并写入元数据,为后续检索和模型理解提供更丰富的上下文信号。

内置摘要类型:均为 SummaryMetadataEnricher.SummaryType 枚举类的属性

枚举属性简述详述元数据字段
PREVIOUS包含上一个切块的内容生成的摘要包含 “上文背景” 或前情提示prev_section_summary
CURRENT包含当前切块的内容最常用的选项,用于生成当前段落或页面的核心摘要section_summary
NEXT包含下一个切块的内容生成的摘要包含 “下文预告” 或后续信息next_section_summary

武技:测试 SummaryMetadataEnricher 增强器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@RequestMapping("/api/v1/summaryMetadataEnricher")
@RestController
@CrossOrigin
@Slf4j
public class SummaryMetadataEnricherController {

    private final ChatModel chatModel;

    public SummaryMetadataEnricherController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @GetMapping("enhance")
    public List<Document> enhance() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(documents);
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());

        // 准备摘要类型
        // PREVIOUS 类型表示包含之前的内容,如果你希望生成的摘要能帮助模型理解“上文背景”,就加入这个
        // CURRENT 类型表示当前切块的内容,这是最常用的选项,用于生成当前段落或页面的核心摘要
        // NEXT 类型表示下一个切块的内容,如果你希望摘要包含“下文预告”或后续信息,就选这个
        List<SummaryMetadataEnricher.SummaryType> summaryTypes = List.of(
                SummaryMetadataEnricher.SummaryType.PREVIOUS,
                SummaryMetadataEnricher.SummaryType.CURRENT,
                SummaryMetadataEnricher.SummaryType.NEXT
        );

        // 创建 SummaryMetadataEnricher 摘要增强器
        DocumentTransformer summaryEnricher = new SummaryMetadataEnricher(chatModel, summaryTypes);

        // 开始增强切块:附加摘要
        return summaryEnricher.apply(chunks);
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }

    /**
     * 对文档列表进行 Token 切分
     *
     * @param documents 文档列表
     * @return 切分后的文档列表
     */
    private List<Document> split(List<Document> documents) {
        return TokenTextSplitter.builder()
                .withChunkSize(50)
                .withMinChunkSizeChars(10)
                .build().apply(documents);
    }
}
  1. 测试控制器:
### enhance  
GET http://localhost:13902/api/v1/summaryMetadataEnricher/enhance

6. 增强器KeywordMetadataEnricher

心法:KeywordMetadataEnricher 是 DocumentTransformer 接口的实现类(实例化时需要传入 chatModel 对象),可以为文档生成关键字 keyword 并写入元数据,为后续检索和模型理解提供更丰富的上下文信号。

KeywordMetadataEnricher 常用参数

参数简述详述
keywordCount关键字数量希望生成多少个关键字

武技:测试 KeywordMetadataEnricher 增强器。

  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@RequestMapping("/api/v1/keywordMetadataEnricher")
@RestController
@CrossOrigin
@Slf4j
public class KeywordMetadataEnricherController {

    private final ChatModel chatModel;

    public KeywordMetadataEnricherController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }
    
    @GetMapping("enhance")
    public List<Document> enhance() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(documents);
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());

        // 创建 KeywordMetadataEnricher 关键词增强器,生成 5 个关键词
        int keywordCount = 5;
        DocumentTransformer keywordEnricher = new KeywordMetadataEnricher(chatModel, keywordCount);

        // 开始增强切块:附加关键词
        return keywordEnricher.apply(chunks);
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }

    /**
     * 对文档列表进行 Token 切分
     *
     * @param documents 文档列表
     * @return 切分后的文档列表
     */
    private List<Document> split(List<Document> documents) {
        return TokenTextSplitter.builder()
                .withChunkSize(50)
                .withMinChunkSizeChars(10)
                .build().apply(documents);
    }
}
  1. 测试控制器:
### enhance  
GET http://localhost:13902/api/v1/keywordMetadataEnricher/enhance

7. 向量化Embedding

心法:大模型无法直接理解文字语义,机器仅能运算数值,因此要把段落、句子、词语转换成一串高维数字数组,该过程被称为向量化:

  • 向量:高维数字数组,语义越相近的文本,向量在空间中的距离越接近。
  • 向量维度:数字数组的长度,维度越高语义刻画越精细、匹配精度上限越高,但存储、算力开销更大,检索速度变慢。
  • 向量数据库:专门持久存储向量、原文片段与元数据,并依靠专属索引快速完成海量向量相似度比对的存储引擎。

向量数据库 VS 普通数据库

对比项普通数据库向量数据库
存储内容普通文本、业务结构化数据原文元数据 + 高维向量数组
检索方式关键词匹配、模糊查询、精确查询语义相似度匹配
语义理解能力仅匹配字面字符,无法理解语句含义读懂文本语义,按含义匹配而非字面匹配
RAG 适配性勉强适配,检索精准度差、易漏内容专为 RAG 知识库场景设计,适配度拉满
大数据检索性能数据量越大检索越慢,无向量索引优化内置向量索引,千万级数据也能毫秒级响应

常见第三方向量数据库

向量数据库描述适用场景
Chroma轻量级开源向量库,开箱即用小型项目
RedisStack基于 Redis 扩展,自带向量检索、JSON、搜索等能力中型项目
Qdrant高性能开源向量库,检索速度快、支持丰富过滤条件大型项目
Milvus开源高性能专业向量库,支持亿级海量数据企业级项目
Pinecone云端托管向量数据库,无需自建运维云端项目
Elasticsearch兼具全文检索与向量检索关键词搜索 + RAG 混合场景

武技:搭建 redis-stack 向量数据库。

  1. 安装 redis-stack 容器:
# 准备相关目录
mkdir -p /opt/redis-stack/data;
chmod -R 777 /opt/redis-stack;

# 拉取镜像(二选一)
docker pull redis/redis-stack:7.4.2;
docker pull registry.cn-hangzhou.aliyuncs.com/joezhou/redis-stack:7.4.2;

# 创建并启动容器
docker run --name redis-stack \
    --network my-net \
	-p 5379:6379 \
	-p 8001:8001 \
	-v /opt/redis-stack/data:/data \
	-e REDIS_ARGS="--requirepass joezhou" \
	-itd registry.cn-hangzhou.aliyuncs.com/joezhou/redis-stack:7.4.2;
  1. 访问向量数据库界面:输入密码 joezhou 即可。

E04. ETL加载Loading-入库

心法:Loading 是 ETL 中的第 3 个步骤,指的是先将切分向量化,再通过 VectorStore 接口,将向量、原文片段与元数据统一持久化至向量数据库,构建结构化知识库,为后续检索问答提供数据支撑。

QuestionAnswerAdvisor 是 Spring AI 官方内置的 RAG 自动执行拦截器,相当于 RAG 的自动引擎,它让开发者无需手动编写 “检索 → 组装 → 注入” 逻辑,只需注入拦截器,即可一键实现完整 RAG 功能,大幅简化开发流程,当用户调用 chatClient.call() 发起对话时,QuestionAnswerAdvisor 会自动拦截请求并执行以下标准 RAG 逻辑:

步骤描述
1自动拦截用户输入的问题文本
2将用户问题通过嵌入模型转为向量表示(Embedding)
3基于向量相似度,在 VectorStore 中检索最相关的知识库文档片段
4将检索到的内容作为参考上下文,自动注入 AI 模型的 Prompt 中
5将携带上下文的增强提示发送给大模型,使模型基于知识库内容生成准确、可溯源的回答

VectorStore 常用方法

方法分类详细方法签名核心简述
批量存入文档add(List<Document> documents)底层自动调用模型转为向量并建立索引
批量存入文档ddDocuments(List<Document> documents)该方法是 add 方法的语义化别名,功能完全一致
基础检索similaritySearch(String query)传入问题文本,返回最相关的文档列表
高级检索similaritySearch(SearchRequest request)支持自定义 topK、相似度阈值及元数据过滤
带评分检索similaritySearchWithScore(String query)检索的同时返回文档内容及其与问题的相似度分值
多样性检索maxMarginalRelevanceSearch(String query)在相关的基础上过滤重复内容,提升信息丰富度
按主键删除文档delete(List<String> idList)根据文档的唯一标识符(ID)物理删除指定的向量数据
按条件删除文档delete(DeleteRequest deleteRequest)支持通过元数据过滤表达式(如 id == '77')批量删除
适配器转换asRetriever()将存储接口包装为标准的 Retriever 检索器,供 Advisor 调用

武技:搭建 ETL-Loading 起始测试环境。

  1. 开发主配文件(加入向量库相关配置):
spring:
  ai:
    dashscope:
	  ...
      embedding:
        options:
          model: text-embedding-v1 # 向量模型,阿里云百炼默认模型,不能修改
          dimensions: 1536  # 向量维度:阿里云该模型固定 1536,不能修改

1. 内存向量库SimpleVectorStore

心法:SimpleVectorStore 本质是内存型的 VectorStore 实现,新增文档、向量、元数据全部放在 JVM 内存,程序重启、服务关闭后,内存数据直接清空,检索时直接在内存做相似度计算,速度极快,无网络 IO 消耗,适合练习。

武技:测试使用 SimpleVectorStore 进行向量存储。

  1. 开发配置类:
package com.joezhou.config;
import org.springframework.ai.embedding.EmbeddingModel;

/** @author 周航宇 */
@Configuration
public class SimpleVectorStoreConfig {

    @Bean("simpleVectorStore")
    public VectorStore simpleVectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}
  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/api/v1/simpleVectorStore")
public class SimpleVectorStoreController {

    private final VectorStore simpleVectorStore;
    private final ChatClient chatClient;

    public SimpleVectorStoreController(ChatClient.Builder chatClientBuilder, @Qualifier("simpleVectorStore") VectorStore simpleVectorStore) {
        this.chatClient = chatClientBuilder
                .defaultSystem("""  
                        你只能根据给定的知识库内容回答问题。
                        如果知识库中没有相关信息,请直接回答“我不清楚”,不要编造内容。
                        回答要简洁明了,不要添加无关信息。
                        """)
                .build();
        this.simpleVectorStore = simpleVectorStore;
    }

    @PostMapping("/load")
    public List<String> load() {

        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());

        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(documents);
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());

        // 存入向量数据库,这个过程会自动调用 embeddingModel 将文本变成向量再存入向量库
        simpleVectorStore.add(chunks);

        // 从 chunks 里直获取 ID 列表
        List<String> ids = chunks.stream().map(Document::getId).toList();
        log.info(">> 知识库添加成功,生成ID:" + ids);

        // 直接返回所有 ID 列表
        return ids;
    }

    @PostMapping("/add")
    public List<String> add(@RequestParam("text") String text) {

        // 将传入的文本包装成 Document
        Document document = Document.builder().text(text).build();
        log.info(">> 文本包装成 Document 完成,文本内容: {}", document.getText());

        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(List.of(document));
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());

        // 存入向量数据库
        simpleVectorStore.add(chunks);

        // 从 chunks 里直获取 ID 列表
        List<String> ids = chunks.stream().map(Document::getId).toList();
        System.out.println(">> 知识库添加成功,生成ID:" + ids);

        // 直接返回所有 ID 列表
        return ids;
    }

    @GetMapping("/search")
    public List<Document> search(@RequestParam("text") String text) {
        // 构建 SearchRequest 对象
        SearchRequest searchRequest = SearchRequest.builder()
                .query(text) // 搜索文本
                .topK(2) // 只返回最相似的 2 条结果
                .similarityThreshold(0.1) // 只返回 0.1 以上相似度的文档,0表示完全不相似,1表示完全相似度
                .build();
        // 相似度搜索(最多找2条)
        return simpleVectorStore.similaritySearch(searchRequest);
    }

    @DeleteMapping("/delete/{id}")
    public String delete(@PathVariable("id") String id) {
        simpleVectorStore.delete(List.of(id));
        return "根据 ID 删除成功:" + id;
    }

    @GetMapping("/chat")
    public String chat(@RequestParam("msg") String msg) {
        // 首先使用 QuestionAnswerAdvisor 将用户消息转为向量
        // 然后再检索最相关的文档
        // 最后将检索到的内容作为上下文(Context)一起发给大模型
        return chatClient.prompt()
                .user(msg)
                .advisors(QuestionAnswerAdvisor.builder(simpleVectorStore).build())
                .call()
                .content();
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }

    /**
     * 对文档列表进行 Token 切分
     *
     * @param documents 文档列表
     * @return 切分后的文档列表
     */
    private List<Document> split(List<Document> documents) {
        return TokenTextSplitter.builder()
                .withChunkSize(50)
                .withMinChunkSizeChars(10)
                .build().apply(documents);
    }
}
  1. 访问控制器:
### load:"f32c0dbd-9d9b-4281-91ac-751f229eee3f","04b8d545-bb80-4a70-9e08-553a86af80f9"
POST http://localhost:13902/api/v1/simpleVectorStore/load

### add:"a0486370-9250-4ce1-8f7b-0ac75cf8ec52"
POST http://localhost:13902/api/v1/simpleVectorStore/add?
    text=小熊是粉色的

### search
GET http://localhost:13902/api/v1/simpleVectorStore/search?
    text=小熊是什么颜色的?

### delete
DELETE http://localhost:13902/api/v1/simpleVectorStore/delete/a0486370-9250-4ce1-8f7b-0ac75cf8ec52

### chat
GET http://localhost:13902/api/v1/simpleVectorStore/chat?
    msg=小熊是什么颜色的?

### chat
GET http://localhost:13902/api/v1/simpleVectorStore/chat?
    msg=小狗是什么颜色的?

### chat
GET http://localhost:13902/api/v1/simpleVectorStore/chat?
    msg=公司什么时间成立的?

2. 持久向量库RedisVectorStore

心法:RedisVectorStore 是基于 Redis Stack 向量模块实现的商用级 VectorStore 的实现,文档、向量、元数据统一持久存入 Redis 实例,服务重启数据不会丢失;依托 Redis 内存高速读写与向量索引能力,检索性能稳定,支持并发读写、分片扩容,适合中小型线上业务落地。

武技:测试使用 RedisVectorStore 进行向量存储。

  1. 开发配置类:
package com.joezhou.config;
import org.springframework.ai.embedding.EmbeddingModel;

/** @author 周航宇 */
@Configuration
public class RedisVectorStoreConfig {

	private final String REDIS_HOST = "192.168.40.77";
    private final int REDIS_PORT = 5379;
    private final String REDIS_PASSWORD = "joezhou";
    private final String REDIS_PREFIX = "doc:";

    @Bean
    public VectorStore redisVectorStore(EmbeddingModel embeddingModel) {

        // 创建独立 Redis Stack 连接
        JedisPooled jedisPooled = new JedisPooled(REDIS_HOST, REDIS_PORT, null, REDIS_PASSWORD);
        return RedisVectorStore.builder(jedisPooled, embeddingModel)
                .prefix(REDIS_PREFIX)
                .initializeSchema(true) // 自动创建索引
                .build();
    }
}
  1. 开发控制器:
package com.joezhou.controller;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/api/v1/redisVectorStore")
public class RedisVectorStoreController {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RedisVectorStoreController(ChatClient.Builder chatClientBuilder, @Qualifier("redisVectorStore") VectorStore vectorStore) {
        this.chatClient = chatClientBuilder
                .defaultSystem("""  
                        你只能根据给定的知识库内容回答问题。
                        如果知识库中没有相关信息,请直接回答“我不清楚”,不要编造内容。
                        回答要简洁明了,不要添加无关信息。
                        """)
                .build();
        this.vectorStore = vectorStore;
    }

    @PostMapping("/load")
    public List<String> load() {
        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = extract("rag/companyInfo.txt");
        log.info(">> 文档读取完成,共读取 {} 条文档", documents.size());
        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(documents);
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());
        // 存入向量数据库,这个过程会自动调用 embeddingModel 将文本变成向量再存入 RedisStack
        vectorStore.add(chunks);
        // 从 chunks 里直获取 ID 列表
        List<String> ids = chunks.stream().map(Document::getId).toList();
        System.out.println(">> 知识库添加成功,生成ID:" + ids);
        // TODO:将 chunks 的 ID 列表关联到 MySQL 表的字段
        // 直接返回所有 ID 列表
        return ids;
    }

    @PostMapping("/add")
    public List<String> add(@RequestParam("text") String text) {
        // 将传入的文本包装成 Document
        Document document = Document.builder().text(text).build();
        log.info(">> 文本包装成 Document 完成,文本内容: {}", document.getText());
        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = split(List.of(document));
        log.info(">> 文档切分完成,共切分 {} 条文档", chunks.size());
        // 存入向量数据库
        vectorStore.add(chunks);
        // 从 chunks 里直获取 ID 列表
        List<String> ids = chunks.stream().map(Document::getId).toList();
        System.out.println(">> 知识库添加成功,生成ID:" + ids);
        // TODO:将 chunks 的 ID 列表关联到 MySQL 表的字段
        // 直接返回所有 ID 列表
        return ids;
    }

    @GetMapping("/search")
    public List<Document> search(@RequestParam("text") String text) {
        // 构建 SearchRequest 对象
        SearchRequest searchRequest = SearchRequest.builder()
                .query(text) // 搜索文本
                .topK(2) // 只返回最相似的 2 条结果
                .similarityThreshold(0.1) // 只返回 0.1 以上相似度的文档,0表示完全不相似,1表示完全相似度
                .build();
        // 相似度搜索(最多找2条)
        return vectorStore.similaritySearch(searchRequest);
    }

    @DeleteMapping("/delete/{id}")
    public String delete(@PathVariable("id") String id) {
        vectorStore.delete(List.of(id));
        return "根据 ID 删除成功:" + id;
    }

    @GetMapping("/chat")
    public String chat(@RequestParam("question") String question) {
        // 首先使用 QuestionAnswerAdvisor 将用户消息转为向量
        // 然后再检索最相关的文档
        // 最后将检索到的内容作为上下文(Context)一起发给大模型
        return chatClient.prompt()
                .user(question)
                .advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
                .call()
                .content();
    }

    /**
     * 从指定文件路径读取文档
     *
     * @param filePath 文件路径
     * @return 文档列表
     */
    private List<Document> extract(String filePath) {
        // 使用 TikaDocumentReader 读取文档
        Resource resource = new ClassPathResource(filePath);
        return new TikaDocumentReader(resource).read();
    }

    /**
     * 对文档列表进行 Token 切分
     *
     * @param documents 文档列表
     * @return 切分后的文档列表
     */
    private List<Document> split(List<Document> documents) {
        return TokenTextSplitter.builder()
                .withChunkSize(50)
                .withMinChunkSizeChars(10)
                .build().apply(documents);
    }
}
  1. 访问控制器:
### load:"131fd47e-1b51-4e78-8a39-7822e05d845e", "f8f23ec3-7a4b-453d-b323-9df8d8e9d0d1"
POST http://localhost:13902/api/v1/redisVectorStore/load

### add:"f439a73d-5809-47dc-92e3-5fdf2cf5790d"
POST http://localhost:13902/api/v1/redisVectorStore/add?
    text=小熊是粉色的

### search
GET http://localhost:13902/api/v1/redisVectorStore/search?
    text=小熊是什么颜色的?

### delete
DELETE http://localhost:13902/api/v1/redisVectorStore/delete/f439a73d-5809-47dc-92e3-5fdf2cf5790d

### chat
GET http://localhost:13902/api/v1/redisVectorStore/chat?
    question=小熊是什么颜色的?

### chat
GET http://localhost:13902/api/v1/redisVectorStore/chat?
    question=小狗是什么颜色的?

### chat
GET http://localhost:13902/api/v1/redisVectorStore/chat?
    question=公司什么时间成立的?

S04. 工具调用

E01. 本地工具调用

心法:Tools/Function Calling 是介于基础对话与智能体之间的 AI 功能形态,它打破了 LLM 的静态知识局限,允许 AI 在遇到知识盲区时主动调用外部工具获取实时数据,从而从单纯的聊天机器人(文本生成)进化为能解决实际问题的智能代理(现实执行)。

场景理解:大模型在出厂时,学习了海量的公开互联网知识(比如什么是衬衫,巴黎在哪里等),但无法获取用户私有业务数据、本地实时数据,比如你向大模型提问:“我的 1 号房间中都有哪些衣服?”:

  • 未附加 ToolCalling 能力时:面对这种私有领域的问题,模型如果强行回答,就只能靠猜(产生幻觉),比如瞎编一件 “红色的毛衣”,这在实际应用中是灾难性的。
  • 已附加 ToolCalling 能力时:模型可识别自身知识边界,检测到项目内已注册 listClothes() 工具后(工具需要程序员开发并注册),自动提取入参 “1” 并发起调用,框架执行工具完成数据查询,模型再基于返回的真实结果,整理生成最终答复。

本地工具调用方案

方案描述场景推荐
Function CallSpring AI 早期版本的工具调用实现
现已被官方统一抽象为 Tool Calling,不再作为独立推荐方案
已被标记过时
不推荐新项目使用
Tool Calling统一了多模型、多类型工具的调用规范
提供更清晰的 @Tool,@ToolParam 等注解
提供了 ToolCallbackResolver,ToolExecutionAgent 等标准组件
支持外部接口、多工具并行调用,多工具链式调用,异常处理等企业级特性
目前唯一推荐方案

武技:创建 springai-tool-calling 子项目,并完成初始化工作。

  1. 添加三方依赖:
<dependencies>
	<!--spring-boot-starter-web-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<!--spring-ai-alibaba-starter-dashscope-->
	<dependency>
		<groupId>com.alibaba.cloud.ai</groupId>
		<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
	</dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13903 # 端口号

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY} # 阿里云百炼 API_KEY
      read-timeout: 100000 # 读取超时时间(毫秒)
      chat:
        options:
          model: qwen-plus # 基础对话模型
          max-tokens: 1000 # Token 限制
          temperature: 0.5 # 采样温度
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class ToolCallingApp {  
    public static void main(String[] args) {  
        SpringApplication.run(ToolCallingApp.class, args);  
    }  
}

1. ToolCalling

心法:SpringAI 框架会自动扫描标记了 @Tool 注解的的方法,并生成对应的,包含工具名、描述、入参 Schema 规则等信息的 Tool Definition 对象。

ToolCalling 标准流程

  • 工具注册:定义 Tool Definition 工具方法:
    • @Tool:用于将方法标记为可被大模型调用的工具。
    • @ToolParam:描述方法参数的含义,帮助大模型理解参数用途。
  • 请求发起:ChatRequest 携带 Tool Definition(含工具名、描述、入参 Schema)发送给大模型。
  • 模型决策:大模型分析问题,按需生成 “工具调用请求” 并返回给 SpringAI 框架。
  • 工具调用:SpringAI 框架通过 DispatchToolCallRequest 调度模块分发请求,执行对应工具。
  • 结果回传:工具执行完成,将结果返回给 SpringAI 框架。
  • 二次请求:SpringAI 框架将工具结果封装为模型能识别的对话消息,再次发送给大模型。
  • 响应返回:大模型基于工具结果生成最终回答,框架封装为 ChatResponse 返回用户。

ToolCalling 标准流程 - 图示

在这里插入图片描述

武技:开发并测试一个用于获取今天的天气的工具。

  1. 开发工具类:
package com.joezhou.tools;

/** @author 周航宇 */
@Slf4j
public class WeatherTool {

	@Tool(description = "根据城市名称返回该城市今天的天气")
    String getWeather(@ToolParam(description = "城市名称") String cityName) {
        String result;
        log.info(">> 调用工具:getWeather(" + cityName + ")");
        if ("上海".equals(cityName)) {
            result = "今天上海的天气是:晴转多云,再转大雨,再转冰雹,1103摄氏度";
        } else if ("北京".equals(cityName)) {
            result = "今天北京的天气是:多云转晴,再转小雨,再转闪电,1104摄氏度。";
        } else {
            result = "不知道";
        }
        log.info(">> 生成结果:" + result);
        return result;
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/toolCalling")
@RestController
@CrossOrigin
public class ToolCallingController {

    private final ChatClient chatClient;

    public ToolCallingController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
                .defaultTools(new WeatherTool())
                .build();
    }
    
    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg)
                .call()
                .content();
    }
}
  1. 测试控制器:
### call
GET http://localhost:13903/api/v1/toolCalling/call?
	msg=上海天气如何

### call
GET http://localhost:13903/api/v1/toolCalling/call?
	msg=北京天气如何

### call
GET http://localhost:13903/api/v1/toolCalling/call?
	msg=哈尔滨天气如何

E02. 模型上下文协议

心法:MCP 的全称是 Model Context Protocol,模型上下文协议,来自 Anthropic 公司,基于 ToolCalling 技术,旨在标准化 “AI 应用” 和 “外部工具/外部数据源” 之间的交互,彻底解决 “M × N 组合爆炸” 的问题。

M × N 场景示例:假设你是一家跨国公司的 IT 负责人,你需要让公司里的 2 名员工(大模型)A 和 B,分别去和 2 位外部供应商(工具)X 和 Y 洽谈业务,且:

  • 员工 A:只会说中文(比如 DeepSeek)
  • 员工 B:只会说英文(比如 QWen)
  • 供应商 X:只懂法语(比如墨迹天气)
  • 供应商 Y:只懂日语(比如高德地图)

不用 MCP 之前:你需要做如下 4 件事(写代码):

  • 为了让人 A 和 X 沟通,你得雇一个 “中翻法” 的翻译。
  • 为了让人 A 和 Y 沟通,你得雇一个 “中翻日” 的翻译。
  • 为了让人 B 和 X 沟通,你得雇一个 “英翻法” 的翻译。
  • 为了让人 B 和 Y 沟通,你得雇一个 “英翻日” 的翻译。

你只有 2 个人和 2 个工具,却要维护 2 × 2 = 4 个翻译官(适配代码),若明天来了新员工或新的供应商,则需要瞬间增加 3 个新的翻译官。

使用 MCP 之后:你只需要做如下 2 件事:

  • 给所有员工发一个普通话耳机(MCP Client),员工 A 说话时自动翻译为普通话。
  • 强制要求所有供应商必须配一个普通话翻译官(MCP Server),翻译官可以将普通话翻译成它们的母语并讲给供应商听。

此后,不管你有 100 个员工还是 100 个供应商,你都不需要再写那些乱七八糟的 “一对一” 翻译代码了。

总结:MCP 就是 AI 世界的通用普通话,让所有大模型和所有外部工具,不用两两学方言,全员说统一语言,自由互通。

1. MCP核心组件

心法:MCP 由三大核心组件构成:主机(Host)、客户端(Client)、服务端(Server),三者各司其职,通过标准协议实现 AI 应用与外部工具的高效互通。

MCP 核心组件

  • 主机 Host:它是基于 MCP 协议来调用工具的应用程序,比如你的 SpringAI 项目,Claude Code 这类的大模型客户端等。
  • 客户端 Client:它是主机的 “翻译官”,负责对接主机和服务端,以及按 MCP 协议规范来完成请求封装与响应解析。
  • 服务端 Server:它是基于 MCP 协议来对外提供工具的应用程序,比如墨迹天气厂商,本地文件服务,数据库查询服务等。

MCP 工作流程 - 图示

在这里插入图片描述

2. 开发本地MCP服务端

武技:创建 springai-mcp-server 子项目,并改造为 MCP 服务端项目。

  1. 添加三方依赖:
<dependencies>
	<!--spring-ai-starter-mcp-server-webflux-->
	<dependency>
		<groupId>org.springframework.ai</groupId>
		<artifactId>spring-ai-starter-mcp-server-webflux</artifactId>
	</dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13904 # 端口号
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class MCPServerApp {  
    public static void main(String[] args) {  
        SpringApplication.run(MCPServerApp.class, args);  
    }  
}
  1. 开发工具类:
package com.joezhou.tool;

/** @author 周航宇 */
@Slf4j
@Component
public class ClothesTool {

    // 模拟数据库中的衣服数据,分别存于 3 个房间
    private final static List<Map<String, Object>> CLOTHES = List.of(
            Map.of("roomId", 1L, "name", "T恤", "color", "白色"),
            Map.of("roomId", 1L, "name", "衬衫", "color", "黑色"),
            Map.of("roomId", 1L, "name", "短裤", "color", "白色"),
            Map.of("roomId", 1L, "name", "长裤", "color", "白色"),
            Map.of("roomId", 1L, "name", "牛仔裤", "color", "白色"),
            Map.of("roomId", 2L, "name", "帽衫", "color", "蓝色"),
            Map.of("roomId", 2L, "name", "帽衫", "color", "红色"),
            Map.of("roomId", 2L, "name", "帽衫", "color", "黄色"),
            Map.of("roomId", 2L, "name", "卫衣", "color", "蓝色"),
            Map.of("roomId", 2L, "name", "羽绒服", "color", "蓝色"),
            Map.of("roomId", 3L, "name", "呢子大衣", "color", "蓝色"),
            Map.of("roomId", 3L, "name", "T恤", "color", "绿色"),
            Map.of("roomId", 3L, "name", "衬衫", "color", "粉色"),
            Map.of("roomId", 3L, "name", "连体裤", "color", "白色"),
            Map.of("roomId", 3L, "name", "开衫", "color", "黄色")
    );

    @Tool(description = "根据房间ID获取该房间内的全部衣服")
    public List<Map<String, Object>> listClothesByRoomId(@ToolParam(description = "房间ID") Long roomId) {
        log.info("执行工具调用:listClothesByRoomId({})", roomId);
        // 查询该房间内的全部衣服数据
        List<Map<String, Object>> result = CLOTHES.stream()
                .filter(clothes -> clothes.get("roomId").equals(roomId))
                .toList();
        log.info("工具调用结果:{}", result);
        return result;
    }
}
  1. 注册工具类:
package com.joezhou.config;

/** @author 周航宇 */
@Configuration
public class ClothesToolConfig {

    @Bean
    public ToolCallbackProvider toolCallbackProvider(ClothesTool clothesTool) {
        // 注册衣服工具
        return MethodToolCallbackProvider.builder()
                .toolObjects(clothesTool)
                .build();
    }
}
  1. 启动 MCP 服务端项目,查看控制台,工具是否注册成功:

在这里插入图片描述

  1. 通过 curl 访问服务端项目:
curl http://localhost:13904/sse

结果如下

在这里插入图片描述

3. 开发本地MCP客户端

武技:创建 springai-mcp-client 子项目,并改造为 MCP 客户端项目。

  1. 添加三方依赖:
<dependencies>
    <!--spring-boot-starter-web-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--spring-ai-alibaba-starter-dashscope-->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
    <!--spring-ai-starter-mcp-client-webflux-->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
    </dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13905 # 端口号
  servlet:
    encoding:
      charset: utf-8 # 字符集(解决 stream 中文乱码)
      enabled: true # 启用字符编码(解决 stream 中文乱码)
      force: true # 强制使用字符编码(解决 stream 中文乱码)
  tomcat:
    threads:
      max: 200 # 默认可能只有 10 或 50,加大到 200

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY} # 阿里云百炼 API_KEY
      read-timeout: 100000 # 读取超时时间(毫秒)
      chat:
        options:
          model: qwen-plus # 基础对话模型
          max-tokens: 1000 # Token 限制
          temperature: 0.5 # 采样温度
    mcp:
      client:
        enabled: true # 启用 MCP 客户端
        request-timeout: 200000 # 请求超时时间(毫秒)
        toolcallback:
          enabled: true # 启用工具回调
        name: springai-mcp-client # 客户端名称
        sse:
          connections:
            my-server01:
              url: http://localhost:13904 # MCP 服务器地址

logging:
  level:
    org.springframework.ai: DEBUG # 调试日志
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class MCPClientApp {  
    public static void main(String[] args) {  
        SpringApplication.run(MCPClientApp.class, args);  
    }  
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/mcp")
@RestController
@CrossOrigin
public class MCPController {

    private final ChatClient chatClient;

    public MCPController(ChatClient.Builder chatClientBuilder, ToolCallbackProvider toolCallbackProvider) {
        this.chatClient = chatClientBuilder
                // 注册工具回调
                .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks())
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg)
                .call()
                .content();
    }
}
  1. 测试控制器:
### call
GET http://localhost:13905/api/v1/mcp/call?
    msg=1号房间都有哪些衣服?
  1. 观察控制台是否成功调用了本地 MCP Server 的工具:

在这里插入图片描述

4. 接入高德MCP服务端

心法:高德地图 MCP Server 现已覆盖很多核心服务接口,提供全场景覆盖的地图服务。

高德 MCP 服务端常用接口

服务接口输入内容输出内容
地理编码省市区及完整详细地址精准经纬度坐标
逆地理编码经纬度坐标省市区层级完整详细地址
IP 定位网络 IP 地址所属省份、城市归属地
天气查询城市名称城市实时及预报天气数据
骑行路径规划起点经纬度、终点经纬度出行距离、预计时长、分段骑行路线步骤
步行路径规划起点经纬度、终点经纬度起止点位信息、完整步行路线详情
驾车路径规划起点经纬度、终点经纬度起止点位信息、最优驾车出行路线方案
公交路径规划起点经纬度、终点经纬度、出发城市、目的城市出行距离、起止点位信息、公共交通换乘出行方案
距离测量起点经纬度、终点经纬度点位基础信息、实际通行距离、出行预估时长
周边搜索搜索关键词、中心位置经纬度、可选搜索范围半径指定范围内全部兴趣点 POI 相关信息

武技:参考 高德开放平台教程,在 springai-mcp-client 子项目中接入高德地图 MCP 服务端。

  1. 登录 高德开放平台控制台
    1. 如果没有开发者账号,请 注册成为开发者,然后重新进入控制台。
    2. 进入【应用管理】,点击页面右上角【创建新应用】,填写表单即可创建新的应用(随意填写)。
    3. 进入【应用管理】,在我的应用中选择需要创建 Key 的应用,点击【添加 Key】,表单中的服务平台选择【Web 服务】。
    4. 创建成功后,可获取 Key 和安全密钥(妥善保存 Key 值)。
  2. 登录 魔搭 MCP 广场,选择高德地图,在右侧【stido】卡片中复制 JSON 内容,如下:

在这里插入图片描述

  1. 在 JSON 中将 “npx” 替换为绝对路径如 “D:\node\nodejs\npx.cmd” 等,若不知道 npx.cmd 安装位置,可以使用如下命令查询(前提是系统中安装了 node 服务器):
# 查询 npx.cmd 安装位置
where npx
  1. 在 springai-mcp-client 子项目中开发配置文件:

mcp-servers-configuration.json:名称随意:

{
  "mcpServers": {
    "amap-maps": {
      "args": [
        "-y",
        "@amap/amap-maps-mcp-server"
      ],
      "command": "D:\\node\\nodejs\\npx.cmd",
      "env": {
        "AMAP_MAPS_API_KEY": "这里需要粘贴你的高德Key值"
      }
    }
  }
}

application.yml:补充一条 spring.ai.mcp.client.stdio.servers-configuration 配置即可:

...

spring:
  ai:
    dashscope:
      ...
    mcp:
      client:
        ...
        stdio:
          servers-configuration: classpath:mcp-servers-configuration.json # MCP 服务器配置文件
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RequestMapping("/api/v1/amap")
@RestController
@CrossOrigin
public class AmapController {

    private final ChatClient chatClient;

    public AmapController(ChatClient.Builder chatClientBuilder, ToolCallbackProvider toolCallbackProvider) {
        this.chatClient = chatClientBuilder
                // 注册工具回调
                .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks())
                .build();
    }

    @GetMapping("call")
    public String call(@RequestParam("msg") String msg) {
        return chatClient.prompt()
                .user(msg)
                .call()
                .content();
    }
}
  1. 启动项目,观察控制台是否成功接入高德 MCP 工具:

在这里插入图片描述

  1. 测试控制器:
### 测试1:查看控制台日志,是否成功调用 maps_weather 工具
GET http://localhost:13905/api/v1/amap/call?
    msg=查询哈尔滨今日的天气?

### 测试2:查看控制台日志,是否成功调用 maps_geo 工具
GET http://localhost:13905/api/v1/amap/call?
    msg=哈尔滨恒隆华府小区的经纬度坐标是多少?
  1. 观察控制台,是否成功调用了高德 MCP Server 的工具:

在这里插入图片描述

S05. 任务编排

心法:Spring Ai Alibaba Graph 是一个声明式的工作流编排引擎,就像是 AI 应用的 “流程图工具”,让你不用写一堆代码,就能把复杂的 AI 流程给串起来。

场景示例:假设你希望大模型帮忙规划周末出游计划:

步骤简述描述
1发送需求你对大模型说:“周末想带家人出去玩,预算 1000 元,不要太累,最好有吃有玩”
2需求理解大模型将拆解你的需求:包括人数、预算、偏好、时间限制等
大模型将拆解的结果存储到【状态】中
3地点筛选大模型根据你的偏好,筛选出符合预算和距离的 3 个备选地点(A, B, C)
大模型将筛选的结果更新到【状态】中
4方案对比大模型分别生成每个地点的行程:几点出发、玩什么、吃什么、预计花费
大模型对比哪个更适合带孩子或老人,给你列好优缺点
5人工决策大模型把几套方案发给你,你可以直接选择 “方案 A”
或者回复 “想换个海边的选项”
或者回复 “再帮我看看雨天的备选”
6分支执行若你选了方案 A,系统自动帮你查实时路况、生成路线地图,发你一份可直接用的行程单
若你说 “换雨天备选”,流程就会回到 “地点筛选”节点,重新找室内景点,再生成新方案
若你说 “预算不够,改 500 以内”,系统会带着你的新需求,重新跑一遍筛选和对比步骤

这整个过程不是一步就能完成的单次对话,而是 多步骤、有状态、可分支、可循环 的流程:

  • 中间每一步的结果(比如你选了哪个方案、改了什么需求),都要传递到下一步用。
  • 你随时可以修改需求,让流程 “倒回去” 重新执行,甚至根据你的选择走完全不同的分支。

如果不用 Graph,你得写一堆硬编码控制流程跳转、保存中间结果、处理分支逻辑,代码又乱又难维护。

Spring AI Alibaba Graph 三大核心模型

术语中文在工作流中的作用详述
State状态跨节点传递和共享数据的上下文容器统一管理全流程上下文,确保数据在节点间安全一致地流转
Node节点一个独立的,原子的执行单元承载具体的业务逻辑,是流程的 “执行者”:

如大模型模型调用,外部 API 调用等
如数据库操作,执行自定义业务代码等
Edge定义节点之间的连接关系
定义节点之间的流转方向
控制某个流程的执行路径
是实现分支、循环等复杂流程逻辑的关键,具体分为两类:

直接边:固定顺序流转
条件边:基于 State 数据动态路由(动态决定具体路径)

对应上文的 “周末出游的场景”

核心概念在出游场景中的角色场景示例
State(状态)全程跟着你走的 “出游数据档案袋”里面装着你的预算、出行偏好、备选地点、最终选定的方案
甚至临时修改的雨天备选需求,全程在各个环节间传递共享
Node(节点)流程里的每一个 “办事窗口”每个窗口只做一件事:比如

理解你的需求 → 筛选合理地点 → 生成行程方案 →
等待你的确认 → 生成最终行程单
Edge(边)决定下一步走哪条路的 “指引箭头”若你确认了方案:箭头直接指向 “生成最终行程单”
若你要修改需求:箭头绕回 “重新筛选地点”
若你一直不满意:箭头循环触发 “重新生成行程方案”

武技:创建 springai-graph 子项目,并完成初始化工作。

  1. 添加三方依赖:
<dependencies>
    <!--spring-boot-starter-web-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--spring-ai-alibaba-starter-dashscope-->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
    <!--spring-ai-alibaba-graph-core-->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-graph-core</artifactId>
    </dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13906 # 端口号
  servlet:
    encoding:
      charset: utf-8 # 字符集(解决 stream 中文乱码)
      enabled: true # 启用字符编码(解决 stream 中文乱码)
      force: true # 强制使用字符编码(解决 stream 中文乱码)

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY} # 阿里云百炼 API_KEY
      read-timeout: 100000 # 读取超时时间(毫秒)
      chat:
        options:
          model: qwen-plus # 基础对话模型
          max-tokens: 1000 # Token 限制
          temperature: 0.5 # 采样温度

logging:
  level:
    org.springframework.ai: DEBUG # 调试日志
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class GraphApp {  
    public static void main(String[] args) {  
        SpringApplication.run(GraphApp.class, args);  
    }  
}

E01. Graph基础编排

心法:SpringAiAlibabaGraph 的基础流转过程包括创建 StateGraph 图对象(包括状态策略,状态值,节点,边等),编译图(将 StateGraph 编译为 CompiledGraph 对象)和调用图(invoke)三部分。

基础流转的相关代码:Alibaba 不允许在图中直接使用同步节点,同步节点必须异步包装,避免阻塞主线程:

步骤简述描述示例(目标值和初始值均为 Object 类型)
1创建状态策略支持定义替换,追加或合并三种策略KeyStrategy strategy = new ReplaceStrategy()
2创建策略工厂定义状态变量,并绑定对应的更新策略KeyStrategyFactory ksf = () -> Map.of("key", strategy)
3创建状态图创建未编译的状态图,支持动态修改StateGraph graph = new StateGraph("graphId", ksf)
4创建同步节点创建同步节点并编写节点业务逻辑NodeAction n = state -> Map.of("key", 目标值)
5包装异步节点使用异步包装器将同步节点异步化AsyncNodeAction aNode = AsyncNodeAction.node_async(n)
6注册异步节点将异步节点注册到状态图中graph.addNode("节点ID", asyncNode)
7添加边定义节点之间的执行流向graph.addEdge("起始节点ID", "目标节点ID")
8生成编译图编译状态图为可运行但不可修改的对象CompiledGraph compiledGraph = graph.compile()
9调用编译图传入初始状态,启动并执行整个图流程compiledGraph.invoke(Map.of("键", 初始值))

StateGraph 内置节点:钩子不会作用于 START、END、ERROR 节点自身:

使用方式类型简述
StateGraph.START__START__接口常量字符串流程默认起点,流程发起首节点,必须使用
StateGraph.END__END__接口常量字符串正常流程收尾终点,业务链路建议收尾至此
StateGraph.ERROR__ERROR__接口常量字符串捕获流程异常,统一异常处理入口
StateGraph.NODE_BEFORE__NODE_BEFORE__接口常量字符串自定义业务节点执行前,自动触发执行的钩子
StateGraph.NODE_AFTER__NODE_AFTER__接口常量字符串自定义业务节点执行后,自动触发执行的钩子

武技:开发一个 Graph 基础流转的测试代码。

  1. 开发配置类:
package com.joezhou.config;

/**  @author 周航宇 */
@Slf4j
@Configuration
public class HelloGraphConfig {

    @SneakyThrows
    @Bean("helloGraph")
    public CompiledGraph helloGraph() {

        // 创建状态变量 a,采用替换策略(新值会替换旧值,旧值会被丢弃)
        KeyStrategyFactory keyStrategyFactory = () -> Map.of("a", new ReplaceStrategy());

        // 创建图,命名为 helloGraph
        StateGraph graph = new StateGraph("helloGraph", keyStrategyFactory);

        // 创建异步节点 nodeA
        graph.addNode("nodeA", AsyncNodeAction.node_async(state -> {
            log.info("节点 A 中获取到的全局状态 OverAllState: {}", state);
            // 将状态变量 a 设置为 "1"
            return Map.of("a", "1");
        }));

        // 创建异步节点 nodeB
        graph.addNode("nodeB", AsyncNodeAction.node_async(state -> {
            log.info("节点 B 中获取到的全局状态 OverAllState: {}", state);
            // 将状态变量 a 设置为 "2"
            return Map.of("a", "2");
        }));

        // 创建边
        graph.addEdge(StateGraph.START, "nodeA");
        graph.addEdge("nodeA", "nodeB");
        graph.addEdge("nodeB", StateGraph.END);

        // 编译图(生成可执行的图,此时图的结构和节点行为会被固定,无法再修改)
        CompiledGraph compiledGraph = graph.compile();

        // 返回编译后的图
        return compiledGraph;
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/helloGraph")
@RestController
@CrossOrigin
@SuppressWarnings("all")
public class HelloGraphController {

    private final CompiledGraph helloGraph;

    public HelloGraphController(@Qualifier("helloGraph") CompiledGraph helloGraph) {
        // 注入编译好的 Graph 实例,用于执行流程
        this.helloGraph = helloGraph;
    }

    @GetMapping("exe")
    public Map<String, Object> exe() {
        // 执行图流程
        // Map 参数表示状态变量的起始值,可以为空,如直接传递 Map.of()
        Optional<OverAllState> optional = helloGraph.invoke(Map.of("a", "0"));
        log.info("最终的全局状态 OverAllState: {}", optional.get());
        // 返回数据 Map
        return optional.map(OverAllState::data).orElseThrow();
    }
}
  1. 测试控制器:
### exe  
GET http://localhost:13906/api/v1/helloGraph/exe

控制台结果如图

在这里插入图片描述

1. SpringAiAlibabaStudio

心法:Spring AI Alibaba Studio 简称 SAA-Studio 是内置 Web UI 的嵌入式本地开发调试工具,引入对应依赖并开启开关后,项目启动即自动托管前端静态资源与配套后端接口,开发者通过浏览器访问内置页面,可视化调试项目内所有注册的 Graph 工作流,Agent、RAG 检索、模型交互流程,大幅降低 AI 智能体迭代调试成本。

SAA-Studio 使用原理:内置 AgentLoader 自动扫描 Spring 容器中所有实现 com.alibaba.cloud.ai.agent.Agent 顶层接口的 Bean:

  • 支持官方内置实现:包括 CompiledGraph,ReactAgent,MultiAgent,LlmRoutingAgent 等。
  • 支持开发者自定义实现 Agent 接口的业务智能体。
  • 原生 ChatClient 不属于 Agent 体系,无法被 Studio 自动识别。

SAA-Studio 注意事项:Spring AI Alibaba Studio 不支持独立分布式部署,仅服务本地开发环境,生产环境建议移除该依赖。

武技:在 springai-graph 子项目中引入 SSA-Studio 工具。

  1. 添加 SSA-Studio 相关依赖:这里使用 1.1.2.2 版本,1.1.2.0 版本有 BUG:
<!--spring-ai-alibaba-studio-->
<dependency>
	<groupId>com.alibaba.cloud.ai</groupId>
	<artifactId>spring-ai-alibaba-studio</artifactId>
	<version>${spring-ai-alibaba-studio.version}</version>
</dependency>
  1. 重新启动 springai-graph 项目。
  2. 通过 SAA-Studio 查看 Graph 流程:首先选择 helloGraph 进入对话,然后随意输入内容并点击发送,即可看到图流程:

在这里插入图片描述

2. KeyStrategyFactory

心法:KeyStrategyFactory 接口是一个函数式接口,用来定义状态变量以及对应的更新策略。

状态更新策略

状态更新策略简述描述
new ReplaceStrategy()替换策略新值替换旧值,适合任何类型的数据,生产环境推荐使用
new MergeStrategy()合并策略适合 Map 类型的数据,新旧 Map 的数据合并(相同会覆盖)
new AppendStrategy()追加策略适合 List 类型的数据,新 List 的数据追加到旧的 List 中

武技:测试 Graph 状态的三种策略。

  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Slf4j
@Configuration
public class KeyStrategyGraphConfig {

    @SneakyThrows
    @Bean("keyStrategyGraph")
    public CompiledGraph keyStrategyGraph() {

        // 创建状态变量 a, b, c,分别采用替换、合并、追加策略
        KeyStrategyFactory keyStrategyFactory = () -> {
            Map<String, KeyStrategy> map = new HashMap<>(3);
            map.put("a", new ReplaceStrategy());
            map.put("b", new MergeStrategy());
            map.put("c", new AppendStrategy());
            return map;
        };

        // 创建图
        StateGraph graph = new StateGraph("keyStrategyGraph", keyStrategyFactory);

        // 创建异步节点 nodeA
        graph.addNode("nodeA", AsyncNodeAction.node_async(state -> {
            log.info("节点 A 中获取到的全局状态 OverAllState: {}", state);
            Map<String, Object> map = new HashMap<>(3);
            map.put("a", "1");
            map.put("b", Map.of("age", 19));
            map.put("c", List.of("喝酒"));
            return map;
        }));

        // 创建异步节点 nodeB
        graph.addNode("nodeB", AsyncNodeAction.node_async(state -> {
            log.info("节点 B 中获取到的全局状态 OverAllState: {}", state);
            Map<String, Object> map = new HashMap<>(3);
            map.put("a", "2");
            map.put("b", Map.of("gender", "男"));
            map.put("c", List.of("烫头"));
            return map;
        }));

        // 创建边
        graph.addEdge(StateGraph.START, "nodeA");
        graph.addEdge("nodeA", "nodeB");
        graph.addEdge("nodeB", StateGraph.END);

        // 编译图  
		return graph.compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/keyStrategy")
@RestController
@CrossOrigin
@SuppressWarnings("all")
public class KeyStrategyController {

    private final CompiledGraph keyStrategyGraph;

    public KeyStrategyController(@Qualifier("keyStrategyGraph") CompiledGraph keyStrategyGraph) {
        // 注入编译好的 Graph 实例,用于执行流程
        this.keyStrategyGraph = keyStrategyGraph;
    }

    @GetMapping("exe")
    public Map<String, Object> exe() {
        // 执行图流程
        Optional<OverAllState> optional = keyStrategyGraph.invoke(Map.of(
                "a", "0",
                "b", Map.of("name", "赵四"),
                "c", List.of("抽烟")
        ));
        log.info("最终的全局状态 OverAllState: {}", optional.get());
        // 返回数据 Map
        return optional.map(OverAllState::data).orElseThrow();
    }
}
  1. 测试控制器:
### exe  
GET http://localhost:13906/api/v1/keyStrategy/exe

控制台结果如图

在这里插入图片描述

3. NodeAction

心法:NodeAction 是 Spring AI Alibaba Graph 中节点执行逻辑的核心函数式接口,统一约定了流程节点的标准契约:“接收状态变量 → 处理逻辑 → 返回新状态变量”。

AsyncNodeAction:是官方推荐的异步包装器,用于将普通同步逻辑封装为 Graph 可识别的异步节点,事实上官方不推荐也不允许直接添加原生 NodeAction 节点,而是必须通过 AsyncNodeAction.node_async(...) 包装后才能使用。

自定义节点:开发者可通过实现 NodeAction 接口定义可复用的业务节点,实现逻辑解耦与复用,然后再使用 AsyncNodeAction 进行异步包装。

武技:测试自定义节点。

  1. 开发自定义节点类:
package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class MyCustomNode implements NodeAction {

    @SneakyThrows
    @Override
    public Map<String, Object> apply(OverAllState state) {
		log.info(">> MyCustomNode:获取到当前状态:{}", state);  
		
		// 获取状态变量 username 的值,默认值为空字符串  
		String username = state.value("username", "");  
		log.info(">> MyCustomNode:获取到 username 的值:{}", username);
		
		// 执行业务逻辑,将 username 转换为大写  
		username = username.toUpperCase();  
		log.info(">> MyCustomNode:修改了 username 的值:{}", username);
		 
		// 返回新状态  
		return Map.of("username", username);
    }
}
  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Slf4j
@Configuration
public class CustomNodeConfig {

    @SneakyThrows
    @Bean("customNodeGraph")
    public CompiledGraph customNodeGraph() {
        // 创建状态
        KeyStrategyFactory keyStrategyFactory = () -> Map.of("username", new ReplaceStrategy());
        // 创建图
        return new StateGraph("customNodeGraph", keyStrategyFactory)
                .addNode("customNode", AsyncNodeAction.node_async(new MyCustomNode()))
                .addEdge(StateGraph.START, "customNode")
                .addEdge("customNode", StateGraph.END)
                .compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/customNode")
@RestController
@CrossOrigin
@SuppressWarnings("all")
public class CustomNodeController {

    private final CompiledGraph customNodeGraph;

    public CustomNodeController(@Qualifier("customNodeGraph") CompiledGraph customNodeGraph) {
        this.customNodeGraph = customNodeGraph;
    }

    @GetMapping("exe")
    public Map<String, Object> exe() {
        // 执行图流程
        Optional<OverAllState> optional = customNodeGraph.invoke(Map.of("username", "joezhou"));
        log.info("最终的全局状态 OverAllState: {}", optional.get());
        // 返回数据 Map
        return optional.map(OverAllState::data).orElseThrow();
    }
}
  1. 测试控制器:
### exe
GET http://localhost:13906/api/v1/customNode/exe

控制台结果如图

在这里插入图片描述

4. 状态数据隔离

心法:和之前的会话隔离原理一样,状态变量也必须做用户隔离,即每个用户只能查看、修改自己独有的状态数据,不能访问或修改别人的,保证数据安全、互不干扰。

解决方案

步骤描述
1前端接收会话 ID
2将会话 ID 传入 RunnableConfig 对象
3调用编译图对象的 invoke() 方法时,传入 RunnableConfig 对象

具体实现如下

// 创建会话配置
RunnableConfig runnableConfig = RunnableConfig.builder()
		.threadId(conversationId)
		.build();
        
// 执行图流程
Optional<OverAllState> optional = conversationGraph.invoke(
		Map.of(),
		runnableConfig);

武技:通过配置不同的 threadId 模拟多个用户,验证各自的状态变量是否独立存储、互不影响,确保隔离功能正常生效。

  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Slf4j
@Configuration
public class ConversationConfig {

    @SneakyThrows
    @Bean("conversationGraph")
    public CompiledGraph conversationGraph() {

        // 创建状态
        KeyStrategyFactory keyStrategyFactory = () -> Map.of("a", new ReplaceStrategy());

        // 创建图
        StateGraph graph = new StateGraph("conversationGraph", keyStrategyFactory)
                .addNode("conversationNode", AsyncNodeAction.node_async(state -> {
                    // 让 a 自增
                    int a = Integer.parseInt(state.value("a", "0"));
                    a++;
                    return Map.of("a", a + "");
                }))
                .addEdge(StateGraph.START, "conversationNode")
                .addEdge("conversationNode", StateGraph.END);

        // 编译图
        return graph.compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/conversation")
@RestController
@CrossOrigin
@SuppressWarnings("all")
public class ConversationController {

    private final CompiledGraph conversationGraph;

    public ConversationController(@Qualifier("conversationGraph") CompiledGraph conversationGraph) {
        this.conversationGraph = conversationGraph;
    }

    @GetMapping("exe/{conversationId}")
    public Map<String, String> exe(@PathVariable("conversationId") String conversationId) {

        // 创建会话配置
        RunnableConfig runnableConfig = RunnableConfig.builder()
                .threadId(conversationId)
                .build();

        // 执行图流程
        Optional<OverAllState> optional = conversationGraph.invoke(
                Map.of(),
                runnableConfig);

        // 获取数据中的 a 值
        String value = optional.orElseThrow().value("a", "暂无数据");

        // 返回会话 ID 和对应会话中的 a 值
        return Map.of(conversationId, value);
    }
}
  1. 测试控制器:
### exe
GET http://localhost:13906/api/v1/conversation/exe/1001

### exe
GET http://localhost:13906/api/v1/conversation/exe/1002

E02. Graph流程走向

心法:搭建 AI 工作流与智能 Agent 时,业务流程并非单一顺序执行,常会依据数据状态、用户反馈、逻辑判定产生分支走向,或是循环往复执行,而 边() 正是实现这类流程逻辑的核心。

  • 直接边:按既定顺序串联流程节点,实现任务线性执行。
  • 条件边:依托条件判断完成分支跳转与流程分流,根据实际业务场景自动匹配执行路径。
  • 循环边:针对重复作业、多轮核验、连续交互等场景,驱动流程循环运行,直至满足终止条件后自动退出。

1. 直接边-智能回复

心法:本案例通过 5 个节点实现商品评价智能分析流水线:提取关键词 → 情感判定 → 生成摘要 → 客服回复 → 最终报告。

业务流程:用户发布一条商品的评价信息,AI 自动完成以下工作:

在这里插入图片描述

武技:开发智能回复用户评论的案例代码。

  1. 开发 5 个自定义节点:

KeyWordNode(关键词提取)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class KeyWordNode implements NodeAction {
    private final ChatClient chatClient;

    public KeyWordNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }
    
    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String input = state.value("input", "");

        // 调用大模型,提取评论的关键词
        String content = chatClient.prompt()
                .user("""
                        根据评论内容 %s,提取出关键词,关键词之间用逗号隔开即可;
                        只返回最终提取的关键词内容即可,不要返回其他无关的内容。
                        """.formatted(input))
                .call()
                .content()
                .trim();
        log.info(">> Node01:提取到评价关键词:{}", content);

        // 更新状态变量
        return Map.of("keywords", content);
    }
}

SentimentNode(情感分析)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class SentimentNode implements NodeAction {
    private final ChatClient chatClient;

    public SentimentNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        // 获取状态变量(默认空串)
        String input = state.value("input", "");

        // 调用大模型,判断评论的情感
        String content = chatClient.prompt()
                .user("""
                        根据评论内容 %s,判断出评价情感;
                        只返回 “好评”、“中评”、“差评” 三者之一即可,不要返回其他无关的内容。
                        """.formatted(input))
                .call()
                .content()
                .trim();
        log.info(">> Node02:判断出评价情感:{}", content);
        
        // 更新状态变量
        return Map.of("sentiment", content);
    }
}

SummaryNode(评价摘要)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class SummaryNode implements NodeAction {
    private final ChatClient chatClient;

    public SummaryNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String input = state.value("input", "");

        // 调用大模型,生成评论的摘要
        String content = chatClient.prompt()
                .user("""
                        根据评论内容 %s,进行一句话的总结;
                        只返回最终提取的总结内容即可,不要返回其他无关的内容。
                        """.formatted(input))
                .call()
                .content()
                .trim();
        log.info(">> Node03:生成了评价摘要:{}", content);

        // 更新状态变量
        return Map.of("summary", content);
    }
}

ReplyNode(客服回复)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class ReplyNode implements NodeAction {
    private final ChatClient chatClient;

    public ReplyNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String input = state.value("input", "");

        // 调用大模型,生成客服回复
        String content = chatClient.prompt()
                .user("""
                        你是一个专业的客服,负责回复评价;
                        根据评论内容 %s,生成礼貌回复;
                        只返回最终回复内容即可,不要返回其他无关的内容。
                        """.formatted(input))
                .call()
                .content()
                .trim();
        log.info(">> Node04:生成了客服回复:{}", content);

        // 更新状态变量
        return Map.of("reply", content);
    }
}

ReportNode(最终报告)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class ReportNode implements NodeAction {

    @Override
    public Map<String, Object> apply(OverAllState state) {
        
        // 获取状态变量(默认空串)
        String input = state.value("input", "");
        String keywords = state.value("keywords", "");
        String sentiment = state.value("sentiment", "");
        String summary = state.value("summary", "");
        String reply = state.value("reply", "");
        
        // 生成评价分析报告
        String report = """
                ====================
                【原始评价】:%s
                【关键词语】:%s
                【情感评级】:%s
                【摘要信息】:%s
                【建议回复】:%s
                ====================
                """.formatted(input, keywords, sentiment, summary, reply);
        log.info(">> Node05:生成了评价分析报告:\n{}", report);
        
        // 更新状态变量
        return Map.of("report", report);
    }
}
  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Configuration
public class UserCommentConfig {

    @SneakyThrows
    @Bean("commentGraph")
    public CompiledGraph commentGraph(ChatClient.Builder chatClientBuilder) {

        // 创建状态
        KeyStrategyFactory keyStrategyFactory = () -> Map.of(
                "input", new ReplaceStrategy(),
                "keywords", new ReplaceStrategy(),
                "sentiment", new ReplaceStrategy(),
                "summary", new ReplaceStrategy(),
                "reply", new ReplaceStrategy(),
                "report", new ReplaceStrategy()
        );

        // 创建图
        return new StateGraph("commentGraph", keyStrategyFactory)
                .addNode("keyword", AsyncNodeAction.node_async(new KeyWordNode(chatClientBuilder)))
                .addNode("sentiment", AsyncNodeAction.node_async(new SentimentNode(chatClientBuilder)))
                .addNode("summary", AsyncNodeAction.node_async(new SummaryNode(chatClientBuilder)))
                .addNode("reply", AsyncNodeAction.node_async(new ReplyNode(chatClientBuilder)))
                .addNode("report", AsyncNodeAction.node_async(new ReportNode()))
                .addEdge(StateGraph.START, "keyword")
                .addEdge("keyword", "sentiment")
                .addEdge("sentiment", "summary")
                .addEdge("summary", "reply")
                .addEdge("reply", "report")
                .addEdge("report", StateGraph.END)
                .compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RequestMapping("/api/v1/userComment")
@RestController
@CrossOrigin
public class UserCommentController {

    private final CompiledGraph commentGraph;

    public UserCommentController(@Qualifier("commentGraph") CompiledGraph commentGraph) {
        this.commentGraph = commentGraph;
    }

    @GetMapping("exe")
    public String exe(@RequestParam("input") String input) {
        return commentGraph.invoke(Map.of("input", input))
                .orElseThrow()
                .value("report", "暂无报告");
    }
}
  1. 测试控制器:
### exe
GET http://localhost:13906/api/v1/userComment/exe?
    comment=这个手机续航很强,拍照清晰,就是有点重

接口结果如图

在这里插入图片描述

控制台结果如图

在这里插入图片描述

2. 条件边-智能分拣

心法:本案例通过 4 个节点 + 条件边实现售后工单智能分流处理流水线:输入解析 → 风险判定 → 动态分流 → 分支处理,根据风险等级自动走 普通处理紧急处理,真正实现企业级智能路由。

业务流程:用户提交一条售后工单,AI 自动完成以下工作:

在这里插入图片描述

武技:使用条件边开发智能分拣投诉工单的案例代码。

  1. 开发 5 个自定义节点:

SimpleNode(输入简化)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class SimpleNode implements NodeAction {
    private final ChatClient chatClient;

    public SimpleNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        // 获取状态变量(默认空串)
        String report = state.value("report", "");

        // 调用大模型,精简概括用户输入的投诉内容
        String content = chatClient.prompt()
                .user("""
                        根据投诉内容 %s,精简概括投诉内容;
                        只返回最终精简后的内容即可,不要返回其他无关的内容。
                        """.formatted(report))
                .call()
                .content()
                .trim();

        log.info(">> Node01:精简投诉内容:{}", content);

        // 更新状态变量
        return Map.of("report", content);
    }
}

judgeNode(条件判断)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class JudgeNode implements NodeAction {
    private final ChatClient chatClient;

    public JudgeNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        // 获取状态变量(默认空串)
        String report = state.value("report", "");

        // 调用大模型,判断风险等级
        String content = chatClient.prompt()
                .user("""
                        根据日常经验判断用户举报的内容 %s 的风险等级,
                        比如包装变形、污渍磕碰、按键卡顿、轻微异响、配件缺失、字迹模糊、轻微掉漆、开合松动等,均视为普通等级的风险(外观、轻微使用瑕疵,无安全威胁),
                        比如线路老化短路、机身冒烟起火、接口漏电、部件脱落伤人、高温烫手、易燃易爆异味、触电隐患等,均视为危险等级的风险(涉及人身、用电安全隐患),
                        你只需要只回复 “普通” 或者 “危险” 即可,不要回复其他内容。
                        """.formatted(report))
                .call()
                .content()
                .trim();
        log.info(">> Node02:判断风险等级:{}", content);

        // 更新状态变量
        return Map.of("level", content);
    }
}

NormalNode(普通处理)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class NormalNode implements NodeAction {
    private final ChatClient chatClient;

    public NormalNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        // 获取状态变量(默认空串)
        String report = state.value("report", "");

        // 调用大模型,进行安抚处理
        String content = chatClient.prompt()
                .user("""
                        你是一个专业的客服,负责处理用户投诉。
                        根据投诉内容 %s,进行安抚处理,并提示会尽快处理。
                        """.formatted(report))
                .call()
                .content()
                .trim();
        log.info(">> Node03:安抚用户情绪:{}", content);

        // 更新状态变量
        return Map.of("result", content);
    }
}

DangerNode(危险处理)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class DangerNode implements NodeAction {

    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("高危工单,升级处理");
        // TODO:联系专员处理
        // 更新状态变量
        return Map.of("result", "高危问题已升级,专员将立即联系您");
    }
}
  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Configuration
public class UserReportConfig {

    @SneakyThrows
    @Bean("reportGraph")
    public CompiledGraph reportGraph(ChatClient.Builder chatClientBuilder) {

        // 创建状态
        KeyStrategyFactory keyStrategyFactory = () -> Map.of(
                "report", new ReplaceStrategy(),
                "level", new ReplaceStrategy(),
                "result", new ReplaceStrategy()
        );
        
        // 创建图
        return new StateGraph("reportGraph", keyStrategyFactory)
                .addNode("simpleNode", AsyncNodeAction.node_async(new SimpleNode(chatClientBuilder)))
                .addNode("judgeNode", AsyncNodeAction.node_async(new JudgeNode(chatClientBuilder)))
                .addNode("normalNode", AsyncNodeAction.node_async(new NormalNode(chatClientBuilder)))
                .addNode("dangerNode", AsyncNodeAction.node_async(new DangerNode()))
                .addEdge(StateGraph.START, "simpleNode")
                .addEdge("simpleNode", "judgeNode")
                // 条件边
                // p1:从 judgeNode 节点出发
                // p2:根据状态变量 level 来判断是否普通或危险
                // p3:若 level 是普通,则流向 normalNode 节点,否则流向 dangerNode 节点
                .addConditionalEdges(
                        "judgeNode",
                        AsyncEdgeAction.edge_async(state -> state.value("level", "普通")),
                        Map.of("普通", "normalNode", "危险", "dangerNode"))
                .addEdge("normalNode", StateGraph.END)
                .addEdge("dangerNode", StateGraph.END)
                .compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/userReport")
public class UserReportController {

    private final CompiledGraph reportGraph;

    public UserReportController(@Qualifier("reportGraph") CompiledGraph reportGraph) {
        this.reportGraph = reportGraph;
    }

    @GetMapping("/exe")
    public String exe(@RequestParam("report") String report) {
        return reportGraph.invoke(Map.of("report", report))
                .orElseThrow()
                .value("result", "执行失败");
    }
}
  1. 测试控制器:
### exe普通
GET http://localhost:13906/api/v1/userReport/exe?
    report=包装有点破损,特别难看

### exe危险
GET http://localhost:13906/api/v1/userReport/exe?
    report=产品漏电,把我的手给烧了

控制台结果如图

在这里插入图片描述

在这里插入图片描述

3. 循环边-智能润色

心法:本案例通过 3 个节点 + 循环边实现文本智能润色流水线:输入文本 → 文本润色 → 检查质量 → 循环处理 → 最终汇总,根据检查结果自动走 最终汇总 或回转到 文本润色,真正实现企业级智能路由。

业务流程:传入一段文案,AI 反复迭代优化措辞,每次优化后校验文本通顺度,合格则结束流程,不合格继续循环打磨,最终输出优质文案:

在这里插入图片描述

武技:使用循环边开发智能文本润色的案例代码。

  1. 开发自定义节点:

PolishNode(文案润色节点)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class PolishNode implements NodeAction {
    private final ChatClient chatClient;

    public PolishNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String text = state.value("text", "");
        log.info("原文:{}", text);

        // 调用大模型优化文案
        String content = chatClient.prompt()
                .user("""
                        根据用户输入的文案 %s,进行润色和优化:
                        1. 让语句通顺流畅;
                        2. 若字数小于100字,则新增 10 - 20个字;
                        3. 仅返回优化后的内容,不要包含任何解释;
                        """.formatted(text))
                .call()
                .content()
                .trim();
        log.info("优化后的文案:{}", content);

        // 更新状态变量
        return Map.of("text", content);
    }
}

CheckNode(质量校验节点)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class CheckNode implements NodeAction {
    private final ChatClient chatClient;

    public CheckNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String text = state.value("text", "");

        // 调用大模型判断文案是否通顺合格
        String content = chatClient.prompt()
                .user("""
                        根据用户输入的文案 %s,进行判断是否合格:
                        1. 语句应该通顺且流畅,否则判断为不合格;
                        2. 字数起码要50字,否则判断为不合格;
                        3. 内容要有内涵,否则判断为不合格;
                        4. 仅返回判断结果 “合格” 或 “不合格”,不要包含任何其它内容。
                        """.formatted(text))
                .call()
                .content()
                .trim();
        log.info("文案判断结果:{}", content);

        // 更新状态变量
        return Map.of("passFlag", content);
    }
}

FinalNode(结果汇总节点)

package com.joezhou.node;

/** @author 周航宇 */
@Slf4j
public class FinalNode implements NodeAction {

    @Override
    public Map<String, Object> apply(OverAllState state) {

        // 获取状态变量(默认空串)
        String text = state.value("text", "");

        // 返回最终文案
        log.info("【流程收尾】最终优化文案:{}", text);
        return Map.of("finalText", text);
    }
}
  1. 开发配置类:
package com.joezhou.config;

/** @author 周航宇 */
@Slf4j
@Configuration
public class TextConfig {

    @SneakyThrows
    @Bean("textGraph")
    public CompiledGraph textGraph(ChatClient.Builder chatClientBuilder) {

        // 创建状态
        KeyStrategyFactory keyStrategyFactory = () -> Map.of(
                "text", new ReplaceStrategy(),
                "passFlag", new ReplaceStrategy(),
                "finalText", new ReplaceStrategy()
        );

        // 创建图
        return new StateGraph("textGraph", keyStrategyFactory)
                .addNode("polishNode", AsyncNodeAction.node_async(new PolishNode(chatClientBuilder)))
                .addNode("checkNode", AsyncNodeAction.node_async(new CheckNode(chatClientBuilder)))
                .addNode("finalNode", AsyncNodeAction.node_async(new FinalNode()))
                .addEdge(StateGraph.START, "polishNode")
                .addEdge("polishNode", "checkNode")
                // 循环边:不合格重回润色节点,合格走向收尾
                // p1:从 checkNode 节点出发
                // p2:根据状态变量 passFlag 来判断是否普通或危险
                // p3:若 passFlag 是不合格,则流向 polishNode 节点,否则流向 finalNode 节点
                .addConditionalEdges("checkNode",
                        AsyncEdgeAction.edge_async(state -> state.value("passFlag", "不合格")),
                        Map.of("合格", "finalNode", "不合格", "polishNode"))
                .addEdge("finalNode", StateGraph.END)
                .compile();
    }
}
  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/api/v1/text")
public class TextController {
    private final CompiledGraph textGraph;

    public TextController(@Qualifier("textGraph") CompiledGraph textGraph) {
        this.textGraph = textGraph;
    }

    @GetMapping("/exe")
    public String exe(@RequestParam("text") String text) {
        return textGraph.invoke(Map.of("text", text))
                .orElseThrow()
                .value("finalText", "文案优化失败");
    }
}
  1. 测试控制器:
### exe
GET http://localhost:13906/api/v1/text/exe?
    text=今天天气还行出门玩心情不错就是路有点远

控制台结果如图

在这里插入图片描述

Java道经第3卷 - 第9阶 - SpringAI(一)


传送门:JB3-9-SpringAI(一)
传送门:JB3-9-SpringAI(二)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值