先说结论:Spring Boot 3 接 Ollama 做本地大模型推理,接口 5 秒以上不是新鲜事,但问题大多不在“推理太慢”,而在于你用错了调用姿势。
我在这条路上折腾了不少时间,最开始的思路特别直接——把用户问题发给 Ollama,同步等着它把结果全部生成完再返回给前端。模型用的是 7B 量化版,一次性回复 200 多个 token 很正常,结果接口普遍 5 秒到 8 秒。后来把架构改成“流式输出 + 首 token 计时”,同一个模型同一块显卡,用户能感知的等待时间压到了 300ms 左右,接口层面的首 token 指标也稳稳落进 500ms 以内。
这篇文章我把自己踩过的坑、改过的代码、调过的参数全部整理出来。不管你是个人项目还是企业级服务,只要用 Spring Boot 调 Ollama,这套优化思路都适用。
1. 响应慢的根源:先搞清楚这 5 秒到底花在哪
动手优化之前,得先把延迟拆开看。Ollama 本地推理的一次完整请求,从 Java 服务发起到最后拿到完整结果,时间主要消耗在这几个环节上。
1.1 推理本身没你想的那么慢
很多人一看到 5 秒就觉得是显卡不行、模型太大。实际上模型推理的“计算时间”在延迟占比里往往不是大头。我实测过几次典型场景,用 4060 Ti 16G 跑 Qwen2.5 7B Instruct 的 Q4_K_M 量化版,一个 20 token 的 prompt,首 token 生成时间在 200ms 左右,后续每个 token 大约 20ms 到 30ms。也就是说,生成 50 个 token 的完整回答,纯推理时间大概 1.2 到 1.7 秒。
这个速度对一个本地模型来说已经不错了,但为什么接口会跑到 5 秒以上?
因为你的请求里还有三个隐性开销:
- 模型加载时间:模型从磁盘读入显存,7B 模型 Q4 量化后约 4.5GB,冷启动加载普遍需要 2 到 5 秒。
- 预填充阶段:模型要先把你的 prompt 完整“读一遍”,这个耗时跟输入 token 数成正比,输入越长越慢。
- 同步等待完整输出:如果代码里用的是同步调用,必须等模型把最后一个 token 生成完毕才能返回,总时间等于“首 token 时间 + 所有 token 的生成时间”。
这三个开销叠加,接口 5 秒以上完全正常。
1.2 500ms 这个目标,定义得不对就永远做不到
优化之前必须搞清楚一个概念:500ms 以内到底指什么?
如果你的需求是“接口同步返回完整模型结果”,在消费级显卡上跑 7B 模型,老实说很难稳定做到 500ms 以内。因为只生成 50 个 token 也需要 1 秒以上,这还不算预填充和网络开销。
但对用户来说,“响应快”的真实感知是:发出问题后多久能看到第一个字或第一个词。这个指标在行业内叫 TTFT,Time To First Token,也就是首 token 延迟。
Ollama 的流式接口天然支持这个需求,模型每生成一个 token 就会通过 SSE 或 JSON 流推给客户端。Java 服务拿到第一个 token 就可以立刻转发给前端,用户几乎瞬间看到内容开始滚动。这时候接口层计时,首 token 进 500ms 是完全可以做到的。
所以整个优化方向就一句话:指标切到首 token 延迟,响应方式切到流式,同时想办法把预填充跟模型加载的时间压小。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一步优化:换用流式响应,吃掉最大的延迟块
思路确定了,代码就要跟着改。我之前用的是 JDK 自带 HttpClient 发同步请求,等响应体全部返回再处理,这显然不行。现在选项有两个:Spring WebFlux 的 WebClient,或者 JDK 11+ 的 HttpClient 配合响应式行处理。
2.1 流式方案怎么选:WebClient 还是 JDK HttpClient
如果是全新项目,或者你的 Spring Boot 服务已经引入了 spring-boot-starter-webflux,那我建议直接用 WebClient。它天生支持流式,配合 Flux 做后续的数据处理很方便。
但很多 Spring Boot 项目用的是 spring-boot-starter-web,也就是传统 Servlet 栈。引入 WebFlux 依赖并不冲突,WebClient 还是可以正常使用,只是 Spring Boot 会优先采用 MVC 作为 Web 框架。这一点实测下来没毛病。
如果不想引入 WebFlux 依赖,JDK 自带的 HttpClient 也够用。它有一个 BodyHandlers.ofLines(),能把响应体按行返回,Ollama 的流式输出本来就是一行一个 JSON 对象,用这个处理非常顺手。
两个方案的取舍看你的依赖情况:项目里已有 WebFlux 就用 WebClient,不想引入新依赖就用 JDK HttpClient。我给两个代码示例。
2.2 Spring Boot 3 接 Ollama 的完整流式代码(WebClient 版)
先加依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
然后配置 WebClient 连接 Ollama:
java复制@Configuration
public class OllamaConfig {
@Bean
public WebClient ollamaWebClient() {
return WebClient.builder()
.baseUrl("http://localhost:11434")
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.build();
}
}
注意 maxInMemorySize 要调大一点,默认 256KB 遇到长响应会报 DataBufferLimitException。
然后是调用的核心 Service:
java复制@Service
public class OllamaStreamService {
private final WebClient webClient;
public OllamaStreamService(WebClient webClient) {
this.webClient = webClient;
}
public Flux<String> chatStream(String model, String prompt) {
Map<String, Object> body = Map.of(
"model", model,
"messages", List.of(
Map.of("role", "user", "content", prompt)
),
"stream", true,
"keep_alive", "30m",
"options", Map.of(
"num_ctx", 2048,
"temperature", 0.7
)
);
return webClient.post()
.uri("/api/chat")
.bodyValue(body)
.retrieve()
.bodyToFlux(String.class)
.filter(line -> line.startsWith("{"))
.map(this::parseContent)
.filter(content -> content != null && !content.isEmpty());
}
private String parseContent(String line) {
try {
JsonNode node = new ObjectMapper().readTree(line);
return node.path("message").path("content").asText(null);
} catch (Exception e) {
return null;
}
}
}
这里有几个细节要注意:
Ollama 的原生接口 /api/chat 和 OpenAI 兼容接口 /v1/chat/completions 返回格式不一样。原生接口流式返回时,每一行都是一个完整的 JSON 对象,消息内容在 message.content 字段里;OpenAI 兼容接口才是标准 SSE 格式,行首带 data: 前缀,结束标识是 data: [DONE]。两种接口都能用,我习惯用原生接口,少一层兼容转换。
keep_alive 参数可以传 "30m" 或者 "5m" 这样的字符串,也可以传 -1 表示常驻内存。这个参数直接决定模型要不要反复加载,对延迟影响极大,后面专门讲。
2.3 把流式结果通过 SSE 转发给前端
Service 层已经拿到 Flux,接下来就是把流式响应暴露成 HTTP 接口给前端。Spring Boot 3 里最简单的做法是直接返回 Flux<ServerSentEvent<String>>,框架会帮你搞定 SSE 格式。
java复制@RestController
public class ChatController {
private final OllamaStreamService ollamaService;
public ChatController(OllamaStreamService ollamaService) {
this.ollamaService = ollamaService;
}
@GetMapping(value = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String prompt) {
return ollamaService.chatStream("qwen2.5:7b", prompt)
.map(content -> ServerSentEvent.builder(content)
.event("message")
.build());
}
}
这样写的好处是,前端可以直接用 fetch API 读取流,不需要 WebSocket。
前端 JavaScript 示例:
javascript复制const response = await fetch('/api/chat?prompt=' + encodeURIComponent(prompt));
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// 按行解析 SSE 数据,渲染到页面
appendToPage(text);
}
改完这一步,你再去测感知延迟,效果立竿见影。原来转圈 5 秒才出全文,现在是 300 毫秒开始蹦第一个字。
3. 第二步优化:从模型到参数,把首 token 真正压进 500ms
流式只是把“完整生成时间”从用户感知里拿掉了,但首 token 时间如果本身是 2 秒,用户还是觉得卡。要真正进 500ms,还得从模型选型、预填充长度、模型加载这几方面下手。
3.1 模型选型和量化:显存不够就别硬撑
首 token 时间的硬底子是硬件算力。你拿 CPU 跑 7B 模型,再怎么优化 TTFT 也得 2 秒往上;拿 4090 跑 3B 模型,首 token 可能 100ms 都不到。
模型选型我给一个保守但好用的对照表,基于我自己测试和社区反馈:
| 硬件条件 | 推荐模型 | 量化级别 | 显存占用 | 首 token 参考(TTFT) |
|---|---|---|---|---|
| 8G 显存 | Qwen2.5 7B / Llama 3.1 8B | Q4_K_M | 4.5G - 5G | 200ms - 400ms |
| 12G 显存 | Qwen2.5 14B | Q4_K_M | 8G - 10G | 300ms - 600ms |
| 24G 显存 | Qwen2.5 32B / Llama 3.1 70B 的低量化 | Q4_K_M / Q3_K_M | 14G - 20G | 300ms - 800ms |
| 纯 CPU | Qwen2.5 3B / 1.5B | Q4_K_M | 2G - 4G 内存 | 700ms - 2s+ |
如果你的显卡只有 4G 显存,跑 7B 模型会频繁发生显存换入换出,速度反而不如老老实实用 3B 模型。很多人觉得模型越大越聪明,但在延迟敏感场景里,选一个显存完全装得下、甚至有余量的模型,远比上大模型硬扛重要。
第二个关键是量化等级。Q8 比 Q4 质量好,但体积和推理耗时都明显上涨。7B 模型 Q8 大约 7GB,Q4 只有 4.5GB,加载和推理速度差距 30% 以上。我现在的习惯是:能用 Q4 绝不上 Q6,模型质量损失在绝大多数对话场景里感知不到,但速度差距是实打实的。
在 Ollama 里拉取指定量化版本,使用带标签的模型名:
bash复制ollama pull qwen2.5:7b-q4_K_M
3.2 缩短预填充:prompt 瘦身与 num_ctx 控制
首 token 时间等于“预填充耗时 + 生成第一个 token 耗时”。预填充就是把你的输入 prompt 一次性过一遍模型,耗时跟输入 token 数基本线性相关。
一个 2000 token 的输入可能让预填充达到 500ms 到 1 秒,所以想进 500ms,先把你发给模型的 prompt 给减下来。
三个实用手段:
- 精简系统提示词:把 system prompt 控制在 100 到 200 token 以内,不是必要的背景知识一概不塞。
- 控制历史对话轮数:多轮对话不要无限叠加历史消息,只保留最近 3 到 5 轮。可以用滑动窗口思路,每次请求前把历史压缩。
- 避免把业务上下文粗暴拼接:很多系统喜欢把用户资料、订单信息、商品详情全部拼进 prompt,结果一次请求 3000 token 打底。正确的做法是只拼必要字段,其他信息等模型问起再给。
还有一个容易被忽略的参数:num_ctx。它控制模型上下文窗口大小,默认值是 2048,有的模型拉下来默认配置是 4096 或更高。上下文窗口越大,预填充的计算量越大,显存占用也越高。如果你只是做单轮问答,把 num_ctx 设成 2048 甚至 1024,TTFT 会明显下降。
java复制"options", Map.of(
"num_ctx", 2048,
"temperature", 0.7
)
有个很容易踩的坑:Ollama 的参数设置需要在运行时通过 options 传入,修改 Modelfile 也能生效,但运行时传入优先级更高。
3.3 keep_alive 和并发参数:把模型加载时间直接抹掉
这一节是全文最容易被忽略、也最可能导致 5 秒延迟的坑。
Ollama 的默认行为是:模型在处理完请求后,在内存里保留 5 分钟。如果 5 分钟内没有新请求,模型会被卸载,下次请求再来时需要重新加载。7B Q4 模型加载一次 2 到 5 秒,这个时间直接吃掉了你的接口预算。
我之前排查一个生产问题,发现接口第一调用巨慢,第二次调用就快很多,就是 keep_alive 的默认行为在捣鬼。
解决方法有两个。
第一,在请求体里传 keep_alive:
java复制"keep_alive", "30m"
传 -1 表示常驻内存,模型永远不卸载。如果你的服务是专用模型服务,建议直接传 -1,彻底消灭模型加载时间。但要注意,常驻会一直占着显存,别的模型没法加载到同一块显卡上。
第二,修改 Ollama 服务的全局环境变量。在 Linux 系统上通过 systemd 配置:
bash复制sudo systemctl edit ollama
填入:
ini复制[Service]
Environment="OLLAMA_KEEP_ALIVE=30m"
Environment="OLLAMA_NUM_PARALLEL=4"
OLLAMA_NUM_PARALLEL 控制同一模型并行处理的请求数。默认值是 1,意味着同一时刻只有一个请求在推理,其他请求只能排队。如果服务并发量高,并行度不提上去,后面的请求等待时间照样爆炸。如果你显存有富余,调到 2 到 4 是安全的。
OLLAMA 还支持 OLLAMA_MAX_LOADED_MODELS,默认同时最多加载 3 个模型。如果你的服务只用一个模型,这个值设成 1 就行,避免 Ollama 自作聪明把正在用的模型卸载去加载别的。
4. 第三步优化:工程侧减延迟与高并发兜底
模型和流式都优化完之后,延迟大头基本解决。接下来要处理的是 Java 服务本身的网络开销、并发承载能力,以及那些不能改成流式的场景该怎么办。
4.1 连接复用、超时与并发控制
WebClient 默认使用连接池,连接会被复用,localhost 场景下连接建立的开销可以忽略。但有两类问题需要主动处理。
第一类是超时配置。Ollama 流式接口的响应时间会很长,如果 WebClient 默认超时设置得太短,会把正常的生成过程误判为超时。我之前遇到过 30 秒超时设置下,长回复到一半直接断连的情况。给 WebClient 配置一个宽松的响应超时:
java复制@Bean
public WebClient ollamaWebClient() {
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofMinutes(2))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);
return WebClient.builder()
.baseUrl("http://localhost:11434")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.build();
}
注意这里的 HttpClient 是 reactor.netty.http.client.HttpClient,不是 JDK 的那个。
第二类是并发控制。Ollama 即使设置了 OLLAMA_NUM_PARALLEL,单卡并行度也有限。如果 Java 服务无脑把所有请求打进 Ollama,超过 Ollama 的并行处理能力后,多余请求只能排队。排队等待时间会直接叠加到用户感知延迟上。
更严重的是,如果每个请求都串行等待且前端还开着长连接,线程资源会被大量占用。Java 21 的虚拟线程对这个场景是利好,Spring Boot 3.2 及以上版本支持虚拟线程,开启方式:
properties复制spring.threads.virtual.enabled=true
但虚拟线程并不能解决 Ollama 的物理并行瓶颈,还是得在 Java 层做信号量限流:
java复制@Configuration
public class SemaphoreConfig {
@Bean
public Semaphore ollamaSemaphore() {
return new Semaphore(2);
}
}
Service 层在发起调用前先获取信号量,拿不到就直接返回 429 或者排队提示,不要无限堆积。
4.2 不能流式的场景:异步任务加状态查询
有些业务场景确实没办法用流式,比如上游系统要求接口返回完整 JSON 结果。这时候如果还追求 500ms,基本是无解。除非模型很小,输出很短,否则 7B 模型生成 100 个 token 注定了秒级响应。
这种场景我的建议是:改架构,把同步响应改成异步任务加查询接口。
具体做法:
- 请求进来时,创建一条任务记录,状态为 PROCESSING,返回任务 ID。
- 后台线程异步调用 Ollama,生成完毕后再回写数据库。
- 前端或调用方拿任务 ID 轮询查询接口,直到状态变为 SUCCESS。
这样接口响应时间能控制在 10ms 以内,模型生成耗时 5 秒和接口没任何关系。用户体验上,拿到任务 ID 后可以立即显示“生成中”,比傻等一个不可预期的长连接要友好得多。
任务表设计不用太复杂:
sql复制CREATE TABLE ai_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL,
prompt TEXT,
result TEXT,
error_msg VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL
);
这个思路本质上是用异步化把“延迟”从接口层挪走,对用户体验的伤害反而小。
4.3 缓存与路由:不是所有请求都需要大模型
大模型推理永远比普通接口慢,所以最激进的优化就是让一部分请求根本不打到模型。
我观察过很多接入大模型的应用,用户提问的重复率其实不低。比如客服系统里的“如何退款”“发货时间多久”这类高频问题,模型回答每次一字不差,何苦每次都算一遍。
两个方案:
- 结果缓存:把“prompt 哈希值 -> 完整回复”缓存到 Redis,命中直接返回。
- 语义缓存:对 prompt 做 embedding,相似度超过阈值的请求直接复用历史结果。这个略复杂,但命中率更高。
另外,如果业务有大量简单查询类问题,可以先用规则引擎或一个小模型做路由分流。简单问题走规则或小模型,复杂推理才走大模型。这个方案能显著降低整体平均延迟。
5. 常见故障与排查技巧实录
这一节把我在实际项目里遇到过的典型问题整理一下,很多问题你看日志根本看不出来,只有逐层定位才找得到。
5.1 越调越慢的典型错误
- 模型加载反复触发:没有设置 keep_alive,模型每隔几分钟就被卸载,接口时快时慢。排查方法是连续调用两次接口,如果第一次明显慢于第二次,基本就是这个原因。
- 上下文窗口开得过大:有人在 options 里把 num_ctx 设成 8192 甚至 16384,模型的预填充时间成倍增长,TTFT 直接劣化到 1 秒以上。你生成 50 个字的回答,根本用不到 8K 上下文。
- 并发参数盲目调大:OLLAMA_NUM_PARALLEL 设成 8,但显存根本装不下 8 个并行序列的 KV cache,Ollama 只能拿部分请求排队,甚至导致显存溢出。显存不够的情况下,并行度 1 的稳定性远比并行度 4 的偶发卡顿好。
- SSE 超时设置太短:前端 fetch 默认没有超时,但如果你用了 axios 或者后端网关有超时配置,长回复很容易被中途切断,客户端表现是只收到一半内容。
5.2 这张速查表帮你快速定位问题
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| 第一次请求非常慢,第二次明显快 | keep_alive 没设置,模型冷加载 | 查看 Ollama 日志,观察模型加载记录 |
| 接口始终 5 秒以上,但模型单独运行正常 | 同步等待完整输出,而不是流式 | 检查响应是否使用 Flux / SSE |
| 偶尔极慢,伴随显存溢出 | OLLAMA_NUM_PARALLEL 设置过大 | nvidia-smi 观察显存使用 |
| 输出被截断 | WebClient 超时或网关超时 | 查看服务端日志 TimeoutException |
| 长 prompt 时 TTFT 明显走高 | 上下文窗口太大或 prompt 太长 | 缩减 num_ctx,精简 system prompt |
排查的时候有几个好用的命令。Ollama 自身的日志在 Linux 下用 journalctl 看:
bash复制journalctl -u ollama -f
看模型加载耗时和推理耗时,直接 curl 测试裸接口:
bash复制curl http://localhost:11434/api/generate -d '{
"model": "qwen2.5:7b",
"prompt": "你好",
"stream": false
}'
返回 JSON 里的 total_duration、load_duration、prompt_eval_count 字段会把每一段时间都算给你。load_duration 高就是模型加载问题,prompt_eval_count 大就是 prompt 太长。用这个数据说话,别靠猜。
Java 服务层也建议打一下 TTFT 指标,Spring Boot Actuator 原生支持 Micrometer,自定义一个 Timer 很简单:
java复制Timer.Sample sample = Timer.start(meterRegistry);
// 等待首个 token
sample.stop(timer);
把 TTFT 和生成总耗时两个指标都暴露给 Prometheus,优化效果一目了然。
我个人在实际项目里的最终配置是:Qwen2.5 7B Q4_K_M 模型,keep_alive 设为 -1,num_ctx 2048,OLLAMA_NUM_PARALLEL 设 2,Java 服务层信号量限流 2,接口统一走 SSE 流式。实测平均 TTFT 在 280ms 左右,20 个并发下也能维持在 450ms 上下。如果你正在被 Ollama 的延迟折磨,按这个顺序一步步调,效果会很明显。
