1. 项目概述与技术选型
1.1 核心需求解析
最近在做一个 AI 问答助手项目,需要把 DeepSeek 的大模型能力集成到 Spring Boot 后端服务里。说白了,就是让用户通过我们自己的接口提问,后端转发给 DeepSeek,拿到回复后再返回给前端。这个需求在现在的应用开发里非常典型,几乎每个产品都想加个 AI 能力,但又不是所有人都愿意直接用 DeepSeek 的网页版。
先说下 DeepSeek API 是怎么回事。DeepSeek 开放了跟 OpenAI 兼容的 HTTP 接口,核心就是 POST /chat/completions,你往这个地址发 JSON,它返回 JSON。最大的好处是生态兼容,OpenAI 的 SDK、客户端工具链基本都能直接改改 base URL 就用,这对我们 Spring Boot 项目来说太友好了。
选 Spring Boot 来对接,没有任何悬念。当前版本用 3.x,内置了 RestClient、WebClient 这些 HTTP 客户端,根本不需要额外引入 Feign 或者 OkHttp。我实测下来,Spring Boot 3.2 以后内置的 RestClient 同步调用特别顺手,配合 Jackson 自动序列化,写起来非常干净。
1.2 为什么不用裸 HttpClient
有朋友会问,JDK 自带的 HttpClient 不也能发请求吗?确实能,但实际写下来你会发现一堆琐碎问题:请求头要手动拼、JSON 序列化要自己找工具类、超时重试全得自己管理、测试还得搭 mock server。这些原本不该关心的事会消耗掉大量时间。
Spring Boot 的 RestClient 把这些都收敛好了。它跟 RestTemplate 相比,链式 API 更现代化,lambda 风格清晰;跟 WebClient 相比,同步场景下不需要关心响应式编程的坑,心智负担小。我在项目里就是统一用 RestClient,既能跑普通请求,也能处理流式输出,一个类全搞定。
提示:如果项目还是 Spring Boot 2.x,建议用 WebClient 或 RestTemplate。但从维护角度讲,能升 3.x 就升 3.x,对接大模型 API 这类 IO 密集场景,Spring Boot 3 的虚拟线程也更好用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础配置
2.1 获取 API Key 与接口地址
先解决钥匙问题。去 DeepSeek 开放平台注册账号,在控制台里创建 API Key。这个 Key 是一串长字符串,形如 sk-xxxxxxxx,调用接口时放在请求头 Authorization: Bearer <key> 里传过去。
需要记住两个关键地址:
| 项目 | 值 |
|---|---|
| Base URL | https://api.deepseek.com |
| Chat 补全接口 | POST /v1/chat/completions |
| 模型名称 | deepseek-chat 或 deepseek-reasoner |
deepseek-chat 是通用对话模型,适合绝大多数场景;deepseek-reasoner 是推理模型,会先输出一段思维链,适合数学、逻辑推理类问题。两者接口格式一样,只是返回的响应里多了一个 reasoning_content 字段。
2.2 密钥安全存储
这个必须单独说,很多人直接把 api key 写在 application.yml 里提交到 GitHub,结果就是被爬虫扫到,钱包直接被刷爆。正确做法是:
properties复制# application.properties
deepseek.api-key=${DEEPSEEK_API_KEY}
deepseek.base-url=https://api.deepseek.com
deepseek.model=deepseek-chat
deepseek.max-tokens=2048
deepseek.temperature=0.7
环境变量 DEEPSEEK_API_KEY 在部署平台的配置中心或服务器 /etc/environment 里设置。本地开发放在 IDEA 的 Environment variables 里,而不是写到配置文件里。这样即使代码仓库泄露,密钥还是安全的。
2.3 引入依赖
Spring Boot 3.x 只需要一个 Web 起步依赖就够了,因为 RestClient 是 spring-web 模块自带的:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
如果后续要处理流式响应(SSE 推送),不用额外加 WebFlux,用 JDK 自带的 HttpClient 配合 java.util.stream 就能搞定,后面详细说。
3. 请求与响应模型封装
3.1 定义请求体
DeepSeek API 的请求体核心字段不多,但每个都有讲究。我封装的请求模型长这样:
java复制package com.example.deepseek.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ChatRequest {
private String model;
private List<Message> messages;
private Boolean stream;
@JsonProperty("max_tokens")
private Integer maxTokens;
private Double temperature;
@JsonProperty("top_p")
private Double topP;
private Map<String, Object> responseFormat;
@Data
public static class Message {
private String role;
private String content;
public Message(String role, String content) {
this.role = role;
this.content = content;
}
public static Message user(String content) {
return new Message("user", content);
}
public static Message system(String content) {
return new Message("system", content);
}
public static Message assistant(String content) {
return new Message("assistant", content);
}
}
}
几个必须注意的细节:
-
@JsonInclude(NON_NULL):这个注解特别重要。DeepSeek API 对某些字段是严格校验的,比如stream你不传,默认就是 false,但如果传了null反而可能报错。让 Jackson 自动忽略 null 字段能避免这类坑。 -
max_tokens的命名:API 要求 JSON 里是下划线风格,但 Java 习惯驼峰。用@JsonProperty("max_tokens")做映射。 -
messages列表要保留完整对话上下文。每次请求都带上之前的对话记录,模型才能“记住”上下文。我之前踩过坑,只传当前问题,结果模型每次都像失忆了一样。
3.2 定义响应体
java复制package com.example.deepseek.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class ChatResponse {
private String id;
private String object;
private Long created;
private String model;
private List<Choice> choices;
private Usage usage;
@Data
public static class Choice {
private Integer index;
private Message message;
@JsonProperty("finish_reason")
private String finishReason;
}
@Data
public static class Usage {
@JsonProperty("prompt_tokens")
private Integer promptTokens;
@JsonProperty("completion_tokens")
private Integer completionTokens;
@JsonProperty("total_tokens")
private Integer totalTokens;
}
@Data
public static class Message {
private String role;
private String content;
@JsonProperty("reasoning_content")
private String reasoningContent;
}
}
usage 字段里有 token 消耗统计,这个在生产环境一定要打日志。一方面方便排查成本问题,另一方面如果某个用户的请求频繁触发超长 token 限制,你要能在日志里定位到。我见过有同事上线一个月没看用量,月底账单出来直接傻眼。
4. 核心实现:同步调用 DeepSeek API
4.1 配置 RestClient
RestClient 的配置放在 @Configuration 类里统一管理。关键在于超时时间——大模型 API 的响应速度不像普通 REST 接口那么快,尤其是 deepseek-reasoner 模型,思考时间可能长达几十秒。超时设短了,用户问题还没答完就断开了。
java复制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 DeepSeekConfig {
@Value("${deepseek.api-key}")
private String apiKey;
@Value("${deepseek.base-url}")
private String baseUrl;
@Bean
public RestClient deepSeekRestClient() {
HttpClient jdkClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(jdkClient);
requestFactory.setReadTimeout(Duration.ofSeconds(60));
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Authorization", "Bearer " + apiKey)
.defaultHeader("Content-Type", "application/json")
.requestFactory(requestFactory)
.build();
}
}
这里我特意说下超时设置为什么这么配。连接超时 10 秒是合理的,服务器只要活着,TCP 握手基本不会超过这个数。但读超时必须给足 60 秒,因为 DeepSeek 在处理长文本、复杂推理时,时间到首 token 的延迟可能达到 20-30 秒。如果设置 30 秒,某些慢请求就会被误杀。
注意:JdkClientHttpRequestFactory 是 Spring Boot 3.x 才有的,它底层走 JDK 的 HttpClient,支持 HTTP/2,性能比传统的 SimpleClientHttpRequestFactory 好不少。Spring Boot 2.x 没有这个类,需要用 SimpleClientHttpRequestFactory 或者引 Apache HttpClient 依赖。
4.2 实现服务层
java复制package com.example.deepseek.service;
import com.example.deepseek.model.ChatRequest;
import com.example.deepseek.model.ChatResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.ArrayList;
import java.util.List;
@Service
public class DeepSeekService {
private final RestClient restClient;
public DeepSeekService(@Qualifier("deepSeekRestClient") RestClient restClient) {
this.restClient = restClient;
}
public String chat(String userMessage) {
List<ChatRequest.Message> messages = new ArrayList<>();
messages.add(ChatRequest.Message.system("你是一个乐于助人的助手。"));
messages.add(ChatRequest.Message.user(userMessage));
ChatRequest request = new ChatRequest();
request.setModel("deepseek-chat");
request.setMessages(messages);
request.setStream(false);
request.setMaxTokens(2048);
request.setTemperature(0.7);
ChatResponse response = restClient.post()
.uri("/v1/chat/completions")
.body(request)
.retrieve()
.body(ChatResponse.class);
if (response != null && response.getChoices() != null && !response.getChoices().isEmpty()) {
return response.getChoices().get(0).getMessage().getContent();
}
throw new RuntimeException("DeepSeek API 返回结果为空");
}
}
这段代码的流程:拼接 messages 列表(系统提示 + 用户问题)→ 组装请求 → POST 到 /v1/chat/completions → 解析响应 → 取出第一个 choice 的文本。特别说明一下,response.getChoices() 为什么取第一个而不是遍历?因为非流式模式下,choices 数组里只有一个元素,除非你指定 n 参数生成多个候选,否则不用循环。
4.3 Controller 层暴露接口
java复制package com.example.deepseek.controller;
import com.example.deepseek.service.DeepSeekService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/ai")
public class ChatController {
private final DeepSeekService deepSeekService;
public ChatController(DeepSeekService deepSeekService) {
this.deepSeekService = deepSeekService;
}
@PostMapping("/chat")
public Map<String, String> chat(@RequestBody Map<String, String> request) {
String message = request.get("message");
if (message == null || message.trim().isEmpty()) {
throw new IllegalArgumentException("message 不能为空");
}
String reply = deepSeekService.chat(message);
return Map.of("reply", reply);
}
}
这里接口设计有个讲究:前端传 {"message": "你好"},后端返回 {"reply": "你好!有什么可以帮你?"}。用 Map 做响应体对前端最友好,不需要额外定义 DTO。但如果你要返回结构化数据(比如带 token 用量),还是建议定义 Response 类。
4.4 全局异常处理
对接第三方 API 时,异常处理是重中之重。DeepSeek 服务器挂掉、网络波动、限流、超时,这些情况都会发生。如果不做兜底,用户的请求就会直接 500,体验极差。
java复制package com.example.deepseek.config;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleServerError(Exception e) {
// 这里要打完整堆栈,方便排查
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "AI 服务暂时不可用,请稍后重试"));
}
}
关键心得:不要把底层异常信息直接暴露给前端。DeepSeek 返回的原始错误信息可能包含敏感信息(比如 Key 相关信息、内部调用细节),统一转成友好提示,具体原因看服务端日志。我在生产环境遇到过 DeepSeek 因余额不足返回 402,如果把原始信息透传出去,用户会看到一堆技术细节,完全没必要。
5. 进阶:流式输出(SSE)实战
5.1 为什么需要流式
非流式调用有个明显问题:等 DeepSeek 把整段回复生成完才返回,长文本场景下用户可能要等 10 秒、20 秒,体验很糟糕。ChatGPT 的网页版早就用上了打字机效果,一个字一个字蹦出来,用户感觉“快”得多。
DeepSeek API 支持流式模式,做法是请求里加 "stream": true,服务器就会通过 SSE(Server-Sent Events)不断推送增量数据。每一行数据以 data: 开头,最后一行是 data: [DONE]。
前端可以用 EventSource 或 fetch 流式读取,后端要做的事情就是把 DeepSeek 的流转发给前端。这里有两种方案:
- 前端直连 DeepSeek API —— 不推荐,Key 会泄露
- 后端转发流式数据 —— 推荐,Key 只在服务端
5.2 后端流式转发实现
Spring Boot 3.x 里,让接口返回 SseEmitter 或者 Flux<String>(需引入 WebFlux)。我这里的做法是返回 SseEmitter,兼容性好,不引入新的依赖。
java复制package com.example.deepseek.service;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@Service
public class DeepSeekStreamService {
private final String apiKey;
private final String baseUrl;
public DeepSeekStreamService(@Value("${deepseek.api-key}") String apiKey,
@Value("${deepseek.base-url}") String baseUrl) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
public SseEmitter streamChat(String userMessage) {
SseEmitter emitter = new SseEmitter(120_000L);
// 使用虚拟线程或普通线程池执行阻塞 IO
Thread.startVirtualThread(() -> {
try {
URL url = new URL(baseUrl + "/v1/chat/completions");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
conn.setConnectTimeout(10_000);
conn.setReadTimeout(120_000);
String body = """
{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "%s"}],
"stream": true,
"max_tokens": 2048
}
""".formatted(userMessage.replace("\"", "\\\""));
// 实际请用 Jackson 构造,避免手动拼接 JSON 转义问题
conn.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8));
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6);
if ("[DONE]".equals(data)) {
emitter.send(SseEmitter.event().data("[DONE]"));
break;
}
// data 是 JSON 字符串,解析出 content 增量字段
emitter.send(SseEmitter.event()
.data(parseContent(data)));
}
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
} finally {
// 清理连接
}
});
return emitter;
}
}
上面这段代码是示意,手动拼接 JSON 有转义风险,实际开发请用 Jackson ObjectMapper 构建请求体。我把这个映射点放出来,就是为了让大家看到流式解析的完整链路:读取每一行 → 判断是否以 data: 开头 → 提取 JSON → 解析 delta content → 发给前端。
5.3 流式响应的 JSON 解析
流式模式下,每一条 data 里的 JSON 结构跟非流式略有区别,choices[0].delta 替代了原来的 message:
json复制{"choices":[{"delta":{"content":"你好"},"index":0}]}
对应的解析方法:
java复制private String parseContent(String data) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(data);
return root.path("choices").path(0).path("delta").path("content").asText();
} catch (Exception e) {
return "";
}
}
注意点:content 可能为 null(比如 reasoning 模型在输出思考过程时,增量在 reasoning_content 里)。所以解析时用 asText() 会安全地返回空串,不影响后续拼接。
5.4 Controller 层返回 SSE
java复制@GetMapping(value = "/chat/stream", produces = "text/event-stream")
public SseEmitter streamChat(@RequestParam String message) {
return deepSeekStreamService.streamChat(message);
}
produces = "text/event-stream" 是 SSE 的标准 MIME 类型。前端用 EventSource 直接连这个地址就行:
javascript复制const eventSource = new EventSource(`/api/ai/chat/stream?message=${encodeURIComponent(message)}`);
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
// 追加输出
output.textContent += event.data;
};
这里有个大坑必须提醒:EventSource 只支持 GET 请求。如果你非要 POST 请求带复杂 body,前端就不能用 EventSource,得改用 fetch + ReadableStream。具体代码稍微复杂,但思路一致——读流里的每一行,解析 SSE 格式。我建议能用 GET 参数解决的场景就直接 GET,少走弯路。
6. 多轮对话与上下文管理
6.1 为什么单纯拼消息不够
很多人做完单轮调用就开始做多轮对话,结果发现模型“记不住”之前聊了什么。原因很简单:DeepSeek API 本身没有记忆功能,每次请求都是独立的。你必须在请求时把之前的对话历史都带上。
看这个例子:
- 用户:我叫张三
- 助手:你好张三!
- 用户:我叫什么
如果你第二次请求只传 {"role": "user", "content": "我叫什么"},模型根本不知道你之前说过叫张三。正确的请求应该传:
json复制{
"messages": [
{"role": "user", "content": "我叫张三"},
{"role": "assistant", "content": "你好张三!"},
{"role": "user", "content": "我叫什么"}
]
}
6.2 会话存储方案
多轮对话要管理会话状态,最简单的方案是用 Redis 存历史消息:
java复制@Service
public class ChatSessionService {
private final StringRedisTemplate redisTemplate;
public ChatSessionService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
private static final String KEY_PREFIX = "chat:session:";
private static final int MAX_MESSAGES = 20;
public void saveMessage(String sessionId, String role, String content) {
String key = KEY_PREFIX + sessionId;
redisTemplate.opsForList().rightPush(key, role + ":" + content);
// 限制长度,防止消息无限增长
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_MESSAGES) {
redisTemplate.opsForList().leftPop(key);
}
}
public List<String> getHistory(String sessionId) {
String key = KEY_PREFIX + sessionId;
List<String> rawList = redisTemplate.opsForList().range(key, 0, -1);
if (rawList == null) return List.of();
return rawList;
}
}
6.3 长对话的 Token 裁剪策略
这里有个很实际的问题:对话越长,token 消耗越大,费用越高,而且超过模型的上下文窗口(DeepSeek chat 模型是 64K token)直接被拒绝。所以要做裁剪策略。
常用的有两种思路:
-
固定条数裁剪:保留最近 N 条消息,最老的丢弃。简单粗暴,适合大多数场景。我上面代码里的
MAX_MESSAGES = 20就是这个思路。 -
按 token 数量裁剪:设置一个 token 上限(比如 4000),新消息加入后计算总 token 数,超了就从头删老消息。更精准,但需要额外的 token 估算逻辑。
按 token 裁剪的估算逻辑可以这样写:
java复制public List<ChatRequest.Message> trimMessages(List<ChatRequest.Message> messages, int maxTokens) {
LinkedList<ChatRequest.Message> list = new LinkedList<>(messages);
int totalTokens = estimateTokens(list);
while (totalTokens > maxTokens && list.size() > 1) {
list.removeFirst();
totalTokens = estimateTokens(list);
}
return list;
}
private int estimateTokens(List<ChatRequest.Message> messages) {
// 简化估算:中文字符约 1 token,英文约 3-4 字符 1 token
int tokens = 0;
for (ChatRequest.Message msg : messages) {
tokens += msg.getContent().length() / 2;
}
return tokens;
}
这个估算方式不精确,但够用。生产环境可以用 tiktoken 库或 DeepSeek 的 tokenizer 做精确计算,但普通场景没必要增加复杂度。
7. 关键参数调优与实践
7.1 Temperature 和 Top-P 怎么选
temperature 控制输出的随机性,取值 0-2,默认 1。top_p 是核采样,默认 1,跟 temperature 二选一调节即可,DeepSeek 官方建议不要同时改两个。
| 场景 | temperature | top_p | 说明 |
|---|---|---|---|
| 客服问答、知识库 | 0.3 | 1.0 | 精确、稳定,减少幻觉 |
| 文案生成、头脑风暴 | 0.8-0.9 | 1.0 | 有创意但不至于乱说 |
| 代码生成 | 0.2 | 1.0 | 代码必须确定性优先 |
| 数学推理 | 0.1 | 1.0 | 极低随机性,配 reasoner 模型 |
我实际测试下来,temperature 设 0.7 是通用场景比较合理的默认值。写代码生成任务时降到 0.2 明显更稳,不会出现变量名乱飘的情况。
7.2 Max Tokens 要怎么算
max_tokens 限制的是模型输出的最大 token 数,不是输入。这个值设小了,回答会被截断;设大了,如果模型真的要输出那么多,费用就高。
建议按业务场景设定:
- 短问答:512
- 通用对话:2048
- 长文写作、代码生成:4096
另外注意,prompt_tokens + max_tokens 必须在模型上下文窗口内。DeepSeek-chat 模型上下文 64K,假设你带了 30K 的历史消息,那 max_tokens 最大也只能设 34K 左右。设置之前可以先算一下。
7.3 重试机制和限流保护
第三方 API 不稳定是常态,所以一定要有重试机制。我实现的方案是 Spring Retry + 指数退避:
java复制@Retryable(
retryFor = ResourceAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String chatWithRetry(String userMessage) {
return chat(userMessage);
}
@Recover
public String recover(ResourceAccessException e, String userMessage) {
// 降级:返回缓存的结果或提示信息
return "AI 服务暂时不可用,请稍后重试";
}
但在 DeepSeek API 场景下,重试有讲究:
- 网络超时可以重试
- HTTP 401(Key 错误)重试没意义,直接报错
- HTTP 429(限流)可以等一会儿重试
- HTTP 402(余额不足)重试也没意义,纯浪费钱
所以更精确的做法是读取异常响应的状态码,对 429 做延迟重试,对其他错误直接抛异常。别无脑重试,否则限流时你越重试越限流,形成反效果。
8. 常见问题与排查技巧实录
8.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 401 Unauthorized | API Key 错误或过期 | 检查环境变量是否正确,重新生成 Key |
| 402 Payment Required | 账户余额不足 | 登录平台充值 |
| 429 Too Many Requests | 触发限流 | 降低 QPS,增加重试延迟 |
| 400 Invalid Request | 请求体格式不对 | 检查是否传了多余的 null 字段,messages 是否为空数组 |
| 连接超时 | 网络问题或代理拦截 | 检查服务器能否访问 api.deepseek.com |
| 响应内容被截断 | max_tokens 设置过小 | 调整 max_tokens 参数 |
| 返回内容乱码 | 编码未设置 UTF-8 | 请求和响应都指定 StandardCharsets.UTF_8 |
| 虚拟线程池溢出 | 并发量过高 | 用有界线程池配合信号量做并发控制 |
8.2 排查技巧:先用 curl 验证
用 Java 代码排查问题效率很低,建议先用 curl 验证 DeepSeek API 本身是否正常:
bash复制curl -X POST https://api.deepseek.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "你好"}],
"stream": false
}'
如果 curl 能正常返回,那问题基本出在 Spring Boot 代码侧;如果 curl 也报错,那就是 Key、网络或者参数的问题。这一招能帮你快速缩小排查范围,省下大量时间。
8.3 日志里不要打全量 Key
我见过有人为了调试在日志里打印了 Authorization 头,结果日志文件被截屏外泄,整个 Key 所有人都看到了。正确做法是记录 Key 的后四位:
java复制String maskedKey = "sk-****" + apiKey.substring(apiKey.length() - 4);
log.info("Calling DeepSeek API with key: {}", maskedKey);
8.4 Token 用量的日志记录
在调用完成后,把 response.getUsage() 打出来。我一般会在生产环境按用户维度统计 token 消耗,这样月底对账时有据可查,也方便发现异常消费。
java复制ChatResponse response = restClient.post()...body(ChatResponse.class);
Usage usage = response.getUsage();
log.info("DeepSeek usage - prompt: {}, completion: {}, total: {}",
usage.getPromptTokens(), usage.getCompletionTokens(), usage.getTotalTokens());
8.5 关于 Zed 编辑器和第三方客户端接入
最近不少朋友在问 Zed 编辑器里怎么配置 DeepSeek 模型。这个其实跟 Spring Boot 无关,但思路是一样的:找到 Zed 的设置文件,把模型服务地址改成 DeepSeek 的接口地址,填上 API Key 就行。Zed 的 AI 功能支持自定义 base_url 和 api_key 字段,指向 OpenAI 兼容接口就能用。Spring Boot 这套实现里封装的请求、响应模型,完全可以作为参考来理解它在底层做了什么——无非就是拼 JSON、发请求、解析流。
9. 实际项目经验与建议
9.1 如果只有一个建议
我会说:先打印出你实际发出的请求 JSON 和 DeepSeek 返回的完整响应。很多人对接失败,就是因为你以为发出的是 A 请求,实际发出的是 B 请求。把你构造的 ChatRequest 用 Jackson toJson 打出来,一眼就能看出字段名对不对、是不是有个 null 值混进去了。这个习惯能帮你解决掉 80% 的对接问题。
Debug 的时候可以在 RestClient 调用前后都打日志:
java复制String requestJson = objectMapper.writeValueAsString(request);
log.info("DeepSeek request: {}", requestJson);
ChatResponse response = restClient.post()
.uri("/v1/chat/completions")
.body(request)
.retrieve()
.body(ChatResponse.class);
log.info("DeepSeek response: {}", objectMapper.writeValueAsString(response));
9.2 单元测试怎么做
对接外部服务最容易出问题,所以测试策略很重要。我推荐引入 MockWebServer(OkHttp 的测试模块)来 mock DeepSeek API 的返回:
java复制@SpringBootTest
class DeepSeekServiceTest {
private MockWebServer mockWebServer;
@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
mockWebServer.enqueue(new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""
{
"choices": [{
"message": {"role": "assistant", "content": "你好!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}
"""));
}
@Test
void testChat() {
// 把 base-url 指向 mockWebServer.url("/")
String reply = deepSeekService.chat("你好");
assertEquals("你好!", reply);
}
}
这样的单元测试不依赖真实网络,跑得快,而且能模拟各种异常情况(超时、500、限流)。上线前把测试用例跑一遍,心里就有底了。
9.3 性能考虑:连接池与并发
RestClient 底层用 JDK HttpClient,默认有连接池机制。生产环境如果 QPS 较高,要注意设置合适的并发控制,防止突发请求打爆 DeepSeek QPS 上限,也防止本地线程被阻塞 IO 拖垮。
一个简单的限流方案,用 Guava RateLimiter 或 Resilience4j:
java复制@Component
public class RateLimiterService {
private final RateLimiter rateLimiter = RateLimiter.create(5.0); // 每秒最多 5 个请求
public boolean tryAcquire() {
return rateLimiter.tryAcquire();
}
}
调用前判断一下,拿不到令牌就返回“请求过于频繁”。这个方案能保护 DeepSeek 的账户不被限流封禁,也能保护自己的后端服务不被拖垮。
9.4 后续可以扩展的方向
接完基础聊天,很多业务场景都是在这个基础上扩展的。常见的方向:
-
结合向量数据库(如 Redis Search、pgvector)做 RAG,让模型能回答私有知识的问题。思路是把文档切块、embedding 后用向量检索,把命中的内容塞进 system prompt。
-
接入 Function Calling,让模型能调用你的业务接口。比如用户说“帮我查下明天的天气”,模型感知到需要调用天气接口,返回一个结构化的函数调用请求,后端解析后执行并把结果回传给模型,最终生成答复。
-
做成流式的中间层,统一管理多个模型厂商的 API 格式。前端对接的时候不用关心底层是 DeepSeek 还是别的模型,由你的服务做路由。
我在实际项目中做完基础版后,紧接着扩展了 RAG 和 Function Calling。核心代码就是在这套请求模型的 messages 里增加工具定义,返回解析时多处理一步工具调用。
这个项目整体做下来,核心并不复杂——本质上就是一次标准的外部 HTTP API 对接。但如果处理不好细节,遇到问题排查起来也确实费劲。希望这套代码结构和思路能给你省些时间。
