最近在做一个内网项目的时候,我被一个需求卡住了:业务系统想接入大模型能力,但数据不能出内网。直接调大模型平台API的方案被一句话否了,剩下的路只有两条——要么自己租卡训练,要么在本地把开源模型跑起来。我选了后者,然后发现最顺手的组合就是Spring AI + Ollama。Spring AI负责Java系统里所有模型调用抽象,Ollama负责把Qwen2.5这类本地大模型变成一个随时可用的服务。这篇文章把我从零开始踩通的路完整记录下来,适合正在做Spring AI落地、又不想把数据送出去的团队参考。
1. 为什么我会把Spring AI和Ollama绑在一起用
1.1 一个"模型API不能进内网"的需求引发的重构
你可能会问:直接用大模型平台的API不香吗?说实话,如果是个人开发或者数据不敏感的Demo,确实香。API一次调用返回内容,SDK现成,prompt随便调。可一旦上了企业内网环境,事情立刻不一样:客户要求模型推理必须在自己的机器上完成,哪怕只是把一句话发给外网服务都会触发合规审查。更不用说有些生产网段根本只有业务端口能出去,大模型API域名根本不通。
我当时的解决办法很朴素:把模型装到客户机房的一台GPU服务器上,然后让Java后端去调用这台服务器的地址。问题也随之而来:模型怎么跑、模型跑起来以后用什么协议暴露给业务系统、Java代码怎么维护多个模型之间的切换。如果这些都用原始HTTP去拼,项目推进到一半就会失控。
1.2 Ollama的角色:把大模型变成一个本地HTTP服务
Ollama不是一个聊天网页,它是模型运行时管理器。它帮你把开源大模型下载到本地、加载进显存/内存、然后暴露一个REST API。你不用关心模型推理背后的Python环境、tokenizer、CUDA版本,只需执行一条命令把模型拉下来,然后对着http://127.0.0.1:11434发POST请求,就能拿到生成结果。
它还有一个很关键的优点,就是模型之间隔离得很干净。我在同一台机器上既跑过qwen2.5:7b,也跑过更小的gemma2:2b,通过一个简单的命令就能切换。对Java开发来说,可以把Ollama理解成一个只在本机/局域网开放的数据库服务,只不过存储的不是表,而是大模型的权重和推理能力。
1.3 Spring AI解决的是Java程序员接模型的"最后一公里"
如果已经有Ollama这个HTTP服务了,为什么还需要Spring AI?你当然可以自己用RestTemplate或WebClient去请求/api/generate,但很快会碰到几个重复性极强的需求:把用户消息和历史记录拼成Prompt、把模型返回的Token流解析成可读文本、把大模型的JSON输出转成Java对象、控制不同模型参数。这些工作每个项目都要做一遍,且特别容易踩细节坑。
Spring AI把这类工作抽象成了几个统一接口。最核心的是ChatModel,它对上层隐藏了模型提供方差异。今天连接Ollama,明天想切到别的兼容OpenAI协议的服务,业务代码改动很小。对团队里的Java工程师来说,这比让他们去学Python推理栈友好太多。所以我的选择是:模型层交给Ollama,接入层交给Spring AI,业务层只认Java接口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地部署Ollama:安装、拉模型、下载慢与盘符问题一次解决
2.1 安装Ollama之前,先决定模型目录
Ollama的Windows安装包本身并不大,真正占空间的是你后续拉取的模型文件。一个7B参数的量化模型通常在4GB到6GB之间,如果你装完以后直接把模型全部丢在C盘,用不了多久系统盘就会告急。
因此安装之前,我的建议是先设好环境变量OLLAMA_MODELS,把它指到大容量磁盘目录。Windows里就是系统环境变量设置里加一条:
code复制OLLAMA_MODELS=D:\ollama\models
Linux/macOS则写到shell配置或systemd service里:
bash复制export OLLAMA_MODELS=/data/ollama/models
有人会问:我已经装完Ollama了,模型也拉了一半,这时改目录行不行?行,但需要手动把旧目录里的模型迁移过去,否则会重复拉取。我踩过一次这个坑,所以现在只要给新机器装Ollama,第一步永远是定模型目录,再谈安装。
安装过程没什么玄学。Windows直接运行安装包,Linux用官方脚本或者二进制包都行。装完检查一下版本:
bash复制ollama --version
如果命令找不到,打开新终端再试一次,Windows下环境变量生效需要重启终端。
2.2 拉取qwen2.5:7b之前,先请看清模型体积
官方模型库里模型很多,但入门阶段我推荐先拉一个qwen2.5:7b。原因是7B参数量级在消费级显卡上还能跑得动,量化后体积约4.7GB,显存8GB左右基本能吃下,没有独立显卡时用CPU也能出结果,只是慢一些。
拉取命令很简单:
bash复制ollama pull qwen2.5:7b
ollama会把模型从默认仓库下载到本地。下载时间取决于你的实际带宽,如果网络环境不稳定,可能等很久。这里我不建议盲目选更大的模型,比如qwen2.5:32b,它可能让16G内存的机器加载时直接swap到死。先用7B跑通全链路,再评估要不要换更大的模型,这是本地模型部署的稳妥路径。
模型列表和资源占用可以通过两条命令查看:
bash复制ollama list # 列出本地已安装的模型
ollama ps # 查看当前正在运行的模型及其占用
2.3 下载速度慢不是只能干等
很多新手在ollama pull这一步被劝退,十几KB/s的速度实在太磨人。官方源在部分地区确实不快,这已经是个普遍现象。
常规解法有几种:
- 错峰下载。某些时段访问模型仓库的速度会明显好一些,但这不可控,只适合碰运气。
- 借助能正常访问的国内模型托管平台,手动下载GGUF文件后,通过Modelfile导入本地Ollama。这个方案慢工出细活,但至少稳定可控。
- 如果安装包下载慢,换下载方式或者找可信的镜像传送,比反复点下载按钮更有效。
手动导入GGUF方式大致如下。从托管平台下载qwen2.5-7b-instruct.q4_k_m.gguf文件,然后在同目录写一个Modelfile:
dockerfile复制FROM ./qwen2.5-7b-instruct.q4_k_m.gguf
再执行:
bash复制ollama create qwen2.5:7b -f Modelfile
这条命令会基于本地GGUF构建一个Ollama模型,之后就能正常调用。不过我不建议所有入门者都走这条进阶路线,核心思路是:当官方拉取严重受阻时,换个文件来源并手动导入是完全可行的,不需要觉得自己卡死了。
2.4 服务启动与两个curl验证命令
安装好模型后,Ollama在Windows上通常会自动常驻后台服务。如果它是手动启动的,或者你跑在无界面Linux上,需要执行:
bash复制ollama serve
正常情况下,服务会监听127.0.0.1:11434。此时打开另一个终端,用curl做一次最小验证,确认服务真的活着:
bash复制curl http://localhost:11434/api/tags
这个请求会返回一个JSON,列出当前已安装的模型列表。能看到模型名字,就说明Ollama服务正常。再试一次真正的推理接口:
bash复制curl http://localhost:11434/api/generate -d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}'
返回内容里的response字段就是模型生成的中文回复。这一步通过后,Ollama侧已经没有拦路虎,接下来可以放心去建Spring Boot工程了。
3. 创建一个Spring Boot项目:架构上不混乱的关键几件事
3.1 版本选型:BOM比你想象的更重要
Spring AI的版本迭代速度非常快,API变动也相当频繁。我在网上看到很多老教程,依赖坐标还是旧格式,代码复制下来连编译都过不了。所以新项目起步时,最稳妥的办法就是跟着官方BOM走,把版本统一管理在spring-ai-bom里,避免子模块各自引入不同版本。
工程环境我采用Java 17 + Spring Boot 3.4.x。pom里加上BOM和依赖:
xml复制<properties>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
然后在dependencies里添加:
xml复制<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
如果你用的IDEA,也可以直接在Spring Initializr页面勾选Spring AI的Ollama依赖生成项目。生成的工程里会带上正确的仓库配置,省得自己再去排查依赖解析问题。
3.2 自动配置背后发生了什么
为什么只加一个starter,就能在Controller里注入ChatModel?Spring Boot的自动配置在启动时做了三件事:
- 读取配置文件中
spring.ai.ollama.*的配置项; - 构建一个指向
http://127.0.0.1:11434的RestClient; - 向容器注册
OllamaChatModel,它实现了Spring AI的ChatModel接口。
我们不需要手动new这些对象,但需要理解一点:ChatModel是一个面向业务代码的模型入口,它背后可能是Ollama,也可能是别的大模型平台。因为Spring AI对上层暴露的是统一接口,以后模型换成了别的兼容服务,controller代码不会跟着大改。
我想强调一个容易忽略的点:Spring Boot的自动配置需要一个明确可用的Ollama地址,如果配置里没写,默认值就是localhost:11434。很多人项目起不来,不是代码问题,而是Ollama服务没启动或者在非本机地址上。
3.3 配置项不是越多越好
新建application.yml时,我建议最开始的配置保持极简:
yaml复制spring:
ai:
ollama:
base-url: http://127.0.0.1:11434
chat:
options:
model: qwen2.5:7b
temperature: 0.7
这里base-url是Ollama服务地址;model告诉Spring AI默认使用哪个模型;temperature控制生成随机性。对聊天场景,0.7是一个中规中矩的起点,太高回复容易跑偏,太低则显得机械。
有个细节值得提醒:如果Spring Boot应用跑在Docker容器里面,而Ollama跑在宿主机上,那么localhost不是你想要的地址。容器内的localhost只会指向容器自己,必须改成host.docker.internal:11434,否则会一直提示连接失败。
4. 从"能通"到"好用":聊天调用、参数调节与流式输出
4.1 十分钟写一个本地模型的聊天Controller
先不搞复杂封装,直接写一个能跑的HTTP接口:
java复制@RestController
@RequestMapping("/demo")
public class DemoController {
private final ChatModel chatModel;
public DemoController(ChatModel chatModel) {
this.chatModel = chatModel;
}
@GetMapping("/chat")
public String chat(@RequestParam(defaultValue = "用一句话介绍自己") String message) {
Prompt prompt = new Prompt(message);
ChatResponse response = chatModel.call(prompt);
String content = response.getResult().getOutput().getContent();
return "模型回复:" + content;
}
}
启动Spring Boot应用,浏览器访问:
code复制http://localhost:8080/demo/chat?message=你好
正常的话,你会看到本地模型生成的中文内容。这个例子虽然短,但已经覆盖了从Java方法到Ollama完整链路:Spring AI把Prompt包装成模型输入,Ollama执行推理,返回值再被Spring AI解析成标准结构。
call方法是阻塞式的,请求发出去以后,界面会一直在等模型生成完毕。初次请求时,由于模型需要从磁盘加载到内存/显存,等待时间可能达到十几秒甚至更久,这是正常现象。之后如果模型还驻留在内存里,响应速度会快很多。
4.2 temperature、topP和maxTokens:想清楚再调
很多人在模型返回效果不对的时候,第一反应是改prompt。实际调参也很重要,尤其在使用本地模型时,参数影响会更明显。
temperature:控制采样随机性。值越高,回答越多变;值越低,输出越稳定。如果业务场景是分类、抽取、生成结构化JSON,建议调到0.2以下。如果是闲聊、头脑风暴,可以放回0.7到0.9。topP:核心采样概率阈值。这个值和小概率词是否出现有关。工程团队通常设成0.8到0.9,不会动太多。maxTokens/numPredict:限制生成长度,防止模型在某次请求里不停地写下去。对话记录越长,这个参数越重要,否则单次请求可能白白消耗几十秒推理时间。
Spring AI的配置写在spring.ai.ollama.chat.options下面,比如:
yaml复制spring:
ai:
ollama:
chat:
options:
model: qwen2.5:7b
temperature: 0.2
top-p: 0.9
我最想提醒的是:不要看到效果不好就同时调一堆参数。一次只改一个变量,记录下效果,对比后再调整。否则你跟本不知道是哪个参数触发了行为变化,上线以后很难维护。
4.3 流式输出的打字机效果
聊天机器人如果等着整段回答生成完再一次性显示,体验会非常差。稍微复杂的本地模型,生成一句话都可能需要好几秒。最理想的方式是让后端通过SSE不断把已经生成的内容推给前端。
Spring AI里推荐用ChatClient完成流式调用。创建一个ChatClient Bean:
java复制@Bean
ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
Controller里返回Flux<String>:
java复制@RestController
@RequestMapping("/demo")
public class StreamController {
private final ChatClient chatClient;
public StreamController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
public Flux<String> stream(@RequestParam String question) {
return chatClient.prompt()
.user("请完整回答:" + question)
.stream()
.content();
}
}
前端用EventSource或fetch流式读取即可。这里不再需要手动处理Flux内部的复杂结构,.stream().content()返回的就是字符串流,每一段对应一小块生成内容。
实测中这个接口在浏览器里打开会看到一句话被逐段推送,像打字机一样。代码层面唯一要留意的是Controller的produces必须设置成text/event-stream,否则浏览器不会触发onmessage事件。
5. 业务系统不能只聊散文:结构化输出与Function Calling的起点
5.1 为什么没有JSON Schema时输出惨不忍睹
很多业务场景根本不是"聊天",而是要模型返回一份固定结构的数据,比如智能客服工单分类、合同要素抽取、课程推荐结果。如果直接在prompt里写"请返回JSON",模型天然会附带解释性文字,严重的时候还会用Markdown代码块把JSON包起来,让后端解析直接崩溃。
解决这个问题,不能指望用户prompt写得足够好。模型输出格式是不可靠的,必须用代码来约束。Spring AI里提供了一个极其好用的工具:BeanOutputConverter。
5.2 BeanOutputConverter帮你强制收拢格式
比如我想让模型返回一份学习计划,计划包括标题、每天投入小时数和参考书列表。我可以先定义一个Java record:
java复制public record StudyPlan(
String title,
int hoursPerDay,
List<String> books
) {}
然后利用BeanOutputConverter在运行时生成JSON格式指令,并且把模型返回的JSON字符串转换成Java对象:
java复制@GetMapping("/plan")
public StudyPlan studyPlan(@RequestParam String goal) {
BeanOutputConverter<StudyPlan> converter = new BeanOutputConverter<>(StudyPlan.class);
String prompt = """
请针对目标「%s」设计一周学习计划。
只输出JSON,不要输出任何解释。
格式如下:
%s
""".formatted(goal, converter.getFormat());
ChatResponse response = chatModel.call(new Prompt(prompt));
String content = response.getResult().getOutput().getContent();
// 这里内部会处理模型偶尔用```包住JSON的情况
return converter.convert(content);
}
这个接口返回的是StudyPlan对象,Spring MVC自动序列化成干净的JSON。业务代码拿到对象后可以直接存库或者组装成视图对象,不需要自己写JSONPath去那些不稳定的文本里捞数据。
结构化输出的意义不只是代码方便,它还在倒逼prompt收敛:模型知道自己只需要输出固定字段,不会在回答里发散。如果你做的是信息抽取类需求,建议配合temperature: 0,效果会明显稳定很多。
5.3 Function Calling是下一步,不是现在就要懂透
当你已经熟练了结构化输出,下一步自然会碰到一个问题:模型需要查询某个数据库、调用某个业务服务才能回答问题。比如用户问"L002课程在哪个教室上课",模型自己是不知道的,它需要触发一个Java方法来查询课程表。
这个能力叫Function Calling/Tool Calling。Spring AI支持通过注解把业务方法暴露给模型调用,模型在生成回复前会判断是否需要调用工具,然后由Spring AI代理执行方法并继续生成最终回答。
不过我的建议是:入门阶段先不要把Function Calling当成必学项。等到你已经搞清楚了ChatModel、ChatClient、结构化输出这三件事,再去看工具调用,会顺畅很多。本地模型要支持工具调用,对模型版本也有要求,像qwen2.5:7b这代模型已经支持了,但并不是所有Ollama模型都支持,选模型时要确认模型描述里有没有tool calling标识。
6. 跑了三天,我整理了一份排错清单与性能感受
6.1 端口通但请求超时,多半是模型还在冷启动
我来复盘一次典型的故障。Spring Boot已经启动,Ollama服务也在跑,浏览器请求接口却一直在转圈。我第一时间看Ollama日志,没有报错,只是没有响应。等了一分多钟后,请求返回了模型内容。
原因很简单:模型之前没有处于加载状态,首次请求需要把权重文件从磁盘读进内存。7B量化模型冷启动可能要几十秒,期间任何请求都会排队等待。排查方法就是看ollama ps,如果模型不在列表里,说明它还没被加载。
实际运维中可以通过预热来缓解这个体验问题。系统启动后主动向模型发一条极短的请求,让模型先加载到显存里。同时要关注Ollama的OLLAMA_KEEP_ALIVE参数,默认模型会在空闲一段时间后自动卸载;如果你对响应时间敏感,可以调大这个保持时间。
常见问题的排查表:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 连接拒绝 | Ollama服务没启动 | 执行ollama serve |
| 连接拒绝 | Spring Boot容器与Ollama不在同一网络 | 检查base-url是否为host.docker.internal |
| 返回404/模型不存在 | 本地没拉取对应模型 | 执行ollama list确认模型名 |
| 接口一直转圈 | 模型冷启动 | 用ollama ps确认加载状态 |
| 输出不稳定 | temperature过高 | 降到0.2以下重试 |
6.2 内存和显存的使用教训
本地部署大模型不是无代价的。以qwen2.5:7b为例,量化后占用的内存/显存大约在5GB上下,实际运行还会额外占用一些上下文空间。如果机器只有16GB内存,同时又跑着MySQL和业务服务,建议不要长时间保持模型常驻,否则系统可能直接进入高负载状态。
在只有CPU的机器上,7B模型有可能出结果,但速度会让人崩溃。我在一台没有独立显卡的笔记本上测试,生成一句20字的话可能要十几秒。如果只是做内部工具的辅助功能,勉强能接受;如果要面向并发用户,那就必须安排带GPU的机器,或者换更小的量化模型。
日常维护时,ollama ps能看到当前模型驻留资源,ollama stop可以主动释放不再使用的模型。这个命令对排查性能问题很有帮助,尤其是在多模型共用同一台机器的时候。
6.3 Spring AI版本更新太快,如何避免被API变动折腾
我从0.8.x时代开始跟踪Spring AI,到1.0正式版,包路径和接口用法都有变化。网上教程很多,但很多代码是基于旧版的,直接复制后编译失败非常正常。
我的经验是,项目级应用一定要锁定大版本,不要在运行中的环境上随便升级Spring AI。依赖版本写死,升级前先看官方Release Notes。遇到API编译错误,先去官方文档找当前版本对应的示例,别拿老博客硬刚。Spring AI本身很年轻,但它的抽象思路和结构正在趋于稳定,锁定版本并跟住官方节奏,会比频繁追新省心很多。
另外,由于模型本地部署的确会给团队带来额外的硬件运维成本,我始终建议先在需求边界上做减法:哪些业务场景真的需要大模型?哪些用传统规则就能做?把一个50%可用率的场景打磨成90%稳定输出,比盲目堆很多看似智能的功能更重要。这也是我在Spring AI + Ollama项目里最实在的体会。
