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 技术栈

分层技术版本
语言/运行时Java17
后端框架Spring Boot3.4.3
AI 框架Spring AI1.0.0-M6(里程碑版)
模型接入spring-ai-openai-starter(DeepSeek 兼容)、spring-ai-ollama-starter由 BOM 统一管理
构建Maven3.9+
前端Vue 3 + ViteVue 3.5 / Vite 5
前端依赖@vueuse/core、@heroicons/vue、marked、dompurify、highlight.js、sass-
容器/工具IntelliJ IDEA、Node.jsNode 18+

关键认识:Spring AI 的目标是把 Spring 的“可移植性、模块化、POJO 编程”理念带到 AI 领域,核心价值是把企业数据/API 与大模型连接起来。Java 开发者可以复用整套 Spring 生态经验,而不必重学一套 Python 工具链。


二、学习路径与详细步骤

参考文档把路径拆成“快速入门 → 核心 API → RAG → Agent → 生产化”,本项目只覆盖其中的快速入门 + 核心 API(ChatClient / Advisor / ChatMemory),并额外补上前端联调。以下按实际动手顺序记录。

阶段 0:环境准备

  1. 安装 JDK 17、Maven、IntelliJ IDEA、Node.js(本机为 Node v24,兼容 Vite 5)。

  2. 申请 DeepSeek API Key(https://platform.deepseek.com),得到形如 sk-xxxxxxxx 的密钥。

  3. 将 Key 放入环境变量而非代码:

    # cmd(仅当前窗口有效)
    set DEEPSEEK_API_KEY=sk-你的完整密钥
    # 或写入用户级环境变量(永久,但需重启已打开的 IDE)
    setx DEEPSEEK_API_KEY "sk-你的完整密钥"
    

    血的教训见“踩坑记录”第 4 条:set 只在当前命令行窗口生效,已启动的 IDEA 不会继承。

阶段 1:搭建后端骨架

使用 Maven 创建 Spring Boot 工程,pom.xml 要点:

  1. 继承 spring-boot-starter-parent,指定 java.version=17。
  2. 引入 Web、OpenAI、Ollama 三个 starter。
  3. 用 spring-ai-bom 统一 Spring AI 家族版本。
  4. 打包方式改为 jar,并加入 spring-boot-maven-plugin(便于 spring-boot:run 与可执行 jar)。
  5. 必须存在一个 @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:联调与运行

  1. 配置好 DEEPSEEK_API_KEY,启动后端,等待日志出现 Started StudyAiApplication。

  2. 前端安装依赖并启动:

    cd frontend
    npm install
    npm run dev     # http://localhost:5173
    
  3. 浏览器访问 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 有效但进程没拿到

这是最耗时的一个问题,定位链条如下:

  1. 后端日志:401 Unauthorized from POST https://api.deepseek.com/v1/chat/completions。
  2. 先确认请求地址正确(/v1/chat/completions 是 Spring AI 自动拼接的,正常)。
  3. 用 curl 直连 DeepSeek 验证 Key,能返回正常回复 → Key 本身有效。
  4. 于是问题定位到“运行中的 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 给你的代码”的观点一致。

四、核心知识点小结

知识点一句话结论
ChatClientAI 调用的统一入口,用 Builder 注入,fluent 链式调用
Advisor拦截器模式;MessageChatMemoryAdvisor 管记忆,SimpleLoggerAdvisor 管日志
ChatMemoryInMemoryChatMemory 以 conversationId 隔离多轮上下文
流式返回.stream().content() → Flux<String>,逐 token 推送
结构化输出可 .call().entity(Class) 直接映射 POJO(本项目未用到,值得下一步练习)
base-urlOpenAI 兼容接口的 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 总结

  1. 从“调用”到“工程”:Spring AI 把大模型调用抽象成 Spring 风格的 Bean + 拦截器,Java 开发者能无缝迁移既有经验。
  2. 先通链路,再谈优化:最小闭环是“前端 → 代理 → 后端 Controller → ChatClient → 模型”。链路打通后,记忆、会话历史、界面美化都是增量。
  3. 排错要有层次:401 这类问题时,先用 curl 把“凭据”和“进程环境”两个变量分离,定位速度成倍提升。
  4. 接口契约以实测为准:示例代码与真实后端不一致时,加一层适配比改后端更稳妥。

6.2 后续可继续深入

  • 结构化输出:.call().entity(Record) 把回答映射为 POJO。
  • RAG:ETL Pipeline(加载→切分→向量化)+ VectorStore(PGVector/Redis)+ QuestionAnswerAdvisor,做私有文档问答。
  • 工具/函数调用:让模型调用后端函数(查天气、查数据库)。
  • 自定义 Advisor:如 Re-Reading 等 prompt 增强技巧。
  • Agent 模式:链式、并行、路由、编排器-工作者、评估-优化。
  • 生产化:可观测性(Micrometer 追踪 token 消耗)、SafeGuardAdvisor 内容安全、多模型多 ChatClient Bean 用 @Qualifier 区分。

七、参考文档链接

本系列/原型文章

官方资源

核心 API 文档

其它

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐