简介:一套面向Java开发者的deepseek-r1本地免费调用示例工程,基于Spring Boot与Spring AI构建,目标是在不依赖云端服务的前提下,帮助中小型团队在本地环境中快速接入大模型能力,同时规避API调用费用与数据传输风险。压缩包共7个文件,包含2个Java源文件、1个Maven工程配置文件、1个应用配置properties、1个README说明文档、1个gitignore及1个LICENSE协议,pom.xml用于一键还原依赖,整体仅10KB,结构精简,便于导入IDE直接阅读或二次开发。源码清晰展示了请求构造、响应解析、参数调优等关键环节,配合说明文档中对Spring Boot与Spring AI集成步骤的梳理,可帮助开发者理解Ollama本地模型的调用逻辑。目前该资源已有1462人学习下载。通过这份示例,读者既能掌握本地部署在数据安全与运行稳定性上的优势,又能获得一套可复用的项目脚手架,适合作为入门参考或工程起点。 4.3 完整的请求-DTO结构
第5章:避坑
5.1 现象→原因→解决 格式,3-5条
- 首次请求超时/卡住——模型加载到内存——预热
- 流式响应中文乱码——编码问题——设置UTF-8
- num_ctx超过默认值导致显存溢出——Ollama默认2K上下文——调小
- 并发请求排队——Ollama单模型串行——用OpenAI兼容接口或手动加副本
- 本地返回token里全是思考内容——R1是推理模型——业务上过滤
- 端口被占用或服务起不来
第6章:验证与进阶技巧——会话保持 400-700字。会话保持的实现:用session id存历史消息数组,每次请求带上历史消息。这是最脏但最有效的办法。 结尾用第一人称教训:从那以后我每次都先确认模型类型,希望帮到你。
好,现在我开始完整写作,确保字数8000-12000。
让我特别注意:
- 第一章只用##,没子标题,150-250字
- 中间章每章至少2个###,至少1800字
- 避坑章是##级
- 最后一章400-700字
- 代码块要有注释,后有参数说明
- 段落4-6行,每段80字+
- 不用嵌套子标题的###,就是##和###两级
- 不用mermaid、emoji
现在按格式要求写完整正文。先粗写,然后细化、扩写到足够字数。
关于章节里内容的具体性:
- Ollama在写这篇文章时是0.5.x版本,但我不能编造版本号。我可以写
ollama --version来查。实际上Ollama的API是/api/chat和/api/generate,OpenAI兼容端点/v1/chat/completions。这些是稳定的,可以写。 - DeepSeek-R1蒸馏模型的具体大小是:deepseek-r1:1.5b约1.1GB,7b约4.7GB,8b约4.9GB,14b约9.0GB,32b约20GB,70b约43GB。这些是Ollama库的常见数据,可以写,但为了避免编造,我可以说“以ollama pull时显示的大小为准”,也可以用常见推理来写。
- 推理过程标签是 ,这是DeepSeek-R1的特性,可以写。
对于硬件要求,我写的是大概值,我要么不写精确的"xx分钟",要么用“一般需要”。OK。
让我组织正文结构:
1. 把 DeepSeek-R1 拉进 SpringBoot 工程:本地部署才是真正意义上的免费调用
(开头引入,200字左右)
2. 先把 DeepSeek-R1 跑起来:Ollama 部署与蒸馏版选型
2.1 为什么用 Ollama 而不是 vLLM
2.2 deepseek-r1 蒸馏版本怎么选:参数、显存与场景匹配
2.3 部署命令与接口自检
3. SpringBoot 调用 DeepSeek-R1:RestTemplate 封装与参数透传
3.1 工程依赖与配置项
3.2 最简调用:一个 ChatService 走通 /api/chat
3.3 参数透传:temperature、top_p、num_ctx 的映射方式
3.4 响应解析: 推理标签怎么剥离
4. 把体验做起来:流式输出、超时控制与连接复用
4.1 用 WebClient 接 SSE 流式响应
4.2 请求超时与线程池参数
4.3 连接复用与 HTTP 客户端选择
5. DeepSeek-R1 本地调用避坑指南:七个现象、原因与解决
(5-6条)
6. 会话保持的一个有效技巧:把上下文交给调用方管理
(400-700字,代码:sessionId -> messages)
这样共6章。目标字数:第1章200,第2-4章型中间章各2000+,避坑章1800,第6章600。总共约8500-9000字。够。
现在正式写作。注意语言口语化、避免AI味道。
让我逐章写并计算。
第1章:
## 1. 把 DeepSeek-R1 拉进 SpringBoot 工程:本地部署才是真正意义上的免费调用 大多数团队接入大语言模型的第一反应是注册厂商 API,一边调接口一边盯着账单。DeepSeek-R1 开源之后,这个问题变成了另一条路:模型权重公开,官方还提供 1.5B 到 70B 的蒸馏版本,一张消费级显卡就能跑起来。这意味着我们可以把模型部署在本机或者内网,业务系统通过 SpringBoot 走 HTTP 调用,彻底绕开按 token 计费的 API 通道。这篇文章就是把「本地部署模型 + SpringBoot 封装调用」这条链路完整拆开来讲,从 Ollama 运行时选型、蒸馏版本怎么挑,到 RestTemplate 请求封装、流式输出、超时控制和典型踩坑,全部按可复现的方式写。适合两类人:一类是手上有 RTX 3060 级别显卡、想把 AI 能力接进现有后端服务的 Java 工程师,另一类是公司对数据出域敏感、必须私有化部署 NLP 能力的项目负责人。约230字。
第2章展开:
2.1 为什么用 Ollama 而不是 vLLM
说明选型。对比表可以做。
2.2 deepseek-r1 蒸馏版本怎么选
表格:模型名、体积、推荐显存、适用场景。
1.5b/7b/8b/14b/32b/70b——但我不该写精确体积,因为不同量化版本大小不同。我写“Ollama 仓库中标记的体积大约为 X”,这个在Ollama官网是公开的。可以写为参考值。我用“约”字。
deepseek-r1:1.5b 约1.1GB deepseek-r1:7b 约4.7GB deepseek-r1:8b 约4.9GB deepseek-r1:14b 约9.0GB deepseek-r1:32b 约20GB deepseek-r1:70b 约43GB
这些都是Ollama库的公开数据(Q4_K_M量化),可以写。
2.3 部署命令与自检
bash代码。
第3章:SpringBoot调用 依赖、application.yml、Service代码、参数映射、think标签处理。
第4章:优化 WebClient SSE代码、超时池化等。
第5章:避坑
第6章:会话保持。
开写正文。注意全文用中文标点,代码块用英文标点没问题。所有代码块都要语言标注。代码内注释要中文。
代码结构注意:
- Java代码尽量标准
- 需要import吗?不写完整import也可以,但给出类级别的关键import更专业。我加上必要的import。
让我现在写全文,注意不输出任何前置后置说明,直接从## 1开始。 ## 1. 把 DeepSeek-R1 拉进 SpringBoot 工程:本地部署才是真正意义上的免费调用
大多数团队接入大语言模型的第一反应是注册厂商 API,一边调接口一边盯着账单。DeepSeek-R1 开源之后,这个问题变成了另一条路:模型权重公开,官方还提供 1.5B 到 70B 的蒸馏版本,一张消费级显卡就能跑起来。这意味着我们可以把模型部署在本机或者内网,业务系统通过 SpringBoot 走 HTTP 调用,彻底绕开按 token 计费的 API 通道。这篇文章就是把「本地部署模型 + SpringBoot 封装调用」这条链路完整拆开来讲,从 Ollama 运行时选型、蒸馏版本怎么挑,到 RestTemplate 请求封装、流式输出、超时控制和典型踩坑,全部按可复现的方式写。适合两类人:一类是手上有 RTX 3060 级别显卡、想把 AI 能力接进现有后端服务的 Java 工程师,另一类是公司对数据出域敏感、必须私有化部署 NLP 能力的项目负责人。
2. 先把 DeepSeek-R1 跑起来:Ollama 部署与蒸馏版选型
2.1 为什么用 Ollama 而不是 vLLM
本地跑大模型可选的运行时不少,vLLM 吞吐高但配置复杂,llama.cpp 效率不错但要自己管模型文件,对 SpringBoot 开发者都不够友好。我一般会推荐 Ollama 作为起点,原因是它把「下载模型、启动服务、暴露接口」三步全都做成了开箱即用的事。一条命令装好,ollama pull拉模型,服务默认挂在 11434 端口,自动带上了原生 API 和 OpenAI 兼容接口,后端工程不需要引入任何深度学习组件。
用 Ollama 还有一个容易被忽略的好处:模型文件是统一管理的,ollama list能看本地全部模型,ollama rm能清掉不用的版本。你可以在同一台机器上同时放 7B 和 14B 两个蒸馏版,跑业务接口时按场景切换 model 名称就行。相比 vLLM 要自己写 deployment 配置、维护磁盘里的模型目录,这个管理成本低很多。下面是三个运行时选型的对比,方便你按团队情况判断。
| 运行时 | 安装成本 | 管理方式 | 适合场景 |
|---|---|---|---|
| Ollama | 单命令安装,模型自动拉取 | 命令管理,无额外组件 | 中小团队、单机或内网部署、快速原型 |
| vLLM | 需要 Python 环境和显存规划 | 手动配置模型路径与推理参数 | 高并发场景、生产级吞吐、K8s 部署 |
| llama.cpp | 需要编译或下载二进制 | 手动处理 gguf 与启动参数 | 无 GPU 的低配服务器、嵌入式设备 |
2.2 deepseek-r1 蒸馏版本怎么选:参数、显存与场景匹配
DeepSeek-R1 原版是 671B 的 MoE 模型,本地普通机器跑不动,日常使用基本都在官方蒸馏版本上做选择。Ollama 仓库里的 deepseek-r1 系列覆盖了从 1.5B 到 70B 六个规格,体积和显存需求差别很大。我的习惯是根据「业务并发量 + 现有显卡 + 回答质量要求」三者取中间值来定。
| 模型名 | 量化后体积 | 最低显存参考 | 适用场景 |
|---|---|---|---|
| deepseek-r1:1.5b | 约 1.1 GB | 纯 CPU 可跑 | 文本分类、关键词抽取、原型验证 |
| deepseek-r1:7b | 约 4.7 GB | 6 GB | 代码生成、通用对话、大多数后端场景 |
| deepseek-r1:8b | 约 4.9 GB | 6 GB | 同 7B,回答风格略有差异 |
| deepseek-r1:14b | 约 9.0 GB | 12 GB | 逻辑推理、长文本分析 |
| deepseek-r1:32b | 约 20 GB | 24 GB | 复杂任务、高精度要求 |
| deepseek-r1:70b | 约 43 GB | 48 GB | 接近满血效果,硬件门槛高 |
在 SpringBoot 集成场景里,7B 是最稳妥的起点:显存要求低,CPU 也能勉强推理,回答质量对大部分业务足够了。如果你做的是复杂 SQL 生成或链路日志分析,直接上 14B,别在 7B 上反复调 prompt。有一个容易踩的误区:只看模型体积不看上下文长度。Ollama 默认上下文是 2048 token,R1 系列是推理模型,思考过程会大量消耗 token,后面讲到 num_ctx 参数时你会看到这个坑有多大。
2.3 部署命令与接口自检
安装和拉模型的过程不复杂,但有几个命令建议按顺序执行,避免后面排查困难。Linux 服务器用下面这条安装,macOS 和 Windows 直接去官网下安装包:
# Linux 一键安装 Ollama curl -fsSL https://ollama.com/install.sh | sh # 查看安装结果 ollama --version # 拉取 7B 蒸馏版模型,等待进度条完成 ollama pull deepseek-r1:7b # 查看本地已有模型 ollama listollama pull拉的是 Q4 量化版,文件大小和上面的表格基本一致。拉取完成后不要急着写代码,先用命令行验证模型能正常响应。这是我认为最重要的一步,它能帮你把「模型问题」和「代码问题」隔离清楚:
# 命令行直接对话,验证模型可用 ollama run deepseek-r1:7b "用一句话介绍 SpringBoot 的自动配置原理" # 验证 HTTP 接口是否正常返回 curl http://localhost:11434/api/chat \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-r1:7b","messages":[{"role":"user","content":"你好"}],"stream":false}'注意首次请求会有模型加载延迟。Ollama 是把模型读入显存后才开始推理,7B 模型在机械硬盘上可能等 20 秒以上,SSD 会快很多。curl 返回 JSON 后,里面的message.content字段就是模型输出,往往带着<think>开头的推理过程,这个问题留到第三章专门处理。
3. SpringBoot 调用 DeepSeek-R1:RestTemplate 封装与参数透传
3.1 工程依赖与配置项
SpringBoot 侧不需要任何 AI 专用依赖,标准spring-boot-starter-web就够用。这里有一个选型取舍:Spring AI 官方对接了 Ollama,但它的抽象层对很多后端团队反而增加了理解成本,而且版本更新很快。我更习惯用 RestTemplate 直接调 HTTP 接口,代码量少、控制力强、排查方便。新建工程时只保留 Web 依赖即可:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>接下来在application.yml里把 Ollama 地址和模型名配成外部化参数,不要写死在代码里。这样换 14B 模型或者把服务迁到内网其他机器时,只改配置不用发版:
deepseek: base-url: http://localhost:11434 model: deepseek-r1:7b timeout: 60s这里单独定义一个deepseek前缀是为了和业务配置区分开。timeout 设置成 60 秒是有意的,R1 的推理过程比较长,非流式接口在 7B 模型上首次调用就有可能要等十几秒,默认的连接超时太短会直接报错。
3.2 最简调用:一个 ChatService 走通 /api/chat
配置类绑定@ConfigurationProperties,然后写一个独立的 Service 封装聊天请求。我先给完整实现,再逐行拆参数:
import com.fasterxml.jackson.databind.JsonNode; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.List; import java.util.Map; @Service public class DeepSeekChatService { private final RestTemplate restTemplate; private final DeepSeekProperties properties; public DeepSeekChatService(RestTemplate restTemplate, DeepSeekProperties properties) { this.restTemplate = restTemplate; this.properties = properties; } public String chat(String userMessage) { // 构建 Ollama /api/chat 请求体 Map<String, Object> payload = new HashMap<>(); payload.put("model", properties.getModel()); payload.put("stream", false); // 消息结构为 {role, content} 列表 Map<String, String> message = new HashMap<>(); message.put("role", "user"); message.put("content", userMessage); payload.put("messages", List.of(message)); // 发起 POST 请求 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers); ResponseEntity<JsonNode> response = restTemplate.exchange( properties.getBaseUrl() + "/api/chat", HttpMethod.POST, entity, JsonNode.class); // 提取回复内容 JsonNode body = response.getBody(); if (body == null || !body.has("message")) { throw new RuntimeException("DeepSeek 响应异常: " + body); } return body.path("message").path("content").asText(); } }这段代码里最核心的是请求体结构:Ollama 的/api/chat接口接收model、messages、stream三个必填字段。messages是数组,每个元素必须有role和content,和 OpenAI 的对话格式一致。stream必须显式设成false,不设置时默认也是 false,但写出来能让读代码的人立刻知道这是个非流式调用。
响应解析要走message.content路径,不是直接取body.content,这是容易看错的地方。RestTemplate.exchange方法接收 HttpEntity 和响应类型,我直接用JsonNode接收,避免为响应体专门写一个 DTO 类。如果你后面要接入更多模型能力,再考虑把响应体抽象成统一结构。
3.3 参数透传:temperature、top_p、num_ctx 的映射方式
直接把命令行的对话接进业务系统还不够,R1 默认参数并不能适应所有场景。Ollama 的/api/chat支持在请求体重传options对象,下面是完整可用的带参数版本:
Map<String, Object> options = new HashMap<>(); options.put("temperature", 0.2); // 降低随机性,代码生成更稳定 options.put("top_p", 0.9); // 核采样,控制候选词范围 options.put("num_ctx", 8192); // 上下文窗口,R1 推理消耗大 payload.put("options", options);这三个参数是后端接入时最常调的。temperature控制输出随机性,代码生成和 SQL 生成建议 0.1 到 0.3,通用对话可以放到 0.6 到 0.7。top_p与 temperature 是配合关系,通常固定 0.9 不动,不要两个同时激进地调。num_ctx是 Ollama 特有的上下文长度参数,默认只有 2048,这意味着模型只能看到最近 2048 个 token 的对话内容。DeepSeek-R1 的思考过程很容易就把这个窗口塞满,导致回答到一半「失忆」。我通常起步直接设 8192,如果你的显存够,甚至可以上 16384。
注意:num_ctx设置太大会直接增加 KV Cache 显存占用,7B 模型在 6GB 显卡上开到 8192 可能就接近极限了。这个参数要在显存和上下文长度之间做取舍,没有银弹。
3.4 响应解析:<think>推理标签怎么剥离
调用通了之后你会立刻发现一个问题:R1 返回的内容开头带着一长串推理过程,被<think>和</think>包住,后面才是真正回答用户的文本。这是深度求索故意保留的思维链输出。直接把这个原始文本返回给前端用户,不仅显得怪异,还会暴露模型内部推理逻辑。
处理方式很简单,在返回前做一次过滤:找到</think>标签,取它后面的部分作为最终回复。我一般把这段逻辑放在 Service 里,不算业务层的事:
public String extractAnswer(String rawContent) { String marker = "</think>"; int index = rawContent.indexOf(marker); if (index >= 0) { return rawContent.substring(index + marker.length()).trim(); } return rawContent; }调用方拿到的是剥离后的干净回答,<think>部分只在排查问题时手动看。这里提醒一句:不要试图用正则去匹配<think>开头的标签,深度求索在部分场景会输出嵌套的尖括号内容,简单的indexOf从尾部找反而最可靠。
4. 把体验做起来:流式输出、超时控制与连接复用
4.1 用 WebClient 接 SSE 流式输出
非流式接口在 7B 模型上生成一段回答可能要等 10 到 30 秒,前端一直转圈,体验很差。Ollama 的/api/chat支持 SSE 流式返回,把stream设成 true,每生成一部分 token 就推送一个 JSON。SpringBoot 里用 WebClient 接流式响应最方便,它对 SSE 的解析是原生的。
# 先用 curl 验证流式返回的数据格式 curl http://localhost:11434/api/chat \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-r1:7b","messages":[{"role":"user","content":"写一个快速排序"}],"stream":true}'curl 输出你会看到一行接一行的 JSON,每行都有message.content字段,这是增量内容。SpringBoot 侧用 WebClient 的retrieve().bodyToFlux()接收:
import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; @Service public class DeepSeekStreamService { private final WebClient webClient; public DeepSeekStreamService() { // 基础 URL 指向 Ollama,连接超时设 5 秒,读取超时留长 this.webClient = WebClient.builder() .baseUrl("http://localhost:11434") .codecs(configurer -> configurer.defaultCodecs() .maxInMemorySize(10 * 1024 * 1024)) .build(); } public Flux<String> streamChat(String userMessage) { Map<String, Object> payload = new HashMap<>(); payload.put("model", "deepseek-r1:7b"); payload.put("stream", true); payload.put("messages", List.of(Map.of("role", "user", "content", userMessage))); return webClient.post() .uri("/api/chat") .bodyValue(payload) .accept(MediaType.TEXT_EVENT_STREAM) .retrieve() .bodyToFlux(JsonNode.class) .filter(node -> node.has("message")) .map(node -> node.path("message").path("content").asText()); } }这段代码的关键是bodyToFlux(JsonNode.class):每次 SSE 推送的 JSON 会被自动反序列化成 JsonNode,然后filter过滤掉没有message字段的元数据行,map取出增量内容。前端拿到这个 Flux 后,可以通过 WebSocket 或者 Spring MVC 的text/event-stream再转发给浏览器。注意 WebClient 默认内存缓冲区只有 256KB,R1 单条回复动辄几千 token,不调大maxInMemorySize很容易报DataBufferLimitException。
4.2 请求超时与线程池参数
RestTemplate 默认用的是 JDK 连接超时,往往只有 3 秒,对大模型推理这种慢接口来说等于不可用。要在配置里显式设置连接超时和读取超时,读取超时才是关键:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @Configuration public class HttpClientConfig { @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(5_000); // 建立连接最长等 5 秒 factory.setReadTimeout(120_000); // 等待响应最长等 120 秒 return new RestTemplate(factory); } }读超时给到 120 秒不是随手写的。R1 的非流式调用在 CPU 上推理时,7B 模型生成 500 token 就可能要 40 秒以上,60 秒超时在高峰时仍可能触发。给了 120 秒后,绝大多数情况能兜住。线程池也要单独考虑:SpringBoot 默认的 Tomcat 工作线程数是 200,如果一个请求占住线程等模型输出 30 秒,20 个并发就能拖垮整个应用。推荐给模型调用单独配一个线程池并限制最大连接数,让 AI 请求排在队列里,而不是占满核心线程。
4.3 连接复用与长连接参数
Ollama 本身是 HTTP 服务,每次新建连接会有 TCP 握手开销。默认的SimpleClientHttpRequestFactory每次请求都新建连接,高并发下会有性能损耗。这里一个常见做法是用 Apache HttpClient 5 或 OkHttp 做底层连接池。我用 Apache HttpClient 做演示:
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; @Configuration public class PooledHttpClientConfig { @Bean public RestTemplate pooledRestTemplate() { PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(); manager.setMaxTotal(20); // 总连接数上限 manager.setDefaultMaxPerRoute(10); // 单路由并发上限 CloseableHttpClient client = HttpClients.custom() .setConnectionManager(manager) .evictExpiredConnections() .build(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client); factory.setConnectTimeout(5_000); return new RestTemplate(factory); } }连接池的setMaxTotal(20)决定了同时能向 Ollama 发起多少个请求。注意这里的 20 是 HTTP 连接层面的并发,Ollama 的模型推理是串行的,连接池再大模型也只能一个一个处理,所以别把这个值设太大。这个池化配置的意义在于:频繁的chat调用复用已有连接,避免每次都走三次握手;同时限流保护 Ollama,不会因为几十个并发请求直接把服务压崩。
5. DeepSeek-R1 本地调用避坑指南:六个高频问题的现象、原因与解法
5.1 首次请求卡了半分钟,甚至直接超时
现象:SpringBoot 接口第一次调用时报Read timed out,第二次调用却正常。
原因:ollama pull只是把模型文件下载到磁盘,模型并没有加载进内存。第一次发起推理时,Ollama 需要把几 GB 的权重文件读入显存,机械硬盘上这个加载过程可能长达 30 秒以上。如果没有预热,任何 HTTP 超时配置都兜不住。
解决:应用启动后主动发一次空请求预热模型,让模型常驻显存。可以在 SpringBoot 的ApplicationRunner里做这个事:
@Component public class ModelWarmupRunner implements ApplicationRunner { private final DeepSeekChatService chatService; public ModelWarmupRunner(DeepSeekChatService chatService) { this.chatService = chatService; } @Override public void run(ApplicationArguments args) { chatService.chat("你好"); } }这一步把模型加载时间从请求链路里剥离出来。如果你的服务经常重启,每次都要多等这段时间。
5.2 模型一直输出<think>推理内容
现象:返回给用户的文本带大量thinking过程,甚至只有推理没有结论。
原因:DeepSeek-R1 是推理模型,为了保证逻辑正确,模型会强制先生成思考过程再输出答案。这是模型本身的特性,不是 bug。
解决:业务接口返回前剥离<think>...</think>内容。第三章已经给了extractAnswer方法,这里再强调一个细节:有的回答是思考过程结束后直接结束,没有</think>后续内容,也就是模型认为不需要回答了。提取时indexOf找不到标记就直接返回原始内容,别让 NPE 把接口打挂。
5.3 回答到一半「失忆」,前面的要求全忘
现象:多轮对话时,模型第三轮开始就把系统提示词里的要求忘掉,答非所问。
原因:Ollama 默认num_ctx=2048,也就是上下文窗口只有 2048 个 token。R1 的思考过程动辄几百上千 token,一轮对话就占掉大半个窗口,轮次稍多旧消息就被挤出去了。
解决:在请求的options里显式设置num_ctx。先看显卡显存,用ollama ps能看到当前模型实际占用的显存。7B 模型开 8192 上下文一般在 6GB 显卡上能跑,如果要长对话,要么升 32B + 更大显存,要么在业务层主动裁剪历史消息,只传最近三轮对话。
5.4 并发一高接口就集体超时
现象:压测时 10 个并发请求全部堆积,前端全部转圈。
原因:Ollama 对同一个模型是串行推理的,后到的请求排队等待,不是并行处理。请求全压在 SpringBoot 的 Tomcat 工作线程上,线程占满后所有接口都跟着卡。
解决:给 AI 调用单独设置线程池,信号量限流,拒绝超额请求。参考第四章的配置,线程池核心线程数设置成 4 到 8 就够了,不要和模型吞吐量脱节。如果是生产环境,可以考虑部署两三个 Ollama 实例,SpringBoot 侧做简单轮询。
5.5 中文乱码或特殊字符被截断
现象:返回的中文偶尔出现?或乱码,emoji 直接丢失。
原因:SSE 流式传输时编码指定不对,或者 WebClient 的 buffer 不够大,完整 JSON 被截断后反序列化失败。
解决:Ollama 服务和 SpringBoot 都强制 UTF-8。WebClient 侧把maxInMemorySize调到 10MB 以上。如果你通过 Servlet 的text/event-stream转发,记得设置response.setCharacterEncoding("UTF-8")。
5.6 Ollama 服务起不来,端口被占用
现象:启动后 11434 端口无响应,ollama serve报错。
原因:macOS 和某些 Linux 发行版上,Ollama 安装后会自动注册开机启动。如果已经有一个实例占住了端口,新起的进程就会失败。
解决:先看进程列表,ps aux | grep ollama,找到已有进程,不要重复ollama serve。Windows 上检查系统托盘的 Ollama 图标,右键退出后重新启动。日志在~/.ollama/logs目录下,起不来时先看这里的输出。
6. 会话保持的一个有效技巧:把上下文交给调用方管理
把 DeepSeek-R1 接进业务系统后,第一个绕不开的问题是:多轮对话怎么保持记忆。Ollama 的/api/chat和无状态 HTTP 接口一样,每次请求都是独立的,模型不记得之前的对话内容。很多人的第一反应是把 session 存在服务端内存里,用 ConcurrentHashMap 维护 sessionId 到历史消息的映射,这个做法小规模没问题,但服务重启数据就丢了,集群部署时还要引入 Redis。实际我用的方案更简单:把历史消息数组直接放在请求体里,由调用方自己维护上下文。
public String chatWithHistory(List<Map<String, String>> messages) { Map<String, Object> payload = new HashMap<>(); payload.put("model", "deepseek-r1:7b"); payload.put("stream", false); payload.put("messages", messages); Map<String, Object> options = new HashMap<>(); options.put("num_ctx", 8192); payload.put("options", options); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers); ResponseEntity<JsonNode> response = restTemplate.exchange( "http://localhost:11434/api/chat", HttpMethod.POST, entity, JsonNode.class); String rawContent = response.getBody().path("message").path("content").asText(); return extractAnswer(rawContent); }messages参数是一个从系统到历轮对话的完整数组,结构是这样的:
List<Map<String, String>> messages = new ArrayList<>(); messages.add(Map.of("role", "system", "content", "你是业务助手,回答简洁")); messages.add(Map.of("role", "user", "content", "什么是三级缓存?")); messages.add(Map.of("role", "assistant", "content", "Spring 三级缓存是解决循环依赖的机制…")); messages.add(Map.of("role", "user", "content", "那第一级缓存是什么?"));每次请求都把完整数组传过去,模型通过注意力机制理解「那」指代的是上一轮的「三级缓存」。这个方案没有跨节点状态同步问题,集群里的任何一台机器都能处理任意用户的请求。代价是消息数组会越来越大,token 消耗指数上升。我一般限制最近三轮到五轮,超出历史就裁剪掉,只保留系统提示和最近对话。一旦消息数组长度超过 8000 token,就触发一次文本摘要,把旧对话压缩成一段背景描述再拼进数组。
从那以后我每次接入新的模型服务,都会先用 curl 把消息数组、流式开关、上下文长度这组参数调通,再动 SpringBoot 代码。这个习惯帮我绕开了不少黑匣子问题,也节省了大量联调时间。希望帮到你。
本文还有配套的精品资源,点击获取