1. 项目定位与整体接入思路1.1 为什么大家都在 Spring Boot 里接 DeepSeek前段时间有个做内部运营工具的朋友找我说想给团队做一个自动写周报的小助手让我帮忙评估一下技术方案。聊到最后他问了我一句你们 Java 后端接 DeepSeek到底怎么搞这个问题我在最近几个月里听了不下十遍基本验证了一个趋势越来越多公司开始把 DeepSeek深度求索的大模型能力接进自己的业务系统而 Java 生态里绝大多数工程都是 Spring Boot。作为一个常年写 Java 后端的开发我的看法很直接DeepSeek 对外暴露的是一个标准的 HTTP 接口并没有官方的 Java SDK所以你在 Spring Boot 工程里做的事情本质上就是发一个 HTTP 请求传一个消息结构体解析返回结果。听起来很简单但真正上手之后你会发现配置管理、流式响应、超时控制、上下文维护这些细节才是决定项目能不能上生产的关键。这篇文章就把我实际跑通的方案完整写出来适合那些准备在 Spring Boot 项目里接入 DeepSeek 的 Java 开发参考。1.2 三种接入方式对比选对了能省很多事我先说说市面上的主流做法对比一下再决定这篇文章用什么路线。第一种是直接用 Spring 自带的 RestTemplate、RestClient 或者 OkHttp 去调 HTTP 接口代码完全可控不引入额外依赖第二种是用 OpenAI 的 Java SDK因为 DeepSeek 的接口协议和 OpenAI 基本兼容改一下 baseUrl 就能用第三种是用 Spring AI 这类框架它把 ChatClient、Prompt、Message 这些概念都封装好了看起来很美但封装层厚出了问题不好排查。我个人强烈推荐第一种也就是直接用 RestClient Jackson 自己封装。原因有三点第一Spring Boot 自带的客户端能力完全够用写出来的代码量并不比 SDK 多第二每一层逻辑都是自己写的请求长什么样、超时怎么控制、错误怎么处理一目了然第三生产环境遇到诡异问题需要抓包定位的时候裸 HTTP 调用最方便。下面我所有的代码都是围绕这条路线展开的而且我会在对应位置说明如果是老版本 Spring Boot 2.7 该怎么调整。2. 工程搭建与依赖选型2.1 版本选择JDK 17 Spring Boot 3.2 是当前最优解如果你是从零开始建项目我建议直接用 JDK 17 和 Spring Boot 3.2 以上版本。一个重要的原因是Spring Framework 6.1 引入了全新的 RestClient它是官方用来替代 RestTemplate 的同步 HTTP 客户端API 设计得很舒服同时和 WebClient 在代码风格上高度一致。这意味着你既能轻松处理同步对话也能在需要流式输出时无缝切换到 WebClient两边共用同一套 DTO 和配置维护成本很低。如果你们公司项目还锁在 JDK 8 Spring Boot 2.7不用慌核心思路完全一样只是技术组件要换一下。RestTemplate 加上 ClientHttpRequestFactory 做超时控制流式部分可以用 WebClient 里内置的 Reactor 或者引入 OkHttp 的 EventSource 监听器。我会在后面的章节里把版本差异点标出来方便你们对照。2.2 Maven 依赖其实只需要两个 starter很多同学以为接入大模型要引入什么AI 专用的 SDK其实不需要。在一个标准的 Spring Boot Web 工程里只需确保下面几个依赖到位dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 如果要使用 WebClient 做流式需要引入 WebFlux 依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency注意同时引入 spring-boot-starter-web 和 spring-boot-starter-webflux 之后Spring MVC 仍然会作为 Web 应用的主容器WebFlux 只是作为依赖提供 WebClient 等类两者可以共存。我实际项目里就是这么做的一点问题没有。至于spring-boot-configuration-processor它本身不是运行时依赖但它能帮你自动生成配置属性的元数据写 application.yml 的时候会有代码提示对配置管理很有帮助。3. 核心代码实现从同步到流式3.1 先定义一套干净的请求与响应模型DeepSeek 的请求体核心就是 messages 数组每条消息由 role 和 content 组成。role 可以是 system、user、assistant分别代表系统提示词、用户输入和模型回复。我通常会在工程里定义下面这几个 Java 记录类public record ChatMessage( String role, String content ) {} public record ChatRequest( String model, ListChatMessage messages, Double temperature, JsonProperty(max_tokens) Integer maxTokens, Boolean stream ) {} public record ChatResponse( String id, String object, Long created, String model, ListChoice choices, Usage usage ) { public record Choice( Integer index, ChatMessage message, String finishReason ) {} public record Usage( Integer promptTokens, Integer completionTokens, Integer totalTokens ) {} }这里有两个非常容易踩的坑。第一个是max_tokens字段DeepSeek 接口要求 JSON 参数名是max_tokens带下划线而 Java 的命名习惯是驼峰maxTokens所以必须用JsonProperty(max_tokens)显式指定序列化后的名字否则请求发过去接口不认这个参数。第二个是流式响应和非流式响应的结构有差异流式返回的 choices 里不是完整的 message而是一个 delta 字段所以我一般还会单独定义一个用于流式解析的 DTO后面会细讲。3.2 同步调用用 RestClient 完成一次对话模型定义好之后最核心的服务类就简单了。我推荐用构造函数注入的方式把 RestClient.Builder 和配置属性类注入进来然后在构造方法里拼装好一个带默认 Header 的 RestClient 实例Service public class DeepSeekChatService { private final RestClient restClient; private final DeepSeekProperties props; public DeepSeekChatService(RestClient.Builder builder, DeepSeekProperties props) { this.props props; this.restClient builder .baseUrl(props.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer props.getApiKey()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } public ChatResponse chat(ListChatMessage messages) { ChatRequest request new ChatRequest( props.getModel(), messages, props.getTemperature(), props.getMaxTokens(), false ); return restClient.post() .uri(/chat/completions) .body(request) .retrieve() .body(ChatResponse.class); } }如果你是 Spring Boot 2.7 的老项目把 RestClient 换成 RestTemplate 即可原理一样HttpHeaders headers new HttpHeaders(); headers.setBearerAuth(props.getApiKey()); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityChatRequest entity new HttpEntity(request, headers); ResponseEntityChatResponse response restTemplate.exchange( props.getBaseUrl() /chat/completions, HttpMethod.POST, entity, ChatResponse.class );这段代码放在一个 Service 里Controller 层只需要接收用户输入组装好 messages 数组调用 service 就能拿到回答。我经验是先跑通同步调用把网络、鉴权、参数这些基础问题解决掉再上流式排查问题时会轻松很多。3.3 流式对话用 WebClient 实现打字机效果生产环境做聊天助手几乎都要流式输出也就是让用户看到内容一个字一个字往外蹦体验远比傻等几秒钟再一次性返回要好。DeepSeek 的流式接口返回的是标准的 Server-Sent EventsSSE所以后端只需要把数据流原样转发给前端即可。我项目中使用的核心代码如下public FluxString chatStream(ListChatMessage messages) { ChatRequest request new ChatRequest( props.getModel(), messages, props.getTemperature(), props.getMaxTokens(), true ); ParameterizedTypeReferenceServerSentEventString sseType new ParameterizedTypeReference() {}; return webClient.post() .uri(/chat/completions) .body(Mono.just(request), ChatRequest.class) .retrieve() .bodyToFlux(sseType) .filter(event - event.data() ! null ![DONE].equals(event.data().trim())) .map(event - extractContent(event.data())); } private String extractContent(String json) { try { JsonNode node objectMapper.readTree(json); JsonNode content node.at(/choices/0/delta/content); if (content.isMissingNode() || content.isNull()) { return ; } return content.asText(); } catch (JsonProcessingException e) { log.error(解析 DeepSeek 流式响应失败: {}, json, e); return ; } }这里有个技术细节值得展开说明。直接用bodyToFlux(String.class)拿原始字符串也不是不行但你需要自己处理 SSE 的data:前缀、空行、多行事件还要手动判断[DONE]结束标记稍微不注意就会漏数据或者解析报错。而用ParameterizedTypeReferenceServerSentEventString来接收WebClient 内部已经帮你把 SSE 事件拆好了直接用 event.data() 就能拿到 JSON 文本清爽很多。Controller 层需要设置响应内容类型为text/event-streamPostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestBody ChatRequestDTO dto) { return deepSeekChatService.chatStream(dto.messages()); }这样前端用 EventSource 或者 fetch 的流式读取就能直接消费不需要做额外转换。3.4 关于新版 OpenAI SDK 的一个补充说明如果你的项目里已经在用 OpenAI 的官方 Java SDK其实可以复用现有代码。DeepSeek 的接口协议是兼容 OpenAI 的所以只需要把 SDK 的 baseUrl 改成 DeepSeek 的地址API Key 换成 DeepSeek 的 Key模型名从gpt-4o改成deepseek-chat就能跑起来。但我不太推荐在核心链路里依赖这个 SDK因为它的版本更新频繁最近几个 release 的 API 变动不小而且它把很多底层细节封装掉以后出了问题你根本不知道是 SDK 的问题还是 DeepSeek 接口的问题。自己用 RestClient 写一遍总共也就几十行核心代码还能把超时、重试、日志都做成统一管理后期维护起来更省心。4. 配置管理与密钥安全4.1 application.yml 的多环境配置配置管理是接入外部 AI 接口时最需要上心的一环。我的习惯是把 DeepSeek 的所有参数抽到application.yml里用自定义前缀deepseek统一管理deepseek: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} model: deepseek-chat temperature: 0.7 max-tokens: 2048这里有一个安全红线必须强调API Key 千万不要硬编码在 yml 里并提交到代码仓库。我见过不止一个团队因为嫌麻烦把 key 直接写在配置文件中结果仓库权限一泄露账号额度几天之内被人刷爆。正确做法是像上面这样用${DEEPSEEK_API_KEY}占位让运维在部署环境变量或者配置中心里注入真实值。如果你使用的是 Nacos、Apollo 这类配置中心把deepseek前缀下的配置统一放在一个命名空间里也不错可以通过配置中心动态修改模型名称和温度参数不需要重启服务。4.2 配置属性绑定类的完整写法配合 yml我会定义一个DeepSeekProperties配置类用ConfigurationProperties绑定ConfigurationProperties(prefix deepseek) public class DeepSeekProperties { /** * DeepSeek 接口基础地址默认 https://api.deepseek.com */ private String baseUrl https://api.deepseek.com; /** * API Key建议通过环境变量注入 */ private String apiKey; /** * 默认模型名称 */ private String model deepseek-chat; /** * 采样温度 */ private Double temperature 0.7; /** * 生成的最大 token 数 */ private Integer maxTokens 2048; // getter / setter 省略 }然后在启动类上加上ConfigurationPropertiesScan或者在某一个Configuration配置类上加EnableConfigurationProperties(DeepSeekProperties.class)属性绑定才生效。我见过有同学漏掉这一步Service 里注入了配置类但所有字段都是 null接口调用直接空指针排查了半天才发现是扫描注解没加。5. 功能进阶会话上下文与参数调优5.1 多轮对话的上下文管理DeepSeek 的接口本身是无状态的它不会记住你上一次问了什么。要实现多轮对话体验客户端必须自己在每次请求时把历史消息拼进 messages 数组。通常的组装逻辑是系统提示词在最前面然后按时间顺序放历史对话最后跟上用户当前输入。public ListChatMessage buildMessages(String systemPrompt, ListMessageRecord history, String userInput) { ListChatMessage messages new ArrayList(); if (StringUtils.hasText(systemPrompt)) { messages.add(new ChatMessage(system, systemPrompt)); } for (MessageRecord m : history) { messages.add(new ChatMessage(m.role(), m.content())); } messages.add(new ChatMessage(user, userInput)); return messages; }但是这里有一个需要非常谨慎的处理点历史消息不能无限往里面塞。有次我在测试环境开着对话窗口聊了 30 轮请求体很快就超过几万 token既慢又贵还一度碰到上下文超限的报错。我的经验是只保留最近 6 到 10 轮并且通过字符数或 token 数做硬上限。如果业务确实需要长期记忆那就要考虑引入向量数据库做检索增强把历史对话摘要存起来需要时再召回但这属于进阶架构不建议第一版就上。另外系统提示词system prompt对回答质量的影响非常大。如果你是做一个客服助手建议在这里写清楚角色定位、回答风格、不能越界的事项。比如你是一个金融产品的售后客服回答要简洁、专业遇到不确定的问题要引导用户转人工效果会明显好于没有系统提示词的裸奔状态。5.2 温度与 max_tokens 到底怎么调DeepSeek 接口的几个核心采样参数和 OpenAI 风格一致它们的含义我用大白话解释一下参数推荐值适用场景temperature0.2~0.4代码生成、SQL 转换、信息抽取、格式校验temperature0.7~0.9文案创作、头脑风暴、闲聊对话max_tokens512~1024短回复、分类、命名实体识别max_tokens2048~4096长文写作、代码补全、详细分析temperature 控制的是输出的随机性。调低模型每次回复更稳定、更靠近高概率答案调高回复更多样但也更容易出现胡编乱造。做财务或法务类场景我一般直接调到 0.2 甚至 0.1。max_tokens 是单次生成的上限不是输入上限如果你在同一个请求里塞了很长的历史消息又把 max_tokens 设得特别大有可能超过模型上下文窗口的限制这时候要么裁剪历史要么减小 max_tokens。还有一个点必须提醒DeepSeek 有专门的deepseek-reasoner模型对应 R1 系列它会在正式回答前生成一大段推理过程。如果业务只是普通问答用默认的deepseek-chat就够了响应快、消耗少。只有做数学题、复杂推理、代码架构分析这类场景再切到 reasoner。我见过有同学全程用 reasoner结果请求慢了一倍token 成本翻了好几倍业务并没有感知到质量提升纯属浪费。5.3 让模型按指定格式返回实际业务里我们往往需要定制模型输出格式而不是让前端去解析一段自然语言。最稳的做法是用 system prompt 约束输出结构再配合 JSON 解析。比如要模型返回一个技能标签列表我会设计如下提示词请从以下用户的提问中抽取技能关键词只返回 JSON 数组格式不要包含其他文字。 示例输出格式[Java, Spring Boot, Redis]然后后端的 system prompt 加上示例模型基本都能稳定输出结构化的 JSON。如果用deepseek-chat还会出现少量不是纯 JSON 的情况后端需要做一层容错把返回内容里首尾的 标记去掉找到第一对花括号或方括号再做解析。这里建议用 Jackson 的 ObjectMapper 解析解析失败时把原始内容记为异常并提示用户重新输入不要直接把解析异常抛给前端。6. 线上问题排查与性能调优实录6.1 高频异常一览与定位思路接入 DeepSeek 过程中我踩过不少坑这里把最常见的问题和一个排查思路整理成速查表现象大概率原因处理建议401 UnauthorizedAPI Key 错误、丢失 Bearer 前缀检查配置用 curl 手动验证鉴权429 Too Many Requests触发限流或账户余额不足查看账户额度增加重试和退避降低并发400 Bad Requestmessages 结构错误、max_tokens 超限打印请求 JSON检查 role 和 content 字段连接超时 / Read timed out模型响应慢客户端超时设置太短调大 read timeout改用流式接口中文乱码响应压缩或编码设置问题检查 HTTP 客户端是否支持 gzip请求头加 Accept-Encoding关于 400我想多说两句。遇到这种错误第一时间要把实际发出的 JSON body 打印出来看很多情况下是你自己把 messages 里的 role 写错或者某个字段传了 null。还有一种情况是历史消息里不小心把 assistant 消息的 content 设成了空字符串DeepSeek 对空 content 的容忍度有时候挺低的直接报 400。削除空字符串后再重试一般就能解决。流式接口还有一个特殊问题服务端返回的 SSE 流可能因为网络原因中断客户端如果没有做超时恢复机制页面就会一直转圈。我的方案是给流式接口设置一个整体的空闲超时比如超过 120 秒没有任何事件到达就断开并给前端返回一个友好提示。6.2 连接池、超时与并发调优很多同学接到第一个项目时并发量并不高随便写一个 RestTemplate 就上线了。一旦流量涨起来就会出现大量连接超时和线程池阻塞。原因多半是外部 AI 接口响应慢把 Tomcat 的业务线程都占满了。解决思路有两个角度第一个角度是配置好 HTTP 客户端的连接池和超时。比如用 JDK 内置的 HttpClient 时可以这样设置HttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .executor(Executors.newFixedThreadPool(32)) .build(); ClientHttpRequestFactory factory ClientHttpRequestFactorySettings .defaults() .withConnectTimeout(Duration.ofSeconds(10)) .withReadTimeout(Duration.ofSeconds(60)) .withHttpClient(httpClient) .toClientHttpRequestFactory(); RestClient client RestClient.builder() .baseUrl(props.getBaseUrl()) .requestFactory(factory) .build();这里连接超时建议放在 10 秒以内读超时如果走流式接口可以放在 60 秒以上因为首字节返回之后后面的内容是一个字一个字生成的整体耗时可能比较长。但要注意如果用的是同步接口过长的读超时会让 Tomcat 线程长时间占用所以能上流式就上流式。第二个角度是给外部 AI 调用单独建一个线程池隔离。我的经验是核心线程 10、最大线程 20、队列容量 200专门用于 DeepSeek 调用请求进来先丢队列有空闲线程再消费这样即使 DeepSeek 偶发抖动也不会把整个业务系统拖垮。等你们后续接入多个模型或者多个供应商还可以在此基础上做一个统一的外部 API 调用网关统一负责超时、重试、监控和限流。6.3 本地调试的 curl 命令与抓包技巧最后分享一个我百试百灵的调试方法任何接口问题先用 curl 验证一遍。这能帮你快速分清问题出在请求本身还是Java 代码层。curl --location https://api.deepseek.com/chat/completions \ --header Content-Type: application/json \ --header Accept: application/json \ --header Authorization: Bearer 你的KEY \ --data { model: deepseek-chat, messages: [ {role: system, content: 你是一个Java架构师}, {role: user, content: 请用一句话介绍Docker} ], temperature: 0.7, max_tokens: 256, stream: false }如果 curl 能正常返回结果而 Java 代码不行那问题一定在你的客户端配置比如请求头没设置对、序列化有问题、或者超时过短。排查的时候还能在 Java 侧临时加一段日志把restClient.post()最终的 URL 和 body 打印出来和 curl 的请求体对比很快就能定位是哪里的差异。最后说点个人体会。我在实际项目里接触 DeepSeek 的接入需求已经有好几次最大的感受是技术难度真的不高真正花时间的是把超时、流式解析、上下文管理、密钥安全这些边角料打磨好。尤其是流式响应很多人第一次做的时候都栽在 SSE 解析和空指针上。我这篇文章几乎把我的踩坑记录都写出来了希望能让后来的人少走点弯路。你用 RestClient 跑通同步再用 WebClient 接上流式按这个顺序走基本两天之内就能交付一个能用的 AI 接口服务。