
去年下半年开始我们团队一直在做业务系统接入大模型的功能最开始大家都觉得“DeepSeek API 调用 - Spring Boot 实现”这种题目不就是发个 HTTP 请求嘛有什么好写的。但真正动手之后才发现从“用网页聊天”到“在自己的 Spring Boot 服务里稳定调用模型能力”中间隔着一整条工程化的路——鉴权怎么管理、超时怎么设、流式输出怎么接、用户中途断开怎么办、怎么测试不烧钱。这篇文章就把我实战中走过的完整链路、写过的代码、踩过的坑一次性说清楚。适合正在用 Java 做后端集成的开发者也适合准备把大模型能力做成正式功能的团队参考。1. 为什么“把模型搬进业务系统”真正的难点不在模型本身1.1 从“能对话”到“能上线”中间隔着工程化我第一次在本地用命令行工具试 DeepSeek 的接口时感觉确实是“一行命令就通了”。拿到 API Key拼一个 JSON 请求体发出去文本就回来了。那种顺畅感很容易让人产生错觉在 Spring Boot 里调用也就是这么回事。但落到真实业务系统里情况立刻不一样。用户不会像命令行一样坐在那里等结果页面上必须给出反馈模型生成一段回答可能需要几十秒这个时间如果直接占着一个 Web 容器的线程高并发下服务很快就撑不住模型服务的网络抖动、限流、超时都可能让上游业务产生连锁故障还有 API Key 的存储、错误码的区分、成本的控制每一样都需要认真设计。所以我常说调用模型 API 的代码本身不难难的是把一次“外部网络请求”做成一个可靠、可控、可观测的集成功能。这也是这篇文章真正要解决的核心问题。1.2 DeepSeek API 走的是通用协议这反而降低了集成成本DeepSeek API 提供的是与主流 LLM 服务相似的 HTTP 接口风格走的是/chat/completions这类成熟的对话补全协议请求体是标准 JSON鉴权方式用的是Authorization: Bearer头。也就是说如果你以前写过其他大模型服务的 Java 集成代码接 DeepSeek 其实只需要换三个东西接口地址、模型名称、API Key。这里有个容易被忽略的价值协议标准化意味着切换成本低。你可以在配置中心里放一个模型供应商的开关按需切换不同的模型服务业务代码几乎不用改。这也是我建议团队在封装的时候不要写死任何厂商专属字段的原因——面向协议编程而不是面向某一家服务编程。1.3 读完这篇内容你能拿走什么我不打算写那种“从入门到放弃”的百科式教程而是直接按我实际落地的顺序来组织一个能直接跑通的 Spring Boot 同步调用示例包含配置类和 DTO 设计流式输出打字机效果的完整实现方案这是对话类功能体验好坏的关键一份异常处理和兜底策略清单覆盖限流、鉴权失败、连接超时等高频问题一套不烧钱的本地测试方法让你不调用真实模型也能验证代码逻辑。如果你正处于“不知道怎么开始”或者“已经能调通但怕上线出问题”的阶段这篇文章应该能帮你把这两段路都铺平。2. DeepSeek API 接入基线鉴权、请求体与响应结构2.1 认证头与接口地址DeepSeek API 的认证方式很简单在请求头里带上一个 API Key 即可Authorization: Bearer 你的API KeyAPI Key 需要到模型服务商的控制台申请。有一点我必须反复强调API Key 是敏感凭据绝对不能硬编码在代码里更不允许提交到代码仓库。我在项目里一般是这样处理的本地开发放在application-local.yml里通过环境变量${DEEPSEEK_API_KEY}占位测试和生产环境放到配置中心或环境变量管理平台上服务启动时动态读取。接口地址方面DeepSeek 提供了一个兼容的 HTTP 基地址你需要在配置类里把基础路径和具体接口路径拼起来。标准做法是维护一个常量例如deepseek: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} model: deepseek-chat max-tokens: 2048 temperature: 0.7这里顺便说一句model名称尽量不要写死在代码里放到配置里可以方便后续模型版本升级时一键切换。2.2 请求体设计messages 如何组织对话补全接口的核心请求体长这样{ model: deepseek-chat, messages: [ {role: system, content: 你是一个专业的Java开发助手}, {role: user, content: 请解释一下Spring Boot自动配置的原理} ], max_tokens: 2048, temperature: 0.7, stream: false }初接触的人容易在三个点上迷糊第一system 和 user 消息的区别。system 消息是给模型设定身份和行为准则的内容不会直接展示给终端用户。比如你可以在这里写“只回答与后端开发相关的问题不知道的内容直接说不知道”。user 消息才是真正的用户输入。合理设计 system 提示词对控制回答质量和安全边界非常关键。第二messages 是完整会话上下文。如果你实现多轮对话就把历史问答全部按顺序放进 messages 里。模型本身没有记忆所有“记忆”都靠请求体里携带的上下文。这里有个很现实的问题上下文越长消耗的 token 越多响应越慢。所以无上限地堆积历史消息是个典型的成本陷阱后面我会专门讲怎么控制上下文长度。第三max_tokens 和 temperature 的作用。max_tokens限制的是响应最大长度不是请求体的总长度设计接口时要注意如果用户输入已经很长max_tokens太大可能导致总体 token 超标。temperature控制随机性值越小回答越稳定值越大越有创造性。做 FAQ 客服类场景我一般用 0.3 以下做创意文本类场景才会调到 0.8 以上。2.3 响应结构解析与 token 计费非流式模式下接口返回的 JSON 结构大概长这样{ id: chatcmpl-xxx, choices: [ { index: 0, message: { role: assistant, content: Spring Boot 自动配置的核心是... }, finish_reason: stop } ], usage: { prompt_tokens: 28, completion_tokens: 120, total_tokens: 148 } }Java 侧需要重点关注两个地方choices[0].message.content是模型的回答正文usage里的三个 token 数字是计费和监控的重要依据。很多人对 token 计费有个误解以为只有用户输入那次才算钱。实际上每次请求的 prompt_tokens 会把 messages 里所有历史消息重新计一遍多轮对话时历史越长单次调用成本越高。我在生产环境里对监控项做了两个重点单次请求的 prompt_tokens 曲线以及会话平均轮数与总 token 的关系一旦发现某个场景 token 消耗陡增基本就是历史消息没做截断。3. 第一个可用版本用 RestClient 完成非流式调用3.1 为什么新项目我建议直接用 RestClient 而不是 RestTemplateSpring Boot 3.x 时代官方推荐的同步 HTTP 客户端是RestClient它不是简单替代RestTemplate而是把构建器模式、拦截器、类型转换这些能力做了一次现代化整合。如果你还在用 Spring Boot 2.x那RestTemplate也完全可行但如果是新项目直接上RestClient更省心。我选择 RestClient 的原因主要有三个一是 API 设计直观链式调用读起来就像在读需求文档二是错误处理更规范可以统一注册ResponseErrorHandler三是它支持同步和异步两套执行方式后续如果要把调用放到响应式栈里迁移成本很低。需要说明的是如果你的模块已经重度依赖 WebFlux那把 HTTP 调用器换成WebClient更合适RestClient并不是唯一正确答案。但我下面的示例以同步、传统 Servlet 栈为主因为绝大多数 Spring Boot 业务系统还是这个架构。3.2 配置类把密钥和超时都收到 Properties 里第一步先定义一个配置属性类用ConfigurationProperties绑定外部配置ConfigurationProperties(prefix deepseek) public class DeepSeekProperties { private String baseUrl https://api.deepseek.com; private String apiKey; private String model deepseek-chat; private Duration connectTimeout Duration.ofSeconds(5); private Duration readTimeout Duration.ofSeconds(60); // getters and setters 省略 }为什么要用ConfigurationProperties而不是Value因为当配置项多起来以后Value会散落在各个类里类型不安全校验也麻烦。集中绑定之后还能配合Validated做启动时校验比如 apiKey 为空直接让服务起不来而不是运行到一半才报错。然后在启动类上加上EnableConfigurationProperties(DeepSeekProperties.class)或者用Configuration配合EnableConfigurationProperties注册。这一步做完配置就算落位了。3.3 Service 实现完整的同步调用代码接下来写核心调用逻辑。我用一个ChatService提供两个方法一个拼请求、发请求、解析响应另一个给上层接口调用。Service public class ChatService { private final RestClient restClient; private final DeepSeekProperties properties; public ChatService(DeepSeekProperties properties) { this.properties properties; this.restClient RestClient.builder() .baseUrl(properties.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer properties.getApiKey()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .requestFactory(createRequestFactory(properties)) .build(); } private ClientHttpRequestFactory createRequestFactory(DeepSeekProperties props) { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout((int) props.getConnectTimeout().toMillis()); factory.setReadTimeout((int) props.getReadTimeout().toMillis()); return factory; } public String chat(String userMessage) { ChatRequest request new ChatRequest( properties.getModel(), List.of( new ChatMessage(system, 你是一个专业的Java开发助手), new ChatMessage(user, userMessage) ), properties.getMaxTokens(), properties.getTemperature(), false ); ChatResponse response restClient.post() .uri(/chat/completions) .body(request) .retrieve() .body(ChatResponse.class); if (response null || response.choices() null || response.choices().isEmpty()) { throw new ChatServiceException(模型响应为空); } return response.choices().get(0).message().content(); } }这里的 DTO 我用 Java 16 的 record 来写简洁且不可变public record ChatRequest( String model, ListChatMessage messages, Integer max_tokens, Double temperature, Boolean stream ) {} public record ChatMessage(String role, String content) {} public record ChatResponse( String id, ListChoice choices, Usage usage ) { public record Choice(Integer index, ChatMessage message, String finish_reason) {} public record Usage(Integer prompt_tokens, Integer completion_tokens, Integer total_tokens) {} }关于超时设置我要多说一句。普通 HTTP 接口的 readTimeout 我通常给 3-5 秒但模型接口完全不是这个量级——大模型生成文本是逐个 token 输出的慢的时候一个 token 可能要好几百毫秒整段回答下来几十秒很正常。所以 readTimeout 我一般给到 60 秒甚至更长。connectTimeout 反而要短一些5 秒足够了连不上就快速失败不要一直傻等。3.4 Controller 层与第一步验证方法Controller 层保持薄只做一个参数接收和结果转发RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } PostMapping(/sync) public ChatResponseVo chat(RequestBody ChatRequestVo vo) { String answer chatService.chat(vo.message()); return new ChatResponseVo(answer); } }跑通之后的验证我建议分两步走。第一步先用curl直接打接口确认网络、鉴权、模型名都没问题第二步再用 Postman 或直接调自己的 Controller观察日志里请求是否成功、响应内容是否完整。这里有个小经验第一次联调时千万别急着上流式输出。先把这个同步版本完整跑通确认鉴权头、请求体序列化、响应反序列化这几条链路都没问题再去做 SSE 的改造。很多同学一上来就搞流式出问题了根本分不清是请求参数错了还是解析有问题。4. 流式响应让对话框拥有打字机效果的正确打开方式4.1 SSE 在 LLM 语境下的数据形态如果非流式接口是一次性返回整段回答那流式接口就是分块输送。DeepSeek API 走的是标准的 Server-Sent EventsSSE协议响应内容是一个text/event-stream流每个事件以data:开头以空行结束最后用一个data: [DONE]标记结束。简化的流长这样data: {id:chatcmpl-xxx,choices:[{delta:{content:Spring},index:0}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:Boot},index:0}]} data: [DONE]为什么要流式两个原因一是首字延迟大幅降低用户可能在 1 秒内就看到第一个字而不是干等几十秒二是整体超时风险变小哪怕生成时间很长只要流还在持续输出连接就不会因为“长时间无响应”而被断开。4.2 基于 StreamingResponseBody 实现第一版流式输出在传统 Servlet 栈里Spring Boot 提供了StreamingResponseBody它允许我们在响应线程中把数据持续写回给客户端非常适合对接 SSE 流。我的实现思路是用RestClient发起请求streamtrue拿到响应体输入流逐行读取解析出data:开头的行把解析后的增量内容实时写给前端。关键代码大致如下:PostMapping(/stream) public StreamingResponseBody stream(RequestBody ChatRequestVo vo) { return outputStream - { ChatRequest request new ChatRequest( properties.getModel(), List.of(new ChatMessage(user, vo.message())), properties.getMaxTokens(), properties.getTemperature(), true ); restClient.post() .uri(/chat/completions) .body(request) .exchange((clientRequest, clientResponse) - { try (BufferedReader reader new BufferedReader( new InputStreamReader(clientResponse.getBody(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { if (line.startsWith(data:)) { String data line.substring(5).trim(); if ([DONE].equals(data)) { break; } // 解析增量JSON提取delta.content String deltaContent extractDeltaContent(data); if (deltaContent ! null !deltaContent.isEmpty()) { outputStream.write(deltaContent.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); } } } } return null; }); }; }要注意的细节有几个BufferedReader必须用 UTF-8 解码否则中文会乱码每次 flush 要及时否则前端感受不到“打字机”效果[DONE]要单独判断不能和普通 JSON 混在一起解析exchange方法给你完全控制响应体的能力比retrieve灵活得多SSE 场景只能用exchange。提取增量内容的 JSON 解析我直接用Jackson的ObjectMapper反序列化一个小 record 即可private record DeltaResponse(ListDeltaChoice choices) { record DeltaChoice(Delta delta) {} record Delta(String content) {} } private String extractDeltaContent(String data) throws JsonProcessingException { DeltaResponse deltaResponse objectMapper.readValue(data, DeltaResponse.class); if (deltaResponse.choices() ! null !deltaResponse.choices().isEmpty()) { Delta delta deltaResponse.choices().get(0).delta(); return delta ! null ? delta.content() : null; } return null; }4.3 不得不防的三个流式坑坑一不要在 SSE 上开启压缩。有些团队会在网关或 Nginx 层统一开启 gzip 压缩但 SSE 一旦被压缩服务端就必须等累积到足够数据才能编码输出这对“边生成边显示”是毁灭性打击。如果前端长时间看不到内容先检查压缩配置。坑二空闲连接被中间环节断开。模型生成的节奏不是均匀的某些时间段可能几秒都没有新 token。如果中间经过的代理或网关有“空闲超时断开”的策略流式连接会被无辜中断。我在生产里遇到过一次最后是通过定期发送 SSE 注释行形如: heartbeat来保活的。坑三客户端断开后的资源回收。用户在前端关闭了页面但服务端可能还在傻傻地继续生成并写入流白白浪费 token 和带宽。处理方式是在写入时捕获IOException一旦出现异常立刻终止循环不需要继续读上游数据了。4.4 前端接流需要注意什么如果你用EventSource接 SSE会发现它有一个限制无法自定义请求头。而我们调用 DeepSeek 的请求是把鉴权头放在服务端的前端其实不用携带 Key所以这个限制对我们的架构没有影响——前端只是从自己的后端取流。如果前端用fetchReadableStream来接流代码逻辑大概是这样const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: userInput }) }); const reader response.body.getReader(); const decoder new TextDecoder(); // 不断读取 response 中的增量文本追加到界面上这里有个和 UI 相关的小建议流式输出场景下前端做文本渲染时尽量用纯文本追加方式不要每次拿到增量都重新做一次 Markdown 渲染否则长文本时性能会很难看。5. 工程化兜底超时、重试、限流与本地测试5.1 错误码语义和异常处置清单模型接口返回的 HTTP 状态码并不复杂但每个码对应的处置策略完全不同。我把高频错误整理成一个表方便对照状态码含义处置策略401API Key 无效或缺失检查配置不重试提示用户402余额不足不重试预警充值429触发限流指数退避重试或排队等待500模型服务内部错误可重试一次再失败则降级503服务暂时不可用可重试注意退避时间拉长我在封装里要求两层异常处理底层 HTTP 调用失败抛出统一异常Service 层捕获后翻译成业务异常附带具体错误码和可读消息。上层接口再统一包装成不含敏感信息的错误报文返回给前端。5.2 重试与幂等设计重试不是无脑重发请求。鉴权失败401、余额不足402、参数错误400这些重试多少次都一样结果纯属浪费。真正值得重试的是 429、5xx 这类临时的服务端问题。我建议用手动指数退避而不是上来就引重试框架代码更直观public ChatResponse chatWithRetry(ChatRequest request, int maxRetries) { int attempt 0; while (true) { try { return restClient.post() .uri(/chat/completions) .body(request) .retrieve() .body(ChatResponse.class); } catch (HttpStatusCodeException e) { if (shouldRetry(e.getStatusCode()) attempt maxRetries) { long waitMillis 500L * (1L attempt); Thread.sleep(waitMillis); attempt; } else { throw e; } } } }注意模型接口调用的每轮重试都意味着真实的花费因为请求已经发出去了。所以我把默认重试次数控制在 2 次以内并且只在连接超时、429、5xx 这三类情况下触发。5.3 并发与限流防护这是我在实际项目里吃过亏的地方。模型接口响应慢如果直接用同步调用方式放在 Tomcat 默认线程池里处理一个请求就会占住一个线程几十秒。并发稍微一高线程池被打满整个应用的其他接口也跟着遭殃。我的做法是线程池隔离单独建一个专供模型调用使用的有界线程池核心线程数和最大线程数根据业务并发量评估队列必须有界满了直接返回友好提示而不是无限排队。Bean(modelCallExecutor) public ExecutorService modelCallExecutor() { return new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() ); }CallerRunsPolicy的选择要慎重它会让调用线程自己执行任务接口延迟会更高但如果你的业务能接受这种“限流反馈”它比直接丢弃请求要安全。5.4 本地测试不烧钱的策略每次开发调试都去调真实模型接口一方面费钱一方面响应结果不稳定根本没法做自动化断言。我在项目里用 MockWebServer 在本地起一个假的模型服务测试用例全部打到 Mock 服务上。这样 CI 环境也可以跑测试速度非常快费用为零。一个最小示例BeforeEach void setUp() throws IOException { mockWebServer new MockWebServer(); mockWebServer.start(); // 把 base-url 指向 mockWebServer.url(/).toString() } Test void shouldExtractContentFromResponse() throws Exception { mockWebServer.enqueue(new MockResponse() .setHeader(Content-Type, application/json) .setBody({\id\:\1\,\choices\:[{\message\:{\content\:\你好\},\index\:0}]})); String result chatService.chat(你好); assertEquals(你好, result); }这套方案跑通之后我从没担心过“改了代码会不会把已有功能弄坏”——每次改动先跑一遍 Mock 测试再决定要不要做真实验证。最后分享一点个人体会。接入 DeepSeek API代码层面真的不神秘本质上就是一次 HTTP 请求加上合适的工程保障。但在我经手的几个项目里真正决定成败的往往是把模型当成一个“延迟高、不稳定、要计费”的外部依赖来对待的工程素养。建议你先跑通同步版本再做流式再补异常和监控一步步把这条路走稳。上面这套方案我已经在多个项目里验证过照着搬基本不会翻车。