Spring AI 学习实践笔记(一):从零实现一个仿 DeepSeek 流式对话机器人
Spring AI 学习实践笔记(一):从零实现一个仿 DeepSeek 流式对话机器人
本文记录一个真实项目的完整学习过程:后端使用 Spring Boot + Spring AI 对接 DeepSeek(OpenAI 兼容模式)并提供流式对话与多轮记忆,前端使用 Vue 3 + Vite 实现仿 DeepSeek 的聊天页面,并完整复盘过程中遇到的编译错误、接口契约不一致、401 鉴权、端口占用等问题。
学习路线参考:Spring AI 完整学习路径与项目实践指南(姿势纠正),项目原型参考:【Spring AI实战】实现仿DeepSeek页面对话机器人。
一、项目目标与技术栈
1.1 目标
- 后端:对外暴露流式聊天接口
/ai/chat,支持按chatId区分会话、多轮上下文记忆、会话历史查询。 - 前端:仿 DeepSeek 的聊天界面(会话列表、消息气泡、Markdown 渲染、代码高亮、流式打字机效果)。
- 模型:既能接本地 Ollama,也能接云端 DeepSeek(OpenAI 兼容接口),通过配置切换。
1.2 技术栈
| 分层 | 技术 | 版本 |
|---|---|---|
| 语言/运行时 | Java | 17 |
| 后端框架 | Spring Boot | 3.4.3 |
| AI 框架 | Spring AI | 1.0.0-M6(里程碑版) |
| 模型接入 | spring-ai-openai-starter(DeepSeek 兼容)、spring-ai-ollama-starter | 由 BOM 统一管理 |
| 构建 | Maven | 3.9+ |
| 前端 | Vue 3 + Vite | Vue 3.5 / Vite 5 |
| 前端依赖 | @vueuse/core、@heroicons/vue、marked、dompurify、highlight.js、sass | - |
| 容器/工具 | IntelliJ IDEA、Node.js | Node 18+ |
关键认识:Spring AI 的目标是把 Spring 的“可移植性、模块化、POJO 编程”理念带到 AI 领域,核心价值是把企业数据/API 与大模型连接起来。Java 开发者可以复用整套 Spring 生态经验,而不必重学一套 Python 工具链。
二、学习路径与详细步骤
参考文档把路径拆成“快速入门 → 核心 API → RAG → Agent → 生产化”,本项目只覆盖其中的快速入门 + 核心 API(ChatClient / Advisor / ChatMemory),并额外补上前端联调。以下按实际动手顺序记录。
阶段 0:环境准备
-
安装 JDK 17、Maven、IntelliJ IDEA、Node.js(本机为 Node v24,兼容 Vite 5)。
-
申请 DeepSeek API Key(https://platform.deepseek.com),得到形如
sk-xxxxxxxx的密钥。 -
将 Key 放入环境变量而非代码:
# cmd(仅当前窗口有效) set DEEPSEEK_API_KEY=sk-你的完整密钥 # 或写入用户级环境变量(永久,但需重启已打开的 IDE) setx DEEPSEEK_API_KEY "sk-你的完整密钥"血的教训见“踩坑记录”第 4 条:
set只在当前命令行窗口生效,已启动的 IDEA 不会继承。
阶段 1:搭建后端骨架
使用 Maven 创建 Spring Boot 工程,pom.xml 要点:
- 继承
spring-boot-starter-parent,指定java.version=17。 - 引入 Web、OpenAI、Ollama 三个 starter。
- 用
spring-ai-bom统一 Spring AI 家族版本。 - 打包方式改为 jar,并加入
spring-boot-maven-plugin(便于spring-boot:run与可执行 jar)。 - 必须存在一个
@SpringBootApplication启动类,否则工程无法启动。
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
注意:Spring AI 里程碑版本通常需要 Spring 的 milestone 仓库(
https://repo.spring.io/milestone)。若使用阿里云等镜像能解析到对应版本,也可以正常工作。
@SpringBootApplication
public class StudyAiApplication {
public static void main(String[] args) {
SpringApplication.run(StudyAiApplication.class, args);
}
}
阶段 2:配置多模型
application.yaml 同时声明 Ollama(本地)与 OpenAI 兼容的 DeepSeek:
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: deepseek-r1:7b
openai:
base-url: https://api.deepseek.com # 注意:不要写成 .../v1
api-key: ${DEEPSEEK_API_KEY}
chat:
options:
model: deepseek-chat
logging:
level:
org.springframework.ai: debug
com.study.study_ai: debug
为什么 base-url 不能写 /v1? Spring AI 的 OpenAI 客户端会自动拼接 /v1/chat/completions。若 base-url 写成 https://api.deepseek.com/v1,最终会变成 .../v1/v1/chat/completions 导致 404。调试日志里正确的请求地址是:
POST https://api.deepseek.com/v1/chat/completions
阶段 3:ChatClient + Advisor(记忆 + 日志)
ChatClient 是 Spring AI 的推荐入口,支持 fluent 风格;Advisor 是拦截器,用于增强交互逻辑。
@Configuration
public class ChatConfiguration {
@Bean
public ChatMemory chatMemory() {
return new InMemoryChatMemory();
}
@Bean
public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory) {
return ChatClient.builder(model)
.defaultSystem("你是一个热心、聪明、全知的智能助手,你的名字叫‘小小’……")
.defaultAdvisors(
new SimpleLoggerAdvisor(), // 打印请求/响应,便于排错
new MessageChatMemoryAdvisor(chatMemory) // 多轮对话记忆
)
.build();
}
}
要点:
ChatClient推荐用 Builder 注入,便于测试与多 Bean 区分。MessageChatMemoryAdvisor+InMemoryChatMemory实现“同一会话的多轮上下文”。SimpleLoggerAdvisor输出完整请求,是排查 401 / 参数问题的关键工具。
阶段 4:流式聊天接口
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {
private final ChatClient chatClient;
private final ChatHistoryRepository chatHistoryRepository;
@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
public Flux<String> chat(@RequestParam("prompt") String prompt,
@RequestParam("chatId") String chatId) {
chatHistoryRepository.save("chat", chatId);
return chatClient.prompt()
.user(prompt)
.advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
.stream()
.content();
}
}
要点:
.stream().content()返回Flux<String>,逐个 token 推送;Spring MVC 支持响应式返回类型,可边生成边返回。CHAT_MEMORY_CONVERSATION_ID_KEY把当前记忆绑定到chatId。会话 ID 由前端生成并传入。
阶段 5:会话历史管理
为了让侧边栏能列出历史会话、点击后恢复消息,需要一个“会话 ID 存储 + 历史查询”的小模块。
ChatHistoryRepository(接口):save(type, chatId)、getChatIds(type)。InMemoryChatHistoryRepository:基于ConcurrentHashMap的内存实现。ChatHistoryController:GET /ai/history/{type}返回会话 ID 列表;GET /ai/history/{type}/{chatId}返回消息列表。MessageVO:把 Spring AI 的Message转成前端友好的{role, content}。
public interface ChatHistoryRepository {
void save(String type, String chatId);
List<String> getChatIds(String type);
}
@Slf4j
@Component
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {
private final Map<String, List<String>> chatHistory = new ConcurrentHashMap<>();
@Override
public void save(String type, String chatId) {
List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());
if (!chatIds.contains(chatId)) {
chatIds.add(chatId);
}
}
@Override
public List<String> getChatIds(String type) {
return List.copyOf(chatHistory.getOrDefault(type, List.of()));
}
}
阶段 6:前端(Vue 3 + Vite)仿 DeepSeek 页面
目录结构:
frontend/
├─ index.html
├─ package.json
├─ vite.config.js
└─ src/
├─ main.js
├─ App.vue
├─ style.css
├─ services/api.js # 统一封装后端接口
├─ components/ChatMessage.vue # 消息气泡 + Markdown + 代码高亮 + 复制
└─ views/AIChat.vue # 会话列表 + 聊天区 + 输入框 + 流式逻辑
关键一:Vite 代理解决跨域。 前端用相对路径 /ai/...,开发时由 Vite 代理到后端:
// vite.config.js
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/ai': { target: 'http://localhost:8080', changeOrigin: true }
}
}
})
关键二:契约适配。 后端 GET /ai/history/{type} 返回的是 List<String>(会话 ID 数组),而博客前端把它当成 {id, title} 对象数组使用,直接照搬会报错。因此在 api.js 里做了映射:
async function getChatHistory(type = 'chat') {
const ids = await (await fetch(`/ai/history/${type}`)).json()
return (ids || []).map(id => ({ id, title: `对话 ${String(id).slice(-6)}` }))
}
关键三:流式读取要处理多字节。 用 TextDecoder.decode(value, { stream: true }),避免中文被包在分片边界处截断而乱码:
const decoder = new TextDecoder('utf-8')
const reader = await chatAPI.sendMessage(content, currentChatId.value)
let accumulated = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
// 用 accumulated 更新最后一条 assistant 消息
}
关键四:中文输入法回车保护。 输入框监听回车发送时,要判断 event.isComposing,否则拼音选字阶段回车会误发送:
const handleKeydown = (event) => {
if (event.key !== 'Enter' || event.isComposing || event.shiftKey) return
event.preventDefault()
sendMessage()
}
阶段 7:联调与运行
-
配置好
DEEPSEEK_API_KEY,启动后端,等待日志出现Started StudyAiApplication。 -
前端安装依赖并启动:
cd frontend npm install npm run dev # http://localhost:5173 -
浏览器访问
http://localhost:5173/,输入问题验证流式输出与会话历史。
三、学习经验与踩坑记录(重点)
这一节是本项目最有价值的部分——把真实踩过的坑和定位方法记录下来。
1. 编译错误:把接口写成了类
- 现象:
ChatHistoryRepository被写成public class,但里面是抽象方法且被implements使用,编译报“此处需要接口 / 缺少方法主体”。 - 修复:声明为
public interface ChatHistoryRepository。 - 经验:先把“契约”(接口)和“实现”分清,再写实现类。
2. Lombok 日志注解导错包
- 现象:
InMemoryChatHistoryRepository导入的是groovy.util.logging.Slf4j,而非lombok.extern.slf4j.Slf4j,log字段不会生成。 - 修复:改为
lombok.extern.slf4j.Slf4j。 - 经验:IDE 自动补全容易选错同名注解,导包前看清
groupId。
3. 未初始化的 Map 导致 NPE
- 现象:
private Map<String, List<String>> chatHistory;只声明未实例化,computeIfAbsent直接空指针。 - 修复:
= new ConcurrentHashMap<>();,同时保证并发安全,返回时用List.copyOf保护内部集合。
4. 401 Unauthorized:Key 有效但进程没拿到
这是最耗时的一个问题,定位链条如下:
- 后端日志:
401 Unauthorized from POST https://api.deepseek.com/v1/chat/completions。 - 先确认请求地址正确(
/v1/chat/completions是 Spring AI 自动拼接的,正常)。 - 用 curl 直连 DeepSeek 验证 Key,能返回正常回复 → Key 本身有效。
- 于是问题定位到“运行中的 JVM 没拿到 Key”,根因有三层:
set DEEPSEEK_API_KEY=sk-只赋了“前缀”,不是完整 Key;- cmd 的
set只对当前窗口有效,IDEA 从别的环境启动,拿不到; - 改成用户级
setx后,已打开的 IDEA 仍不会感知,必须重启 IDE,或直接在 Run Configuration 里配DEEPSEEK_API_KEY=sk-...。
结论与最佳实践:
- 密钥只放环境变量或 IDE Run Configuration,绝不写进代码/仓库。
- IDEA 里配置:Run → Edit Configurations → Environment variables。
- 用独立 curl 验证 Key,是区分“Key 问题”还是“加载问题”的最快手段。
5. 端口被占用导致启动失败
-
现象:
mvn spring-boot:run报端口 8080 已被占用(旧的后端进程还在跑)。 -
定位(Windows):
Get-NetTCPConnection -LocalPort 8080 -State Listen | ForEach-Object { $_.OwningProcess } Stop-Process -Id <PID> -Force -
经验:改配置/改 Key 后要确保旧进程确实停掉,否则连的还是旧进程。
6. Tomcat Native 库版本不匹配警告
- 现象:
An incompatible version [1.2.16] of the Apache Tomcat Native library is installed, while Tomcat requires version [1.2.34]。 - 结论:无害。Tomcat 会回退到 Java NIO 连接器,不影响启动与功能。要消除可更新/移除
tcnative-1.dll。
7. 前后端契约不一致
- 现象:博客前端期望
getChatHistory返回对象数组,后端实际返回字符串数组。 - 经验:以真实接口为准,在
api.js里做适配层,而不是盲信示例代码。这与参考文档“不要绝对相信 AI 给你的代码”的观点一致。
四、核心知识点小结
| 知识点 | 一句话结论 |
|---|---|
ChatClient | AI 调用的统一入口,用 Builder 注入,fluent 链式调用 |
Advisor | 拦截器模式;MessageChatMemoryAdvisor 管记忆,SimpleLoggerAdvisor 管日志 |
ChatMemory | InMemoryChatMemory 以 conversationId 隔离多轮上下文 |
| 流式返回 | .stream().content() → Flux<String>,逐 token 推送 |
| 结构化输出 | 可 .call().entity(Class) 直接映射 POJO(本项目未用到,值得下一步练习) |
| base-url | OpenAI 兼容接口的 base-url 不要手动加 /v1 |
| 前后端契约 | 用适配层兜住差异,别硬套示例 |
五、项目结构总览
spring-ai-study/
├─ pom.xml
├─ src/main/java/com/study/study_ai/
│ ├─ StudyAiApplication.java # 启动类
│ ├─ ChatConfiguration.java # ChatClient / ChatMemory 配置
│ ├─ ChatController.java # 流式聊天接口 /ai/chat
│ ├─ ChatHistoryController.java # 历史接口 /ai/history/**
│ ├─ ChatHistoryRepository.java # 历史存储接口
│ ├─ InMemoryChatHistoryRepository.java
│ └─ MessageVO.java # 返回给前端的消息 VO
├─ src/main/resources/application.yaml
└─ frontend/ # Vue 3 + Vite 前端
├─ vite.config.js # /ai 代理到 8080
└─ src/{services,components,views}/...
六、总结与后续学习方向
6.1 总结
- 从“调用”到“工程”:Spring AI 把大模型调用抽象成 Spring 风格的 Bean + 拦截器,Java 开发者能无缝迁移既有经验。
- 先通链路,再谈优化:最小闭环是“前端 → 代理 → 后端 Controller → ChatClient → 模型”。链路打通后,记忆、会话历史、界面美化都是增量。
- 排错要有层次:401 这类问题时,先用 curl 把“凭据”和“进程环境”两个变量分离,定位速度成倍提升。
- 接口契约以实测为准:示例代码与真实后端不一致时,加一层适配比改后端更稳妥。
6.2 后续可继续深入
- 结构化输出:
.call().entity(Record)把回答映射为 POJO。 - RAG:
ETL Pipeline(加载→切分→向量化)+VectorStore(PGVector/Redis)+QuestionAnswerAdvisor,做私有文档问答。 - 工具/函数调用:让模型调用后端函数(查天气、查数据库)。
- 自定义 Advisor:如 Re-Reading 等 prompt 增强技巧。
- Agent 模式:链式、并行、路由、编排器-工作者、评估-优化。
- 生产化:可观测性(Micrometer 追踪 token 消耗)、
SafeGuardAdvisor内容安全、多模型多ChatClientBean 用@Qualifier区分。
七、参考文档链接
本系列/原型文章
官方资源
- Spring AI 项目主页:https://spring.io/projects/spring-ai
- Spring AI 参考文档:https://docs.spring.io/spring-ai/reference
- Spring AI API(JavaDoc):https://docs.spring.io/spring-ai/api
- 官方示例:https://github.com/spring-projects/spring-ai-examples
- 社区资源合集:https://github.com/spring-ai-community/awesome-spring-ai
核心 API 文档
- ChatClient:https://docs.spring.io/spring-ai/reference/api/chatclient.html
- Advisors:https://docs.spring.io/spring-ai/reference/api/advisors.html
- Prompt:https://docs.spring.io/spring-ai/reference/api/prompt.html
- 结构化输出:https://docs.spring.io/spring-ai/reference/api/structured-output-converter.html
- Chat Memory:https://docs.spring.io/spring-ai/reference/api/chat-memory.html
- Tools(函数调用):https://docs.spring.io/spring-ai/reference/api/tools.html
- Prompt 工程模式:https://docs.spring.io/spring-ai/reference/api/chat/prompt-engineering-patterns.html
- Effective Agents:https://docs.spring.io/spring-ai/reference/api/effective-agents.html
- RAG:https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html
- ETL Pipeline:https://docs.spring.io/spring-ai/reference/api/etl-pipeline.html
- Vector DBs:https://docs.spring.io/spring-ai/reference/api/vectordbs.html
- Cloud Bindings:https://docs.spring.io/spring-ai/reference/api/cloud-bindings.html
其它
- DeepSeek 开放平台:https://platform.deepseek.com
- Vue 3 文档:https://cn.vuejs.org
- Vite 文档:https://cn.vitejs.dev
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)