ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spring Boot集成DeepSeek API:从调通到上线的工程实践

Spring Boot集成DeepSeek API:从调通到上线的工程实践 把 DeepSeek API 接进 Spring Boot这事看起来无非就是“发 HTTP 请求 解析 JSON”。但我实际上手做了几个内部项目之后发现真正决定项目成败的往往是那些边角料问题密钥放哪儿、超时怎么配、流式返回怎么断句、限流之后怎么退避。这篇文章把我会用到的完整做法拆开讲适合正在做 AI 应用集成的后端同学也适合想把项目从“能调通”升级到“能上线”的人。1. 动手之前技术选型和配置设计1.1 为什么是 Spring Boot而不是直接写 HTTP 工具类如果你只是临时跑一个脚本直接拿HttpClient写几十行代码完全够用。但放到 Spring Boot 项目里问题就不一样了连接怎么复用、超时怎么统一管理、鉴权头怎么注入、接口返回怎么反序列化、调用失败后怎么重试这些如果全部靠手写团队里每个人都有自己的写法最后代码会变成一锅粥。Spring Boot 一个比较舒服的地方是它把 HTTP 客户端的生命周期交给了容器。你可以在配置类里声明RestTemplate或WebClientBean统一设置超时、拦截器、连接池然后到处注入使用。调用方只关心业务方法不关心底层走了什么协议、连接怎么建立的。尤其是项目里要同时对接对话补全和流式对话两种模式统一封装的价值会更明显。至于性能很多人担心 Spring 封装的客户端不够快。其实只要连接池配置合理异步场景用 WebClient同步场景用配置好连接池的 RestTemplate性能完全够日常业务使用。真正的问题通常不在客户端而是你在每次请求时 new 了一个 HttpClient导致连接频繁建立和销毁。所以先用 Spring 管理客户端后续再根据压测结果优化路径会顺畅很多。1.2 API Key 的存放方式别写死在配置文件里第一次对接时最容易踩的坑就是把 API Key 直接写在application.yml里然后不小心提交到代码仓库。这个习惯一旦养成后面要么找人帮忙重置密钥要么写一堆脚本去清理仓库历史。我现在的做法是配置文件里只留占位符真实密钥从环境变量或者部署平台的密钥管理服务里读取。deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com connect-timeout: 5s read-timeout: 60s这里用${DEEPSEEK_API_KEY}Spring 在启动时会自动去环境变量里取值。本地开发可以在 IDE 的运行配置里设置环境变量 CI/CD 平台一般也都有 Secrets 功能。这样代码仓库和镜像里都看不到明文密钥出问题的时候只需要在部署平台统一更新不需要重新提交代码。另外一个细节是不要把Authorization头打到业务日志里。很多公司会有全局的请求日志切面如果你在切面里打印了HttpHeaders密钥就全出去了。我在项目里会给日志过滤器加一个脱敏逻辑只要 header 名是Authorization值统一显示成***这个后面在排查实录里再展开。1.3 版本组合Spring Boot 2.x 还是 3.x如果你是新项目我推荐直接上 Spring Boot 3.x JDK 17。原因很简单Boot 3 的 WebClient 使用体验更完整jakarta.*命名空间也解决了历史包袱。如果你维护的是老项目Boot 2.7 也能做只是要注意javax.servlet变成jakarta.servlet那批依赖还有 Spring Security 版本关系。DeepSeek API 本身是 HTTPS 接口没有 Java 版本相关的强绑定所以核心矛盾在 Spring 版本。我的建议是新服务Spring Boot 3.2JDK 17用 WebClient 处理流式用 RestTemplate 或者RestClient处理简单同步请求。老服务Spring Boot 2.7JDK 8 也能跑但注意RestTemplate的线程安全性和超时配置。下面我会按要求同时给出同步和异步两种实现你可以根据自己项目的基础设施选择。不管用哪个DTO 设计是完全一样的。2. 请求与响应模型设计2.1 Chat Completions 接口的协议要点DeepSeek 的对话补全接口本质上是一个标准的 HTTP POST 接口。你需要请求一个固定的 URL把对话消息列表、模型名称、生成参数放到 JSON 请求体里然后从响应里拿生成结果。最核心的请求字段有这几个model指定使用哪个模型比如deepseek-chat或deepseek-reasoner。messages对话消息列表每一条包含role和content。role通常是system、user、assistant。temperature采样温度值越高回答越随机。max_tokens限制生成的最大 token 数。stream是否开启流式返回。如果之前对接过类似的 Chat Completions 接口会发现这套字段非常眼熟。这个格式现在基本属于行业通用习惯所以我建议不要自己发明字段直接沿用标准结构可以降低以后接入其他模型的迁移成本。2.2 定义 Java DTO字段越少越不容易出错在 Spring Boot 里我建议用 Java Record 来承载这类纯数据对象。Record 自带构造器、equals、hashCode和toString写起来干净也不容易手抖加错 setter。public record ChatMessage(String role, String content) {} public record ChatRequest( String model, ListChatMessage messages, Double temperature, Integer maxTokens, Boolean stream ) { // Jackson 默认使用参数名做 JSON 映射需要在编译期开启 -parameters // 或者通过 jackson-databind 的字段命名策略来兼容 snake_case。 }这里有个小坑DeepSeek API 的请求字段是max_tokens这种下划线风格而 Java 风格是maxTokens。如果你不想在每个字段上写JsonProperty可以在项目里配置 Jackson 的PropertyNamingStrategies.SNAKE_CASE让全局字段命名自动转换。Bean public Jackson2ObjectMapperBuilderCustomizer snakeCaseCustomizer() { return builder - builder.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); }我个人的习惯是全局开启 SNAKE_CASE 反而对大型项目不友好容易把别的接口也转换了。更稳妥的是只在 DTO 关键字段上显式标注public record ChatRequest( String model, ListChatMessage messages, Double temperature, JsonProperty(max_tokens) Integer maxTokens, Boolean stream ) {}显示标注带来的好处是以后走查代码时谁都能一眼看出这个字段对应 API 里的哪个名字不用依赖隐式配置去猜。2.3 响应结构解析到哪一层最合适响应结构比请求稍微复杂一点。外层有几个固定字段真正有用的是choices数组里的第一项因为你请求一次对话通常只希望模型返回一条候选内容。每一项里有message和finish_reason。message.content是最终文本finish_reason则告诉你生成是因为正常结束stop还是达到了 token 上限length。public record ChatResponse( String id, String model, ListChoice choices, Usage usage ) { public record Choice(Integer index, ChatMessage message, String finishReason) {} public record Usage(Integer promptTokens, Integer completionTokens, Integer totalTokens) {} }我见过很多新手把整个ChatResponse直接返回给前端让前端自己从choices[0].message.content里取文本。短时间能用但只要 API 结构稍微变化前端就要跟着改。比较好的做法是在 Service 层再包一层业务对象把需要的数据都组装成ChatResultController 只面向业务对象。public record ChatResult(String content, String finishReason, Integer totalTokens) {}这样做还有一个好处后续如果要记录 token 消耗、做费用统计Usage信息已经被 Service 层拿走了Controller 不会把敏感信息暴露出去。3. 两步写出可用的调用客户端3.1 先用 RestTemplate 把同步流程跑通很多业务场景其实不需要流式返回比如后台生成摘要、批量打标签、离线分析。这时候用RestTemplate最直观代码也最好维护。先配置一个独立的RestTemplateBean避免使用 Spring Boot 自动装配的默认实例。Configuration public class DeepSeekClientConfig { Bean public RestTemplate deepSeekRestTemplate(DeepSeekProperties properties) { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout((int) properties.connectTimeout().toMillis()); factory.setReadTimeout((int) properties.readTimeout().toMillis()); return new RestTemplate(factory); } Bean public DeepSeekClient deepSeekClient( RestTemplate deepSeekRestTemplate, DeepSeekProperties properties) { return new DeepSeekClient(deepSeekRestTemplate, properties); } }注意这里设置了connectTimeout和readTimeout。大模型接口通常不是毫秒级返回如果readTimeout只给 5 秒长一点的文本生成一定会超时。我一般把连接超时控制在 5 秒内读取超时根据业务容错能力放宽到 30 到 120 秒。然后写一个客户端调用方法Service public class DeepSeekClient { private final RestTemplate restTemplate; private final DeepSeekProperties properties; public ChatResult chat(String userMessage) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(properties.apiKey()); ListChatMessage messages new ArrayList(); messages.add(new ChatMessage(system, 你是一个有用的中文助手)); messages.add(new ChatMessage(user, userMessage)); ChatRequest request new ChatRequest( properties.model(), messages, 0.7, 2000, false ); HttpEntityChatRequest entity new HttpEntity(request, headers); ResponseEntityChatResponse response restTemplate.exchange( properties.baseUrl() /chat/completions, HttpMethod.POST, entity, ChatResponse.class ); if (response.getStatusCode().is2xxSuccessful() response.getBody() ! null) { ChatResponse body response.getBody(); ChatResponse.Choice choice body.choices().get(0); return new ChatResult( choice.message().content(), choice.finishReason(), body.usage().totalTokens() ); } throw new DeepSeekException(调用失败状态码 response.getStatusCode()); } }这段代码里的重点是headers.setBearerAuth。它会自动生成Authorization: Bearer xxxx比手动拼接字符串更安全也避免出现多一个空格、少一个空格的问题。实际项目中system提示词不建议写死在客户端里。更好的做法是让调用方传入或者放到配置中心这样运营或者算法同事调整提示词时不需要动代码。3.2 用 WebClient 做流式返回如果要实现“打字机”效果就需要对接 SSE 流式返回。DeepSeek API 开启stream: true后HTTP 响应头是Content-Type: text/event-stream每一行以data:开头里面是一小段 JSON。Spring Boot 里的 WebClient 对 SSE 有比较好的支持。先声明一个支持流式的 WebClient BeanBean public WebClient deepSeekWebClient(DeepSeekProperties properties) { return WebClient.builder() .baseUrl(properties.baseUrl()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer properties.apiKey()) .build(); }注意这里通过baseUrl指定了根地址业务调用时只要写相对路径/chat/completions即可。后续如果要切换到别的 API 域名只需要改配置不用改代码。流式方法可以这样写public FluxString streamChat(String userMessage) { ListChatMessage messages List.of( new ChatMessage(user, userMessage) ); ChatRequest request new ChatRequest( properties.model(), messages, 0.7, 2000, true ); return webClient.post() .uri(/chat/completions) .bodyValue(request) .retrieve() .bodyToFlux(new ParameterizedTypeReferenceServerSentEventString() {}) .filter(event - Objects.nonNull(event.data())) .map(event - parseContent(event.data())) .filter(Objects::nonNull); }ServerSentEventString会把 SSE 的data字段解析成String。因为data本身是一段 JSON所以还需要一个parseContent方法从 JSON 里把增量文本取出来private String parseContent(String json) { JsonNode node objectMapper.readTree(json); if (node.path(choices).isEmpty()) { return null; } JsonNode delta node.path(choices).get(0).path(delta).path(content); return delta.isMissingNode() ? null : delta.asText(); }流式返回里每一段只是增量文本不是完整回答。前端拿到后要做字符串拼接形成最终展示。这个逻辑放在前端做比较合适后端只负责把文本片段按顺序推给前端。如果你的业务需要通过 HTTP 对外暴露常见做法是用 WebSocket 或者 SSE 把后端收到的内容和前端对接不要让业务服务直接依赖外部 API 的格式。3.3 超时、连接池和并发控制同步调用如果用默认的SimpleClientHttpRequestFactory它在高并发下连接复用能力比较弱。如果你的项目要长期承接较高的调用量我会建议换成基于 Apache HttpComponents 的客户端。Bean public RestTemplate deepSeekRestTemplate(DeepSeekProperties properties) { PoolingHttpClientConnectionManager connectionManager new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); connectionManager.setDefaultMaxPerRoute(50); CloseableHttpClient httpClient HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout((int) properties.connectTimeout().toMillis()) .setSocketTimeout((int) properties.readTimeout().toMillis()) .build()) .build(); HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(httpClient); return new RestTemplate(factory); }为什么要关注连接池因为大模型接口的平均响应时间可能远高于普通接口比如 10 秒甚至 20 秒。如果每个请求都占用一个连接高峰期连接池很容易被占满。同一个线程如果还有第二个请求要发就只能排队等空闲连接导致整体的吞吐量下降。所以给RestTemplate配连接池不是可有可无的优化而是高并发下的必需品。对于 WebClient默认响应时间是 30 秒我一般会在 WebClient 的 builder 里显式设置responseTimeout。注意不要只在请求级设置最好在 Bean 初始化时就固定下来避免每一处调用都重复配置。4. 异常处理、重试与降级4.1 先把异常分门别类对接第三方 API 和对接本地数据库不一样网络抖动、服务端限流、模型过载都非常常见。如果只是抛一个Exception出来Controller 层很难针对性地做处理。我通常先建一个异常分类表场景特征处理方式鉴权失败HTTP 401不重试只告警提示检查 Key限流HTTP 429读取Retry-After指数退避后重试服务端错误HTTP 5xx重试 2-3 次仍失败则降级连接超时SocketTimeoutException重试一次避免重复提交读取超时ReadTimeoutException无法确认服务端是否已生成谨慎重试网络中断IOException指数退避后重试这个表的价值在于它决定了你写catch逻辑时该往哪个方向处理。如果所有异常都用一个重试策略很可能出现“401 也重试三次”这种既浪费时间又浪费配额的情况。4.2 用 Spring Retry 实现指数退避Spring Retry 是现成的重试框架配合RetryTemplate可以控制重试次数和退避时间。调用外部大模型接口指数退避比固定间隔更有意义因为限流和服务端过载往往需要一定时间恢复。Configuration public class RetryConfig { Bean public RetryTemplate deepSeekRetryTemplate() { return RetryTemplate.builder() .maxAttempts(3) .exponentialBackoff(1000, 2, 10000) .retryOn(DeepSeekRetryableException.class) .build(); } }使用方式很简单public ChatResult chatWithRetry(String userMessage) { return retryTemplate.execute(context - chat(userMessage)); }这里我会单独定义一个DeepSeekRetryableException然后在 catch 里做翻译catch (IllegalArgumentException e) { throw e; } catch (Exception e) { return new DeepSeekRetryableException(e); }为什么要做这层翻译因为RetryTemplate默认会重试所有异常但认证错误、参数错误、业务校验失败这些情况是不应该重试的。通过明确的异常类型你才能精确控制“什么该重试、什么不该重试”。4.3 降级策略宁可返回默认结果也不要让依赖链崩掉如果调用方是一个对实时性要求不高的功能比如后台离线聚合降级策略可以直接返回缓存结果或者兜底文案。如果调用方是实时对话界面降级会复杂一些但至少要有“模型暂不可用”的提示而不是让请求直接 500。一个简单的写法public ChatResult chatSafely(String userMessage) { try { return retryTemplate.execute(ctx - chat(userMessage)); } catch (Exception e) { log.warn(DeepSeek 调用失败返回兜底结果, e); return new ChatResult(抱歉我暂时无法回答这个问题请稍后再试。, fallback, 0); } }实际项目中兜底结果可以来自 Redis 缓存。举个例子如果同一个 prompt 之前已经调用成功过就直接把结果返回既省 token又避免频繁触发限流。我会把这种需求放到“成本控制”一节具体讲。4.4 重试与幂等的平衡调用聊天补全接口时“重试”不像普通数据库更新操作那么严格因为一次生成请求被重试可能造成多次计费也可能产生两条不同回答。所以在重试之前要问自己这个业务能不能接受重复调用我的习惯是单轮问答可以重试。多轮对话重试可能改变上下文通常不自动重试。计费敏感场景重试次数控制在 2 次以内。如果你用的是流式接口重试会更麻烦因为前端可能已经接收到一部分内容了。这时候我更推荐让 WebSocket 或前端业务层来负责“断线续传”而不是后端默默重发整个请求。5. 常见问题排查实录5.1 Connection timed out排查网络边界有一个很容易忽略的问题服务器在云端但云端主机默认只能访问特定网段或者需要经过额外的网络出口才能访问公网。我遇到过最典型的情况是代码在本地开发环境能调通部署到测试环境后一直Connection timed out。排查步骤一般如下用命令行curl先测试 API URL 是否可达。检查安全组或防火墙有没有限制出方向访问。检查 DNS 解析是否正常。检查应用服务器上是否配置了全局代理。curl都通了再去看 Java 代码里的客户端超时配置。很多时候问题不是代码逻辑而是环境策略。如果确认网络没问题再看是不是connectTimeout设置得太短。5.2 429 Too Many Requests限流了别硬顶429 出现时说明当前调用频率超过了服务端限制。此时唯一正确的做法是退避重试或者降级。千万不要写一个while(true)循环不仅浪费 API 配额还可能让限流时间更长。DeepSeek API 如果返回了Retry-After请求头建议优先读取这个时间。没有的话用指数退避第一次等 1 秒第二次等 2 秒第三次等 4 秒。我遇到比较极端的一次是调用方没有做并发限制几十个线程同时请求结果 429 持续了好几分钟。后来加了信号量限流才把问题解决。5.3 返回的 content 被截断了先看 finish_reason如果你发现模型回答到一半就停了不要急着怀疑 API 出错了先看choices[0].finish_reason。如果是stop说明模型认为回答已经完整。如果是length说明max_tokens不够回答被截断了。如果是content_filter说明内容被过滤这是一种正常保护策略。“回答到一半”最常见的根源是max_tokens设置太小。尤其你用deepseek-reasoner这类推理模型时思考过程本身会占用很多 token最后留给正式回答的空间可能不够。此时可以调大max_tokens或者在前端追加一次“请继续”请求。5.4 密钥泄露到日志Authorization 头必须脱敏有一次我排查线上问题顺手翻了应用日志发现请求日志里把完整的Authorization: Bearer sk-xxxx打印出来了。当场冒了冷汗。密钥一旦出现在中心化日志系统里所有能看日志的同事都能拿到权限控制再严格也经不起这种暴露。我的解决方案是在 WebFilter 里统一脱敏。Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String auth request.getHeader(HttpHeaders.AUTHORIZATION); if (StringUtils.hasText(auth)) { String masked auth.substring(0, 15) ***; log.info(request uri: {}, authorization: {}, request.getRequestURI(), masked); } chain.doFilter(request, response); }注意这里只是顺手举例子真正生产环境我不建议你打印任何请求头。即使要打也必须确保Authorization只保留前几位。5.5 中文乱码和 JSON 转义对接时如果直接用String接收响应可能会出现中文乱码。最简单的解决办法是确保请求头带有Content-Type: application/json;charsetUTF-8同时 Jackson 默认就是 UTF-8 解码一般不大会乱。比较讨厌的是返回内容里有换行、引号、反斜杠被 JSON 转义后显示成\n或\。前端拿到后要记得用 JSON 解析不要直接把 content 显示出来。后端如果是传给内部系统可以在ChatResult里先做一次反转义但更推荐保留原始 JSON 字段由前端统一处理。5.6 流式返回拼接出现重复或丢失流式返回的每一段data都是不同步到达的可能存在空delta也可能出现连续两个事件内容为同一个 token 的情况。我在处理时习惯的做法是过滤掉data为空的事件。只取delta.content有值的片段。前端按顺序 append不在后端做聚合。如果你在后端强行聚合完整文本再一次性推给前端就失去流式意义了。只要保持事件顺序正确拼接工作交给前端是最省心的。6. 再聊几个工程化习惯6.1 用 MockRestServiceServer 写单元测试对接真实 API 的测试不稳定也烧钱。更好的方式是使用 Spring 提供的MockRestServiceServer预先定义好请求和响应的 mock然后验证客户端逻辑。SpringBootTest class DeepSeekClientTest { Autowired DeepSeekClient deepSeekClient; Autowired RestTemplate restTemplate; Test void shouldParseChatResponse() { MockRestServiceServer server MockRestServiceServer.bindTo(restTemplate).build(); server.expect(once(), requestTo(https://api.deepseek.com/chat/completions)) .andExpect(method(HttpMethod.POST)) .andRespond(withSuccess( { id: test, model: deepseek-chat, choices: [ { index: 0, message: {role: assistant, content: 你好}, finish_reason: stop } ], usage: {prompt_tokens: 10, completion_tokens: 5, total_tokens: 15} } , MediaType.APPLICATION_JSON)); ChatResult result deepSeekClient.chat(早上好); Assertions.assertEquals(你好, result.content()); server.verify(); } }有了这种测试你可以放心调整 DTO 字段、异常逻辑和重试策略不用每次都跑到真实环境去验证。我甚至会在测试里故意返回429确认重试模板是否按预期执行。6.2 在 Controller 和底层客户端之间加一层门面直接让 Controller 调用 DeepSeek 的RestTemplate方法短期没问题长期会越来越难维护。因为 Controller 面向的是“业务输入输出”不应该知道消息列表怎么组装、温度怎么设置、重试策略是什么。我习惯先定义一个面向业务的接口public interface ChatClient { ChatResult chat(String userMessage); FluxString streamChat(String userMessage); }然后写一个基于 DeepSeek 的实现Service public class DeepSeekChatClient implements ChatClient { // 内部注入 DeepSeekClient }将来如果业务需要切换模型或者同时对接多个模型只需要新增一个实现然后通过配置切换具体实现类。调用方完全不感知测试也更好做。6.3 成本控制缓存相同请求结果大模型 API 是按 token 计费的。同一个问题如果被重复请求费用会不断累积。我在内部项目里常用两种手段短时间窗口缓存把相同 prompt 的结果在 Redis 内存 5 分钟之后用ChatResult直接从缓存返回。并发限流用Semaphore限制同时发往 DeepSeek 的请求数量避免瞬时峰值触发 429。限流代码很简单private final Semaphore semaphore new Semaphore(20); public ChatResult chatWithLimit(String message) { if (!semaphore.tryAcquire()) { return fallback(); } try { return chat(message); } finally { semaphore.release(); } }有人会觉得这种限流会牺牲响应速度但对于大多数内部系统来说宁可让少部分请求降级也不能让所有请求因为限流或超时全部失败。缓存和限流结合起来既节省成本又让系统的行为更可预期。6.4 最后一点心得实际做下来最耽误进度的往往不是 API 本身而是没有提前想清楚异常、日志和测试。先写一个能跑通的最小按钮再做超时和重试最后才铺开业务功能是我现在比较推荐的方式。如果你刚接触建议先复制一份同步调用代码跑通一次再尝试改成流式最后再往工程化方向打磨。这个顺序走完以后再接别的模型 API基本一天之内就能上线。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表