最近我在一个内部工具项目里做本地化改造,需求很直接:Java 后端、不能依赖公网大模型 API、还要能拉起一个能对话的界面。技术选型绕了一圈,最后落在 Spring Boot3 + Spring AI + Ollama 这套组合上。简单说,Ollama 负责把大模型跑在本地,Spring AI 负责把模型封装成 Java 后端好用的接口,Spring Boot3 负责把整条链路暴露成 REST 服务。这篇把搭建过程、配置参数、流式输出、多轮记忆,以及我踩过的坑一并整理出来。
这不是一篇讲原理的科普文,是能照着抄作业的实操记录。适合已经在用 Spring Boot3、想快速接本地模型做 Demo 或私有化服务的后端同学;也适合第一次接触 Spring AI,想了解它到底怎么和本地模型配合的人。我会先讲为什么这么选,再讲环境搭建和代码实现,最后把排错清单放在后面。我的习惯是先把链路跑通再谈优化,所以下面所有步骤都按“最小闭环”的顺序来排,你照着做基本不会卡住。
1. 为什么我把技术栈定为 Ollama + Spring AI
1.1 本地模型真正解决了什么
先说业务侧。我之前遇到过三种典型场景,都是公网模型 API 解决不了的。第一种是隐私敏感,调研数据、合同摘要这类文本不能出内网;第二种是离线环境,机房和开发网络完全隔离,想用大模型只能自己部署;第三种是成本敏感,如果每天有几百万次短文本分类调用,按 token 计费的方案根本 hold 不住,换成本地小模型反而是一锤子买卖。
本地模型这种方案里,Ollama 是比较省心的落地载体。它跟 Docker 的管理体验很像:模型不是散装文件,而是有仓库、有 tag、有拉取命令的统一对象。ollama pull qwen2.5:7b一条命令就把模型下载好了,再ollama run就能直接对话。更重要的是,它默认暴露一个 HTTP API,地址一般是http://localhost:11434,这给后端起服务提供了很干净的边界。你不需要关心底层是 GGUF 量化还是 CPU/GPU 的调度,Ollama 都帮你处理了。
选 Ollama 而不是直接裸跑 llama.cpp,还有一个原因是模型管理。llama.cpp 要自己下载 GGUF 权重、自己写启动参数、自己管理多模型,项目稍微复杂一点就很容易出现环境混乱。Ollama 把这层封装成了类似容器镜像的体验,ollama pull和ollama run两条命令就能完成模型生命周期管理。对 Java 后端团队来说,这能省下大量运维学习成本。
1.2 Spring AI 在链路里扮演的角色
Spring AI 是 Spring 生态里的 AI 集成框架,专门解决“Java 后端怎么接模型”这件事。它把对话、嵌入、向量存储这些能力都抽象成了通用接口,比如 ChatClient、EmbeddingModel、VectorStore。以前我们要调 Ollama 或者公网模型,得自己写 HTTP 客户端、解析 JSON、处理流式返回;有了 Spring AI,这些都被 starter 和自动装配接管了。
我选择 Spring AI 还有一个很实际的原因,它跟 Spring Boot 的配置体系是打通的。模型地址、模型名、温度参数、超时时长,都可以直接写在application.yml里,不需要散落在 Java 代码中。这对后续交付运维非常友好,排障的同学不用碰代码,改配置就行。对于已经在 Spring Boot3 体系里的团队,这几乎是心智负担最小的接法。
另外,Spring AI 对模型供应商做了统一抽象。你在业务代码里用 ChatClient 接口,底层可以是 Ollama、也可以是别的模型服务。这意味着团队内部可以先把 Ollama 跑通,将来有预算或需求再接更强的云端模型,业务层不用大改。对中型项目来说,这种可替换性是值得用一层抽象去换的。
1.3 为什么不直接写 HTTP 调 Ollama
有人会说,Ollama 的 API 那么清晰,我自己拿 RestTemplate 也能调,何必再套一层。这个观点在只做一次性 Demo 时成立,但往生产走就会出现重复劳动:你要自己管理 prompt 历史、自己处理流式解析、自己写错误重试、自己对接各种向量化存储。这些工作每换一个模型供应商就要重写一遍。
用 Spring AI 之后,模型供应商只是一个实现,上层业务代码可以保持稳定。将来想从 Ollama 切到其他支持 OpenAI 协议的模型服务,只需要换依赖和配置,服务层代码基本不用动。这个解耦价值,在项目中期和后期会体现得越来越明显。当然,如果你只写几十行脚本,Spring AI 的抽象确实多余,这个要按项目复杂度来判断。
我见过很多团队一开始图省事直接调 HTTP,后面加了流式、加了几轮记忆、又加了 RAG,代码越写越厚,最后重构成本远超当初省下的那一层依赖。所以在项目启动时就决定用 Spring AI,我认为是性价比最高的选择。
2. 环境准备:先把 Ollama 本地模型跑起来
2.1 版本组合的取舍
Spring AI 目前版本迭代非常快,不同版本之间 API 和配置项变化很大,所以我先说当时实测稳定的组合,避免你被最新版的 breaking change 卡住。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 17 或 21 | Spring Boot3 强制要求 17+,实际开发我用 21,配合虚拟线程更省心 |
| Spring Boot | 3.2.x | 与 Spring AI 0.8.x 兼容性好,生产环境稳定 |
| Spring AI | 0.8.1 | ChatClient 和 Ollama starter 在这个版本比较成熟,文档多 |
| Ollama | 最新稳定版 | 安装后会自动更新模型运行时,API 基本稳定 |
| 本地模型 | qwen2.5:7b | 中文效果好、显存要求相对友好,做实验首选 |
我建议不要一上来就追新。Spring AI 1.x 已经发布,接口比 0.8.x 有不少调整,如果你拿到的示例代码是 0.8.x 的,直接套用到 1.x 可能跑不通。先把这套稳定组合跑通,再决定要不要升级。JDK 版本上,如果你所在团队还在用 JDK 8,那 Spring Boot3 整条线都用不了,这个话题和本地模型无关,就不展开了。
版本问题最容易出在 Spring AI 依赖上。它不是 Spring Boot 官方那一套依赖管理,必须通过 BOM 统一版本,否则多个 Spring AI 子包之间的版本不一致,启动时会出现各种方法签名找不到的异常。这一点我在后面的 pom 配置里会重点处理。
2.2 安装 Ollama 并拉取模型
Ollama 的安装没什么特殊技巧,官网提供各平台安装包。装完以后,在命令行确认版本:
ollama --version然后拉取我推荐的模型。qwen2.5 系列对中文支持比同规格 Llama 好不少,7b 参数级别在 16GB 内存的机器上能比较流畅地跑:
ollama pull qwen2.5:7b下载完成后,用ollama list确认模型是否就位。接着可以启动一个临时对话测试原生效果:
ollama run qwen2.5:7b这里有一个容易被忽略的点:Ollama 服务默认端口是 11434,而且它只在localhost上监听。如果你要部署到其他机器,或者让容器中的应用去访问,就要设置环境变量OLLAMA_HOST=0.0.0.0,否则从别的机器访问会直接连接失败。我一开始就在这个坑里栽过,后面排错还专门写了一段。
模型文件默认存储在用户目录下的.ollama/models里。如果磁盘空间紧张,可以通过OLLAMA_MODELS环境变量把模型目录改到数据盘。这个操作对长期运行很关键,尤其当你同时拉取多个模型时,几个 7B 模型加起来就要十几 GB,系统盘很容易被撑爆。
2.3 准备 Spring Boot3 项目骨架
项目骨架用 Spring Initializr 生成即可,注意这几项:语言选 Java,Spring Boot 版本选 3.2.x,依赖先勾选 Spring Web,后续的 Spring AI 依赖我们手动加到 pom 里。为什么不直接勾选 Spring AI?因为不同初始化器里 Spring AI 的版本管理未必是你想要的,手动加反而更可控。
生成后,先用一个最简单的 Controller 验证项目能启动。这一步不是为了写业务,而是把 Java 环境和 Maven 依赖的问题先排除掉,避免后面和 Spring AI 的配置问题混在一起。我通常会在这一步确认mvn spring-boot:run能正常起端口,再继续接下来的集成。
如果你用的是 JDK 21,还可以顺手把 Spring Boot 的虚拟线程开关打开。配置里加一句spring.threads.virtual.enabled=true,在高并发 IO 场景下能显著减少线程资源消耗。本地模型生成回答时,IO 等待时间很长,虚拟线程在这种场景下收益非常明显。
3. Spring Boot3 里配置 Spring AI 的关键参数
3.1 引入依赖:BOM 与 starter
Spring AI 官方提供了 BOM,版本用0.8.1。在 pom.xml 的<dependencyManagement>里先加入 BOM,这样所有 Spring AI 相关依赖都不会出现版本冲突:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>0.8.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后加入 Ollama 的 starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> </dependency>如果你遇到依赖拉不下来的情况,先检查 Maven 仓库是否配置了镜像源。Spring AI 的依赖都在 Maven 中央仓库,理论上不需要额外配置,但国内网络环境下,某些内网私服没有完整同步中央仓库,就会报 401 或 404。解决办法是把私服仓库配置改成代理中央仓库,或者临时走中央仓库。
BOM 的作用是统一管理版本号。没有 BOM 时,你不光要写<version>,还要保证多个 Spring AI 模块之间的版本一致。有 BOM 之后,子依赖可以不写版本号,由 Maven 从 BOM 里继承。这是简化依赖管理最有效的手段,不要省略。
3.2 完整的 application.yml 配置与其含义
下面这份配置是我实测过可以直接用的。把 Ollama 的地址、模型名以及采样参数都放在配置里,代码里不写死任何模型细节:
spring: ai: ollama: base-url: http://localhost:11434 chat: model: qwen2.5:7b options: temperature: 0.7 top-k: 40 top-p: 0.9 num-ctx: 4096 repeat-penalty: 1.1逐项解释一下:
| 配置项 | 作用 | 建议 |
|---|---|---|
spring.ai.ollama.base-url | Ollama 服务的地址 | 默认http://localhost:11434,跨机器部署要改成实际地址 |
spring.ai.ollama.chat.model | 对话使用的模型名 | 要和ollama list里的名字完全一致,包括 tag |
temperature | 采样温度,越大越随机 | 通用问答用 0.7,代码生成或结构化输出降到 0.2 以下 |
top-k | 只从概率最高的 K 个 token 里采样 | 数值越大越发散,一般 40 左右 |
top-p | 核采样,累积概率阈值 | 0.9 左右比较常规 |
num-ctx | 上下文窗口长度 | 设置太小会让长对话截断,太大占用内存更多 |
repeat-penalty | 重复惩罚系数 | 让回答不要无限重复同样的内容,1.1 左右即可 |
num-ctx是一个很容易被忽略的参数。Ollama 默认的上下文窗口可能只有 2048 或者更小,如果对话一长,前面的内容就被截断了,模型会像失忆一样。调大到 4096 或更高能让长对话体验改善不少,但代价是显存和内存占用上升。我建议先 4096 起步,有需要再往上调。
这里最坑的一点是不同版本配置前缀不一样。0.8.x 用的是spring.ai.ollama.chat,到了 1.x 有些配置项会变成spring.ai.ollama下面直接平铺,或者挪到options内部。如果你发现配置写对了但温度参数完全不生效,多半是版本和前缀不匹配。解决方案不是死记配置,而是去本地 Maven 仓库翻一下spring-ai-ollama-spring-boot-autoconfigure里的@ConfigurationProperties类,字段名就是配置名,这个方法对任何版本都有效。
3.3 最小可用代码:先让对话跑通
配置写好后,用一个最小的接口验证全链路。Spring AI 自动装配会提供一个ChatClient.Builder,我们用构造函数注入来构建ChatClient:
@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping("/chat") public String chat(@RequestParam(defaultValue = "你好,介绍一下你自己") String message) { return chatClient.call(message); } }启动应用后,直接请求/chat,如果 Ollama 模型已经拉取成功,你会看到一段中文模型回复。这个最小闭环一旦通了,说明 Spring Boot、Spring AI、Ollama 三层的连接都没问题,后面往上加功能就快多了。
如果启动时报找不到ChatClient.Builder,先检查依赖是否真的引入了、BOM 是否生效,再到自动装配类里看一眼条件注解。最常见的原因是 pom 里只写了 BOM 没写具体 starter,或者 starter 被排除掉了。
如果调用时报模型连接失败,不要急着查 Java 代码,先用 curl 打一下 Ollama 的 API。模型服务和 Java 进程是两套独立的东西,先确认哪一个挂了,能少走很多弯路。
4. 实现对话接口:从同步调用到流式、多轮记忆
4.1 封装 Service,避免业务逻辑全挤在 Controller
最小闭环跑通后,第一件事就是把对话逻辑抽到 Service 层。因为后面要加系统提示词、会话记忆、异常处理,全写在 Controller 里会非常乱。我习惯写一个ChatService,只对外暴露业务方法:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String userMessage) { Prompt prompt = new Prompt(new SystemMessage("你是内部知识助手,回答要简洁、准确。"), new UserMessage(userMessage)); return chatClient.call(prompt).getResult().getOutput().getContent(); } }很多人第一次用ChatClient.call会觉得返回值结构很绕。在 0.8.x 里,call(Prompt)返回ChatResponse,真正内容在getResult().getOutput().getContent()。业务层千万不能直接把ChatResponse抛给前端,一定要在边界处转成自己的 DTO,否则后面升级版本或换模型供应商,接口定义就会水土不服。
Service 层还应该做一层异常兜底。本地模型偶尔会输出空内容,或者 Ollama 超时返回 500,如果不对调用做 try-catch,错误会一路冒到 Controller,用户看到一堆堆栈信息。我通常的做法是捕获异常后返回一个业务上的错误码,同时在日志里把模型返回的原始内容记录下来,方便定位到底是模型问题还是网络问题。
4.2 流式输出的两种落地方式
同步阻塞式返回只适合调试。真实对话体验,尤其是打字机效果,必须用流式输出。Spring AI 支持流式接口,但落地方式受限于你的 Web 框架选型。
方案A:纯响应式项目。如果你一开始就用了 Spring WebFlux,可以这样:
@GetMapping(value = "/chat/stream", produces = "text/event-stream;charset=UTF-8") public Flux<ServerSentEvent<String>> stream(@RequestParam String message) { Flux<String> content = chatClient.stream(message); return content.map(chunk -> ServerSentEvent.builder(chunk).build()); }在这种模式下,前端用原生 EventSource 或者 fetch 流式读取都能收到增量文本。不过我要提醒一句:Spring MVC 和 WebFlux 不要同时在一个项目里用,两边都想抢占 Web 容器,最后会出现一堆诡异的启动报错。选型时就要定好。
方案B:传统 Spring MVC 项目用 SseEmitter。如果你不想为了流式输出把一个 Java 项目改成响应式,可以直接用 Servlet 的SseEmitter:
@GetMapping("/chat/sse") public SseEmitter chatSse(@RequestParam String message) { SseEmitter emitter = new SseEmitter(0L); Flux<String> stream = chatClient.stream(message); stream.subscribe( content -> { try { emitter.send(content); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); return emitter; }SseEmitter 的方案在 Spring MVC 里完全够用,实现不复杂,但要注意线程管理。模型生成一个回答可能持续几十秒,如果请求量大,每个连接都会占一个线程。这类场景后续最好还是拆成独立的消息通道去处理,而不是在 Web 层硬撑。
前端如果使用 SSE,需要注意编码问题。text/event-stream一定要显式指定charset=UTF-8,否则中文会出现乱码。我刚开始就漏了这个,接口在浏览器里显示完全正常,但在某些 HTTP 客户端里就变成了乱码,排查了半天才发现是 Content-Type 里的 charset 丢了。
4.3 多轮对话:把闲聊变成有上下文的会话
ChatClient本身是无状态的。你每次调用,模型只会看到当前这条消息,不会记得“我们之前聊过什么”。所以要做多轮对话,必须在应用层维护历史消息。
最简单的做法是用 Spring AI 的MessageChatMemory加ChatMemory接口:
ChatMemory chatMemory = new InMemoryChatMemory(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultSystem("你是内部助手") .defaultChatMemory(chatMemory) .build();如果你不想把版本绑定太死,也可以自己维护一个会话列表,在每次请求前把历史消息拼进 Prompt:
List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage("你是一个严谨的问答助手。")); messages.add(new SystemMessage("用户之前的提问:" + historyText)); messages.add(new UserMessage(currentQuestion)); Prompt prompt = new Prompt(messages); ChatResponse response = chatClient.call(prompt);这里最核心的问题是窗口长度。本地模型的上下文窗口通常有限,qwen2.5:7b 默认上下文大约几十K token,但历史消息太多后,首 token 延迟会明显变高。我实践下来的策略是只保留最近 4 到 6 轮对话,超出部分直接丢弃,或者用摘要模型把旧对话压缩成一句话。简单任务下,丢掉历史并不会让体验下降太多,但能显著减少 token 消耗和内存压力。
裁剪历史可以用一个很朴素的方法:
private List<Message> trimHistory(List<Message> history, int maxTurns) { if (history.size() <= maxTurns) { return history; } return new ArrayList<>(history.subList(history.size() - maxTurns, history.size())); }这只是最粗糙的丢弃策略。真实项目里,还应该根据 token 数量裁剪,因为“轮数”并不能准确反映长度,一条长消息可能顶十句短对话。更好的做法是每次追加消息前估算 token 数,超过阈值就删最旧的消息。虽然本地模型没有直接暴露 tokenizer,但你可以用模型名对应的分词规则做一个近似估算,先跑起来再调。
5. 进阶玩法:接入 Embedding 做成小范围 RAG
5.1 Embedding 模型接入与向量计算
对话能跑通以后,下一个自然需求是让模型回答内部知识库的问题。这就要用到 Embedding。Embedding 做的事情是把文本变成一组浮点数向量,让语义相近的文本在向量空间里距离更近。Ollama 也提供了嵌入模型,最常见的是nomic-embed-text:
ollama pull nomic-embed-textSpring AI 对 Embedding 的封装同样很简洁。配置里指定嵌入模型后,注入EmbeddingModel就能用:
spring: ai: ollama: embedding: model: nomic-embed-text@Autowired private EmbeddingModel embeddingModel; public double[] embed(String text) { var response = embeddingModel.embed(text); return response; // 返回向量数组 }向量本身没什么业务含义,但你可以用它做相似度计算。两个文本的语义相似度,通常用余弦相似度衡量,向量越接近,余弦值越接近 1。实际应用中,你不可能把全量文档都实时编码一遍,一般是离线把文档切成小块,预先算好向量再存储。
余弦相似度的实现并不复杂:
public double cosineSimilarity(double[] a, double[] b) { double dot = 0, normA = 0, normB = 0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-9); }这个相似度值落在 -1 到 1 之间。做检索的时候,通常取相似度大于某个阈值的前 N 条文本作为上下文。阈值设低了,会把无关内容塞进 Prompt,干扰模型;设高了,又会漏掉相关文档。没有一个万能阈值,只能基于你自己的测试集去调。
5.2 一个极简 RAG 的完整思路
RAG(检索增强生成)听起来高大上,原理却很简单。先用一个存储把所有文档块向量化,用户提问时先找出最相关的几段文本,再把这些文本塞进 Prompt,让模型基于给定材料回答。下面是一个可以在本地跑通的最小版本:
@Service public class RagService { private final EmbeddingModel embeddingModel; private final ChatClient chatClient; private final List<DocumentChunk> vectorStore = new ArrayList<>(); // 省略构造注入 public void index(String text) { // 简单按长度切块,实际项目中还要考虑重叠和标题结构 for (String chunk : splitByLength(text, 500)) { vectorStore.add(new DocumentChunk(chunk, embeddingModel.embed(chunk))); } } public String ask(String question) { double[] qVector = embeddingModel.embed(question); String context = vectorStore.stream() .sorted(Comparator.comparingDouble( chunk -> -cosineSimilarity(qVector, chunk.vector()))) .limit(3) .map(DocumentChunk::text) .reduce("", (a, b) -> a + "\n" + b); String prompt = "仅根据以下资料回答问题,如果资料中没有相关内容,请说不知道。\n\n资料:\n" + context; return chatClient.call(prompt + "\n\n问题:" + question); } }这个版本把向量存在内存里,只适合演示。生产环境一定要用真正的向量库,比如 pgvector、Redis 的向量模块或专用的向量数据库。Spring AI 为VectorStore提供了一组统一接口,你只要换实现,上面的业务逻辑可以原样保留。这也是我坚持用 Spring AI 的原因之一,它确实让“换存储”这件事成本变得可接受。
文本切块是 RAG 里容易被低估的细节。如果直接按字符硬切,可能把一句话从中间切断,导致语义破碎。我实践里会优先按段落切分,段落太长再按句子切,同时保留相邻块之间 50 字左右的重叠,避免检索时漏掉边界信息。块大小可以先用 500 字左右试,再根据检索效果调整。
6. 踩坑实录:本地模型集成常见问题排查
6.1 Ollama 连接失败怎么办
Spring AI 启动没问题,但一调用就报 “Connection refused”,十有八九是base-url写错了,或者 Ollama 服务根本没起来。先直接在命令行里验证:
curl http://localhost:11434/api/tags如果 curl 也失败,检查 Ollama 进程是否存在。如果 curl 成功,再把spring.ai.ollama.base-url改成http://localhost:11434,注意不要漏掉端口。如果是在远程机器上访问,Ollama 默认只监听本机,需要设置OLLAMA_HOST=0.0.0.0并重启服务。
在 Windows 机器上还有一个常见问题:Ollama 首次安装后可能没有加到系统服务里,需要手动启动客户端或者设置开机自启。防火墙也可能拦截 11434 端口,尤其是使用自定义安全策略的办公网。遇到连接失败,先用curl确认本机能不能通,再检查网络层面。
6.2 模型没拉取或名字拼错
调用时报 “model not found”,最常见的是模型名和 tag 与本地不一致。Ollama 里的模型名是qwen2.5:7b这种格式,冒号后面是 tag。你用ollama list看一眼本机到底装了什么,然后照着复制到配置文件里。不要随手写一个qwen2.5,没有 tag 时 Ollama 会默认尝试匹配 latest,有时候行为和你预期不一致。
还有一种隐蔽情况:项目跑在服务器上,但模型是在你自己电脑上拉的。比如本地开发环境明明能对话,部署到服务器后却报 model not found,因为服务器上的 Ollama 还没拉过这个模型。部署步骤里一定要把ollama pull写进去,最好做成初始化脚本,否则换一台机器就要手动补一遍。
6.3 配置没生效与 Spring AI 版本差异
这个问题最隐蔽。比如你设置了temperature: 0.2,但模型回答发散程度没有任何变化,要怀疑 Spring AI 版本和配置项是否匹配。前面说过,去看spring-ai-ollama-spring-boot-autoconfigure里的配置属性类,那里是最权威的字段来源。另外一个排查思路是启动时把debug=true打开,看自动装配报告里OllamaChatModel的 condition 是否满足。如果某个自动配置被跳过,通常日志里会写明缺少了什么条件。
在实际项目里,我还遇到过配置值被覆盖的情况。多模块项目里,某个子模块的application.yml把父级的模型配置覆盖了,导致本应用连上了另一个模型地址。排查这种问题最快的方式不是看代码,而是启动时打印SpringEnvironment里实际生效的spring.ai.ollama相关配置,看一眼就知道有没有被覆盖。
6.4 性能瓶颈与并发问题
本地模型在 CPU 上跑 7B 模型,单并发请求可能就要等几十秒,高并发基本不现实。所以我通常把这类服务定位成内部工具或低并发 AI 网关,而不是面向终端的高并发应用。如果响应太慢,先看是模型太大还是机器太弱;换个更小的模型,比如qwen2.5:3b,或者用量化版本,速度会明显提升。并发请求多时,还可以在 Ollama 侧设置OLLAMA_NUM_PARALLEL控制并行数,避免一个请求把显存打满,其他请求全部排队。
如果你用的是独立显卡,显存不够时模型会回退到 CPU 推理,速度会断崖式下降。观察 Ollama 日志可以确认它到底用了哪块设备。内存不够时,尽量选带q4这类低比特量化的模型 tag,体积和推理时的占用会小很多,代价只是回答质量稍微下降。
把常见的症状和方向整理成一张表,排查时能省不少时间:
| 症状 | 可能原因 | 处理方向 |
|---|---|---|
| Connection refused | Ollama 未启动、端口不对 | curl 验证 API,检查 base-url |
| model not found | 模型名/tag 不一致 | ollama list核对模型名 |
| 回复乱码或很差 | 模型太小、没写系统提示词 | 换中文模型,加 SystemMessage |
| 温度参数不生效 | Spring AI 配置前缀不对 | 翻自动配置源码确认属性名 |
| 首字延迟高 | 模型大、历史消息过长 | 减小模型、裁剪会话上下文 |
| 并发请求堆死 | 显存/内存不足 | 换小模型,配置 OLLAMA_NUM_PARALLEL |
这一小节算是整个集成项目里最容易踩的部分。我的建议是,不要试图一步到位把生产级并发和 RAG 全做出来,先跑通一个最小流程,再对照上面的问题表逐项优化。很多线上问题都是“配置看着对但实际没加载”这类隐性错误,单纯靠肉眼 review 代码是发现不了的。
最后再分享一点个人体会:这套本地模型方案,现阶段和公网大模型比推理能力还有差距,但在内部知识问答、代码片段解释、结构化摘要这类封闭场景里已经完全够用。我第一次跑通时也觉得 Spring AI 返回值绕、版本乱,但为了抽象和后续可切换性,这些代价是值得的。如果你刚准备入坑,先用 qwen2.5:7b 把链路跑通,再慢慢换更大的模型;别一上来就追求超大参数量,机器和运维都会被拖垮。