我在第一版的代码里遇到了一个很典型的问题:单独用 RestTemplate 请求 DeepSeek API,接口能通,但一个错误提示几乎让人崩溃——400 This model's maximum context length is 1048576 tokens。那篇文章断在这里,估计很多读者也卡在这里。这次我把完整的 Spring Boot 实现思路、错误排查和流式升级方案一次性整理出来,作为一个可直接上手的参考。
记得第一次接到这个需求的时候,团队后端是 Spring Boot 3.2 + JDK 17,产品要在一个新模块里接入大模型能力,要求不能动现有前端架构,也不能把整个服务推倒重来。最终选择是在 Spring Boot 项目里直接调用 DeepSeek API。原因很简单:它的接口协议跟 OpenAI 的 Chat Completions 高度兼容,Java 端不需要专门 SDK,自己封装一个 REST 客户端就行。这篇文章就从选型开始,讲环境搭建、非流式与流式调用、常见 400/401 报错,以及 Java 21 虚拟线程在性能上的配合。目标读者是需要在 Spring Boot 服务里接入大模型 API 的后端同学,希望能帮你们少踩几个我踩过的坑。
1. 为什么在 Spring Boot 里接 DeepSeek,而不是单独开一个 Python 代理服务
1.1 什么情况下适合直接在 Java 服务里调用大模型 API
我之前见过不少团队,一提到大模型调用,条件反射式地认为应该用 Python 写一个独立服务,再让 Spring Boot 去调它。理由是 Python 生态里 LangChain、LlamaIndex 这些框架成熟,方便做 agent。对于一个有大量 prompt 工程、工具调用、复杂记忆管理的项目,这个思路没问题。但如果你的需求只是“给现有业务加一个 AI 接口”,比如文本摘要、智能分类、生成回复、问答匹配,那在 Spring Boot 里直接调模型 API 是最省事的方案。
直接集成的好处有三个:
- 链路短:Java 服务直接请求模型,不用让请求多跳一层 Python 网关,排查问题少一个环节。
- 部署简单:不引入新的语言运行时,Docker 镜像、监控、日志这些基础设施全部复用现有体系。
- 统一异常处理:Java 服务已经有的统一返回体、重试机制、熔断降级可以直接用在这次调用上。
判断标准我一般就一个:如果核心价值在“编排逻辑”,比如大模型只是其中一个环节,后面还要接数据库、搜索引擎,那可以考虑独立代理服务;如果核心价值是“把模型能力嵌入业务”,那直接在业务服务里写一个 Client 就够了。
1.2 DeepSeek API 的三个特点,决定了它在 Java 后端很好接
第一个特点是协议兼容性。DeepSeek 的/chat/completions接口风格跟 OpenAI 基本一致,Java 侧用RestTemplate或WebClient就可以调,不需要额外安装任何模型 SDK。这意味着所有基于 OpenAI 协议封装好的 Spring AI Starter、开源客户端,稍微改一下 Base URL 和模型名就能用。
第二个特点是中文任务效果好。DeepSeek 系列模型在中文理解、抽取、文本润色上表现稳定,这对国内业务场景很重要。很多情况下,用同样 prompt 去对比其他模型,DeepSeek 在中文长文本上的忠实度更好。
第三个特点是上下文窗口大。DeepSeek 的上下文高达 1048576 tokens,也就是 1M 级别。这恰好解决了我之前遇到的一个痛点:产品让我做长文档摘要,动辄几千行文本,普通 128K 窗口要分段多次调,还得拼接结果。DeepSeek 可以一次把文档塞进去,简化了很多工程逻辑。
1.3 既然有 Spring AI,为什么我还推荐先手写
Spring AI 是 Spring 官方做的大模型抽象层,思路类似JdbcTemplate,用ChatClient屏蔽厂商差异。如果你的服务是 Spring Boot 3.x 起步,又想快速接入 OpenAI 兼容接口,Spring AI 确实是个选项。它的配置类似:
spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat实际用起来,请求和响应封装都比较整洁。但我个人的建议是,第一次接 DeepSeek 先用原生 HTTP 写一个最小实现,让先跑通。原因有两个:一是 Spring AI 的版本迭代快,有时会调整 API 包名,如果你不熟悉它的抽象,出问题后很难判断是模型报错还是框架封装问题;二是手写版本逻辑透明,请求体、响应体、错误处理都在自己手里,后期迁移到 Spring AI 或自定义 Agent 框架都更容易。
2. 动手前先把环境准备好:JDK 版本、Maven 依赖和密钥配置
2.1 Spring Boot 3.x 与 JDK 的选择
DeepSeek 官方对 Java 没有特殊要求,所以选型完全取决于服务现状。我建议 Spring Boot 3.2 及以上,JDK 17 起步。如果团队已经在用 Java 21,那就更好,后面要说的虚拟线程优化会用得上。Spring Boot 3.2 以上版本里,spring-boot-starter-web默认基于 Spring MVC 6,配合 RestTemplate 足够处理普通 REST 请求;如果要流式输出,还需要再引入 WebFlux 依赖。
2.2 pom.xml 依赖怎么加,HTTP 客户端选哪个
如果只做非流式请求,spring-boot-starter-web就够了,因为里面包含了RestTemplate所需的 Spring MVC 核心。如果你想做 SSE 流式,我建议加上 WebFlux,用WebClient去消费事件流。不要指望RestTemplate在流式场景下体验好,能跑但解析麻烦。
我这边实际用到的依赖如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> </dependencies>有人会问,同时引入 spring-boot-starter-web 和 spring-boot-starter-webflux 会不会冲突?实际上 Spring Boot 会自动识别 Web MVC 作为主配置,WebClient只是作为客户端工具使用,不影响现有的 Controller 运行。当然,如果你不想引 WebFlux,也可以用RestTemplate加ResponseExtractor手动读流,但代码会繁琐一些。
2.3 API Key 配置的正确方式:环境变量 + ConfigurationProperties
API Key 不能硬编码在 Java 代码里,也不能直接写在 application.yml 里提交到 Git。最稳妥的做法是用环境变量注入。配置文件这样写:
deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com model: deepseek-chat max-tokens: 2048然后定义一个配置属性类:
@ConfigurationProperties(prefix = "deepseek") public class DeepSeekProperties { private String apiKey; private String baseUrl; private String model; private int maxTokens; // getter / setter }在主类上加上@EnableConfigurationProperties(DeepSeekProperties.class),之后在@Service里注入就可以。这样密钥与环境绑定,部署时通过配置中心或 CI/CD 注入即可。
2.4 先用 curl 冒烟测试,把问题控制在请求之外
我踩过一个坑:代码写了半天,结果报 401,才发现是环境变量没生效。其实用 curl 先验证一次,一分钟就能定位问题。启动服务之前,先跑一下:
export DEEPSEEK_API_KEY=你的key curl -X POST https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEEPSEEK_API_KEY" \ -d '{"model": "deepseek-chat", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 20}'如果能正常返回 JSON,说明 Key、网络、模型名都正常。后面写 Java 代码遇到的 4xx/5xx 就可以明确归类到代码层。curl 这一步一定要做,否则你无法区分是密钥问题还是代码问题。
3. 非流式调用:RestTemplate + chat/completions 的最小可用实现
3.1 DTO 设计:请求、消息、响应
我用 Java 21 的 record 来简化代码。数据结构上只需要三个核心类型:请求体、消息体、响应体。
public record DeepSeekMessage(String role, String content) {} public record DeepSeekChatRequest( String model, List<DeepSeekMessage> messages, Double temperature, Integer max_tokens, Boolean stream ) { public static DeepSeekChatRequest of(String model, String userContent) { return new DeepSeekChatRequest( model, List.of( new DeepSeekMessage("system", "你是一个严谨的技术助手"), new DeepSeekMessage("user", userContent) ), 0.7, 2048, false ); } } public record DeepSeekMessageResponse( String role, String content ) {} public record DeepSeekChoice( DeepSeekMessageResponse message ) {} public record DeepSeekUsage( int prompt_tokens, int completion_tokens, int total_tokens ) {} public record DeepSeekChatResponse( List<DeepSeekChoice> choices, DeepSeekUsage usage ) {}temperature是采样温度,一般文本生成场景设 0.7 左右;代码生成或小任务可以更低,比如 0.2。max_tokens控制单次回答最大长度,默认 2048 够用。stream非流式下设为false即可。
3.2 RestTemplate 连接超时配置与 Service 封装
RestTemplate 需要一个 Bean,这个 Bean 要配置连接超时和读取超时。DeepSeek 生成长文本时响应时间可能超过 30 秒,读取超时不能设太短,否则会出现“任务还在生成,客户端已经超时断开”的情况。我自己的经验值是连接超时 5 秒,读取超时 60 秒。
@Configuration public class RestTemplateConfig { @Bean public RestTemplate deepSeekRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5000); factory.setReadTimeout(60000); return new RestTemplate(factory); } }Service 层代码如下:
@Service public class DeepSeekApiClient { private final RestTemplate restTemplate; private final DeepSeekProperties deepSeekProperties; public DeepSeekApiClient(RestTemplate restTemplate, DeepSeekProperties deepSeekProperties) { this.restTemplate = restTemplate; this.deepSeekProperties = deepSeekProperties; } public DeepSeekChatResponse chat(String userContent) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(deepSeekProperties.getApiKey()); DeepSeekChatRequest request = DeepSeekChatRequest.of( deepSeekProperties.getModel(), userContent ); HttpEntity<DeepSeekChatRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<DeepSeekChatResponse> response = restTemplate.exchange( deepSeekProperties.getBaseUrl() + "/chat/completions", HttpMethod.POST, entity, DeepSeekChatResponse.class ); if (response.getStatusCode().is2xxSuccessful()) { return response.getBody(); } throw new DeepSeekApiException("DeepSeek API 调用失败: " + response.getStatusCode()); } }setBearerAuth方法会自动处理Bearer前缀,比自己拼字符串更保险。我见过有人写成"Bearer " + key时不小心在Bearer后面加了两个空格,结果 API 返回 401。用现成方法最稳。
3.3 Controller 暴露接口,跑通第一个请求
Controller 层尽量保持轻薄,不要在里面拼请求体,只做参数接收和结果返回:
@RestController @RequestMapping("/api/ai") public class AiController { private final DeepSeekApiClient deepSeekApiClient; public AiController(DeepSeekApiClient deepSeekApiClient) { this.deepSeekApiClient = deepSeekApiClient; } @PostMapping("/chat") public DeepSeekChatResponse chat(@RequestBody ChatRequest request) { return deepSeekApiClient.chat(request.message()); } public record ChatRequest(String message) {} }启动服务后,用 Postman 或 curl 发一个 POST 请求:
{"message": "用一句话解释什么是 RESTful API"}如果一切正常,返回的 JSON 会包含choices[0].message.content,你从前端把这段 content 展示出来就好。到这里,基本的最小闭环已经完成。
4. 线上最常遇到的 5 个 400/401 错误,附排查链路
4.1 "maximum context length is 1048576 tokens" 不是让你把 max_tokens 调成 1M
这个 400 错误我在群聊和社区里见过太多次,热词里也明确出现了:api error: 400 this model's maximum context length is 1048576 tokens。howevere...。如果你的请求里包含了大量历史消息,或者把max_tokens设成了一个很大的数,比如直接照抄上下文窗口长度,那么prompt_tokens + max_tokens一旦超过模型上限,就会触发这个错误。
我排查过一次线上问题:产品要做长文档分析,代码里把用户传来的整份合同直接塞进messages,又把max_tokens设置成了 4096。结果算出来总长度超过限制,接口直接 400。处理方式很简单:
// 错误示范 new DeepSeekChatRequest( "deepseek-chat", messages, 0.7, 1048576, false ); // 正确做法 new DeepSeekChatRequest( "deepseek-chat", messages, 0.7, 2048, false );如果你的业务场景是长文档摘要,建议先做切片。比如把超过 5000 字的内容按章节拆成多段,每次只提交一段给模型,最后再让模型合并结果。不要以为上下文窗口大就无脑全塞,超长输入不仅慢,还会因为中间内容被截断而出现摘要遗漏。
4.2 api_key_required:鉴权报错先查 Authorization 头
另一个高频错误是:
{"code":"api_key_required","message":"api key is required in authorization header"}这个错误看着是没带 API Key,实际原因很多。我总结了一条排查链路,按顺序走基本十分钟能定位:
echo $DEEPSEEK_API_KEY看环境变量是否真的存在。- curl 用同一个 Key 试一次,确认 Key 本身有效。
- 检查 Java 代码里
HttpHeaders是否真的设置了Authorization头,方法是不是setBearerAuth。 - 确认 deployment 环境没有把配置覆盖成空字符串。
- 留意日志里是否无意中打印了
headers,如果打印了也只会泄露风险,不会让请求成功。
从经验看,大部分情况是配置中心覆盖了本地环境变量,或者 CI/CD 部署时没传入这个环境变量。把错误信息当成“一定没带 key”来排查,容易被带偏。
4.3 model 名称与 messages 格式,慢慢对文档,别自己发明参数
如果你用的模型名不准确,或者 messages 少了必要字段,也可能收到 400。我列一个对照表:
| 错误现象 | 常见原因 | 修正方式 |
|---|---|---|
| Model not found / invalid model | 写成 deepseek-v3 或 deepseek-coder | 使用deepseek-chat或deepseek-reasoner |
| messages might be empty | 数组传了空列表 | 至少传一条 user 消息 |
| system 角色位置不对 | system 放在 user 后面,部分模型建议 system 在最前 | 按 system、user、assistant 顺序排列 |
| Unsupported parameter | 传了 OpenAI 专有字段,比如 n、logprobs | 先删掉,再逐个打开 |
DeepSeek 虽然兼容 OpenAI 协议,但并不是所有 OpenAI 参数都支持。例如response_format在部分模型上支持有限,seed也不一定每次都生效。这些参数一旦不被模型支持,返回的 400 提示可能很隐晦。我建议只保留必要参数:model、messages、temperature、max_tokens、stream,其余的一步一步试。
4.4 tool_calls 返回后,必须马上补一条 tool 消息再请求
如果你在做 function calling,会经常遇到choices[0].message.tool_calls。很多新手的误区是,拿到工具调用结果后就只回给前端,不再提交给模型。但协议要求第二轮请求的messages里必须包含三样东西:
- 原始 user 消息
- assistant 消息,其中包含
tool_calls字段 - 一条 role 为
tool的新消息,tool_call_id对应要执行的工具调用 ID,content是本地执行结果
所以 DTO 需要扩展,不能只保留 role 和 content。示例扩展:
public record DeepSeekMessage( String role, String content, List<DeepSeekToolCall> tool_calls, String tool_call_id ) { public DeepSeekMessage(String role, String content) { this(role, content, null, null); } } public record DeepSeekToolCall( String id, String type, DeepSeekFunction function ) {} public record DeepSeekFunction( String name, String arguments ) {}第二轮的请求消息类似:
List<DeepSeekMessage> messages = List.of( new DeepSeekMessage("user", "这周杭州天气怎么样?"), new DeepSeekMessage("assistant", null, List.of(toolCall), null), new DeepSeekMessage("tool", "{"result":"明天杭州有雨,最高温度 28℃}", null, "call_123") );我发现很多人会漏掉“assistant 消息必须原样放回”这一点。第二次请求如果不带 assistant 的tool_calls,模型会不知道这个 tool 是谁调用的,上下文断裂,就会报类似messages tool calls need immediate results的错。处理方式就是手写一个“循环调用”:拿到tool_calls-> 执行本地方法 -> 追加 tool 消息 -> 重新请求,直到模型返回正常content为止。
4.5 超时、连接池和重试策略,别把 400 当 500 处理
DeepSeek 这种大模型 API 有几个和普通接口不一样的地方。
第一是响应时间波动大。简单问答可能 1 秒返回,长文本生成可能要 50 秒。如果你用默认的 RestTemplate,默认读取超时是无穷大,在生产上不推荐;但如果你设置成 5 秒,又会频繁超时。建议长文本场景下读取超时给 60 秒以上。
第二是连接池问题。SimpleClientHttpRequestFactory不维护连接池,每次请求都新建 TCP 连接,QPS 上来之后性能会急剧下降。建议用 Apache HttpClient 或 OkHttp 作为底层实现。举个例子:
@Bean public RestTemplate deepSeekRestTemplate() { CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() .setMaxTotal(50) .setDefaultMaxPerRoute(20) .build()) .build(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient); factory.setConnectTimeout(5000); factory.setConnectionRequestTimeout(5000); factory.setReadTimeout(60000); return new RestTemplate(factory); }第三是重试策略。注意,400 类错误是参数或上下文问题,重试多少次都一样,不应该重试;超时、5xx 可以考虑重试。幂等性也要考虑:同一个问题发给模型,虽然结果是概率性的,但从业务角度,生成答案这个动作本身可重复,只是会消耗 token。我的做法是超时错误最多重试 1 次,而且要退避,比如间隔 1 秒,避免把服务打爆。
5. 流式输出与性能优化:WebClient + SSE、虚拟线程、缓存降级
5.1 什么时候必须用流式
非流式调用适合“前端不着急,等完整结果再一起展示”的场景。但如果你做的是对话机器人、客服助手、文档生成编辑器,用户等 20 秒才看到第一句话,这个体验很难接受。流式响应(SSE)可以做到模型每生成一小段,就立即推给浏览器,实现打字机效果。
DeepSeek 的流式调用不复杂:请求体里stream: true,服务端返回text/event-stream格式,每行是一个data:数据块,直到最后data: [DONE]。前端用EventSource或 fetch 流式读取,后端用 WebClient 的bodyToFlux(String.class)逐行解析。
5.2 WebClient 解析 SSE 的代码实现
先简化请求构建。因为stream是 true,我会显式构造一个请求对象:
public Flux<String> streamChat(String userContent) { DeepSeekChatRequest request = new DeepSeekChatRequest( deepSeekProperties.getModel(), List.of(new DeepSeekMessage("user", userContent)), 0.7, 2048, true ); return webClient.post() .uri("/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + deepSeekProperties.getApiKey()) .contentType(MediaType.APPLICATION_JSON) .bodyValue(request) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data: ")) .filter(line -> !line.contains("[DONE]")) .map(this::parseDelta); }parseDelta方法解析响应块中的增量内容:
private String parseDelta(String line) { String json = line.substring("data: ".length()); DeepSeekStreamResponse chunk = objectMapper.readValue(json, DeepSeekStreamResponse.class); if (chunk.choices() == null || chunk.choices().isEmpty()) { return ""; } DeepSeekStreamChoice choice = chunk.choices().get(0); if (choice.delta() == null) { return ""; } return choice.delta().content() == null ? "" : choice.delta().content(); } record DeepSeekDelta(String content) {} record DeepSeekStreamChoice(DeepSeekDelta delta) {} record DeepSeekStreamResponse(List<DeepSeekStreamChoice> choices) {}Controller 直接返回Flux<ServerSentEvent<String>>,Spring 会帮你包装成 SSE 格式:
@GetMapping(value = "/api/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestParam String message) { return deepSeekApiClient.streamChat(message) .map(content -> ServerSentEvent.builder(content).build()); }这里有一个容易被忽略的地方:bodyToFlux(String.class)拿到的是一行一行的数据,HTTP 自动分块后,JSON 里不一定会一次性到齐。所以解析时最好判断字符串是否满足data:前缀,不满足就先跳过。我曾经因为没判断前缀,在超大响应把多行拼接成半个 JSON 时崩溃过。
5.3 Java 21 虚拟线程:阻塞调用场景下的低成本升配
如果你用的还是 RestTemplate 这种阻塞式客户端,在高并发下需要注意线程池占满问题。一个请求阻塞 30 秒,意味着 Tomcat 的线程池里有一个线程被“挂起”,如果并发 100 个这类请求,再叠加其他业务接口,线程池可能直接耗尽。
Spring Boot 3.2 之后,配合 JDK 21,可以直接开启虚拟线程,把每个请求处理线程从“重量级操作系统线程”变成“轻量级虚拟线程”。开启方式非常顺滑:
spring.threads.virtual.enabled=true相当于给 Tomcat 的 worker 线程换了个实现。对 DeepSeek 这种 IO 密集型的阻塞调用,收益非常明显。我测过一个小场景,原本一个长文本生成接口占用 100 个 Tomcat 线程后会开始出现排队,开启虚拟线程后同样负载下几乎没有线程池告警。
有几个坑要记住:虚拟线程并不是万能的,CPU 密集型计算不会因为虚拟线程变快;另外,如果代码里用了synchronized去占一块共享资源,虚拟线程阻塞在那也会被挂起,本质上没有减少等待。所以启用前先确认瓶颈确实是“等待远程 IO”。
5.4 缓存和降级才是线上稳定的关键
模型接口的性能再好,也不如不调。我见过一项数据分析需求,每天要生成几百次文本,内容重复率还不低。一开始是每次调用 API,管理后台一刷新就发起一次请求,token 消耗快,接口响应又慢。后来加了缓存,对同一批 key 的结果直接复用,性能立刻提升一个量级。
用 Spring Cache 加 Caffeine 实现就很合适:
@Cacheable(value = "deepseek", key = "#message") public String chatWithCache(String message) { return chat(message).choices().get(0).message().content(); }配置:
spring: cache: type: caffeine注意,多轮对话不能无脑缓存,因为上下文不同,同样一句“你好”在不同会话里可能期待不同回答。我的策略是只对无状态单轮请求开缓存,并且缓存 key 加上模型版本和 prompt 版本,避免升模型后返回旧结果。
降级方面,可以用 Resilience4j 给 DeepSeek 调用加熔断。当模型接口连续失败超过阈值,就直接走 fallback 返回兜底文案,而不是把异常抛给用户。代码逻辑类似:
@CircuitBreaker(name = "deepseek", fallbackMethod = "chatFallback") public DeepSeekChatResponse chat(String userContent) { // 调用 DeepSeek API } public DeepSeekChatResponse chatFallback(String userContent, Exception e) { return new DeepSeekChatResponse( List.of(new DeepSeekChoice(new DeepSeekMessageResponse("assistant", "AI 服务繁忙,请稍后再试"))), new DeepSeekUsage(0, 0, 0) ); }线上重要接口一定要有兜底,因为模型服务商的稳定性再好,也可能存在热点时段超时、限流。有了降级机制,用户至少能看到合理的提示,而不是一个刺眼的 500。
5.5 给新手的接入顺序建议
如果你现在正打算在 Spring Boot 项目里接 DeepSeek,我的建议是先跑通非流式调用,再做流式,最后再考虑虚拟线程、缓存、熔断这些优化。别一上来就上 WebFlux,也不要理想化地直接做 function calling。按这个顺序来,每一步都能独立验证,出问题也能快速定位。
好记的推进顺序就是:非流式跑通一个接口 -> 封装异常和处理 400/401 -> 换连接池和超时 -> 加缓存降级 -> 按需升级到流式 -> 再决定要不要开虚拟线程。这样你每一步的收益都很明确,而且不会出现“一次集成太多,不知道哪里出错”的困境。