☰
本地部署DeepSeek-R1:SpringBoot+Ollama+Spring AI实战指南
2026/9/26 20:32:44 网站建设 项目流程

简介:随着大模型应用普及,本地化部署成为企业保护数据隐私、降低调用成本的重要选择。Ollama作为轻量级模型管理工具,屏蔽了模型加载与推理细节,提供OpenAI兼容接口;Spring AI则统一了LLM访问抽象,可让开发者像使用普通Bean一样调用ChatClient。二者结合,能将DeepSeek-R1低成本接入SpringBoot服务,实现同步/流式对话、上下文记忆与并发控制。本文从本地环境搭建出发,详解模型变体选择、Spring AI配置、REST接口封装及真实避坑经验,适合需要在内网构建AI服务能力的Java工程师参考。

1. 本地跑deepseek-r1到底图什么:把大模型变成SpringBoot里的一个Bean

很多人看到“本地免费使用deepseek-r1”第一反应是下载一个几百GB的模型文件,然后焦虑显卡。实际上本地部署真正值钱的地方在于:数据不出内网、请求零成本、可以按自己业务调参。而SpringBoot + Spring生态这套组合,解决的是“模型有了之后怎么接进业务代码”的问题——你不需要自己写HTTP客户端去拼JSON,不需要手工管理对话上下文,更不需要把模型推理进程和Web服务割成两套难维护的系统。

我用这套方案给内部工具做过几轮改造,从“写Python脚本调Ollama接口”进化到“SpringBoot启动后直接注入一个ChatClient当普通Bean用”,整个链路是顺的。这篇文章按落地顺序来:先把deepseek-r1在本地拉起来,再用Spring AI接入SpringBoot,最后给出一份避坑清单。适合两类读者:一是想在公司内网搭一个免费AI接口的Java工程师,二是折腾过Ollama但不知道怎么优雅接入Web服务的Spring玩家。

2. 本地部署deepseek-r1:Ollama安装与模型变体选择

2.1 为什么选Ollama而不是自己起一个模型服务

本地跑大模型,绕不开“谁来加载模型、谁来管显存、谁来暴露接口”这三件事。常见的方案有四种:直接用transformers库写Python脚本、用llama.cpp自己编译、用Docker跑官方镜像、用Ollama。我试过前三种,最后还是回归Ollama,原因很直接。

transformers方案需要自己处理Python环境、CUDA版本、模型分片加载,跑通第一句对话就要折腾半天,而且和SpringBoot属于两个世界——要么用命令行调Python脚本,要么再包一层HTTP服务,中间全是胶水代码。llama.cpp性能好但编译参数多,模型量化文件也要自己找,对Java团队不友好。Docker方案能跑,但显存分配、模型热切换、日志查看都得自己写脚本维护。

Ollama把“加载模型”和“暴露接口”打包成了两个命令:ollama serve启动常驻服务,默认监听11434端口,提供OpenAI兼容的/v1/chat/completions接口;ollama pull负责下载和管理模型。这意味着SpringBoot这边不用关心模型推理细节,只当它是一个本地的远程API。更关键的是,Ollama内置了量化运行能力,显存不够时可以把一部分层卸载到CPU,这在模型参数量超过单卡显存时是救命功能。

2.2 一条命令拉取deepseek-r1:安装、下载与验证

安装Ollama在Windows、macOS、Linux上都有对应安装包。Linux服务器上常见做法是执行官方安装脚本,这里我用Linux命令说明:

# 安装Ollama(Linux) curl -fsSL https://ollama.com/install.sh | sh # 启动服务 ollama serve

提示:ollama serve会以前台方式运行,生产环境建议用systemd管理,安装脚本通常会自动注册服务,直接systemctl start ollama即可。

服务起来后,拉取模型:

# 拉取deepseek-r1的7b蒸馏版,体积小,适合验证链路 ollama pull deepseek-r1:7b # 查看本地已有哪些模型 ollama list

拉取完成后,先用一行命令验证模型能正常出话:

curl http://localhost:11434/api/chat \ -d '{"model":"deepseek-r1:7b","messages":[{"role":"user","content":"你好,用一句话介绍你自己"}],"stream":false}'

stream:false表示等完整回复一次返回,方便排查问题。返回里会带done字段,值为true就说明推理链路没毛病。到这一步,deepseek-r1已经在本地跑起来了,剩下的工作就是让SpringBoot去调它。

2.3 选哪个deepseek-r1变体:7b、32b还是官方671b

先澄清一个认知:deepseek-r1官方发布的是671B MoE架构,完整权重本地跑需要多张高端显卡,普通开发机根本带不动。Ollama仓库里以deepseek-r1命名的标签,实际上是官方蒸馏版(基于Qwen/Llama蒸馏)和量化版的组合。所以“本地免费使用deepseek-r1”的真实含义是:跑它的蒸馏小模型,而不是把671B原版塞进你的电脑。

我按实际体验把几个常见变体列成表,方便你按机器配置对号入座:

模型标签参数量量化后体积最低配置建议适合场景
deepseek-r1:7b7B约4.7GB16GB内存,无显卡也能跑链路验证、简单问答、低延迟要求
deepseek-r1:14b14B约9GB16GB内存 + 8GB显存中文质量明显提升,通用场景折中
deepseek-r1:32b32B约20GB32GB内存 + 24GB显存复杂推理、代码生成、内网知识库
deepseek-r1:70b70B约40GB64GB内存 + 2×24GB显存接近满血效果,但成本高
deepseek-r1:671b671B数百GB多卡服务器不推荐个人本地尝试

我的经验是:如果你只是想验证SpringBoot能不能调通,直接上7b,十分钟跑通;如果要做内部工具真正给人用,14b是性价比最高的起点。32b回答质量确实上了一个台阶,尤其在推理步骤和代码生成上,但你需要先确认机器扛得住。选型时还有个重要的点:显存不够会退化成CPU推理,32b在CPU上跑速度很感人,一条回复等两三分钟很正常,这是本地免费要付出的代价。

3. 用Spring AI把deepseek-r1接进SpringBoot:依赖、配置与第一个对话

3.1 Spring AI为什么适合做这件事:统一抽象与自动装配

Spring AI是Spring生态为LLM应用提供的集成框架。它能被SpringBoot直接管理,这意味着模型客户端、对话记忆、提示词模板都能通过自动配置组装好,业务代码里只管调用。标题里的“SpringBoot+Spring”落到实处,就是SpringBoot负责启动和装配,Spring AI负责把Ollama的HTTP接口抽象成Java对象。

有人会问:我直接用RestTemplate调Ollama不行吗?当然行,但后患很多。第一,Ollama的响应体比较复杂,嵌套的message、usage、done_reason字段要写一堆DTO去接,每加一个字段就要改类。第二,流式输出要处理application/x-ndjson格式的分行JSON,手写解析容易翻车。第三,将来想换模型服务商,从Ollama换到云端API,手写代码要从头改一遍。Spring AI把这些都封装了,换服务商时只改配置不看代码。

我一般建议团队用Spring AI的另一个理由是它内置了ChatMemory抽象。大模型应用最难缠的“多轮对话记忆”,在Spring AI里通过一个Advisor就挂上,不需要自己把历史消息拼进数组。这部分在最后一章展开讲,先把单轮调用跑起来。

3.2 引入依赖与application.yml最简配置

Spring Boot项目引入Spring AI的Ollama Starter即可。注意Spring AI版本要和Spring Boot版本对齐,我用的是Spring Boot 3.x + Spring AI 1.x组合,依赖如下:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency>

提示:Spring AI的1.0版本前后API有较大调整,如果你用的是0.8.x旧版,后文的ChatClient写法会略有不同。建议新项目直接上1.x。

配置文件只改两处:Ollama服务地址和模型名。最小配置如下:

spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: deepseek-r1:7b temperature: 0.6 num-predict: 2048

base-url指向Ollama服务的根地址,Spring AI会自己拼/api/chat等路径。model必须是ollama list里能看到的模型名。temperature控制随机性,deepseek-r1这类推理模型建议0.6以下,太高容易胡说。num-predict限制最大生成token数,本地模型没这个限制容易生成到天荒地老。

3.3 第一段Java代码:把“你好”发给本地模型

Spring AI 1.x里最核心的入口是ChatClient,它长得像RestTemplate,但语义上更接近“和模型对话”。先注入并调用一次最简单的对话:

import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient.Builder; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.stereotype.Service; @Service public class DeepSeekService { private final ChatClient chatClient; public DeepSeekService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String message) { ChatResponse response = chatClient.call( new Prompt(message) ); return response.getResult().getOutput().getText(); } }

代码逻辑不复杂:构造器通过ChatClient.Builder构建客户端,因为Spring Boot的自动配置已经把我们配置的Ollama地址和模型参数绑定进去了,builder.build()拿到的就是一个“指向deepseek-r1:7b的对话客户端”。call方法返回完整的ChatResponse,里面嵌套了三层:getResult()拿到Generation,再getOutput()拿到AssistantMessage,最后getText()才是字符串内容。

这里有个容易踩的坑:Prompt(message)只传了用户消息,没有任何系统提示词。deepseek-r1的蒸馏版本在没有系统提示的情况下,回答语言风格可能随机漂移——有时中文有时英文。所以生产代码里建议用SystemPromptTemplate或直接构造ChatMessage列表,把角色和内容写清楚:

List<ChatMessage> messages = List.of( new SystemMessage("你是deepseek-r1模型,请用简体中文回答,回答应包含推理步骤。"), new UserMessage(message) ); ChatResponse response = chatClient.call(new Prompt(messages));

到这一步,已经能通过一个@Service类的chat方法拿到本地模型的回复。后面要做的就是把DeepSeekService暴露成REST接口,让浏览器或其他系统能调用。

4. 把本地模型做成REST接口:同步调用、流式输出与参数调优

4.1 同步接口:适合内部工具与离线任务

如果调用方是后端服务,比如定时任务、内部管理后台、批处理脚本,同步接口足够用。它的优点是实现简单,客户端等一次HTTP请求就能拿到完整结果,不需要处理事件流。代码结构就是一层薄薄的Controller包住Service:

import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/ai") public class DeepSeekController { private final DeepSeekService deepSeekService; public DeepSeekController(DeepSeekService deepSeekService) { this.deepSeekService = deepSeekService; } @PostMapping("/chat") public Map<String, String> chat(@RequestBody Map<String, String> request) { String answer = deepSeekService.chat(request.get("message")); return Map.of("answer", answer); } }

同步接口的调用方视角很直观:POST一个JSON,里面带message字段,响应里拿answer字段。这个接口可以直接用curl验证:

curl -X POST http://localhost:8080/api/ai/chat \ -H "Content-Type: application/json" \ -d '{"message":"1+1等于几?"}'

同步接口有个细节要注意:本地模型推理是耗时的,CPU跑7b模型生成100个token可能要几十秒,所以Web层的请求超时时间要放开。Spring内置的server.tomcat.connection-timeout只控制连接建立,不控制请求处理时间;真正可能拦路的是网关或Nginx的proxy_read_timeout,默认60秒很容易断。如果部署在Nginx后面,记得把这个参数调到300秒或更高。

4.2 流式输出:用SSE让用户看到逐字回复

面向用户的页面如果走同步接口,体验是“转圈30秒然后一次性弹出全文”。换成流式输出,模型每生成一个token就推给浏览器,用户看到的是打字机效果,体感快很多。Spring AI的chatClient.stream方法返回Flux<ChatResponse>,配合Spring MVC的SSE能力可以直接推流:

import org.springframework.http.MediaType; import reactor.core.publisher.Flux; @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chatStream(@RequestBody Map<String, String> request) { return chatClient.stream(new Prompt(request.get("message"))) .map(response -> response.getResult().getOutput().getText()); }

逻辑说明:stream方法和call一样接收Prompt,区别是返回Flux响应式流。每次模型生成一个增量,Spring AI会把它包装成ChatResponse推过来,map操作提取其中的文本片段。produces = TEXT_EVENT_STREAM_VALUE告诉浏览器响应是SSE格式,前端用EventSource或fetch读流即可。

前端用fetch读SSE有个坑:浏览器对EventSource只支持GET请求,而我们的接口是POST。实际做法是用fetch配合ReadableStream手动解析,或者把接口改成GET并把消息放在查询参数里。为了不把代码搞复杂,我通常这样配合前端:fetch发起POST,拿到response.body后通过getReader()逐块读取文本,按data:前缀切割。如果你的前端团队不熟悉流式处理,最简单的降级方案是:后端流式接口照写,前端暂时用同步接口顶上,后面再优化体验。

4.3 三个必调的生成参数:temperature、num_predict与系统提示词

流式和同步接口跑通后,就该处理回答质量了。本地模型的可用性很大程度上取决于参数,我整理三个必调项:

参数建议区间调低的效果调高的效果我的默认值
temperature0.3 ~ 0.8回答保守、稳定、少幻觉回答多样、有创造力但容易跑偏0.6
num_predict512 ~ 4096响应快,但长文被截断能生成完整长文,等待更久2048
repeat_penalty1.0 ~ 1.3可能循环重复减少重复,但可能打断推理节奏1.1

temperature对deepseek-r1的影响尤其明显。这个模型在0.6以上时,推理步骤容易发散,可能从“计算1+1”跳到“顺便解释一下数学史”。内部工具场景建议0.4到0.6,需要创意文案再调高。

系统提示词是另一个隐藏关键。deepseek-r1的蒸馏模型对中文指令跟随能力不如原版,直接问“用中文解释”它可能还是蹦英文。我在Service层固定了一段系统提示:“你是deepseek-r1模型,使用简体中文回答所有问题,先给出推理过程,再给出最终结论。”加了这个提示后,中文回答的稳定性明显提升,这比调任何采样参数都管用。

还有一个容易忽略的点:num_predict如果设太小,长代码补全会在中间截断,看起来像模型能力不行。判断模型是不是被截断,看响应里ChatResponse的finishReason字段——如果是length就说明到长度上限了,不是推理出错。把这个字段透出到接口日志里,能省很多排查时间。

5. 本地调用deepseek-r1的避坑清单:5个真实翻车场景

5.1 坑一:ollama pull卡住或下载到一半失败

现象:执行ollama pull deepseek-r1:32b后进度条长时间不动,或者下载到某个百分比直接报错退出,重新执行又从头开始下载。

原因:Ollama从公共仓库拉取大文件,网络抖动或存储空间不足都会导致中断。7b模型4个多G勉强能扛,32b近20G,一次拉完的概率直线下降。很多人误以为是模型仓库被墙,其实大部分情况是网络不稳或磁盘满了。

解决:先确认磁盘空间,ollama pull前用df -h看/usr/share/ollama所在分区剩余容量,模型体积要按量化后体积的两倍预留。如果网络确实不稳,先拉体积小的模型验证网络,再拉大的;或者找一台网络条件好的机器拉完,把整个/usr/share/ollama/models目录打包拷到目标机器。Ollama的模型目录是自包含的,拷过去重启ollama服务就能识别。

5.2 坑二:显存不够,模型加载到一半进程被杀

现象:Ollama服务还在,但请求模型时响应极慢,日志里出现killed字样;或者ollama run deepseek-r1:32b后命令行直接卡死,接着进程退出。

原因:启动32b模型需要约20GB显存,8GB显卡根本装不下。Ollama的调度器会把一部分层放到CPU,但如果总内存也不够,操作系统会直接触发OOM杀掉进程。症状就是“服务活着,但模型没了”。

解决:先查显存再选模型。nvidia-smi看显存总量和空闲量,显存不够就选14b甚至7b。如果你只有显卡显存小而内存大,可以在环境变量里限制Ollama使用GPU的层数:

export OLLAMA_NUM_GPU=20 ollama serve

OLLAMA_NUM_GPU代表把模型的前多少层放在GPU,剩下的跑CPU。这个值没有绝对标准,我的经验是从20开始试,观察ollama ps显示的GPU/CPU占用比例,再微调。内存也紧张的话,可以加OLLAMA_MAX_LOADED_MODELS=1强制同时只加载一个模型,防止多个模型抢资源。

5.3 坑三:回答全是英文,中文质量明显变差

现象:模型能正常对话,但不管问什么都会先蹦一段英文 reasoning,最后才给简短中文结论;或者中文回答夹着英文术语,读起来很生硬。

原因:deepseek-r1的蒸馏版,尤其是7b,指令跟随能力有限,它默认用训练数据里占比最高的英文模式来组织回答。这不是模型坏了,是没有明确提示它用中文。另一个隐藏因素是采样参数设置不对,temperature太高会让模型在语言选择上摇摆。

解决:在每次对话的消息列表里,把系统提示词放第一位,明确写“用简体中文回答”,并且最好在用户消息里也带一句“请用中文”。双保险之后,中文回答的稳定性会好很多。如果仍然频繁切英文,把temperature从0.8降到0.5,减少随机性对语言选择的干扰。对于内部工具,这是性价比最高的解法;如果还不行,只能换14b或更大模型。

5.4 坑四:Spring AI升级后ChatClient API编译不过

现象:项目从Spring AI 0.8.x升到1.x,原本注入OllamaChatModel的代码一片飘红,chatModel.call()方法直接编译失败。

原因:Spring AI 0.8.x时代主推OllamaChatModel,1.x重构后统一收敛到ChatClient,很多方法签名都变了。这是开源框架早期版本迭代的正常现象,但确实会坑到升级用户。

解决:我的建议是不要混用新旧API。新项目直接按1.x的ChatClient.Builder写法;老项目升级时,先对照Spring AI官方samples里的chat-client示例,把OllamaChatModel替换成ChatClient,同时留意Prompt构造方式变化——1.x里Prompt(String)构造器仍然保留,但推荐用Prompt(List<ChatMessage>)显式传消息。如果团队里同时在维护多个项目,统一锁定同一个Spring AI版本,不要有的用0.8有的用1.x,否则两套API并存维护成本翻倍。

5.5 坑五:流式接口前端看不到输出或乱码

现象:接口返回了text/event-stream类型,但前端一直不打印内容;或者打印出来了全是乱码,尤其是中文。

原因:一个是响应类型不匹配,整个应用的全局Filter或拦截器拦截了响应流并做了缓冲,把SSE流“攒”在一起一次性返回,前端自然看不到逐字效果。另一个是字符编码问题,流式响应的Content-Type里没带charset=UTF-8,默认编码和解码不一致导致中文乱码。

解决:先确认接口的produces是否设置了MediaType.TEXT_EVENT_STREAM_VALUE,这是基础。再看项目里有没有自定义OncePerRequestFilter写的响应包装类——有的话要对/api/ai/chat/stream路径放行,不做缓冲包装。编码问题在后端指定produces = "text/event-stream;charset=UTF-8",前端读取response.body时按TextDecoder('utf-8')解码。排查时最直接的办法是用curl -N看原始响应头,确认Content-Type和分块情况,避免扯皮。

6. 进阶:给deepseek-r1加上上下文记忆,并做一次并发验证

单轮对话接口能用之后,下一步就是处理“多轮对话”和“并发”。这两件事不做,应用只能算demo,算不上工具。

Spring AI的ChatMemory模块可以解决上下文问题。核心思路是把历史消息存在服务端,每次请求自动带上最近的N轮。我用的是InMemoryChatMemory加MessageChatMemoryAdvisor,代码很轻:

ChatClient chatClient = ChatClient.builder(ollamaChatModel) .defaultAdvisors(new MessageChatMemoryAdvisor( new InMemoryChatMemory(), "default", 10)) .build();

MessageChatMemoryAdvisor的构造参数三个:对话存储的实例、会话ID(按用户区分)、保留最近10轮消息。超过10轮自动丢弃最早的,防止上下文越来越长把num_predict预算吃光。会话ID从请求参数里取,比如用户ID,这样每个用户各聊各的,互不干扰。InMemoryChatMemory适合单机部署,重启丢记忆;如果是集群,换RedisChatMemory,用法一样。

并发验证我用的办法很简单:写一个shell脚本用ab模拟20个并发请求,观察两个指标——接口成功率、平均响应时间。目的是确认本地模型在多人同时访问时的真实表现。

ab -n 20 -c 5 -p body.json -T application/json \ http://localhost:8080/api/ai/chat

-c 5表示5个并发,body.json里放{"message":"介绍一下你自己"}。如果没有ab,用curl加&后台跑20个也行。跑完看两个数:Failed requests必须是0;Time per request如果从几十毫秒涨到几秒,说明推理排队了。这是因为本地模型推理是串行的,显存不够时并发请求会在Ollama内部排队。

如果并发结果不理想,最有效的兜底是给Service层加一个信号量限流:

private final Semaphore semaphore = new Semaphore(2);

Semaphore(2)限制同时最多两个推理请求,其余请求在Spring层排队而不是挤爆Ollama,这样系统的行为更可预测。我习惯把限流数设成显存能支撑的并行模型数,7b模型在12GB显存下跑2个实例问题不大,超过这个数性能会断崖下跌。

这套方案我从Spring AI 0.8.x一路用过来,最大的体会是“本地免费”的真正成本不在钱,而在折腾。把Ollama跑稳、把Spring AI版本对齐、把并发限流设计好,后面就剩日常维护了。希望这些实践经验能帮你少走弯路,把deepseek-r1真正用起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询