
1. 这个Demo到底解决什么问题先说一下我为什么写这个项目。上个月我在做一个内部知识库问答工具需要把大模型的能力接进SpringBoot服务里翻遍了网上各种教程要么是只贴一段RestTemplate调用完事要么是流式输出写到一半就没下文了。好不容易找到一个能跑的demo历史记录又是用内存List存的服务一重启全没了。折腾了大概一周我决定自己从头撸一个干净利落的版本。这个项目的定位很明确给你一个基于SpringBoot的DeepSeek-demo自带流式输出和历史记录能力拿过来就能跑跑通之后你能照着改成自己业务里的模样。适合谁看想在SpringBoot项目里接入DeepSeek或其他OpenAI兼容接口的后端开发已经跑通过普通接口调用但不知道怎么处理流式输出的朋友需要一个带历史记录参考实现的AI对话后端做毕业设计或项目原型我在设计时做了几个关键取舍先把结论放前面用WebFlux的FluxString做流式响应而不是传统的Servlet异步或SSE手写推送历史记录存H2内存数据库零配置开箱即用同时把DAO层抽象出来你想换MySQL就改个依赖加个配置前端只用一个HTML页面用fetch加ReadableStream解析流式数据不引入任何前端框架下面我从头开始拆。2. 为什么选SpringBoot WebFlux处理流式输出2.1 流式输出的本质HTTP分块传输先搞清楚一件事大模型接口的流式输出说白了就是服务器一边生成一边把内容推给客户端而不是等全部生成完了再一次性返回。DeepSeek的API兼容OpenAI格式当你设置streamtrue时服务端会通过text/event-streamSSE协议按行返回数据片段每一行是一个data: {...}的JSON。这里有一个容易被忽略的核心点SSE本身是HTTP协议的分块传输Transfer-Encoding: chunked在事件流场景下的应用它要求服务端不能缓存整个响应体必须边生成边写。在SpringBoot里实现这种能力传统方式是SseEmitter它需要你自己管理线程池、处理超时、处理客户端断开。而WebFlux天然就是响应式编程模型Flux就是流FluxString往客户端一丢框架自动处理背压和异步代码少写一大半。我最初试过SseEmitter用起来其实也能跑通但有两个痛点一是超时时间要手动设置客户端一断线线程池就堆积二是拼接SSE事件格式的代码很啰嗦。WebFlux把这些都封装掉了代码量大概能少30%。2.2 SpringBoot版本选择的坑这里必须单独提醒一下版本问题。现在网上很多SpringBoot教程还在用2.x但你如果要接DeepSeek这种比较新的服务建议直接上SpringBoot 3.x原因有三个3.x基于Spring Framework 6内置了更好的HTTP接口客户端RestClient比RestTemplate和WebClient都更适合这种API对接场景3.x的WebFlux对响应式流式处理的支持更完善JDK 17是SpringBoot 3的最低要求如果你还在用JDK 8很多新特性用不了我这个demo用的是SpringBoot 3.2.4JDK 17。如果你公司项目还在SpringBoot 2.x也没关系核心思路一样把jakarta.*的包导回javax.*就行。2.3 RestClient还是WebClient这是第二个容易纠结的点。对接大模型API发送请求的方式有三种选择RestTemplate、WebClient、RestClient。我的建议是客户端异步支持流式支持代码简洁度推荐场景RestTemplate不支持不友好中普通同步调用老项目WebClient支持支持但API繁琐中Spring 5时代的响应式项目RestClient支持支持且API简洁高SpringBoot 3.x新项目RestClient在SpringBoot 3.2中正式可用它的API设计和RestTemplate很像但底层用的是响应式HTTP客户端天然支持流式。我最终选了RestClient配合Flux既保持了代码可读性又拿到了流式能力。3. 项目骨架一个能直接跑的工程长什么样3.1 目录结构设计先说目录结构这决定了你后面加功能的时候会不会乱。我按照常见的分层架构来做但比教科书上的更精简deepseek-demo/ ├── pom.xml └── src/ └── main/ ├── java/ │ └── com/example/deepseekdemo/ │ ├── DeepseekDemoApplication.java │ ├── controller/ │ │ └── ChatController.java │ ├── service/ │ │ ├── DeepSeekService.java │ │ └── ChatHistoryService.java │ ├── config/ │ │ └── DeepSeekConfig.java │ ├── model/ │ │ ├── ChatRequest.java │ │ ├── ChatResponse.java │ │ ├── Message.java │ │ └── HistoryRecord.java │ └── repository/ │ └── HistoryRepository.java └── resources/ ├── application.yml └── static/ └── index.html几个设计要点controller层尽量薄只做参数接收和响应装配业务逻辑全在service层model里的Message直接对齐DeepSeek API的请求体结构方便后续扩展repository只管历史记录不掺和业务逻辑static/index.html是测试页面正式集成到你自己的前端时可以直接扔掉3.2 pom.xml关键依赖pom.xml里最核心的依赖只有这几个parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies !-- WebFlux提供响应式编程模型和Flux支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- H2内存数据库存储历史记录 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency !-- Spring Data JPA简化数据库操作 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- Lombok减少样板代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意一个小细节我用了spring-boot-starter-webflux没加spring-boot-starter-web。这两个starter同时存在会有冲突WebMVC会覆盖WebFlux的自动配置导致响应式特性失效。如果你的项目里必须同时用需要在application.yml里显式配置spring.webflux.pathmatch之类的参数但一般来说没必要共存。3.3 application.yml配置server: port: 8080 spring: application: name: deepseek-demo h2: console: enabled: true path: /h2-console datasource: url: jdbc:h2:mem:chatdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true deepseek: api-key: ${DEEPSEEK_API_KEY:your-api-key-here} base-url: https://api.deepseek.com model: deepseek-chat max-tokens: 2048 temperature: 0.7这里把api-key做成环境变量注入是我坚持的一个习惯。直接把key写死在配置文件里然后推到GitHub等于把密钥送人了。用${DEEPSEEK_API_KEY:your-api-key-here}这种写法本地没有环境变量时用默认值兜底部署时只要在环境变量里设一个DEEPSEEK_API_KEY就行。4. 流式输出从请求构造到Flux响应4.1 DeepSeek API的请求格式DeepSeek的接口兼容OpenAI格式POST到/chat/completions请求体长这样{ model: deepseek-chat, messages: [ {role: system, content: 你是一个有帮助的助手}, {role: user, content: 你好} ], stream: true, max_tokens: 2048, temperature: 0.7 }这里的关键字段是stream设为true后响应不再是单个JSON而是一连串data:开头的行每一行是一个增量片段最后有一个data: [DONE]标记结束。我构造请求体的时候用了Map来组装JSON这样最灵活也最贴近实际业务中各种动态参数的需求MapString, Object requestBody new HashMap(); requestBody.put(model, deepseek-chat); requestBody.put(messages, messages); requestBody.put(stream, true); requestBody.put(max_tokens, 2048); requestBody.put(temperature, 0.7);如果你喜欢强类型也可以定义ChatRequest和Message的POJO来传参。但在这个demo里直接操作Map更便于你看清楚整个请求结构后续要加stop、top_p之类的参数也直观。4.2 核心Service实现拆解流式输出的核心就一段代码我直接贴出来然后逐行解释为什么这么写Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekConfig config; public DeepSeekService(RestClient.Builder builder, DeepSeekConfig config) { this.config config; this.restClient builder .baseUrl(config.getBaseUrl()) .defaultHeader(Authorization, Bearer config.getApiKey()) .defaultHeader(Content-Type, application/json) .build(); } public FluxString chatStream(ListMessage messages) { MapString, Object requestBody new HashMap(); requestBody.put(model, config.getModel()); requestBody.put(messages, messages); requestBody.put(stream, true); requestBody.put(max_tokens, config.getMaxTokens()); requestBody.put(temperature, config.getTemperature()); return restClient.post() .uri(/chat/completions) .body(requestBody) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull); } private String parseSSELine(String line) { if (line null || !line.startsWith(data:)) { return null; } String data line.substring(5).trim(); if ([DONE].equals(data)) { return null; } try { JsonNode node new ObjectMapper().readTree(data); return node.path(choices).path(0).path(delta).path(content).asText(null); } catch (JsonProcessingException e) { return null; } } }几个写代码时容易踩的坑我一个个说坑一流式响应里每个data:行的内容是JSON片段不是完整JSON。我第一次写的时候天真地以为整个流是一个完整的JSON数组直接readTree整个流结果解析报错。正确的做法是一行一行解析每一行单独readTree取choices[0].delta.content字段。坑二RestClient的bodyToFlux(String.class)返回的每个元素实际是SSE消息的一部分可能是多行拼一起的。实测你会发现有时候一个元素就是完整的一行data: {...}有时候是两行空行分隔。所以parseSSELine要兼容这种情况。我在代码里只处理了以data:开头的行空行直接忽略这样最稳。坑三delta.content可能是null。流式返回的第一个片段和最后一个片段content字段经常是空字符串或null。所以解析完要filter(Objects::nonNull)不然前端会收到一堆undefined。4.3 Controller层怎么把Flux交给前端Controller层更简单只需要返回FluxStringSpringBoot WebFlux会自动用text/event-stream格式输出到客户端RestController RequestMapping(/api/chat) public class ChatController { private final DeepSeekService deepSeekService; private final ChatHistoryService chatHistoryService; public ChatController(DeepSeekService deepSeekService, ChatHistoryService chatHistoryService) { this.deepSeekService deepSeekService; this.chatHistoryService chatHistoryService; } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { // 保存用户消息 chatHistoryService.saveMessage(request.getSessionId(), user, request.getLastMessage()); // 构建完整消息列表历史 当前 ListMessage messages chatHistoryService.buildMessageList(request.getSessionId(), request.getLastMessage()); // 流式返回同时在结束时把助手回复存库 return deepSeekService.chatStream(messages) .doOnComplete(() - { // 这里有个问题流式输出过程中不好累积内容 }); } }produces MediaType.TEXT_EVENT_STREAM_VALUE这一步是关键它告诉Spring这个接口会返回SSE流。没有这个声明浏览器端的EventSource或fetch的流式解析可能拿不到正确的Content-Type导致解析失败。4.4 一个没被讲透的问题流式过程中如何保存完整回复上面的Controller代码里我留了一个问题流式输出的过程中回复内容是分批到前端的那后端怎么在结束时把完整的助手回复存进历史记录这里有几种方案我逐一分析方案一在整个流中累积字符串。用一个StringBuilder每收到一个片段就append在doOnComplete里把累积的内容保存到数据库。这是最简单直接的做法但有个微不足道的性能代价就是每次都要拼接字符串。考虑到对话场景的回复长度一般几千字这个代价可以忽略。方案二把累积逻辑放到Service层返回一个带完整内容的包装对象。比如服务端先累积完整回复再返回FluxString给Controller同时把完整回复存库。但这样会牺牲实时性因为必须等Flux完全结束才能知道完整内容。方案三前端负责把流式片段拼起来在结束时单独调一个接口保存历史。这种方案把存储压力交给前端后端多一个POST /api/chat/saveHistory接口。优点是后端逻辑简单缺点是多了一次网络往返而且如果前端异常退出历史记录就丢了。我最终采用的是方案一的思想但在实现上做了个小优化——除了StringBuilder累积还额外用了一个AtomicInteger做计数器避免并发场景下doOnComplete回调里访问累积变量时出问题。完整实现我放在后面第6章里讲。5. 历史记录不只是存个List那么简单5.1 需求分析历史记录要解决什么问题很多人做demo的时候历史记录就是ListMessage history new ArrayList()请求来了就往里add看似能用实际上有三个问题服务重启历史记录全丢多个用户共用一份历史互相串数据没有会话概念所有对话挤在一个列表里我这版设计从一开始就加入sessionId的概念。每个前端会话对应一个sessionId后端的chatHistoryService根据sessionId拉取对应会话的历史消息拼进请求上下发给DeepSeek。这样DeepSeek才能记住之前的对话内容实现多轮对话的连贯性。5.2 数据模型设计历史记录表的结构不要设计得太复杂够用就行Entity Table(name chat_history) public class HistoryRecord { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String sessionId; private String role; Column(length 4096) private String content; private LocalDateTime createTime; }这里有个细节content字段我限定了length 4096。大模型的回复动辄上千字如果字段长度不够JPA在H2里会自动建表但如果换到MySQLvarchar(255)的默认长度会直接导致插入失败。我踩过一次这个坑后来统一用Column(length 4096)H2里对应VARCHAR(4096)MySQL里对应VARCHAR(4096)都不会截断。5.3 Repository层Spring Data JPA一句话搞定public interface HistoryRepository extends JpaRepositoryHistoryRecord, Long { ListHistoryRecord findBySessionIdOrderByCreateTimeDesc(String sessionId); void deleteBySessionId(String sessionId); }就这两个方法够用了。findBySessionIdOrderByCreateTimeDesc用于加载历史消息倒序排列方便后面拼装deleteBySessionId用于清理会话。5.4 如何把历史记录拼进请求消息列表与上下文长度控制这一步是整个历史记录功能里最容易被低估的部分。直接把所有历史消息一股脑拼进请求会发生两个问题问题一请求体超过DeepSeek的token限制。DeepSeek的上下文长度有上限目前deepseek-chat是64K你把几十轮对话全塞进去迟早爆掉。问题二早期消息稀释了当前问题的相关性。模型会更关注最近的上下文历史太长反而可能答非所问。所以我在buildMessageList做了个简单的截断策略public ListMessage buildMessageList(String sessionId, String newUserMessage) { ListHistoryRecord records repository.findBySessionIdOrderByCreateTimeDesc(sessionId); // 最多取最近20条历史 ListHistoryRecord recent records.stream() .limit(20) .collect(Collectors.toList()); ListMessage messages new ArrayList(); messages.add(new Message(system, 你是一个乐于助人的AI助手请用简洁清晰的中文回答问题。)); // 倒序重新排回正序 Collections.reverse(recent); for (HistoryRecord record : recent) { messages.add(new Message(record.getRole(), record.getContent())); } messages.add(new Message(user, newUserMessage)); return messages; }limit(20)是我经过几次实测后拍的数。20轮对话按每轮平均500字算大概1万token在64K的上下文里占不到四分之一既不会立刻爆长度又足够让模型理解对话脉络。如果你的场景里单条消息特别长建议把这个数调小到10甚至5按字符数估算更准确。5.5 清空会话删除历史记录的时机前端需要一个清空会话按钮对应的后端接口是DeleteMapping(/history/{sessionId}) public ResponseEntityVoid clearHistory(PathVariable String sessionId) { chatHistoryService.clearHistory(sessionId); return ResponseEntity.ok().build(); }这里没什么技术含量但要注意一个业务决策清空历史之后前端应该同时重置页面上的聊天列表并且下一次请求不要带sessionId对应的历史。我实测中遇到过一个尴尬情况前端清了UI但没调后端接口重新发消息后旧历史又回来了模型还在记得前面聊过什么很出戏。6. 完整实现把流式输出和历史记录缝在一起前面分开了讲这一章给一个完整的、能直接跑通的缝合版本。先说明这一版的取舍为了兼容历史记录保存我把流式的累积逻辑放到了Service层Controller只负责组装和响应。6.1 DeepSeekService完整版Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekConfig config; public DeepSeekService(RestClient.Builder builder, DeepSeekConfig config) { this.config config; this.restClient builder .baseUrl(config.getBaseUrl()) .defaultHeader(Authorization, Bearer config.getApiKey()) .defaultHeader(Content-Type, application/json) .build(); } public FluxString chatStream(ListMessage messages) { MapString, Object requestBody new HashMap(); requestBody.put(model, config.getModel()); requestBody.put(messages, messages); requestBody.put(stream, true); requestBody.put(max_tokens, config.getMaxTokens()); requestBody.put(temperature, config.getTemperature()); return restClient.post() .uri(/chat/completions) .body(requestBody) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull); } private String parseSSELine(String line) { if (line null || line.isBlank() || !line.startsWith(data:)) { return null; } String data line.substring(5).trim(); if ([DONE].equals(data)) { return null; } try { JsonNode node new ObjectMapper().readTree(data); String content node.path(choices).path(0).path(delta).path(content).asText(null); return content null || content.isEmpty() ? null : content; } catch (JsonProcessingException e) { return null; } } }注意parseSSELine里多了一个line.isBlank()的判断。原因是RestClient在解析SSE流时每个元素可能包含行尾的空行这些空行不是有效业务数据过滤掉能避免前端收到一大串换行符。6.2 保存完整回复到历史记录为了能在流式结束时保存完整的助手回复我加了一个包装类型ChatStreamResult包含两部分流式片段FluxString和完整回复的MonoStringpublic class ChatStreamResult { private final FluxString stream; private final MonoString fullContent; public ChatStreamResult(FluxString stream, MonoString fullContent) { this.stream stream; this.fullContent fullContent; } public FluxString getStream() { return stream; } public MonoString getFullContent() { return fullContent; } }然后在Service里public ChatStreamResult chatStreamWithHistory(String sessionId, ListMessage messages) { // 用StringBuilder累积完整回复 StringBuilder fullContent new StringBuilder(); AtomicBoolean firstChunk new AtomicBoolean(true); FluxString stream restClient.post() .uri(/chat/completions) .body(messages) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull) .doOnNext(content - { fullContent.append(content); firstChunk.set(false); }); MonoString fullMono stream.reduce(, (acc, item) - acc item); return new ChatStreamResult(stream, fullMono); }这里有个小坑doOnNext里的fullContent.append是在响应式线程池里跑的虽然StringBuilder不是线程安全的但在Flux的串行处理中doOnNext默认是同一个线程顺序执行所以实测没问题。如果你不放心可以用StringBuffer或者把累积逻辑放到reduce里。Controller层完整版PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { String sessionId request.getSessionId(); String lastMessage request.getLastMessage(); chatHistoryService.saveMessage(sessionId, user, lastMessage); ListMessage messages chatHistoryService.buildMessageList(sessionId, lastMessage); return deepSeekService.chatStream(messages) .doOnComplete(() - { // 把完整回复存库 // 注意这里拿不到累积的内容在实际项目中需要把累积值传出来 // 更优雅的方式是用 Mono.zip让保存动作等流结束 }); }说实话上面这个版本还有瑕疵doOnComplete里拿不到完整回复内容。更优雅的写法是用Mono.zip或doOnNext配合外部变量我实际项目里是这样处理的PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { String sessionId request.getSessionId(); String lastMessage request.getLastMessage(); chatHistoryService.saveMessage(sessionId, user, lastMessage); ListMessage messages chatHistoryService.buildMessageList(sessionId, lastMessage); StringBuilder fullReply new StringBuilder(); return deepSeekService.chatStream(messages) .doOnNext(fullReply::append) .doOnComplete(() - { chatHistoryService.saveMessage(sessionId, assistant, fullReply.toString()); }); }这段代码虽然看起来简单但有一个前置条件Spring Boot默认对SSE返回的FluxdoOnNext和doOnComplete的调度器和生成流的线程必须是同一个否则StringBuilder会有并发问题。实测下来如果不调subscribeOn和publishOn默认就是串行的没问题。如果你加了并发操作符记得把累积逻辑单独抽到一个线程安全的结构里。6.3 前端页面用fetch的ReadableStream解析SSE后端流式接口写好了前端如果还用axios等待完整响应体验就废了。我这里用原生fetch配合ReadableStream手动解析SSE数据async function sendMessage() { const userInput document.getElementById(userInput).value; if (!userInput.trim()) return; appendMessage(user, userInput); const response await fetch(/api/chat/stream, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ sessionId: currentSessionId, lastMessage: userInput }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let assistantMessage ; appendMessage(assistant, ); while (true) { const {done, value} await reader.read(); if (done) break; const text decoder.decode(value, {stream: true}); const lines text.split(\n); for (const line of lines) { if (!line.startsWith(data:)) continue; const data line.substring(5).trim(); if (data [DONE]) continue; try { const json JSON.parse(data); const content json.choices[0].delta.content; if (content) { assistantMessage content; updateLastMessage(assistantMessage); } } catch (e) { // 解析失败的行直接跳过 } } } }这段代码有个处理细节要注意SSE数据流在fetch的reader.read()里一次可能返回多个data:行也可能一个data:行被拆成两次read()。所以不能依赖一次read就是一行需要用split(\n)按行拆而且要处理跨read()的断行我上面的简化版没处理断行实际项目里需要一个缓冲变量累积不完整的行。简化版已经能覆盖90%的场景但如果你的API偶尔返回很长的行建议加一个buffer变量let buffer ; while (true) { const {done, value} await reader.read(); if (done) break; buffer decoder.decode(value, {stream: true}); const lines buffer.split(\n); buffer lines.pop(); // 最后一个元素可能是不完整的行留到下一次 for (const line of lines) { // 处理完整的行 } }7. 测试与排查我实际跑过的几种异常场景7.1 跑通标准流程启动项目后访问http://localhost:8080在输入框里输入你好你可以看到页面顶部立刻出现空白的assistant气泡约1~2秒后文字开始一个字一个词地往外蹦结束后打开H2控制台http://localhost:8080/h2-console能看到历史表里多了user和assistant两条记录这里有个体验优化的点如果内容迟迟不出现大部分原因是DeepSeek的API响应延迟尤其是高峰期首次token可能要等5秒以上。前端最好加一个等待中的状态提示我在demo里用setTimeout做了个2秒未出字就显示正在思考的兜底。7.2 网络异常项目启动报SSL或连接超时这个我实测踩过一次。公司内网访问外网API经常要走代理DeepSeek的接口走HTTPS如果代理证书不被信任RestClient会报sun.security.validator.ValidatorException: PKIX path building failed。解决办法有两个方案一推荐走系统代理并信任证书。在启动参数里加-Dhttps.proxyHostyour-proxy -Dhttps.proxyPort8080方案二本地测试临时用跳过证书校验。给RestClient配置一个不校验证书的ClientHttpRequestFactory。这个方案只适合本地联调千万别带到生产环境。// 临时方案生产禁用 SSLContext sslContext SSLContextBuilder.create() .loadTrustMaterial(null, (cert, authType) - true) .build();7.3 返回空内容过滤条件太狠了有朋友反馈接口通了但前端一个字都不显示排查下来是parseSSELine里过滤了太多内容。delta.content有时是一个空格有时是\n这些看起来是空的内容其实是有意义的格式字符。我在parseSSELine里只过滤null和isEmpty()但保留空白字符。如果你发现回复里丢换行大概率是这里过滤规则太严格。改成String content node.path(choices).path(0).path(delta).path(content).asText(null); return content null ? null : content; // 不过滤空字符串和空白这样前端拼出来的内容才和官方API返回的一致。7.4 SpringBoot版本太高导致的AutoConfiguration问题网上很多老教程还在用spring.factories来注册自动配置但SpringBoot 3.x已经改为AutoConfiguration.imports。如果你搜到老博客照着配自定义starter的时候大概率会报ClassNotFoundException或Failed to load auto-configuration。这个demo里没涉及自定义starter但如果你的项目要扩展记住SpringBoot 3.x用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports别再写spring.factories了。7.5 流式接口后面挂网关超时SSE是长连接如果你们公司有Nginx或Spring Cloud Gateway做统一入口默认超时时间可能只有60秒。DeepSeek生成一篇长文可能要两三分钟网关超时就会把连接掐断前端表现为回复到一半突然断了。我处理过Nginx的配置proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_buffering off;记住最后一行proxy_buffering off很关键。Nginx默认会缓冲响应导致SSE的流式效果失效——前端要等整个响应结束才能看到内容等于流式白做了。8. 再往前一步这个demo还能怎么扩展8.1 换成MySQL持久化H2只在本地开发方便线上肯定要换MySQL。操作分两步第一步在pom.xml把H2依赖换成MySQL驱动dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency第二步改application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/deepseek_demo?useSSLfalseserverTimezoneAsia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: your-password jpa: hibernate: ddl-auto: update因为Repository层用的Spring Data JPASQL方言的切换都由框架处理业务代码一行不用动。这算是当初抽象Repository层换来的一大便利。8.2 支持多轮记忆的本地向量检索现在buildMessageList里是盲目取最近20条记录如果用户聊了50轮更早的关键信息可能被挤掉了。进阶方案是引入向量检索把每条历史记录embedding化每次请求时按相关性召回而不是按时间截断。实现思路用spring-ai或langchain4j的embedding能力把每条历史消息转成向量存在专门的向量表里生产建议用pgvector本地demo用H2凑合每次构建请求时用当前问题做向量相似度检索召回topK条历史这个方案能显著提升长对话场景下的回答质量但工程量会翻几倍。如果项目周期紧我建议先做基于时间的截断后续再演进。8.3 前端做成一个完整的聊天Web应用目前index.html只是单文件demo实际产品可以扩展左侧会话列表点击切换sessionId支持停止生成取消fetch消息气泡的markdown渲染错误提示和重试按钮如果你用Vue或React核心原理不变只是把fetch的解析逻辑封装成useChat之类的hook后端接口完全可以直接复用。9. 部署到服务器时要注意的几个点9.1 API Key安全一定不要把key写到application.yml里提交到Git。我见过太多人因为这一行配置泄露了密钥然后被刷了几百块钱的API调用费。推荐做法是启动时从环境变量读export DEEPSEEK_API_KEYsk-xxxxxxxx java -jar deepseek-demo.jar或者用Docker部署时通过--env传docker run -d -p 8080:8080 \ -e DEEPSEEK_API_KEYsk-xxxxxxxx \ deepseek-demo:latest9.2 CORS跨域配置如果你前端是独立部署比如Vue打包的静态资源在Nginx上后端接口必须配CORS不然浏览器报跨域错误Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, DELETE, OPTIONS) .allowedHeaders(*); } }注意如果走的是WebFlux要用CorsWebFilter或者实现WebFluxConfigurer别照抄WebMVC的写法。9.3 内存占用与线程模型WebFlux是非阻塞模型理论上线程占用比传统WebMVC低很多。但DeepSeek的流式响应是IO密集型的长时间占用的连接数是主要资源指标。RestClient默认的连接池足够支撑几百并发小项目不用调优。如果并发量上来了关注两个指标tomcat.threads.maxWebFlux默认用Netty对应server.netty.max-connections和数据库连接池上限。H2内存库没有连接池问题换MySQL后注意spring.datasource.hikari.maximum-pool-size默认10并发高的时候要往上调。10. 写在最后这个demo的边界与进一步优化建议跑完整个项目最大的体会是流式输出这个看似简单的需求牵扯到的技术点其实不少——HTTP分块传输、响应式编程、SSE协议解析、前端流式读取每一层都有坑。当初如果只想快速交差可以直接让后端把完整回复一次性返回前端再做个打字机效果但那样既浪费了DeepSeek的流式接口能力还会让用户首字等待时间变长体验差不少。我个人的建议是如果你的业务场景对实时性有要求比如客服、助手、问答系统一定要把流式输出作为核心链路来设计从一开始就选好WebFlux和RestClient这套技术栈后面扩展会顺畅很多。如果只是内部工具、不太在意响应速度用同步接口加前端模拟打字也能凑合。有两点我目前还在继续打磨一是遇到长回复时前端的SSE解析偶尔还会出现断行问题虽然不影响最终内容但会让渲染过程有一点点闪烁。我打算改用官方推荐的text/event-stream解析库比如microsoft/fetch-event-source而不是自己手写解析。二是多会话管理目前只做到按sessionId区分历史没有做用户权限隔离。如果你要放生产需要在sessionId上绑定用户标识并对接口鉴权。最后分享一个调试小技巧本地联调时直接用curl看DeepSeek API的原始返回比在代码里打日志快得多curl -N https://api.deepseek.com/chat/completions \ -H Authorization: Bearer sk-xxxx \ -H Content-Type: application/json \ -d { model: deepseek-chat, messages: [{role: user, content: 你好}], stream: true }-N参数是关键它告诉curl不要缓冲实时打印流式数据。这样你能快速判断是API的问题还是自己代码解析的问题省掉一大半排查时间。