如果你所在的后端团队今年开始讨论“给系统接一个大模型”,Spring AI 加 DeepSeek 这套组合大概率会出现在备选清单的前排。先说清楚:这篇不是给任何模型厂商做推广,纯粹是从 Java 工程化角度聊聊怎么把 DeepSeek 的聊天能力接进 Spring 生态,并且接得稳、接得省心。
我做过的几个内部系统,之前接大模型都是自己用 RestTemplate 写调用,刚开始挺爽,后来随着需求从“能聊”变成“要流式、要多轮、要工具调用”,那套手写代码越来越难维护。换到 Spring AI 之后,最大的感受是它把模型接入这件事从“拼 HTTP 接口”变成了“声明一个组件”,门槛一下子降下来了。这篇文章就把我整合 DeepSeek 的全过程拆开讲,包括依赖怎么选、配置怎么写、代码怎么组织、参数怎么调,以及那些文档里不会明说的坑。
1. 项目概览:Spring AI 与 DeepSeek 能碰撞出什么
1.1 为什么选 Spring AI 而不是自己封装 HTTP 调用
很多团队一开始都会纠结一个问题:DeepSeek 的 API 本身是 OpenAI 兼容格式,我用 HttpClient 直接调不就行了,为什么要多引入一套 Spring AI?
单看“发一次请求、拿一次回复”这个最小场景,手写确实够了。但你把需求列表拉长看:流式返回要解析 SSE 分帧,多轮对话要维护 Message 序列,工具调用要做函数注册和结果回填,不同模型供应商的接口差异要抽象……这些如果全自己写,工作量不是线性增长,是几何级数。
Spring AI 的价值在于它把“对接大模型”这件事做成了类似 JDBC 的适配层。你面向的是 ChatClient 这种统一门面,底层无论是 DeepSeek、还是其他兼容 OpenAI 协议的模型,代码主体都不需要改。这跟你用 JDBC 写 SQL、底层换数据库连接池是一个道理——业务代码不绑定具体厂商。
另外,Spring AI 是 Spring 官方生态的一部分,自动配置、依赖注入、配置项绑定这些机制都跟 Spring Boot 天然融合。你不需要自己造一套配置加载逻辑,也不需要担心 Bean 生命周期管理。对于已经在用 Spring Boot 的团队,引入成本非常低。
1.2 DeepSeek 在聊天模型里的定位
从纯技术接入角度看,DeepSeek 最友好的地方在于它的 API 协议是 OpenAI 兼容的。这意味着 Spring AI 不需要为它单独写一套接入实现,直接复用 OpenAI 模块,把 Base URL 指过去、模型名换成 DeepSeek 的模型标识就行。
DeepSeek 提供两个主要模型标识:一个是 deepseek-chat,面向通用对话场景,日常问答、内容生成、代码辅助都能胜任;另一个是 deepseek-reasoner,偏推理型,适合数学、逻辑、复杂分析这类需要深度思考的任务。我这边的项目主要用 deepseek-chat,reasoner 偶尔用在需要高准确率的子任务上。
这个“OpenAI 兼容 + 模型标识切换”的设计,是整个项目最关键的一个决策点。它省去了一个专门的 starter,也让后续横向切换模型供应商变得非常轻量。如果你接的是其他兼容 OpenAI 协议的服务,思路一模一样,只是改几个配置字符串的事。
1.3 这套方案能解决的实际场景
我实际落地过的场景包括:内部知识库问答助手、工单自动分类与摘要、代码仓库的提交信息规范化、以及对外的智能客服后端服务。这些场景有一个共同特征:都需要跟现有 Spring Boot 业务系统深度集成,比如查订单状态、读用户信息、写数据库记录。
Spring AI 在这类场景里的优势是它的 Function Calling 机制。模型在对话过程中如果需要实时数据,可以通过注册好的函数回调拿到业务系统的结果,再组织成自然语言回复。这个能力让“聊天机器人”变成“能办事的助手”,而不只是一个会喷段子的聊天窗口。
至于适合谁来参考,我觉得如果你是做后端开发的,对 Spring Boot 已经熟悉,想快速给系统接入 LLM 能力,这篇文章的路径可以直接复制。如果你刚接触 Spring AI,也能通过后面的配置和代码一步步跟下来。
2. 环境准备与依赖搭建
2.1 版本选型:JDK、Spring Boot 与 Spring AI
版本选型是第一步,也是最容易被忽视的一步。Spring AI 的版本迭代节奏很快,API 在 0.8.x 和 1.0.x 之间有过一些调整,自动配置类、ChatClient 的包名和构建方式都有差异。我推荐直接使用 1.0.x 的稳定版本,配合 Spring Boot 3.2 及以上,JDK 用 17 或 21。
为什么强调 JDK 17?因为 Spring Boot 3.x 系列基线就是 JDK 17,你如果还在 JDK 8 上跑,要么先升级基础环境,要么就只能用旧版的 Spring Boot 2.7 加上早期的 Spring AI 0.8.x,但那套组合的自动配置能力和后续扩展性都差一些。长期维护的角度,我建议新项目直接上 JDK 17 + Spring Boot 3.2 + Spring AI 1.0.x。
这里多说一句:不要在版本上追求“最新”。Spring AI 的 SNAPSHOT 版本和 M 系列预览版虽然能体验新特性,但生产环境遇到诡异问题的时候,你很难判断是配置错了还是框架本身的 Bug。锁定一个稳定版本,读对应版本的官方文档,能省掉大量排查时间。
2.2 最小依赖配置
用 Maven 的话,需要引入 Spring AI 的 BOM 和 OpenAI 模块的 starter。之所以引入 OpenAI 模块而不是某个 DeepSeek 专属模块,就是前面说的:DeepSeek 走的是 OpenAI 兼容协议,复用 OpenAI 适配器即可。
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> </dependencies>如果你的项目还用到 WebFlux,需要另加 spring-boot-starter-webflux,因为流式返回那块要用到 Flux。如果只是普通 MVC 项目,那 spring-boot-starter-web 就够了,流式响应用 SSE 的响应体也能处理。
2.3 基础配置 application.yml 与密钥管理
依赖引入之后,最核心的配置就是把 Spring AI 的 OpenAI 适配器指向 DeepSeek 的地址。base-url 填 DeepSeek 官方提供的 API 地址,api-key 填你在控制台申请到的密钥,model 填 deepseek-chat。
spring: ai: openai: base-url: https://api.deepseek.com/v1 api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 2048这里有一个原则性的经验:API Key 绝对不要硬编码在配置文件里,也不要提交到 Git 仓库。我见过不止一个团队因为图省事把 key 写在 application.yml 里,结果仓库权限稍微没控住,key 就泄露了,账单哗哗地涨。正确做法是用环境变量或者配置中心来托管,本地开发环境放 .env 文件,线上走配置中心或 K8s Secret。
base-url 这里有个细节:DeepSeek 的接口地址既可以填不带 /v1 的根地址,也可以填带 /v1 的完整地址,两者都能通。Spring AI 的 OpenAI 模块内部有默认的路径拼接逻辑,我实际使用下来填完整地址更稳,省得路径拼接出幺蛾子。
2.4 写一个最简单的连通性验证
配置写完之后,我习惯先做一步“最小连通性验证”,而不是直接写业务代码。随便起一个 Spring Boot 应用,在启动类或者测试类里注入 ChatClient,调一次最简单的问答,确认整条链路是通的。
验证通过的标准有三个:请求没有抛鉴权异常、返回内容非空、响应时间在合理范围内。只要这三个都满足,就说明 base-url、api-key、模型名这三个最关键的配置项都没问题。后续再往业务侧扩展,遇到问题可以更聚焦地排查。
3. 核心编码:从零配置一个可对话的聊天模型
3.1 ChatClient 注入与第一个 Hello 请求
Spring AI 1.0.x 里的核心对象是 ChatClient,它是所有对话操作的统一入口。你不需要手动 new,直接注入 ChatClient.Builder 然后构建即可。
@Service public class ChatAssistantService { private final ChatClient chatClient; public ChatAssistantService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个乐于助人的中文助手,回答简洁准确。") .build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }这里 ChatClient.Builder 会从 Spring 容器里自动读取前面在 application.yml 中配置的 base-url、api-key、model 等属性,构建出一个可用的客户端。defaultSystem 方法可以设置系统级提示词,相当于给模型定了一个统一的人设和回答风格,所有对话默认都会带上这段系统消息。
call().content() 是同步阻塞调用,适合接口内部需要拿到完整结果再返回的场景。如果你在 Controller 里直接暴露这个方法,注意同步调用会占用 Tomcat 工作线程,并发高的时候要评估线程池容量。
3.2 用 Prompt 的 Options 在代码里动态调整参数
全局配置里的 temperature、max-tokens 是默认值,但实际业务中不可能所有场景都吃同一套参数。比如做知识库问答希望回答尽量稳定,temperature 调低一些;做文案头脑风暴希望输出有发散性,temperature 调高一些。
Spring AI 允许在每次构建 Prompt 时动态覆盖这些参数,优先级高于全局配置。
public String askWithOptions(String question, double temperature, int maxTokens) { return chatClient.prompt() .user(question) .options(OpenAiChatOptions.builder() .withTemperature(temperature) .withMaxTokens(maxTokens) .build()) .call() .content(); }这里要注意的是 options 的类型是跟具体模型供应商相关的。因为底层接的是 DeepSeek 的 OpenAI 兼容接口,所以用 OpenAiChatOptions 来构造。如果哪天换了别的供应商,可能需要换成对应的 Options 实现类。这也是 Spring AI 抽象层的一个小小代价:统一门面之下,供应商特有参数还是各写各的。
3.3 流式输出:给接口加分的关键一步
同步调用在模型生成完整个回复后才返回,用户要等好几秒。为了体验,绝大多数对话类接口都应该做成流式输出,让文字逐个蹦出来。Spring AI 的 stream 方法返回的是 Flux,天然适配响应式编程。
public Flux<String> askStream(String question) { return chatClient.prompt() .user(question) .stream() .content(); }如果你的服务是 WebFlux,直接在 Controller 里返回 Flux 就能自动以 Server-Sent Events 的方式推送。如果是传统 MVC,也可以把 Flux 转换成 SseEmitter,或者在前端用 EventSource 去接。实测下来,切到流式之后,用户体感上的“首字延迟”大幅缩短,满意度提升非常明显。
但流式也有代价:因为它是一段一段地返回,在网关层、代理层容易被缓冲。后面我会单独讲这个坑,这里先记住一个原则——流式接口的响应头必须设置 Cache-Control: no-cache,Content-Type 要是 text/event-stream,否则中间层可能给你攒到一起再返回,流式效果直接消失。
3.4 系统提示词与角色化助手
聊到系统提示词,很多人以为就是给模型写一句“你是助手”就完事了,其实它对回复质量的影响远超想象。我做过一个对比:同一道问题,不带系统提示词和带一段精心设计的角色约束,回复的格式规范性和专业度差别非常大。
在 Spring AI 里,系统消息和用户消息是分开的,都可以显式构造:
public String askWithRole(String question, String systemPrompt) { return chatClient.prompt() .messages( new SystemMessage(systemPrompt), new UserMessage(question) ) .call() .content(); }如果你没有一个动态系统提示词的需求,直接用 3.1 里的 defaultSystem 就行。如果有,比如不同角色进入工作台看到不同人设的助手,那就在每次请求时动态传入系统消息。
我给一个实用的提示词结构参考:角色定位、任务目标、约束条件、输出格式。四段写清楚,模型的回复基本就能稳定在你想要的范围里。比如“你是一名资深的 Java 代码评审专家,请指出以下代码的安全隐患和性能问题,按严重程度从高到低输出”,这种明确指令比“请帮我看看代码”效果好得多。
4. 进阶功能:参数调优与多轮对话管理
4.1 关键生成参数逐个拆解
很多人配置模型参数都是凭感觉,temperature 随手填个 0.7 就完事。这里我把几个关键参数讲透,你调起来就有方向感了。
temperature 控制的是随机性,取值范围一般是 0 到 1 或 0 到 2,不同服务商区间不一样。它好比给骰子灌铅:温度越低,骰子越沉,每次输出越倾向于选最高概率的路径;温度越高,输出越发散,各种意想不到的词语都可能蹦出来。知识库问答、信息抽取这类任务,我建议调到 0.1 到 0.3;文案生成、创意写作可以放到 0.7 到 0.9。
max-tokens 控制生成的最大长度。这里有个容易踩的误区:max-tokens 只是截断上限,不是模型一定回复这么长。但如果你设得太短,比如只有 200,长一点的回答就会被硬生生切断,看起来像是模型“话没说完”。
top-p 是核采样参数,控制候选词的累积概率范围。OpenAI 兼容协议里,官方一般建议不要同时大改 temperature 和 top-p,因为两者本质都在调节输出的确定性,同时调容易互相干扰。我自己的习惯是:优先调 temperature,top-p 保持默认或锁定在 0.9 左右。
下面是我常用的参数速查:
| 参数 | 作用 | 推荐场景 | 我常用的值 |
|---|---|---|---|
| temperature | 输出随机性 | 客服/知识问答/抽取 | 0.1 - 0.3 |
| temperature | 输出随机性 | 文案/创意生成 | 0.7 - 0.9 |
| max-tokens | 最大生成长度 | 按业务回复长度 | 512 - 2048 |
| top-p | 候选词累积概率 | 与temperature配合 | 默认或0.9 |
| presence_penalty | 话题重复惩罚 | 内容多样性要求高 | 0.5左右 |
4.2 多轮会话的上下文管理
聊到多轮对话,很多初期的实现方案是前端把整个聊天记录都传回来,后端一股脑拼进 Prompt。这个方案在小场景下能用,但有两个问题:一是越聊越长,Token 成本直线上升;二是上下文太长会超出模型窗口限制,直接报错或者把早期的内容“挤”出注意力范围。
Spring AI 提供了 ChatMemory 这类的会话记忆组件,但真正用起来还是要自己做取舍。我的做法是维护一个最近 N 轮的滑动窗口,把超期的消息丢弃,只保留最近最有价值的上下文。N 的取值要看业务:普通客服场景 10 轮左右绰绰有余,复杂分析场景可以放宽到 20 轮。
Token 预算是必须要算的一笔账。DeepSeek 的上下文窗口有限,系统提示词加上最近几轮历史消息,再加上当前用户消息,总 Token 数建议控制在窗口的 70 以下。多退少补,留出生成回复的空间。如果单条消息特别长,还可以对历史消息做截断,比如只保留每条消息的前 200 个 Token。
4.3 Function Calling:让模型真正“动手”
如果说 ChatClient 是聊天的基础,那 Function Calling 就是让聊天变成“办事”的关键。原理其实不复杂:模型本身不执行任何代码,但在它可以读取的上下文中声明“你有一个工具可以用来查询订单状态”,当对话内容需要这个能力时,模型会输出一个结构化的调用意图,Spring AI 拦截到之后替它调用注册好的函数,再把结果作为上下文喂给模型。
注册函数的方式比较干净,先定义标准的 Java Function,然后注册成 Bean:
@Bean public Function<OrderQueryRequest, OrderQueryResponse> queryOrderStatus() { return request -> orderService.queryStatus(request.orderId); } public record OrderQueryRequest(String orderId) {} public record OrderQueryResponse(String orderId, String status) {}然后构建 ChatClient 的时候把函数挂上去:
this.chatClient = builder .defaultFunctions("queryOrderStatus") .build();这样用户在对话里说“帮我查一下订单 A10086 的状态”,模型就会触发 queryOrderStatus 函数,拿到订单状态之后组织成自然语言回复。
做 Function Calling 最大的坑是函数的入参定义一定要清晰,描述要详细。模型靠函数名和参数描述来理解什么时候该调用、参数怎么填,写得含糊就容易出现该调不调、不该调乱调的情况。
4.4 给模型调用加一层兜底与降级
外部大模型接口和内部 RPC 不一样,它的可用性、延迟都不由你控制。所以接入生产环境之前必须想清楚:模型超时了怎么办?模型返回的内容不符合预期怎么办?模型服务方限流了怎么办?
我通常会做三层兜底:第一层是超时控制,连接超时设 5 秒,读取超时设 60 秒,避免一个慢请求把线程池拖垮;第二层是异常兜底,捕获 OpenAI 相关的异常,返回一条友好的降级文案给前端,比如“服务暂时繁忙,请稍后重试”;第三层是熔断限流,用 Resilience4j 给模型调用加一个熔断器,连续失败率达到阈值就直接短路,不再把流量打给模型服务。
Spring AI 的自动配置本身支持通过 spring.ai.openai.http 相关配置调整 HTTP 客户端的超时和连接池,这块我放到踩坑部分一起说。
5. 踩坑实录:常见问题与排查思路
5.1 鉴权失败:401/403 先别怀疑代码
我敢说一半以上的接入问题出在 API Key 上。Spring AI 的自动配置看起来一切正常,一调接口就报 401,很多人就开始怀疑是不是依赖版本不对。实际上最常见的原因是环境变量没有生效,或者配置文件里的 key 带了空格。
排查思路就三步:先确认环境变量真的传到应用里了;再用 curl 直接打一次接口验证 key 有效性;最后检查配置前缀是否正确。curl 验证是最直接的:
curl --location 'https://api.deepseek.com/v1/chat/completions' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer sk-你的key' \ --data '{"model": "deepseek-chat", "messages": [{"role":"user", "content":"ping"}]}'如果 curl 通了但 Spring AI 不通,那问题就在配置加载层。检查 application.yml 里 spring.ai.openai.api-key 有没有被环境变量正确覆盖,不要在 yml 里写死空字符串占位符,Spring 的占位符解析有时候会静默失败。
5.2 流式输出被网关缓冲“吞掉”
流式接口接好之后,本地测试一切正常,一上测试环境就变成“等好久然后一次性返回全部内容”。这个问题十有八九是网关或反向代理做了响应缓冲。
解决方案是在网关层关掉缓冲。以最常见的 Nginx 为例,需要设置 proxy_buffering off,同时保证响应头里有 Cache-Control: no-cache。响应类型要设置成 text/event-stream,不然一些代理组件不认识这个 Content-Type,会按普通文本处理,导致缓冲行为不可控。
如果你的服务前面还有一层自研网关或者API网关组件,也要检查它是否支持流式透传。有些网关为了统一日志,会把整个响应体读入内存再转发,这种设计对 SSE 流式极不友好,可能需要放行白名单。
5.3 上下文过长导致超时或截断
多轮对话越聊越长,突然某次请求报超时,或者回复内容明显变差,先看是不是 Token 数爆了。Spring AI 本身不会主动帮你算历史消息的 Token 数,超了窗口它会直接报错。
我的建议是在进入模型调用之前加一道 Token 估算逻辑。不需要很精确,按 1 个汉字约等于 1 到 1.5 个 Token 估算即可,超预算就把最老的 UserMessage 和 AssistantMessage 摘掉。这个步骤做在前置拦截器或者 AOP 切面里,不用侵入每个业务方法。
还有一种情况是 max-tokens 设置得太小,导致模型生成到一半被截断,返回的 content 不完整。复现方法很简单:把 max-tokens 调大再看。如果恢复正常,说明就是长度设少了。
5.4 连接池耗尽与重试风暴
没有显式配置连接池的时候,很多人以为“反正是 HTTP 调用,没什么好配的”。但高并发下请求的一发不可收拾,会导致连接池排队,进而引发超时重试,重试又进一步压垮连接池,形成雪崩。
Spring AI 底层走的是 RestClient 或 WebClient,连接池参数可以通过配置进行调整。一个比较保守的起步配置:
spring: ai: openai: http: connect-timeout: 5s read-timeout: 60s如果你用的是 WebClient 那套,还可以额外配置 max-in-memory-size,防止极端情况下响应体过大把内存打爆。连接池的调优核心思路是:不要让一个下游服务的抖动拖垮整个应用。熔断、限流、重试必须分开控制,不要一遇到超时就盲目重试,尤其是对生成式大模型,一个请求的耗时本就长,无脑重试的代价很大。
5.5 空回复但账单没少扣的诡异现象
有一种情况排查起来非常隐蔽:接口正常返回,模型也没报错,但 content 是 null,或者只返回了几个字,而账单里 Token 消耗却不少。
我遇到过几次,要么是 max-tokens 设太小导致输出被截断成空,要么是模型的 reasoning 内容被计入了 Token 但没进 content。DeepSeek 的 reasoner 模型会把思考过程单独放在 reasoning_content 字段里,如果你只是取 content,就会看到空回复,但 Token 已经被扣掉了。
解决方案是取消息内容时多判断几个字段,把 reasoning_content 也读出来,或者统一改用 deepseek-chat 模型避免这类问题。这个坑在日志里尤其隐蔽,因为你可能只打了 content,完全不知道底层发生了什么。
6. 上线前的性能与成本优化建议
6.1 响应性能优化:流式优先与缓存策略
性能优化第一条仍然是流式。同步等待完整回复的用户体验太差,而且占用的线程资源也更久。走流式之后,Tomcat 工作线程可以尽早释放,吞吐量能明显改善。
第二条是缓存。很多场景下用户的提问高度重复,比如内部知识库里“休假政策是什么”这种问题。直接在模型调用前面加一层缓存,命中就走缓存,不要重复去烧 Token。缓存键的设计我用的是“语义指纹”而不是原始字符串,因为同义问法的表达差异很大。简单做法是把用户问题先让模型做成向量再检索,重一点的方案是直接接向量数据库。
6.2 Token 成本控制:从提示词到历史消息全面收紧
Token 成本是大模型应用上线后最大的运营成本项,而且这东西不像服务器资源,看不见摸不着,每个月账单出来才肉疼。控制成本从三处下手。
第一处是系统提示词,能精简就精简。我见过一个团队的系统提示词写了一千多字,每次请求都带,日请求量一大,这个固定成本非常可观。
第二处是历史消息,滑动窗口能留 5 轮就别留 20 轮,代价只是回复连贯性稍微下降。对于多数场景,这个性价比很高。
第三处是模型选择。轻量任务用 deepseek-chat,复杂推理任务才切 deepseek-reasoner。reasoner 的 Token 消耗明显更高,不要全局一把梭。
6.3 可观测性:模型调用也要有日志与告警
模型调用这种外部依赖,光有业务日志是不够的。我把每一次模型调用的关键信息都记为一条结构化日志,至少包含:用户ID(或业务标识)、模型名、输入 Token 数、输出 Token 数、耗时、是否命中缓存、是否走降级路径。有了这些数据,才能对成本和性能做量化分析。
告警规则我建议设两条:一条是模型调用错误率超过阈值,另一条是平均响应耗时超过阈值。这两条能在模型服务侧出问题的时候第一时间报警,而不是等用户来投诉才发现系统已经在降级边缘。
最后分享一个我在多个项目里反复验证过的体会:Spring AI 整合 DeepSeek,真正困难的部分不在“能不能通”,而在“通得稳不稳”。连通性验证半小时就能做完,但参数调优、上下文窗口管理、函数调用设计、熔断降级、成本控制,每一项都需要结合自己的业务场景反复打磨。先小流量跑起来,把同步接口换成流式,把死板的问答升级成能调函数的助手,一步步演进,会比憋一个大而全的方案更靠谱。希望这篇实战记录能帮你少走几个我走过的弯路。