上周把DeepSeek API接进我们Spring Boot项目时,群里一个同事贴了张报错截图:api_key_required。Key明明配了,文档也翻了,怎么还是401?这个场景几乎每个团队第一次接大模型接口都会遇到。模型本身不难,难的是在工程体系里把一次HTTP调用安排得明明白白。这篇文章我会把在Spring Boot里对接DeepSeek API的完整过程,从选型、鉴权、请求封装、流式输出到生产环境踩坑,一层层拆开讲。适合后端Java工程师,也适合想自己搭一个AI功能Demo的爱好者参考。
1. 为什么在Spring Boot里接DeepSeek API:先想清楚再动手
1.1 这不止是一次HTTP调用
很多人以为接大模型API就是“发一个POST请求,拿到返回字符串”,真正做起来你会发现,这只是冰山一角。DeepSeek API本身是一个HTTP接口,但你的业务系统需要处理的问题远远超出“能调通”这个层面:HTTP连接超时和读超时怎么设置、Token用量怎么统计、模型返回格式错误怎么兜底、用户上下文怎么保持、并发上来之后线程池怎么不被打满。
这些问题单靠一个HttpClient随手调一调是扛不住的。Spring Boot的好处在于,它有成熟的依赖注入、配置体系、AOP、线程池管理和监控埋点,很适合把一个“裸的HTTP调用”包装成公司内部可复用、可观测、可治理的AI能力组件。我们最终选择用Spring Boot做这件事,不是因为它有多花哨,而是因为团队所有人都在这个技术栈里,维护成本最低。
1.2 先决定API调用放在哪一层
动手之前,先把DeepSeek API调用放在什么位置想清楚。我当时给团队提的建议是:不要把API Key散落在各个业务服务里,而是单独做一个llm-gateway模块,所有业务方(搜索助手、内容总结、客服问答)都通过这个模块访问大模型。这样API Key只存在一个地方,审计日志只在一个地方,限流和降级也只在一个地方。
如果只是写一个简单Demo,那随意,Controller里直接注入一个Service即可。但如果你预判这个能力会扩展到多个业务场景,从第一天就做一层隔离,后面会轻松很多。Spring Boot的包结构天然适合这种方式:configuration放请求客户端配置,client放HTTP封装,dto放请求响应模型,service放业务编排。
1.3 官方SDK和自封装,我为什么选后者
DeepSeek的API兼容OpenAI协议,所以社区里很多OpenAI的Java SDK也能直接用,另外Spring官方生态里也有Spring AI,它已经把OpenAI、DeepSeek等模型商接入做了统一抽象。那为什么我还要手写一个REST客户端?
原因有三:第一,很多SDK为了兼容所有模型商,抽象层特别厚,出了问题排查链路很长;第二,我们需要的自定义逻辑,比如按业务线拆分token配额、把错误码映射成内部异常、在请求日志里记录消息摘要,SDK不一定支持得那么顺手;第三,DeepSeek的API本身很简单,一个POST /chat/completions就搞定了,自己封装几十行代码就够,没必要引入一整个依赖树。
当然,如果你们团队已经用了Spring AI,那也没有必要推翻重来,接着用就好。我这里讲的实现方式,是“不依赖额外大模型SDK、用Spring Boot原生能力也能跑得很好”的路线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 接入前必做的基础准备:Key、模型和请求格式
2.1 获取API Key,并选对模型
去DeepSeek开放平台注册账号、创建API Key,这些操作按文档走就行。需要注意几点:
- API Key要保存好,一旦泄露就要立即吊销,它等同于你账户的资金凭证;
- 生产环境不要硬编码在代码里,放在环境变量或者配置中心;
- 根据场景选模型,
deepseek-chat和deepseek-reasoner是两个不同方向。
| 模型 | 适用场景 | 特点 |
|---|---|---|
| deepseek-chat | 通用对话、文本生成、内容总结 | 响应快,成本较低,上下文最长支持1M tokens |
| deepseek-reasoner | 数学推理、代码逻辑、复杂分析 | 带思维链推理过程,耗时更长,消耗token更多 |
我们大部分业务用的都是deepseek-chat,只有需要深度推理的内部工具用了deepseek-reasoner。选模型这件事,不要一个配置走天下,建议在请求DTO里把model字段设计成可配置项。
2.2 OpenAI兼容格式:把请求体写对
DeepSeek API的接口路径是https://api.deepseek.com/chat/completions,请求结构跟OpenAI几乎一样。最精简的请求体长这样:
json复制{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个智能助手"},
{"role": "user", "content": "用一句话介绍Spring Boot"}
],
"stream": false
}
messages数组是整个对话的核心,数组里每个元素代表一条消息,role有system、user、assistant三种,content是消息正文。系统提示词、历史对话、新问题都通过这个数组传给模型。不需要像老式接口那样拼字符串,模型会把数组里的所有内容当成完整的对话上下文。
2.3 鉴权头:Authorization里最常见的401
鉴权方式是在HTTP头里加Authorization: Bearer <你的API Key>。我见过最多的错误,就是代码里漏了Bearer这个前缀,或者把Key填进Header的参数名错了,服务端返回的一律是api_key_required之类错误。
一个完整的调通验证,用curl直接在命令行测最快:
bash复制curl https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "你好"}],
"stream": false
}'
这个命令能通过,说明Key、网络、请求体格式都是对的,接下来再写Spring Boot代码才不抓瞎。很多人一上来就先写代码,代码跑不通又分不清是网络问题、Key问题还是JSON格式问题,浪费时间。
3. 核心实现:手写REST客户端完成一次对话请求
3.1 用WebClient而不是RestTemplate
Spring Boot里调外部HTTP接口,常见选择是RestTemplate、WebClient和OpenFeign。如果是同步阻塞、追求简单,RestTemplate够用;但考虑到后面要做流式输出,能原生支持SSE(Server-Sent Events)的WebClient明显更合适。
WebClient是Spring WebFlux里的非阻塞客户端,但也可以在传统Servlet项目里用,配合.block()方法还能转成同步调用。我最后选了WebClient,核心理由就一个:只要涉及流式返回,RestTemplate用起来会别扭很多,不如直接在客户端层面就把这条路铺好。
依赖只需要在pom.xml里加Spring Web和WebFlux的依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
注意,一个传统的spring-boot-starter-web项目里加WebFlux依赖不会有冲突,因为只是引入了客户端能力,不会改变你的Web MVC行为。
3.2 配置参数:连接、读取、超时
API调用最烦人的就是第三方服务慢,如果读超时设得太短,DeepSeek在复杂推理时可能Response还没返回,你的线程就超时释放了;如果设得太长,服务又容易被慢请求拖住。我生产中用的参数供参考:
yaml复制deepseek:
api-key: ${DEEPSEEK_API_KEY}
base-url: https://api.deepseek.com
connect-timeout: 5s
read-timeout: 60s
max-tokens: 4096
connect-timeout是TCP连接建立的超时,5秒足够;read-timeout是等待响应数据的超时,这个一定要给足,尤其deepseek-reasoner模型思考时间可能以十秒计,我甚至遇到过90秒才返回的推理请求。把配置放在application.yml里,通过@ConfigurationProperties绑定到Java对象,后续调整不用改代码。
3.3 DTO设计:字段别一股脑全接
调用DeepSeak的请求字段很多,但业务代码真正关心的没有几个。我建了三个核心DTO:
ChatCompletionRequest:封装model、messages、stream、max_tokens等字段;ChatMessage:表示一条消息,含role和content;ChatCompletionResponse:接收响应,重点取choices和usage。
响应体从choices[0].message.content取内容,从usage.prompt_tokens和usage.completion_tokens取token消耗。有一点容易迷惑:非流式响应里,choices[0].message.content是完整文本;如果开了stream,响应结构会变成多行SSE事件,后面单独说。
java复制@Component
public class DeepSeekChatClient {
private final WebClient webClient;
private final DeepSeekProperties properties;
public DeepSeekChatClient(WebClient.Builder webClientBuilder,
DeepSeekProperties properties) {
this.properties = properties;
this.webClient = webClientBuilder
.baseUrl(properties.getBaseUrl())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getApiKey())
.build();
}
public ChatCompletionResponse chat(String userMessage) {
ChatCompletionRequest request = new ChatCompletionRequest();
request.setModel(properties.getModel());
request.setMessages(List.of(
new ChatMessage("system", "你是一个严谨的技术助手"),
new ChatMessage("user", userMessage)
));
request.setStream(false);
request.setMaxTokens(properties.getMaxTokens());
return webClient.post()
.uri("/chat/completions")
.bodyValue(request)
.retrieve()
.bodyToMono(ChatCompletionResponse.class)
.block();
}
}
3.4 错误处理:把HTTP错误翻译成业务异常
WebClient默认遇到4xx、5xx会抛WebClientResponseException,但那是通用异常,业务方看到也不知道是什么问题。我习惯写一个全局异常映射,把HTTP状态码翻译成业务可读的异常类型:
- 401:
InvalidApiKeyException,提示检查API Key; - 429:
RateLimitException,提示触发限流,需要退避重试; - 400:
BadRequestException,通常是请求体格式不对,比如消息数组异常、model不存在; - 500/502/503:
UpstreamServerException,属于模型服务端不稳定,可以重试。
这一步看似琐碎,却是“能用”和“好用”的分水岭。没有映射之前,每次调用失败只能看到一串HTTP状态码,有了映射之后,告警通知里直接能知道该找谁、该做什么。
4. 流式输出:从SSE到WebSocket的前后端体验优化
4.1 为什么要开stream
非流式调用是等模型全部生成完再一次性返回,用户体验就是“转圈好久,忽然蹦出一大段文字”。流式调用开启后,模型每生成一小段内容就推送到客户端,效果类似打字机。
DeepSeek的流式接口开启方式很简单:请求体里加"stream": true。但响应格式变了:返回内容不再是普通JSON,而是一行一行以data:开头的SSE事件,最后还有一个data: [DONE]标记表示结束。
4.2 用WebClient消费SSE流
WebClient对SSE有比较好的支持,用retrieve().bodyToFlux(ServerSentEvent.class)就能逐条消费事件。核心代码思路是这样:
java复制public Flux<String> streamChat(List<ChatMessage> messages) {
ChatCompletionRequest request = new ChatCompletionRequest();
request.setModel(properties.getModel());
request.setMessages(messages);
request.setStream(true);
return webClient.post()
.uri("/chat/completions")
.bodyValue(request)
.retrieve()
.bodyToFlux(ServerSentEvent.class)
.filter(event -> event.data() != null)
.takeUntil(event -> "[DONE]".equals(event.data()))
.map(event -> parseDelta(event.data()));
}
这里的parseDelta从每个事件里的choices[0].delta.content取值。流式事件的JSON结构和非流式不完全一样,不是message.content,而是delta.content。这个细节我一开始也看漏了,抓了半天没数据。
4.3 把流推给前端:WebSocket比SSE更实用
后端拿到了Flux<String>,接下来是怎么推给前端。如果是纯后端Demo,直接把Flux返回给HTTP接口,Spring也能处理。但真实项目里,通常还要把AI生成的过程展示在网页上,而且用户可能同时发多个问题。
我建议用WebSocket转发流式内容。前端建立WebSocket连接后,后端把每次生成的片段通过socket.sendMessage()推过去。这样做的优势是双向通信,前端可以随时发“取消生成”的指令,这是纯SSE不好做到的。
Spring Boot里用spring-boot-starter-websocket,写一个Handler,把DeepSeek流和WebSocket会话拼起来。这里提醒一句:一个WebSocket连接的生命周期内,可能要处理多次问答,所以消息里最好带一个requestId,前端才能区分当前内容属于哪个问题。
4.4 流中断、客户端断开和错误兜底
流式体验好,但坑也更多。最常见的问题是客户端中途断开,但后端还在消费DeepSeek的流,白白消耗token。解决方案是在WebSocket关闭事件里,取消对应的Flux订阅。
另一个问题是流式输出中途报错。SSE流在中间某一行突然出现错误事件,后面就断掉了。我的兜底策略是:解析事件时如果遇到错误数据,就把已经收到的文本返回给前端,并在内容末尾加一个标记,提示“生成中断”,同时记录错误日志。这样用户至少能看到部分内容,而不是整个对话卡死。
5. 从单次调用到复杂应用:工具调用、上下文管理与并发
5.1 别让messages数组无限膨胀
很多业务系统做多轮对话时,会把所有历史消息一股脑塞进messages数组。一旦对话轮次变多,token消耗会指数级上涨,最后触发上下文超限。
DeepSeek的官方文档写得很清楚,上下文窗口上限很高(比如1M tokens,后面会讲这个数字的坑),但窗口再大也架不住无限加历史。我的做法是滑动窗口:只保留最近N轮对话,超过N轮就把更早的消息丢弃,同时压缩系统提示词,把固定规则写在程序里而不是每次塞给模型。
一个简单逻辑:
java复制public List<ChatMessage> buildMessages(String userInput,
List<ChatMessage> history,
int maxRounds) {
List<ChatMessage> messages = new ArrayList<>();
messages.add(systemMessage());
if (history != null && history.size() > maxRounds * 2) {
messages.addAll(history.subList(history.size() - maxRounds * 2, history.size()));
} else if (history != null) {
messages.addAll(history);
}
messages.add(new ChatMessage("user", userInput));
return messages;
}
上下文管理属于典型的“不做不会报错、做了明显省钱”的优化。每次省几十个token看起来不起眼,日均百万次调用的时候就差别很大了。
5.2 工具调用:让模型学会调用你的函数
热词里有个报错信息是“messages tool calls need immediate results”,乍看莫名其妙。这个其实是在讲工具调用(Tool Calls / Function Calling)的流程错误。DeepSeek API支持让模型在对话中决定要不要调用你预设好的函数,比如查天气、查库存。
工具调用的完整流程是两段式的:
- 第一段:请求里带上
tools参数,模型如果认为需要调用工具,响应里会返回tool_calls数组,而不是直接给最终答案; - 第二段:你执行完工具,把工具结果以
role: tool的消息追加到messages中,再一次请求模型,模型才能基于工具结果生成最终文本。
错误信息“tool calls need immediate results”指的就是:模型第一段返回了tool_calls,但你的程序没有立刻把工具执行结果作为tool消息传回去,而是先去做了别的逻辑,或者构造了一个不完整的messages数组,API就会直接报错。注意这一步必须立即完成,不能在中间插入其他轮次的用户消息。
json复制{
"role": "tool",
"tool_call_id": "call_xxx",
"content": "查询结果:库存剩余12件"
}
工具调用是让DeepSeek从“聊天机器”变成“能操作系统的接口”的关键能力。企业内部的“用自然语言查订单”、“用自然语言调报表”,本质都是这个模式。
5.3 虚拟线程:Java 21让并发调用变简单
DeepSeek API调用是典型的IO密集型操作,一个请求可能等好几秒。传统线程池模式下,每个请求占用一个线程,线程数量不敢开太大。Java 21的虚拟线程(Virtual Threads)解决得恰到好处:线程开销极低,可以支撑很多并发等待中的请求。
如果你们用的是Spring Boot 3.2以上版本,配合Java 21,可以通过启用虚拟线程来提升吞吐:
yaml复制spring:
threads:
virtual:
enabled: true
开启之后,Tomcat处理和业务调用的线程模型会切到虚拟线程。实测下来,同一个接口的并发能力明显提升,因为等待DeepSeek响应时不再长期占用昂贵的平台线程。我们的主业务从Java 17升级到Java 21之后就顺手开了虚拟线程,改动很小收益明显。
5.4 缓存与降级:控制调用量和成本
大模型API是按token收费的,调用上要有点成本意识。两个常用手段:
一是在逻辑层加缓存,问题相同、上下文相同就直接返回缓存结果,比如“用户问一段固定FAQ”,完全不用每次去调模型;二是降级策略,当API返回429限流或云端不稳定时,返回预设的兜底文案,而不是让请求直接失败。
缓存我用Spring的CacheManager,在Service层加注解就行。降级我一般写到接口的错误处理里:捕获RateLimitException或者UpstreamServerException后,返回一个“服务暂时繁忙,请稍后再试”的固定响应。宁可告诉用户暂时不可用,也不要让前端页面白屏。
6. 实测中绕不开的坑:400错误、上下文超限与超时
6.1 maximum context length 1048576 tokens的真相
看到这个报错时,第一反应是难以置信:1048576,正好是1024乘1024,也就是1M tokens。它不是告诉你模型上下文不够,而是在说——你这次请求要送进去的内容,加上希望返回的内容,超过了模型的最大上下文窗口。
很多人以为1M tokens很大,随便用,但真踩报错的通常有两种情况:
- 多轮对话历史没裁剪,几十轮对话把上下文塞爆了;
- 请求里
max_tokens设置过大,预留的生成空间跟历史消息加起来超限了。
我遇到第二次这个报错之后就翻代码,发现是某个循环里把同一段历史消息重复添加了几十次。排查方式很简单:在请求日志里打上本次messages数组的预估token数,或者更直接,统计一下messages数组里有几条消息、总字符数是多少。通常一剪裁就能解决。
6.2 “messages tool calls need immediate results”的完整排查链路
还有一次,同事在集成本地大模型工具调用时一直报这个错。我们完整的排查路径是这样的:
- 先确认第一段请求的响应里是不是真的有
tool_calls字段,如果没有,说明模型没有触发工具,问题出在prompt或tools参数配置; - 如果有
tool_calls,下一步检查第二段请求的messages是否包含了上一次的assistant响应,并且这个assistant响应的内容里有原始的tool_calls字段; - 再检查tool消息是否带上了正确的
tool_call_id,这个ID需要跟模型返回的tool_calls[].id完全一致; - 最后确认这第二段请求是否紧挨着第一段,中间有没有混入新的user消息。
走到第4步才发现,同事为了加一个日志,在第二段请求前插入了一条普通消息,破坏了“立即衔接”的规则。去掉之后就通了。工具调用对消息顺序的要求非常严格,它就是一场“我说、你做、你汇报、我再总结”的对话流程,中间插嘴自然要报错。
6.3 常见错误码排查清单
| HTTP状态码 | 错误信息 | 原因 | 处理 |
|---|---|---|---|
| 400 | Invalid request format | JSON格式错误、model字段写错、messages为空 | 用curl验证请求体 |
| 400 | maximum context length exceeded | 输入+输出超过上下文窗口 | 裁剪历史、减小max_tokens |
| 400 | tool calls need immediate results | tool调用流程被打断 | 按上一小节的4步排查 |
| 401 | api_key_required | Authorization头缺失或格式错误 | 检查Bearer前缀和Key |
| 401 | Invalid API key | Key本身错误或已吊销 | 重新生成Key |
| 429 | Rate limit reached | 请求频率超过限制 | 退避重试或降级 |
| 503 | Service unavailable | 模型服务端过载 | 重试或切备用通道 |
表格里列的是我实际遇到过的错误,覆盖面足够了。建议每个人都把这张表贴到团队Wiki里,省得每次报错都从零开始查。
6.4 本地部署与第三方聚合:接线方式的区别
除了官方平台,接入DeepSeek还有两种方式。一种是本地部署:用Ollama或vLLM这类工具加载模型服务,Base URL改成http://localhost:11434之类的地址,Key可以留空或写个占位符。适合内部私有化需求。如果用Docker Desktop在Windows上跑本地模型,偶尔会遇到连不上docker API的问题,先检查Docker Desktop是否已经切到Linux容器模式并正常启动。
另一种是走OpenRouter这类模型聚合平台,好处是一个Key能切换多个模型商的接口,参数格式也是OpenAI风格。如果你用OpenRouter,请求Base URL要改成OpenRouter的地址,Key用OpenRouter平台发的,模型名也可能变成deepseek/deepseek-chat这种带前缀的格式。整体代码几乎不用改,改配置就行。生产环境建议还是优先官方API,聚合平台适合做多模型对比和灵活切换的场景。
7. 生产可用的最后一道工序:日志、重试和监控
7.1 每次调用都要能追溯
接大模型API之后,出一个新问题:“这个回答是哪次调用生成的?”“消耗了多少token?”“是不是把Key写进了日志?”大模型接口的日志比普通接口更敏感,因为请求内容可能包含用户隐私。
我的做法是在Service层统一打点,每次调用记录:请求ID、业务方标识、模型名、是否流式、token用量、耗时、最终错误码。日志里不打印完整的messages内容,只打印消息数量和内容摘要,涉及用户敏感信息的一律脱敏。这样真要排查问题时,能从traceId关联到调用链,又不把全文都落在日志里。
7.2 重试:别把重试写成雪崩
API调用肯定要做重试,但重试策略如果写得不细致,会变成故障放大器。DeepSeek返回429(限流)时,盲目立刻重试只会让限流更严重。正确的是用指数退避:第一次失败等1秒,第二次等2秒,第三次等4秒,最多重试3次。5xx错误可以重试,但4xx错误(尤其是400、401)重试没有意义,因为改代码之前重试多少次都是白费。
Spring框架有spring-retry,加注解就能实现退避:
java复制@Retryable(
value = {UpstreamServerException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChatCompletionResponse chatWithRetry(String message) {
return client.chat(message);
}
注意把RateLimitException排除在重试列表之外,或者用单独的退避策略,不然429的场景反而被重试打得更死。
7.3 埋点:让成本和性能可见
最后一步是把metrics暴露给监控系统。我用Micrometer埋了几个关键指标:
- 请求总数(按模型、业务方、是否成功拆分);
- 请求耗时分布(P50、P95、P99);
- token消耗速率(每分钟的输入token和输出token);
- 限流次数和上游5xx次数。
这些指标一旦上线,你就可以在监控大盘上看到“哪个业务的prompt太长导致token消耗飙升”“哪个接口的P99涨了多少秒”。没有监控之前,大模型调用的成本是黑盒,每个月账单来了才知道贵;有监控之后,成本和性能都在掌控内。最后再分享一个生产环境的小经验:所有大模型API调用统一走一个出口,不管后端还是前端要接,都只面向这个出口暴露方法,这样日志、重试、熔断、监控这些能力只用实现一遍。上个月我们排查一个线上偶发超时,就是靠着统一的链路日志快速定位到某个业务方的prompt塞了上万字历史,裁剪后接口P99从8秒降到了2秒。到这一步,DeepSeek API在这个Spring Boot项目里才算真正进入了生产可用状态。
