年后开工第一周,我接了一个不算复杂但挺有意思的任务:把 DeepSeek(深度求索)的 API 接到现有 Spring Boot 服务里,做一个基于内部文档的智能问答接口。说实话,接一个第三方 HTTP 接口本身并不难,真正难的是把它做成一个能上线、能维护、能扛住各种异常的后端能力。这篇就把我实际趟过的一整条路拆开来讲,包括接口设计、代码封装、流式输出、上下文管理、超时重试这些躲不开的细节。如果你正在用 Java 做后端,正打算在 springboot 项目里接入 deepseek 或者其他兼容 Chat Completions 协议的大模型接口,这篇可以直接当参考手册用。
1. 为什么是 Spring Boot + DeepSeek
1.1 这套组合适合什么场景
先说结论:Spring Boot 接入 DeepSeek,本质上不是“AI 开发”,而是“后端对接一个 HTTP 服务”。DeepSeek 开放的接口走的是标准的 Chat Completions 协议,你不需要引入任何专用的 AI SDK,只要服务端能发 HTTP 请求、能解析 JSON,就能把它当做一个普通的第三方接口来用。这一点对 Java 团队来说极其重要,因为接入成本被压得非常低,现有后端的配置中心、监控、日志、权限控制这些基建几乎可以原样复用。
我实际接触过的场景主要有这么几类:
- 企业内部知识库问答:把文档做切片检索,检索结果拼进 prompt,再交给 deepseek-chat 生成回答。这种场景在企业内部落地最快,因为数据安全可控,模型只负责“组织语言”。
- 客服工单的意图分类和摘要:不需要额外训练模型,靠 few-shot 示例就能把工单自动打标、生成摘要,节省大量人工处理时间。
- 代码生成辅助:在研发平台里提供“根据注释生成单元测试”“根据接口定义生成 Mock 数据”这类能力,本质也是调同一个接口。
- 内容审核辅助:大模型做第一道筛选,人工做最终确认。AI 的误判率不可控,但可以帮助人工缩小范围。
这几种场景的共同点是:AI 是作为后端能力被调用的,不是在前端页面里直接调 API。把密钥放在服务端、把调用逻辑收敛到一个 service 层,既方便控制权限,也方便后续做缓存、风控、审计和模型切换。
反过来讲,如果你的需求只是“写个脚本自己跑一下”,那直接在 DeepSeek 开放平台控制台里调试接口就够了,完全用不上 Spring Boot。但一旦进入团队协作、多服务调用、严格权限控制阶段,Java 后端这套封装的价值就会完全体现出来。
1.2 DeepSeek API 的本质:统一的 Chat Completion 协议
接入前我先把官方文档翻了一遍。DeepSeek 开放平台的接口基础地址是 https://api.deepseek.com,认证方式是 Bearer Token,请求体里面包含 model、messages、stream、temperature 这些字段,响应结构是 choices 加上 usage 的标准格式。说白了,凡是熟悉 chat/completions 风格接口的人,看 DeepSeek 文档基本没有障碍,只是模型名和计费规则不同。
两个模型要区分清楚:
- deepseek-chat:通用对话模型,适合绝大多数问答、生成、总结场景,响应速度快,成本也低。
- deepseek-reasoner:深度推理模型,适合数学、逻辑、代码分析这些需要思考链的复杂任务。它会先输出一段内部推理过程再给出答案。
从接口协议来说,两个模型的请求方式完全一致,差别在于任务类型、响应耗时和计费策略。我强烈建议把模型名放到配置项里,不要写死在 Java 代码中。我一开始就是把模型名写死在常量类里,后来想给部分用户灰度切到 reasoner,只能改代码重新发版,非常被动。
还有一点容易被忽略:DeepSeek 的接口支持多轮对话格式,但它不维护任何会话状态。也就是说,“上下文”这件事必须在我们业务侧自己维护。这个问题处理不好,后面的对话效果和成本都会失控,我放到第 4 章单独讲。
1.3 接入之前先想清楚的三件事
别急着敲代码,先想清楚三件事,否则后面大概率返工。
第一,调用方式:同步还是流式?同步最简单,前端等后端,后端等 DeepSeek,一个请求下来可能要几十秒,HTTP 层面的超时、网关层面的超时都要重新评估。流式则是对用户体验更友好的方案,用户能像用聊天产品一样逐字看到回答,但要求后端能把 SSE 流透传给前端,技术栈上要额外做一些处理。我的建议是:如果产品形态是聊天框,必须上流式;如果是“提交任务后异步取结果”,同步也能接受。
第二,网络与超时策略:第三方 API 的可用性不在你手里,服务端必须配置合理的连接超时和读超时。重试也要做,但必须有策略。DeepSeek 是按 token 计费的,一次重试就可能是双倍消耗,重试退避、限制次数这些策略要在设计阶段就定下来,后面临时补很容易出事故。
第三,上下文和成本控制:多轮对话不能无限堆 messages,模型窗口有限,成本更有限。要么做滑动窗口,只保留最近 N 轮;要么做摘要压缩,把历史对话定期总结成一段背景信息。这两件事在接入第一天就要设计好,等对话量上来再改架构,代价非常大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与参数配置
2.1 拿到 API Key 和模型名
去 DeepSeek 开放平台注册账号,创建一个 API Key。这里有个血的教训:Key 创建完只在页面上显示一次,必须立刻存到密码管理器里。我见过不止一个同事把 Key 截图发到聊天群,结果第二天被迫重新创建,之前的调用记录也一起失效了。
模型名的对应关系以官方控制台为准,现在主要就是 deepseek-chat 和 deepseek-reasoner 这两个。设计配置时按“api-key + base-url + model”三个维度来,这三个参数一定要放配置文件,不能写死在代码里。其他像 temperature、max-tokens 这些推理参数,也可以在 YAML 里配出来,方便不同环境微调。
2.2 Spring Boot 配置项
在 application.yml 里增加自定义配置段:
yaml复制deepseek:
api-key: ${DEEPSEEK_API_KEY:sk-xxx}
base-url: https://api.deepseek.com
model: deepseek-chat
max-tokens: 2048
temperature: 0.7
connect-timeout: 10s
read-timeout: 60s
注意这里 ${DEEPSEEK_API_KEY:sk-xxx} 的意思是优先读环境变量,读不到就用默认值,只适用于本地开发。真实环境里密钥一定走环境变量或配置中心,千万不要把真实 Key 提交到 Git 仓库,这个锅一旦背了,轻则泄露费用,重则影响线上业务。
用 @ConfigurationProperties 绑定配置类是最规范的做法:
java复制@ConfigurationProperties(prefix = "deepseek")
public class DeepSeekProperties {
private String apiKey;
private String baseUrl = "https://api.deepseek.com";
private String model = "deepseek-chat";
private Integer maxTokens = 2048;
private Double temperature = 0.7;
private Duration connectTimeout = Duration.ofSeconds(10);
private Duration readTimeout = Duration.ofSeconds(60);
// getter / setter 省略
}
记得在启动类或者配置类上启用这个配置类。如果是 Spring Boot 3,Duration 类型可以直接绑定 10s 这种写法。如果绑定不生效,先检查是不是少了 @EnableConfigurationProperties,或者配置文件里的前缀和注解里的 prefix 不一致。
2.3 依赖选型:RestTemplate 还是 WebClient
很多教程一上来就推荐用 Spring AI,我个人觉得如果只是接一个模型,完全没必要引入一个全家桶框架,自己封装 Service 反而更可控、更好维护。HTTP 客户端的选择才是需要认真考虑的:RestTemplate、WebClient、OkHttp,各有适用场景。
- 同步调用:RestTemplate 足够,Spring Boot 3 里可以直接用 RestTemplateBuilder 自动装配,配置超时非常方便。
- 流式调用:WebClient 更顺手,因为它的响应式 API 天然适合处理持续到达的 SSE 数据流。
- 已有 OkHttp 的项目直接用 OkHttp 也完全可以,重点是团队熟悉。
需要引入的依赖其实不多。如果项目里已经有 spring-boot-starter-web,RestTemplate 默认可用;如果要用 WebClient,需要加 spring-boot-starter-webflux。但要小心:webflux 和 web 两个 starter 同时存在可能会有自动配置冲突,团队必须统一选型。我在一个老项目里不小心引了 webflux,结果某些 Spring MVC 自动配置直接失效,接口行为变得很奇怪,排查了大半天最终定位到是依赖冲突。
3. 核心代码实现:把 DeepSeek 变成你的服务
到重点环节了。我用下面这套结构组织代码:实体类 → 配置类 → Service 封装 → Controller 示例。这个分层方式是我在几个项目里验证过比较舒服的,既不会过度设计,也不会让业务代码直接裸调 HTTP。
3.1 实体类设计:先处理下划线字段名
DeepSeek 请求和响应是 JSON,Java 侧需要先建模。我建了这四个核心类,字段尽量保持精简:
java复制public class ChatMessage {
private String role; // system / user / assistant
private String content;
// 构造函数、getter/setter
}
public class ChatRequest {
private String model;
private List<ChatMessage> messages;
private Boolean stream;
private Double temperature;
private Integer maxTokens;
// getter/setter
}
public class ChatResponse {
@JsonProperty("id")
private String id;
@JsonProperty("model")
private String model;
@JsonProperty("created")
private Long created;
@JsonProperty("choices")
private List<Choice> choices;
@JsonProperty("usage")
private Usage usage;
// getter/setter
}
public class Choice {
@JsonProperty("index")
private Integer index;
@JsonProperty("message")
private ChatMessage message;
@JsonProperty("finish_reason")
private String finishReason;
// getter/setter
}
public class Usage {
@JsonProperty("prompt_tokens")
private Integer promptTokens;
@JsonProperty("completion_tokens")
private Integer completionTokens;
@JsonProperty("total_tokens")
private Integer totalTokens;
// getter/setter
}
这里要特别强调:DeepSeek 响应字段的命名风格是下划线,比如 finish_reason、prompt_tokens。如果你希望 Jackson 能正确映射,要么在每个字段上写 @JsonProperty,要么在全局配置里开启下划线映射。我强烈建议用 @JsonProperty 而不是改全局配置,因为全局 PropertyNamingStrategy 会影响项目里所有接口的序列化行为,风险不可控。
另一个很容易踩的细节:choices 数组里的 message.content 在异常情况下可能是 null,所以这个字段要设计成可空类型,解析层再做判空兜底。你要是用 String 但没判空,可能在某个边界场景直接抛 NPE,而且只会在线上触发。
3.2 同步调用:一个最基本的 chat 方法
先生成一个 RestTemplate Bean,统一配置超时:
java复制@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(60))
.build();
}
然后写核心 Service:
java复制@Service
public class DeepSeekService {
private final RestTemplate restTemplate;
private final DeepSeekProperties properties;
public DeepSeekService(RestTemplate restTemplate, DeepSeekProperties properties) {
this.restTemplate = restTemplate;
this.properties = properties;
}
public String chat(List<ChatMessage> messages) {
ChatRequest request = new ChatRequest();
request.setModel(properties.getModel());
request.setMessages(messages);
request.setTemperature(properties.getTemperature());
request.setMaxTokens(properties.getMaxTokens());
request.setStream(false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(properties.getApiKey());
HttpEntity<ChatRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<ChatResponse> response = restTemplate.exchange(
properties.getBaseUrl() + "/chat/completions",
HttpMethod.POST,
entity,
ChatResponse.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new DeepSeekException("DeepSeek API 调用失败: " + response.getStatusCode());
}
ChatResponse body = response.getBody();
if (body == null || body.getChoices() == null || body.getChoices().isEmpty()) {
throw new DeepSeekException("DeepSeek API 返回空结果");
}
return body.getChoices().get(0).getMessage().getContent();
}
}
这段代码有三个地方容易出问题。第一,base-url 的路径拼接。有些教程会教你拼 /v1/chat/completions,但 DeepSeek 的基础地址本身就是 https://api.deepseek.com,正确路径就是 /chat/completions。我最初照搬别的接口写法加了 /v1,结果直接 404。第二,HttpHeaders 里必须明确 Content-Type 是 application/json,Authorization 是 Bearer 格式。第三,maxTokens 要根据模型调整,reasoner 模型要先输出一段思维链,如果 max_tokens 设置太小,回答会被截断,而且截断是静默的,不报错。chat 模型建议 2048 到 4096,reasoner 模型建议至少 8192。
3.3 流式输出:用 WebClient 处理 SSE
流式输出是体验提升最明显的一步。用户输入问题后如果干等 20 秒再一次性吐出一大段文本,很多人会以为系统卡死了。流式方案下,DeepSeek 按 SSE 格式逐步推送内容片段,后端把这些片段转给前端,用户就能看到逐字出现的打字机效果。
我用 WebClient 来实现流式代理,核心代码如下:
java复制public Flux<String> chatStream(List<ChatMessage> messages) {
ChatRequest request = new ChatRequest();
request.setModel(properties.getModel());
request.setMessages(messages);
request.setStream(true);
request.setTemperature(properties.getTemperature());
return webClient.post()
.uri("/chat/completions")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getApiKey())
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class)
.flatMap(chunk -> Flux.fromArray(chunk.split("\n")))
.filter(line -> line.startsWith("data: "))
.map(this::parseDelta)
.filter(Objects::nonNull)
.doOnError(e -> log.error("DeepSeek 流式调用失败", e));
}
private String parseDelta(String line) {
String payload = line.substring(6).trim();
if ("[DONE]".equals(payload)) {
return null;
}
StreamResponse response = objectMapper.readValue(payload, StreamResponse.class);
if (response.getChoices() == null || response.getChoices().isEmpty()) {
return null;
}
String content = response.getChoices().get(0).getDelta().getContent();
return StringUtils.hasText(content) ? content : null;
}
这里有几个非常实际的坑。第一,SSE 事件是按换行符分割的,但 bodyToFlux(String.class) 拿到的是整个响应体,你不能假设一个 chunk 就是一个完整事件。上面代码里的 flatMap + split("\n") 就是为了先把数据按行切分,再逐行处理。第二,流式响应里 choices 数组中的字段是 delta,不是 message,和同步响应的结构不一样,不能复用同一套实体解析。第三,第一个事件往往只带角色信息,delta.content 是 null 或者空字符串,要过滤掉。第四,结束标志是 data: [DONE],这不是合法 JSON,要先判断再解析。
Controller 暴露流式接口时,注意 EventSource 只支持 GET 请求,如果你需要 POST 传参,前端只能自己用 fetch + ReadableStream 模拟 SSE,这个要在接口文档里写清楚,否则前端同事又要来问。
3.4 把一个 Service 封装到可复用
有了同步和流式两个底层方法,我再在 Service 里封装几个更贴近业务的方法,让调用方不用关心协议细节:
java复制public String chatSingle(String userMessage) {
List<ChatMessage> messages = new ArrayList<>();
messages.add(new ChatMessage("system", "你是一个乐于助人的助手,请用简洁准确的中文回答。"));
messages.add(new ChatMessage("user", userMessage));
return chat(messages);
}
public String chatWithContext(String sessionId, String userMessage) {
List<ChatMessage> messages = contextService.loadMessages(sessionId);
messages.add(new ChatMessage("user", userMessage));
String answer = chat(messages);
contextService.saveMessages(sessionId, messages, answer);
return answer;
}
什么算“可复用”?我的标准很简单:业务代码里不出现“组装 JSON 请求”“解析响应结构”这些细节,只传业务参数进去,拿结果出来。这样后续如果模型协议有升级或小版本变化,只需要改 Service 内部,上游完全不用动。
同时建议打全日志。我在 Service 里加了请求路径、模型名、token 消耗、耗时这些关键字段。线下看不出来,线上排查问题的时候,日志里能直接看到每个请求是否成功、消耗了多少 token、响应延迟多长时间,这是救命级别的信息。
4. 把 AI 能力做成后端业务的正确姿势
4.1 超时、重试与熔断:一个都不能省
接第三方 API 必须配超时,这是老生常谈但真的不能省。如果不配超时,RestTemplate 默认是无限等待,一个线程可能被 DeepSeek 堵死半天,然后整个线程池被拖垮。连接超时设 10 秒是合理的,读超时就要根据业务来:同步问答建议 60 秒到 120 秒,流式接口通常几秒内返回首包,超时配置可以更短。
重试策略我给一个可以直接抄的公式:
- 超时类异常:最多重试 1 次,间隔 200 毫秒。
- 4xx 错误:一律不重试,这是客户端问题,重试也白费。
- 5xx 错误:可以重试 2 次,间隔按 500 毫秒、1 秒递增。
- 所有重试次数必须显式写死,并且要有降级开关。
关键点:模型调用是按 token 计费的,重试意味着更多费用。有些产品团队担心模型输出不稳定,要求同一条消息重试多次取最佳结果,这个逻辑也不是不行,但一定要让产品知道成本是乘倍数增加的,不能无脑重试。
再往上一步,可以在调用端做简单熔断。连续失败 N 次后,暂时不再请求 DeepSeek,直接返回固定兜底文案;这个状态记录可以通过 Redis 缓存和监控告警系统联动。大型系统一般会用 Sentinel 或者 Resilience4j,小项目里自己用一个 AtomicInteger 计数器也能顶住。重要的是先有这个意识,不要裸调。
4.2 并发控制:别让 AI 请求拖垮整个服务
AI 接口的响应时间是普通业务接口的几十倍,所以线程模型必须专门设计。如果你用 Tomcat 默认的 200 线程,每个线程都阻塞在等待 DeepSeek 响应上,一旦并发请求多起来,其他业务接口也会跟着变慢。实时聊天接口必须和普通业务流量做隔离。
我的做法是给 AI 调用单独分配一个线程池:
java复制@Bean("deepseekExecutor")
public Executor deepseekExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("deepseek-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
注意 setWaitForTasksToCompleteOnShutdown(true) 这个参数,我之前没配,服务停机时正在生成的回答被直接打断,用户看到的是内容截断。配置后停机时会等正在跑的任务完成或者最多等 30 秒,这就是优雅关闭。
另一个隐藏雷区:RestTemplate 默认的 SimpleClientHttpRequestFactory 不提供连接池。高并发场景下每个请求都新建 TCP 连接,延迟和资源消耗都会急剧上升。正确做法是把底层实现换成 Apache HttpClient 或 OkHttp,用 HttpComponentsClientHttpRequestFactory 这类连接池工厂来管理连接。
4.3 上下文管理与 token 控制:写代码之前先算钱
多轮对话的上下文策略,是决定体验和成本的关键。不要无脑把 50 轮历史消息全部塞进请求里,上下文窗口再大也经不住这么用,而且成本是直线上升的。
我实际用的方案是维护一个滑动窗口。创建一个会话时设置最大记忆轮数,比如 6 轮,超出部分最早的消息被裁剪。实现上就是把消息列表当成 LinkedList,超过阈值就 removeFirst。但有个容易被忽略的细节:system 消息必须保持在最前面,裁剪时不能把它删掉。
java复制public void appendAndTrim(List<ChatMessage> messages, ChatMessage newMessage) {
messages.add(newMessage);
int maxMessages = 12; // 6轮会话 = 12条消息
long systemCount = messages.stream()
.filter(m -> "system".equals(m.getRole()))
.count();
int maxNonSystem = maxMessages - (int) systemCount;
Iterator<ChatMessage> iter = messages.iterator();
long nonSystemCount = 0;
while (iter.hasNext()) {
ChatMessage msg = iter.next();
if (!"system".equals(msg.getRole())) {
nonSystemCount++;
if (nonSystemCount > maxNonSystem) {
iter.remove();
}
}
}
}
在大模型调用里,每条消息都会参与计费。上下文越长,单次调用成本越高。这件事要讲给产品听,让产品知道“对话长度和账单成正比”,后续设计会话轮数上限时才会有成本意识。
4.4 一个完整的智能问答接口示例
把上面的所有碎片拼起来,就是一个能直接用的 Controller:
java复制@RestController
@RequestMapping("/api/ai")
public class AiChatController {
private final DeepSeekService deepSeekService;
private final ContextService contextService;
public AiChatController(DeepSeekService deepSeekService, ContextService contextService) {
this.deepSeekService = deepSeekService;
this.contextService = contextService;
}
@PostMapping("/chat")
public Result<String> chat(@RequestBody ChatRequestDTO dto) {
// 参数校验省略
List<ChatMessage> messages = contextService.loadMessages(dto.getSessionId());
messages.add(new ChatMessage("user", dto.getQuestion()));
String answer = deepSeekService.chat(messages);
contextService.saveMessages(dto.getSessionId(), dto.getQuestion(), answer);
return Result.success(answer);
}
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestBody ChatRequestDTO dto) {
List<ChatMessage> messages = contextService.loadMessages(dto.getSessionId());
AtomicReference<String> finalAnswer = new AtomicReference<>("");
return deepSeekService.chatStream(messages)
.doOnNext(finalAnswer::set)
.doOnComplete(() -> contextService.saveMessages(
dto.getSessionId(), dto.getQuestion(), finalAnswer.get()));
}
}
几个细节。第一,sessionId 是必须的,没有会话 ID 就没法做上下文管理。第二,流式接口需要自己累积内容,这里用 AtomicReference 是因为 doOnComplete 回调里的闭包变量不能保证线程安全。第三,这个接口返回的是 Flux,前端需要用 fetch 读流。第四,生产环境不建议在 Controller 里直接做业务编排,应该再抽一层 ApplicationService,这里为了示例清晰而简化了。
5. 常见问题与排查实录
5.1 401 / 402 / 429:三类状态码对应三种处理方式
401 基本是 API Key 错误。先检查 Authorization 头是不是 Bearer sk-xxx 格式,再检查配置中心里环境变量是否真的注入了。我遇到过一个隐蔽问题:API Key 里包含特殊字符,在 YAML 文件里没加引号,结果被解析成别的值。另外,如果 Key 从配置中心读取时带了换行符,HTTP 头拼接后多一个 \n,服务端也会直接返回 401。
402 是余额不足。DeepSeek 是按量计费,余额归零会拒绝请求。这种情况后端要返回明确的错误码给前端,不要直接抛一个 500。429 是请求频率过高,通常伴随超时。解决方法是本地加限流或排队,控制好单位时间内的请求数。生产环境建议针对每个 API Key 做并发计数,超过阈值直接排队。
5.2 JSON 解析失败:按这个顺序查
响应解析失败是接入期最高发的问题,我的排查顺序固定是三步。第一步,把原始响应体完整打印出来,用 Postman 或 curl 调一次,和实体类字段逐一对比,重点看下划线字段名是否漏了 @JsonProperty。第二步,看响应里有没有 null 值出现在非空类型字段上,比如 content 为 null。第三步,确认失败是发生在同步还是流式场景,因为两者的响应结构不一样,不能复用同一套实体类。
最典型的一个坑是:同步响应里 choices 数组里是 message 字段,流式响应里是 delta 字段。我一开始图省事用同一个实体类解析两种响应,流式场景必然报错。解决办法很简单:同步和流式各自定义响应模型,不要强行共用。
5.3 接口变慢:先查线程池和连接池
如果你发现不只是 AI 接口慢,连普通接口都变慢了,九成是线程池被 AI 请求占满的连锁反应。排查方法:先看日志里有没有大量超时异常,再看线程 dump,确认有多少线程卡在外部 HTTP 调用上。
另一个原因是连接池耗尽。RestTemplate 默认实现不提供连接池,高并发下每个请求新建连接,TCP 握手开销大,连接数也容易触顶。解决办法是换用 Apache HttpClient 或者 OkHttp 作为底层实现,同时把连接池的最大连接数和每路由连接数调到合理值。这个配置做完,接口延迟会有明显改善。
5.4 流式输出乱码和粘包
SSE 流式输出的乱码基本都是字符集没对齐。DeepSeek 返回的 content-type 里 charset 默认是 UTF-8,但如果你在 WebClient 里没有强制指定字符集,或者中间网关把 charset 弄丢了,前端拿到的字节流就可能被错误解码。解决办法是读取响应时强制指定 UTF-8,同时在服务端 SSE 响应头里显式写出:
java复制response.setContentType("text/event-stream;charset=utf-8");
粘包问题则是流式转发时没有按行处理。SSE 的事件边界是换行符,如果直接把缓冲区内容原样转发,一个事件可能被切成两半或者两个事件粘在一起。按 \n 切割再过滤 data: 前缀,是很可靠的做法。
5.5 参数调优速查表
汇总一下我常用的参数,可以直接抄:
| 参数项 | 推荐配置 | 说明 |
|---|---|---|
| 模型 | deepseek-chat / deepseek-reasoner | 通用对话选 chat;复杂推理选 reasoner |
| temperature | 0.7 通用 / 0.2 代码或分析 | 值越大越发散,代码场景设低 |
| maxTokens | chat 2048~4096;reasoner 8192+ | 太小会导致输出被静默截断 |
| connectTimeout | 10s | 连接建立阶段 |
| readTimeout | 60s ~ 120s | 按回答长度调整 |
| 重试次数 | 最多 2 次,带退避 | 防止计费翻倍与放大故障 |
| 上下文轮数 | 6 轮,保留 system | 平衡成本与效果 |
| 线程池容量 | 核心 10,最大 20,队列 200 | 根据实际并发量压测调整 |
最后再分享一个实际经验:接入 DeepSeek 这类模型接口,真正的难度不在“调通”,而在“稳住”。调通只要照着文档半小时搞定;稳住则需要考虑超时、重试、线程池、上下文、成本、异常隔离这些看似普通但决定项目能否上线的细节。你要是现在正准备接入,建议按配置层、模型层、服务层、接口层四层来组织代码,先把同步调通,再把流式加上,最后补监控和降级。踩过几次坑之后再回头看,你会感谢当初多花半小时设计连接的那个人。
