1. 为什么把“调通 DeepSeek”当成第一站
如果你最近在关注 AI 应用开发,大概率会发现一个现象:身边做后端的人,讨论最多的不是某个大模型跑分多高,而是“怎么把大模型接进自己的项目”。我在自己的项目里做这块时,最初也犹豫过:是直接调官方 API,还是先研究怎么本地部署一套模型?后来权衡下来,用 Spring Boot 调用 DeepSeek API 是最快能出活、又能把原理讲清楚的一条路。
先说结论:DeepSeek 这类模型服务的 API 设计得足够简单,本质上就是一个 HTTP 接口,你把文本发过去,它把文本返回给你。但它又不是一个普通的 HTTP 接口,因为牵扯到鉴权、流式传输、上下文管理、超时处理、费用控制等一系列现实问题。这些问题不踩一遍,光看文档是记不住的。
这篇内容适合三类人:
- 后端 Java 开发,想在 Spring Boot 项目里快速集成一个 AI 对话能力。
- 产品经理或全栈工程师,想搞一个 demo 验证业务想法,但不想碰复杂的 Python 异步框架。
- 已经从教程里跑通过 Hello World,但不知道怎么组织代码、怎么处理异常、怎么上生产的同学。
我需要先把丑话说在前面:这篇文章不是“一行代码接入大模型”的魔法教程。它会从一个最普通的 Spring Boot 工程出发,走一遍我实际开发时的完整路径——先调通,再工程化,然后谈优化。看完之后,你会有一个可以跑起来的项目骨架,同时理解每一块代码为什么要这么写。
2. 准备阶段:环境、密钥、第一次请求
2.1 环境选择:JDK 17 + Spring Boot 3.x
我在新项目里默认使用 JDK 17 和 Spring Boot 3.x,不只是因为它们是当前的主流版本,更因为 Spring Boot 3.x 基于 Jakarta EE,对现代 HTTP 客户端支持更好,后面我们用的RestClient是 Spring Framework 6.1 才正式成为一等公民的组件。如果你还在用 Spring Boot 2.x,当然也能实现,但要自己引入RestTemplate或者OkHttp,代码会稍微绕一点。
Maven 依赖只需要一个spring-boot-starter-web,后面做 JSON 解析时再补一个jackson-databind(其实 Web 起步依赖里已经包含了)。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.1</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>2.2 拿到 API Key 之后,先用 curl 试探
很多教程一上来就让写代码,但我建议你先用curl走一遍。为什么?因为这样能把“网络问题”和“代码问题”分开。如果 curl 都通了,代码还报错,那一定是你代码写错了;如果 curl 就不通,那你直接写代码,报错都不知道该查哪一层。
注册并登录之后,在控制台创建一个 API Key,复制保存好。然后在终端执行:
curl https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的密钥" \ -d '{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个测试助手。"}, {"role": "user", "content": "用一句话介绍你自己"} ], "stream": false }'如果一切正常,你会看到一段 JSON 响应,里面结构大概是这样的:
{ "id": "chatcmpl-xxx", "object": "chat.completion", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "你好,我是测试助手..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 14, "completion_tokens": 20, "total_tokens": 34 } }这段响应里,最核心的就是choices[0].message.content,这就是模型生成的内容。usage里的三个 token 数值则直接对应费用,后面我们聊成本控制时会用到。
到这步,基本可以确认:模型服务没问题、密钥没问题、网络没问题。接下来就是纯粹的后端工程问题了。
2.3 接口信息的记忆方法
DeepSeek 的 API 地址和 OpenAI 的兼容地址非常好记:https://api.deepseek.com后面直接跟路径就可以,比如/chat/completions。也就是说,base URL 不需要拼/v1才生效。这一点我单独拎出来说,是因为很多人在网上查到的示例都是https://api.deepseek.com/v1/chat/completions,两种写法其实都能通,但如果你接的是某些 OpenAI 官方的 SDK,那 SDK 内部可能会默认补一个/v1,这时候如果你在配置里也写了/v1,就会变成/v1/v1,直接 404。
这是非常典型的“配置拼接”问题。我的习惯是:在配置里统一写不带/v1的地址,把路径拼接逻辑放代码里维护。
3. 代码实现:先把一句话发出去
3.1 最小工程结构长什么样
不引入多余的业务复杂度,一个可运行的工程只需要这几部分:
com.example.deepseek ├── DeepSeekDemoApplication.java ├── config │ └── RestClientConfig.java ├── dto │ ├── ChatRequest.java │ ├── Message.java │ └── ChatResponse.java ├── service │ └── DeepSeekService.java └── controller └── ChatController.java有的同学喜欢直接在 Controller 里拼 JSON,图省事。我强烈不建议这样干:一旦遇到请求参数校验、日志记录、响应字段升级,你会被那坨代码恶心到怀疑人生。DTO 层多写几个类,花不了三分钟,但后面所有环节都会受益。
3.2 写请求和响应的 DTO
请求体本身很简单,但要注意字段名必须和 DeepSeek API 定义保持一致,JSON 反序列化时靠的就是字段名。
package com.example.deepseek.dto; import java.util.List; public class ChatRequest { private String model; private List<Message> messages; private boolean stream; public ChatRequest() { } public ChatRequest(String model, List<Message> messages, boolean stream) { this.model = model; this.messages = messages; this.stream = stream; } public String getModel() { return model; } public void setModel(String model) { this.model = model; } public List<Message> getMessages() { return messages; } public void setMessages(List<Message> messages) { this.messages = messages; } public boolean isStream() { return stream; } public void setStream(boolean stream) { this.stream = stream; } }Message的作用是承载对话里的一条消息。为什么不能只传一个字符串?因为大模型的对话是基于消息列表的,system消息定义人设,user消息是用户输入,assistant消息是模型的历史回复。多轮对话的本质,就是不断往这个列表里追加消息。
package com.example.deepseek.dto; public class Message { private String role; private String content; public Message() { } public Message(String role, String content) { this.role = role; this.content = content; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } }响应 DTO 不需要把全部字段都定义出来,只定义你关心的字段即可。Jackson 在反序列化时会自动忽略 JSON 里没有对应属性的字段。这个特性经常被忽略,但它非常实用:你只需要定义一个“最小可用模型”。
package com.example.deepseek.dto; import java.util.List; public class ChatResponse { private List<Choice> choices; public List<Choice> getChoices() { return choices; } public void setChoices(List<Choice> choices) { this.choices = choices; } public static class Choice { private Message message; public Message getMessage() { return message; } public void setMessage(Message message) { this.message = message; } } }3.3 用 RestClient 发请求,把代码写顺
Spring 6.1 的RestClient是我现在最喜欢的 HTTP 客户端,它比RestTemplate更流畅,比WebClient更轻。配置方式非常直接:
package com.example.deepseek.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.web.client.RestClient; @Configuration public class RestClientConfig { @Bean public RestClient deepSeekRestClient( @Value("${deepseek.api-key}") String apiKey, @Value("${deepseek.base-url}") String baseUrl) { return RestClient.builder() .baseUrl(baseUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) .build(); } }这里有一个细节:Authorization头的格式是Bearer <密钥>,中间有个空格,拼错或者漏掉,服务端会返回 401。我见过不下三个人在这里栽跟头,排查半天最后发现是少了空格。
Service 层是核心,它要做三件事:组装请求、发送请求、解析响应。
package com.example.deepseek.service; import com.example.deepseek.dto.ChatRequest; import com.example.deepseek.dto.ChatResponse; import com.example.deepseek.dto.Message; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.util.List; @Service public class DeepSeekService { private static final String MODEL_CHAT = "deepseek-chat"; private static final String PATH_CHAT_COMPLETIONS = "/chat/completions"; private final RestClient restClient; public DeepSeekService(RestClient deepSeekRestClient) { this.restClient = deepSeekRestClient; } public String chatWithSingleMessage(String userInput) { Message systemMessage = new Message("system", "你是一位乐于助人的中文助手。"); Message userMessage = new Message("user", userInput); ChatRequest request = new ChatRequest(MODEL_CHAT, List.of(systemMessage, userMessage), false); ResponseEntity<ChatResponse> response = restClient.post() .uri(PATH_CHAT_COMPLETIONS) .body(request) .retrieve() .toEntity(ChatResponse.class); if (response.getBody() == null || response.getBody().getChoices().isEmpty()) { throw new RuntimeException("DeepSeek API 返回了空响应"); } return response.getBody().getChoices().get(0).getMessage().getContent(); } }Controller 只需薄薄一层,把输入接住,把结果返回:
package com.example.deepseek.controller; import com.example.deepseek.service.DeepSeekService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/chat") public class ChatController { private final DeepSeekService deepSeekService; public ChatController(DeepSeekService deepSeekService) { this.deepSeekService = deepSeekService; } @PostMapping public Map<String, String> chat(@RequestBody Map<String, String> payload) { String message = payload.get("message"); String reply = deepSeekService.chatWithSingleMessage(message); return Map.of("reply", reply); } }application.yml里这样配置:
deepseek: api-key: sk-你的密钥 base-url: https://api.deepseek.com启动项目之后,用 Postman 或 curl 发一个 POST 请求到http://localhost:8080/api/chat,请求体为{"message": "你好"},就可以收到模型的回复。到这一步,你已经在自己的 Spring Boot 项目里跑通了大模型调用。
4. 工程化改造:从“能跑”到“扛得住”
如果你的目标只是做一个本地 demo,上面那版代码已经够了。但假如这个接口将来要面对真实用户,光有正路径是不够的。
4.1 连接池和超时:大模型接口不是数据库
第一次把代码部署到测试环境时,我遇到了一个经典问题:请求偶尔成功、偶尔报超时。刚开始以为是模型服务不稳定,后来抓日志发现,根本原因是我直接用RestClient.builder()建客户端,底层使用的是 JVM 默认的 HTTP 连接配置,连接池小、超时时间也不合理。大模型接口的响应时间波动极大,快的时候几百毫秒,慢的时候十几秒,这取决于当前服务的负载和输入长度。如果你把超时时间设成 5 秒,那一定会时不时报错。
我的建议是不要在 Spring 的RestClient默认配置上裸奔,而是用 ApacheHttpClient或 JDKHttpClient作为底层实现,显式控制连接池、超时等参数。
以 JDK 的HttpClient为例:
package com.example.deepseek.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; import java.net.http.HttpClient; import java.time.Duration; @Configuration public class RestClientConfig { @Bean public RestClient deepSeekRestClient( @Value("${deepseek.api-key}") String apiKey, @Value("${deepseek.base-url}") String baseUrl) { HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(Duration.ofSeconds(60)); return RestClient.builder() .baseUrl(baseUrl) .requestFactory(requestFactory) .defaultHeader("Content-Type", "application/json") .defaultHeader("Authorization", "Bearer " + apiKey) .build(); } }读超时设成 60 秒,是因为 deepseek-chat 在处理超长上下文时,单次响应确实可能达到 30 秒以上。如果你用的是 deepseek-reasoner(推理模型),耗时会更长,因为它在返回最终答案前要先生成一段思维链。
4.2 异常处理:把错误信息翻译成人话
直接.retrieve().toEntity(...)有一个问题:当 API 返回 4xx 或 5xx 时,Spring 会直接抛异常,但这个异常里携带的服务端错误信息是英文的,而且会被通用异常包装掉,不方便排查。
更好的做法是使用exchange方法,手动处理响应状态码:
public String chatWithSingleMessage(String userInput) { // ... 组装请求 ... String content = restClient.post() .uri(PATH_CHAT_COMPLETIONS) .body(request) .exchange((request1, response) -> { if (response.getStatusCode().is2xxSuccessful()) { ChatResponse body = objectMapper.readValue(response.getBody(), ChatResponse.class); if (body.getChoices().isEmpty()) { throw new RuntimeException("DeepSeek API 返回了空 choices"); } return body.getChoices().get(0).getMessage().getContent(); } // 读取错误流,记录详细错误信息 String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); throw new DeepSeekApiException(response.getStatusCode().value(), errorBody); }); return content; }自定义一个DeepSeekApiException,在全局异常处理器里对用户给出友好提示,同时把完整错误信息打印到日志。这样线上出问题时,你不需要靠猜,直接看日志里记录的errorBody就能定位原因。
4.3 敏感信息保护:API Key 不能进配置文件
把 API Key 直接写在application.yml里再提交到 Git,等于把自己的钱包公开了。我自己的习惯是环境变量优先,本地开发用.env文件,生产环境用部署平台的机密管理能力。
application.yml里改成:
deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: ${DEEPSEEK_BASE_URL:https://api.deepseek.com}启动时通过环境变量注入,既保证代码仓库里不泄露密钥,又保持了配置的灵活性。
4.4 日志记录:记下每次调用的代价
每次调用大模型都在产生费用,如果不做日志,月底看到账单才发现异常就很被动了。我在 Service 层加了个简单的时间统计和 token 统计:
long start = System.currentTimeMillis(); ChatResponse response = ...; long cost = System.currentTimeMillis() - start; if (response.getUsage() != null) { log.info("DeepSeek 调用完成,耗时 {}ms,输入 tokens={},输出 tokens={},总 tokens={}", cost, response.getUsage().getPromptTokens(), response.getUsage().getCompletionTokens(), response.getUsage().getTotalTokens()); }这串日志在开发时看不出价值,一旦上了生产,配合监控报警,你会发现它是排查性能问题和成本问题的第一手资料。
5. 进阶一步:把单次对话扩展成多轮上下文
5.1 为什么需要上下文管理
上面的代码每次请求都是“失忆”的,模型不知道你之前说过什么。如果用户问“它是谁?”而你之前说的是“帮我介绍一下 Spring Boot”,模型根本无从回答。要实现类似 ChatGPT 的连续对话体验,必须把历史消息一起发给服务端。
注意,这里的“历史消息”不是无限累积的。每轮对话都要把之前所有的消息重新发送一遍,如果聊了 50 轮,那第 50 次的请求体会非常庞大,token 费用也会直线上升。所以上下文管理是接入大模型后第一个真正需要动脑的工程问题。
5.2 会话级上下文实现
一个轻量级的做法是:用ConcurrentHashMap在内存里维护一个 sessionId 到消息列表的映射。每次用户请求时,从 sessionId 取历史消息,追加当前输入,再整体发给模型。
package com.example.deepseek.service; import com.example.deepseek.dto.ChatRequest; import com.example.deepseek.dto.ChatResponse; import com.example.deepseek.dto.Message; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Service public class DeepSeekChatService { private static final String MODEL_CHAT = "deepseek-chat"; private static final int MAX_HISTORY_SIZE = 20; private final RestClient restClient; private final Map<String, List<Message>> sessions = new ConcurrentHashMap<>(); public DeepSeekChatService(RestClient deepSeekRestClient) { this.restClient = deepSeekRestClient; } public String chat(String sessionId, String userInput) { List<Message> history = sessions.computeIfAbsent(sessionId, k -> new ArrayList<>()); history.add(new Message("user", userInput)); // 只保留最近 N 条消息,防止无限制增长 if (history.size() > MAX_HISTORY_SIZE) { int startIndex = history.size() - MAX_HISTORY_SIZE; history = new ArrayList<>(history.subList(startIndex, history.size())); sessions.put(sessionId, history); } ChatRequest request = new ChatRequest(MODEL_CHAT, history, false); // ... 调用 API 并获取回复 reply ... history.add(new Message("assistant", reply)); return reply; } public String createSession() { return UUID.randomUUID().toString(); } }这个方案的问题也很明显:内存存会话,服务重启后全部丢失,多实例部署也无法共享会话。但是作为 Day 1 的实践,它足以帮你理解“上下文管理”的核心逻辑。后续可以换成 Redis 存储,原理是一模一样的:以 sessionId 为 key,以消息列表为 value,只是存储介质换了而已。
5.3 控制 token 消耗的两条经验
- 设定消息条数上限:超过 20 条就把最早的丢掉,代价是模型可能会“忘记”太久远的内容。更精细的做法是按 token 数截断,但这通常需要额外的 tokenizer 支持。
- system 提示词单独管理:它是每次请求都固定存在的,建议放在消息列表第一位,不参与截断。
6. 实战中踩过的坑:附排查思路
6.1 401 和 403:先查 Key,再查头
这两个状态码是所有接入者最常遇到的。我在排查时基本是按下面的顺序:
- 确认 API Key 没被误加空格或换行。
- 确认 Authorization 头格式是
Bearer加 Key。 - 确认请求发到了正确的域名,是不是自己把
/v1重复拼接了。 - 如果以上都没问题,检查一下服务器时间是否正确。JWT 令牌对时间偏差很敏感,不过我实测中这个概率极低,通常走到前两步就能解决。
6.2 响应慢:到底是谁的锅
有一次测试环境反馈接口要十几秒才返回,我第一反应是模型生成慢。后来看日志发现,请求到达 Service 层之前就卡了很久,原来是网关层有一个默认的转发超时设置,导致连接被反复重置。这个问题的排查思路是:
- 在客户端记录“发出请求前”和“收到响应后”两个时间点。
- 在服务端也记录接收请求和返回响应的时间点。
- 两段耗时一对比,就能定位瓶颈是在你自己的网络链路、网关、还是模型 API 本身。
6.3 stream 参数:为什么建议先关掉
Day 1 的阶段,建议把stream设为false,等所有业务逻辑跑通后,再考虑流式输出。流式输出的响应是text/event-stream格式,需要按data:前缀逐行解析,代码复杂度会上一个台阶。它不是必须第一轮掌握的。
如果一定要尝试流式,记得把RestClient的读超时设长一些,因为流式场景下连接是长时间保持的,默认超时设置会导致中途断流。
7. 最后想说的话
从早上调通第一个接口,到晚上把完整的上下文对话跑起来,我用了一天时间。坦白讲,调通 DeepSeek API 本身并不难,难的是后面那些“看起来无关紧要”的工程细节:超时、异常、日志、上下文、成本。这些细节决定了一个 demo 能不能撑起真实业务。
如果你也对 Spring Boot 接入 AI 有兴趣,别急着追各种新的开发框架,先把原生 HTTP 的调用方式吃透。因为所有上层封装,不管是 Spring AI 还是 LangChain4j,核心原理都是你手写的那几十行代码。后面的进阶路线也很清晰:把单次调用封装成可配置的 Starter,把会话存储迁移到 Redis,把流式输出加到接口里,再配合 function calling 让模型能调用你的业务工具。
我踩过的坑,希望你能绕开。祝你们的第一天顺利。