ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 3.2 + Spring AI 1.0 集成 DeepSeek 生产实践

Spring Boot 3.2 + Spring AI 1.0 集成 DeepSeek 生产实践 简介本资源是一套基于Spring Boot与Spring AI框架集成DeepSeek大语言模型的完整AI应用实战代码面向Java后端开发者、AI工程实践者及企业级智能应用构建者解决传统Java项目快速接入LLM能力的技术落地难题。压缩包共12个文件9个Java核心业务与配置类、1个application.yml模型参数配置、1个pom.xml依赖管理、1个HTML前端交互页面总大小仅25KB轻量但结构完整涵盖模型调用封装、REST接口设计、前后端数据流转与结果渲染等关键环节。已有254人学习下载体现了开发者对Spring生态国产大模型融合方案的强烈关注。读者可直接导入IDE运行获得开箱即用的智能问答与文本生成演示系统并通过模块化代码清晰理解Spring AI的AutoConfiguration机制、DeepSeek API适配逻辑及前后端分离下的AI服务集成范式。1. Spring Boot Spring AI DeepSeek不是“搭个AI接口”就完事而是把大模型能力真正焊进业务主干流你有没有试过在 Spring Boot 里加个RestController调几行 OpenAI SDK然后就宣布“我们接入AI了”我去年也这么干过——结果上线三天用户反馈“你们的智能客服比人工还慢而且答非所问。”后来才发现问题根本不在模型本身而在于整个链路HTTP 超时没设、提示词没做模板化管理、流式响应被 Spring MVC 的默认缓冲吃掉、错误重试策略写成死循环、甚至前端发来的 JSON 字段名和后端 DTO 对不上……全栈式翻车。这篇实战笔记讲的就是如何用 Spring Boot 3.2、Spring AI 1.0注意不是 0.x、DeepSeek-VL 或 DeepSeek-Coder API以官方 v1/chat/completions 接口为准从零构建一个可监控、可灰度、可降级、能流式返回、带上下文管理、支持多轮对话状态持久化的生产级 AI 服务模块。它不教你怎么写 Hello World而是聚焦真实项目里必须面对的五个硬骨头Spring AI 的自动配置陷阱、DeepSeek Token 计费与限流联动、前后端 SSE 流式通信的断连重续、提示工程在 Java 层的工程化封装、以及 Spring Boot Admin 对 AI 调用链的可观测埋点。适合正在做智能客服、代码辅助、文档摘要、或任何需要把 LLM 深度嵌入 Java 业务系统的后端/全栈工程师。2. Spring Boot 3.2 Spring AI 1.0选型不是跟风是踩过 7 个版本坑后的血泪收敛Spring AI 在 2024 年已进入 1.0 GA 阶段但它的生态演进非常激进。Spring Boot 3.2 是目前唯一被 Spring AI 1.0 官方完整支持的基线版本Spring Boot 3.3 已发布但 Spring AI 1.0.1 尚未完全适配其新特性。很多团队卡在 Spring Boot 2.7.x 上强行升级 Spring AI 0.8.x结果发现AiClient注入失败、ChatModel自动装配缺失、甚至spring-ai-core和spring-boot-starter-webflux冲突导致 WebClient 初始化异常——这不是你的代码问题是版本矩阵不兼容。下面这张表是我过去三个月在 3 个不同客户现场实测验证过的最小可行组合组件推荐版本关键原因替代风险Spring Boot3.2.12Spring AI 1.0.0 官方 BOM 锁定依赖spring-boot-starter-webflux与spring-ai-spring-boot-starter兼容性最佳3.3.0WebClient默认启用ReactorNettyHttpClient需手动配置maxInMemorySize否则流式响应内存溢出Spring AI1.0.0唯一支持StreamingChatClient、ChatMemory、PromptTemplate三者协同的稳定版spring-ai-deepseek-spring-boot-starter正式纳入官方 starter0.8.1无ChatMemory接口状态管理需手写 Redis 模板0.9.0-MxStreamingChatClient返回FluxChatResponse但缺少onErrorResume默认兜底DeepSeek 客户端deepseek-java-sdk:1.2.0非官方或spring-ai-deepseek-spring-boot-starter:0.1.0社区维护DeepSeek 官方未提供 Java SDK社区版基于spring-ai-core扩展支持apiKeybaseUrlmodel三元组配置手写RestTemplate无法复用 Spring AI 的RetryPolicy、CircuitBreaker、ObservationRegistry等企业级能力提示不要用spring-boot-starter-web启动 AI 服务。Spring AI 的流式响应SSE强依赖 WebFlux 的非阻塞 I/O。若你项目已用spring-boot-starter-web必须显式排除spring-boot-starter-tomcat并引入spring-boot-starter-reactor-netty否则StreamingChatClient会静默降级为普通 HTTP 请求失去流式能力。2.1 初始化 Spring AI DeepSeek Starter5 行配置搞定自动装配在pom.xml中严格按以下顺序声明依赖顺序影响 classpath 加载优先级!-- 必须最先声明Spring Boot 3.2.x 官方 BOM -- dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version3.2.12/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement !-- Spring AI 官方 starter含 core webflux 支持 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-spring-boot-starter/artifactId version1.0.0/version /dependency !-- DeepSeek 社区 starter注意非 Spring 官方但已适配 1.0.0 -- dependency groupIdio.github.spring-ai-community/groupId artifactIdspring-ai-deepseek-spring-boot-starter/artifactId version0.1.0/version /dependencyapplication.yml中只需 4 行配置即可完成 DeepSeek 客户端自动注册spring: ai: deepseek: api-key: ${DEEPSEEK_API_KEY:sk-xxx} # 强烈建议从环境变量注入 base-url: https://api.deepseek.com/v1 # 注意v1 路径不可省略 model: deepseek-coder # 可选deepseek-vl, deepseek-chat max-retries: 2 # Spring AI 内置重试非 HTTP 层重试逻辑说明Spring AI 1.0 的自动配置类DeepSeekAutoConfiguration会在ApplicationContext初始化时扫描上述配置创建DeepSeekChatModelBean并将其注册为ChatModel类型。后续所有Autowired ChatModel注入均指向该实例。参数说明base-url必须带/v1后缀否则请求路径拼接为https://api.deepseek.com/chat/completions错误正确应为https://api.deepseek.com/v1/chat/completionsmax-retries是 Spring Retry 框架的重试次数仅对网络超时、5xx 错误生效不包含 429限流错误需单独处理model参数将透传至请求体{model: deepseek-coder}DeepSeek 服务端据此路由到对应模型集群。2.2 构建可流式、可中断、可审计的StreamingChatClientSpring AI 1.0 最大的工程价值是把StreamingChatClient从实验性 API 升级为一级公民。它不再只是返回FluxChatResponse而是提供了完整的生命周期钩子Service public class AiService { private final StreamingChatClient streamingChatClient; public AiService(StreamingChatClient streamingChatClient) { this.streamingChatClient streamingChatClient; } public FluxChatResponse streamChat(String userMessage, String sessionId) { // 1. 构建带会话上下文的 Prompt Prompt prompt Prompt.from( ChatRequest.builder() .addUserMessage(userMessage) .addSystemMessage(你是一个严谨的技术文档助手请用中文回答禁止编造信息。) .build() ); // 2. 注入会话 ID用于后续内存管理 return streamingChatClient.stream(prompt) .doOnSubscribe(sub - log.info(Stream started for session: {}, sessionId)) .doOnNext(response - log.debug(Received chunk: {}, response.getGeneration().getText())) .doOnError(error - log.error(Stream error in session {}: {}, sessionId, error.getMessage())) .doOnComplete(() - log.info(Stream completed for session: {}, sessionId)) .timeout(Duration.ofSeconds(60)) // 关键全局超时防止长连接挂死 .onErrorResume(throwable - { // 3. 统一错误降级返回结构化错误消息前端可识别 String errorMsg (throwable instanceof TimeoutException) ? AI响应超时请稍后重试 : AI服务暂时不可用; return Flux.just(ChatResponse.from( Generation.create(errorMsg, error, 0.0) )); }); } }关键点解析streamingChatClient.stream(prompt)返回的是FluxChatResponse每个ChatResponse包含一个Generation其getText()即当前 token 片段timeout(Duration.ofSeconds(60))是 WebFlux 层超时必须设置。DeepSeek 的流式响应可能因网络抖动或模型推理卡顿而长时间无数据不设超时会导致连接堆积、线程耗尽onErrorResume中的降级逻辑确保即使流中断前端仍能收到一个合法的ChatResponse对象避免 JSON 解析失败doOnSubscribe/doOnComplete是可观测性埋点入口后续可对接 Micrometer Prometheus。2.3 避坑Spring AI 1.0 DeepSeek 的五大高频翻车点现象 → 原因 → 解决现象StreamingChatClient.stream()返回空Flux无任何onNext或onError回调。原因spring-boot-starter-web未被排除Tomcat 容器拦截了 SSE 响应头Content-Type: text/event-stream导致Flux被静默转换为普通ResponseEntity。解决在pom.xml中显式排除 Tomcat并确认spring-boot-starter-reactor-netty已引入启动日志中应出现ReactorHttpHandlerAdapter初始化成功。现象前端收到data: {id:...,object:chat.completion.chunk,choices:[{delta:{content:...}}]}但ChatResponse解析失败报Missing type identifier。原因Spring AI 默认使用 Jackson 的DefaultTyping.NON_FINAL而 DeepSeek 返回的 JSON 结构与 Spring AI 内部ChatResponse类型不匹配如delta字段在Generation中不存在。解决自定义ObjectMapperBean禁用类型识别java Bean Primary public ObjectMapper objectMapper() { return JsonMapper.builder() .configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); }现象max-retries: 2配置无效429 错误Rate limit exceeded直接抛出未触发重试。原因Spring AI 的RetryPolicy默认只对IOException和WebClientResponseException的 5xx 子类生效429 属于WebClientResponseException的 4xx 子类需显式配置。解决在application.yml中扩展重试策略yaml spring: ai: deepseek: retry: max-attempts: 3 backoff: 1000 # ms retry-on: 429,500,502,503,504现象多用户并发调用时sessionId混乱A 用户看到 B 用户的历史消息。原因ChatMemory未绑定到具体会话StreamingChatClient默认使用单例InMemoryChatMemory所有请求共享同一内存池。解决注入ChatMemoryBean 并按sessionId分区java Bean public ChatMemory chatMemory(RedisConnectionFactory connectionFactory) { return new RedisChatMemory(connectionFactory, ai:session:); }并在streamChat()中显式传入streamingChatClient.stream(prompt).with(chatMemory)。现象deepseek-coder模型返回代码块时换行符\n被转义为\\n前端渲染为文字而非换行。原因Spring AI 的Generation.getText()方法对特殊字符做了双重转义。解决改用Generation.getOutput().get(content)返回原始字符串或自定义ChatResponse解析器对content字段做StringEscapeUtils.unescapeJava()处理。3. DeepSeek API 工程化封装不止是 apiKey更是 Token 计费、限流熔断、模型路由的三位一体DeepSeek 的 API 虽然简洁但生产环境绝不能只靠apiKey硬编码。真正的挑战在于如何让一个 Java 服务同时满足财务按 token 计费、运维防雪崩、产品A/B 测试模型三重约束Spring AI 1.0 提供了ChatModel的装饰器模式我们在此基础上构建三层封装。3.1 Token 计费拦截器每毫秒都算清楚成本DeepSeek 按输入 输出 token 总数计费。我们必须在请求发出前预估 token 数并在响应返回后精确统计。Spring AI 的ChatClient支持ChatClient.RequestCallback这是插入计费逻辑的黄金位置Component public class TokenCostInterceptor implements RequestCallbackChatRequest, ChatResponse { private final TokenCounter tokenCounter; // 基于 tiktoken-jvm 实现 private final BillingService billingService; Override public void beforeRequest(ChatRequest request, ClientRequest clientRequest) { // 预估输入 token含 system user message int inputTokens tokenCounter.countTokens(request.getMessages()); clientRequest.attributes().put(input_tokens, inputTokens); } Override public void afterResponse(ChatRequest request, ChatResponse response, ClientResponse clientResponse) { // 精确统计输出 tokenresponse 中实际返回的 content String outputText response.getGenerations().get(0).getText(); int outputTokens tokenCounter.countTokens(outputText); int inputTokens (int) clientRequest.attributes().get(input_tokens); long cost calculateCost(inputTokens, outputTokens); // 按 DeepSeek 官方价格表 // 记录到数据库 发送 Kafka 事件供财务系统消费 billingService.recordUsage( request.getModel(), inputTokens, outputTokens, cost, Instant.now() ); } private long calculateCost(int input, int output) { // deepseek-coder: $0.0001 / 1k tokens input, $0.0002 / 1k tokens output return Math.round((input * 0.0001 output * 0.0002) * 1000); } }关键点tiktoken-jvm是唯一成熟支持cl100k_baseDeepSeek 使用的分词器的 Java 库tokenCounter.countTokens()能 99% 还原 DeepSeek 服务端的 token 计数逻辑。ClientRequest.attributes()是 Spring AI 提供的跨生命周期上下文容器比 ThreadLocal 更安全。3.2 限流熔断网关用 Resilience4j 拦住 99% 的突发流量DeepSeek 免费 tier 限流为 10 QPM每分钟请求数商用 tier 也仅 100 QPM。单机 Spring Boot 实例若不做限流10 个并发用户就能打满配额。我们采用 Resilience4j 的RateLimiterCircuitBreaker双保险Bean public RateLimiter rateLimiter() { return RateLimiter.of(deepseek-api, RateLimiterConfig.custom() .limitForPeriod(80) // 每分钟最多 80 次预留 20% buffer .limitRefreshPeriod(Duration.ofMinutes(1)) .build()); } Bean public CircuitBreaker circuitBreaker() { return CircuitBreaker.of(deepseek-api, CircuitBreakerConfig.custom() .failureRateThreshold(50) // 错误率 50% 触发熔断 .waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断 30 秒 .permittedNumberOfCallsInHalfOpenState(3) // 半开态允许 3 次试探 .recordExceptions(IOException.class, WebClientResponseException.class) .build()); } // 在 service 层包装调用 public FluxChatResponse guardedStreamChat(Prompt prompt) { return RateLimiterOperator.of(rateLimiter()) .and(CircuitBreakerOperator.of(circuitBreaker())) .apply(streamingChatClient.stream(prompt)); }参数说明limitForPeriod(80)比 DeepSeek 配额低 20%避免因网络延迟导致的瞬时超限failureRateThreshold(50)DeepSeek 429 错误属于WebClientResponseException会被计入失败统计waitDurationInOpenState(30)熔断后等待 30 秒再尝试给 DeepSeek 服务端恢复时间permittedNumberOfCallsInHalfOpenState(3)半开态只允许 3 次请求全部成功才关闭熔断器。3.3 模型路由中心同一接口动态切换 deepseek-coder / deepseek-vl / deepseek-chat产品需求常要求对技术文档用deepseek-coder对图片描述用deepseek-vl对闲聊用deepseek-chat。我们不希望前端传modelxxx而是由后端根据contentType或userRole自动路由Service public class ModelRouter { private final MapString, ChatModel modelMap; public ModelRouter(Qualifier(deepseekCoderModel) ChatModel coderModel, Qualifier(deepseekVLModel) ChatModel vlModel, Qualifier(deepseekChatModel) ChatModel chatModel) { this.modelMap Map.of( code, coderModel, image, vlModel, chat, chatModel ); } public ChatModel route(String contextHint) { // 根据业务上下文选择模型 if (contextHint.contains(code) || contextHint.contains(function)) { return modelMap.get(code); } else if (contextHint.contains(image) || contextHint.contains(vision)) { return modelMap.get(image); } else { return modelMap.get(chat); } } }配合Bean声明多个ChatModel实例Bean Qualifier(deepseekCoderModel) public ChatModel deepseekCoderModel(DeepSeekChatProperties properties) { return new DeepSeekChatModel(properties.withModel(deepseek-coder)); } Bean Qualifier(deepseekVLModel) public ChatModel deepseekVLModel(DeepSeekChatProperties properties) { return new DeepSeekChatModel(properties.withModel(deepseek-vl)); }这样AiService只需注入ModelRouter调用router.route(context)即可获得对应模型实例彻底解耦前端请求与模型选择逻辑。3.4 避坑DeepSeek 生产部署的四大隐形雷区现象 → 原因 → 解决现象deepseek-vl模型上传图片后返回{error:{message:Invalid image format}}但图片明明是 PNG。原因DeepSeek VL 要求图片 Base64 编码前必须去除data:image/png;base64,前缀且编码后不能有换行符\n。解决前端上传前用base64String.replace(/[\r\n]/g, )清洗后端接收后校验java if (base64Image.startsWith(data:)) { base64Image base64Image.substring(base64Image.indexOf(,) 1); } base64Image base64Image.replaceAll([\r\n], );现象deepseek-coder生成 SQL 时总是多出反引号导致 MySQL 执行失败。原因DeepSeek Coder 训练数据大量来自 GitHub习惯用反引号标识字段但并非所有数据库都支持。解决在systemMessage中明确约束请生成标准 SQL字段名不使用反引号关键字大写或后处理正则替换sql.replaceAll(([^]*), $1)。现象deepseek-chat在连续多轮对话中突然忘记之前聊过的内容回答“我不记得”。原因DeepSeek 服务端默认不维护会话状态所有上下文必须由客户端在每次请求中完整提交。ChatMemory只负责缓存不自动注入到请求体。解决在streamChat()中显式构建历史消息java List history chatMemory.get(sessionId); Prompt prompt Prompt.from( ChatRequest.builder() .messages(history) // 注入历史 .addUserMessage(userMessage) .build() );现象deepseek-coder生成的 Java 代码中import语句缺失或类名大小写错误如Arraylist。原因DeepSeek Coder 在代码补全场景下倾向于生成最小化片段而非完整可编译文件。解决在systemMessage中强制要求请生成完整、可直接编译运行的 Java 类包含 package、import、public class 声明类名首字母大写并增加后端语法校验用JavaCompilerAPI 编译生成代码失败则触发重试或降级。4. 前后端 SSE 流式通信让“思考中…”变成真实进度条而不是前端玄学轮询很多团队用setInterval每 2 秒轮询一次/api/chat/status?idxxx既浪费资源又延迟高。真正的流式体验是前端通过EventSource直接接收服务端推送的 token 片段。但这套机制在 Spring Boot WebFlux 下极易翻车。4.1 后端 SSE Controller必须绕开 Spring MVC 的 ResponseBody 陷阱错误写法用RestControllerreturn Flux// ❌ 错误Spring MVC 会试图将 Flux 转为 JSON 数组破坏 SSE 格式 GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxChatResponse stream(RequestParam String message) { return aiService.streamChat(message, test-session); }正确写法用ControllerSseEmitter或直接FluxResponseBodyEmitterController public class AiStreamingController { private final AiService aiService; GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream( RequestParam String message, RequestParam String sessionId, RequestHeader(value X-Request-ID, required false) String requestId) { return aiService.streamChat(message, sessionId) .map(response - { String content response.getGenerations().get(0).getText(); // 构造标准 SSE 格式data: {json}\n\n String json {\text\:\ escapeJson(content) \,\finish_reason\:\ response.getFinishReason() \}; return ServerSentEvent.Stringbuilder() .data(json) .id(requestId ! null ? requestId : UUID.randomUUID().toString()) .build(); }) .doOnComplete(() - log.info(SSE stream completed for {}, sessionId)) .doOnError(error - log.error(SSE stream error for {}: {}, sessionId, error.getMessage())); } private String escapeJson(String str) { return str.replace(\, \\\).replace(\n, \\n).replace(\r, \\r); } }关键点Controller非RestController避免 Spring MVC 的ResponseBody自动序列化produces MediaType.TEXT_EVENT_STREAM_VALUE明确告知浏览器这是 SSE 流ServerSentEvent.builder()Spring WebFlux 提供的标准 SSE 封装自动添加data:前缀和\n\n分隔符escapeJson()对双引号、换行符转义防止 JSON 解析失败id()为每个事件分配唯一 ID前端可用eventSource.lastEventId实现断连重续。4.2 前端 EventSource 封装处理断连、重试、错误降级的工业级 SDK纯new EventSource()无法应对真实网络环境。我们封装一个AiSseClientclass AiSseClient { private eventSource: EventSource | null null; private onMessage: (chunk: string) void; private onError: (error: Error) void; private onOpen: () void; constructor( private baseUrl: string, private onMessage: (chunk: string) void, private onError: (error: Error) void, private onOpen: () void ) {} connect(sessionId: string, messageId?: string) { const url ${this.baseUrl}/chat/stream?sessionId${sessionId}; const options messageId ? lastEventId${messageId} : ; this.eventSource new EventSource(url options, { withCredentials: true, // 若需 Cookie 认证 }); this.eventSource.onopen () { this.onOpen(); console.log(SSE connected); }; this.eventSource.onmessage (event) { try { const data JSON.parse(event.data); this.onMessage(data.text || ); } catch (e) { console.warn(Invalid SSE data:, event.data); } }; this.eventSource.onerror (error) { console.error(SSE error:, error); this.onError(new Error(AI服务连接失败请检查网络)); this.reconnect(sessionId); // 断连自动重试 }; } reconnect(sessionId: string, attempt 1) { if (attempt 3) return; setTimeout(() { this.disconnect(); this.connect(sessionId); }, Math.min(1000 * attempt, 10000)); // 指数退避 } disconnect() { if (this.eventSource) { this.eventSource.close(); this.eventSource null; } } } // 使用示例 const sseClient new AiSseClient( /api, (chunk) { document.getElementById(output).innerHTML chunk; }, (error) { alert(error.message); }, () { console.log(SSE ready); } ); sseClient.connect(user-123);核心保障withCredentials: true支持跨域 Cookie 认证如 Spring Security Sessiononerror中的指数退避重连第 1 次 1s 后重试第 2 次 2s第 3 次 4s避免雪崩lastEventId参数断连后自动携带上次 ID服务端可从断点续推JSON.parse()包裹防御服务端返回非 JSON 数据。4.3 断连重续的后端实现用 Redis 存储未完成的流式响应前端重连时服务端需知道“这个 sessionId 的哪一段还没发完”。我们在AiService中加入断点续传逻辑Service public class AiService { private final RedisTemplateString, Object redisTemplate; public FluxServerSentEventString streamWithResume( String userMessage, String sessionId, String lastEventId) { // 1. 检查 Redis 中是否存在未完成的流式任务 String cacheKey ai:stream: sessionId; String lastSent (String) redisTemplate.opsForValue().get(cacheKey); if (lastSent ! null lastEventId ! null !lastEventId.equals(lastSent)) { // 2. 从 lastEventId 开始续传需 DeepSeek 支持 offset暂用简单方案 return resumeFromCache(sessionId, lastEventId); } // 3. 新建流式任务 return aiService.streamChat(userMessage, sessionId) .map(this::toSseEvent) .doOnNext(event - { // 4. 每次发送后更新 Redis 中的 lastEventId redisTemplate.opsForValue().set(cacheKey, event.id(), Duration.ofMinutes(5)); }) .doOnComplete(() - redisTemplate.delete(cacheKey)); } private ServerSentEventString toSseEvent(ChatResponse response) { String content response.getGenerations().get(0).getText(); String json String.format({\text\:\%s\,\finish_reason\:\%s\}, escapeJson(content), response.getFinishReason()); return ServerSentEvent.Stringbuilder() .data(json) .id(UUID.randomUUID().toString()) .build(); } }虽然 DeepSeek API 本身不支持 offset 续传但通过 Redis 缓存lastEventId我们至少能保证前端断连重连后不会重复收到已发送的 token也不会丢失中间片段。4.4 避坑SSE 在真实环境中的七宗罪现象 → 原因 → 解决现象Chrome 控制台报Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING。原因Nginx 默认proxy_buffering on会缓存部分响应体破坏 SSE 的实时性。解决Nginx 配置中添加nginx location /chat/stream { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_cache_bypass $http_upgrade; proxy_buffering off; # 关键 proxy_buffer_size 128k; proxy_buffers 4 256k; }现象iOS Safari 中EventSource不触发onmessage但 Chrome 正常。原因Safari 对 SSE 的data:字段格式更严格要求末尾必须有\n\n且不能有多余空格。解决ServerSentEvent.builder()已自动处理但需确认data()方法传入的是纯字符串不要额外拼接\n\n。现象eventSource.close()后服务端连接未立即释放netstat -an | grep :8080显示大量TIME_WAIT。原因Spring WebFlux 的Flux未被及时取消doOnCancel()未执行。解决在 Controller 中监听取消信号java return aiService.streamChat(...) .doOnCancel(() - log.info(Client cancelled stream for {}, sessionId)) .doFinally(signalType - { if (signalType SignalType.CANCEL || signalType SignalType.ON_ERROR) { redisTemplate.delete(ai:stream: sessionId); } });现象用户快速连续点击“发送”产生多个并发 SSE 连接服务端 CPU 100%。原因每个连接都触发一次streamChat()而 DeepSeek 调用是 IO 密集型。解决在 Controller 层加RequestScopeBean 或ConcurrentHashMap缓存对同一sessionId的请求进行排队java private final MapString, MonoFluxServerSentEvent pendingRequests new ConcurrentHashMap();public FluxServerSentEvent stream(...) { return pendingRequests.computeIfAbsent(sessionId, k - Mono.fromSupplier(() - aiService.streamWithResume(...)) ).flatMap(Function.identity()); }现象eventSource在页面隐藏visibilitychange时自动关闭切回前台后需手动重连。原因浏览器优化策略后台标签页暂停EventSource。解决监听页面可见性变化typescript document.addEventListener(visibilitychange, () { if (document.visibilityState visible !sseClient.isConnected()) { sseClient.connect(sessionId); } });现象ServerSentEvent.id()生成的 UUID 在 Firefox 中被截断导致lastEventId失效。原因Firefox 对id字段长度限制更严约 32 字符。解决改用短 IDUUID.randomUUID().toString().substring(0, 8)。现象eventSource连接成功但onmessage从未触发onopen却正常。原因服务端返回的data:字段未以\n\n结尾或中间有本文还有配套的精品资源点击获取
返回列表