尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot 集成 DeepSeek API:从调通到工程化实战

Spring Boot 集成 DeepSeek API:从调通到工程化实战 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.1/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies2.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 ListMessage messages; private boolean stream; public ChatRequest() { } public ChatRequest(String model, ListMessage 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 ListMessage getMessages() { return messages; } public void setMessages(ListMessage 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 ListChoice choices; public ListChoice getChoices() { return choices; } public void setChoices(ListChoice 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); ResponseEntityChatResponse 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 MapString, String chat(RequestBody MapString, 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 MapString, ListMessage sessions new ConcurrentHashMap(); public DeepSeekChatService(RestClient deepSeekRestClient) { this.restClient deepSeekRestClient; } public String chat(String sessionId, String userInput) { ListMessage 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 让模型能调用你的业务工具。我踩过的坑希望你能绕开。祝你们的第一天顺利。
返回列表