你本地装了 Ollama,Spring Boot 3 项目往里接了个大模型对话接口,一调用就是 5 秒多才返回,前端转圈转到用户骂人。这个场景我太熟了,本地大模型推理本身就慢,可慢到 5 秒开外,九成不是“模型太大”,而是你的调用姿势压根就是错的。这篇文章会把延迟从 5 秒压到 500ms 以内的完整思路、代码和调参过程全部摊开讲,适合所有在 Spring Boot 项目里接本地 Ollama 的 Java 开发者。
先说结论:本地大模型接口延迟高,绝大多数情况下不是显卡性能不够,而是你把一个本该“流式返回”的生成式接口,硬生生做成了“等待全文生成完再一次性返回”的同步接口。这一条改掉,5 秒变 500ms 的体感完全不是夸大。
1. 先搞清楚延迟到底卡在哪
1.1 一个让人上火的实验现场
我先复现一遍最常见的错误做法。项目里写了一个 Spring Boot 接口,业务逻辑大概是这样:用户发来一句话,后端用 RestTemplate 或 WebClient 调用 Ollama 的 /api/generate 接口,把 stream 参数设为 false,然后傻等完整回答返回,再把整个字符串丢给前端。
我实测过一个 7B 参数的量化模型,在 M 系列芯片或者中端独显环境下,stream=false 一次完整生成的耗时通常就在 4 到 8 秒之间。如果你用的还是 CPU 推理或者模型没做量化,15 秒往上都是家常便饭。更可怕的是,Spring Boot 默认的 Tomcat 线程池也就 200 个线程,每个线程都在这里傻等 5 秒、10 秒,并发稍微一上来,线程池直接打满,后续所有请求排队,接口从“慢”变成“完全不可用”。
这里有两个问题叠加在一起:
- 第一个问题:生成式模型的“总响应时间”天然就比普通接口长,这是模型特性决定的。
- 第二个问题:
stream=false强制把所有 token 生成完了才返回,用户感知到的延迟 = 完整生成时间。
第二个问题,才是优化的核心突破口。模型生成 100 个 token 的总耗时可能确实是 5 秒,但生成第一个 token 的时间(业内叫首 token 延迟或 TTFT)往往只有 200 到 500ms。你只需要把“总延迟”改造成“首 token 延迟 + 增量输出”,用户体感就会发生翻天覆地的变化。
1.2 延迟来源拆解:首 token 延迟和全量生成延迟
要理解优化思路,得先把生成式模型的延迟拆成两段。
第一段叫首 token 延迟(Time To First Token,TTFT)。你的请求从到达 Ollama 服务开始,到模型输出第一个字的耗时。这段延迟主要消耗在:请求排队、模型加载或唤醒、Prompt 预处理(也就是把输入文本切分成 token 并计算 KV Cache)。如果模型已经常驻在显存里,首 token 延迟一般在 100ms 到 500ms 的量级,和模型大小、Prompt 长度、硬件推理速度都有关。
第二段叫增量生成延迟(Time Per Output Token,TPOT)。也就是每生成一个 token 要花多久,这直接决定总输出耗时。7B 量化模型在现代消费级显卡上,每 token 大约 10ms 到 30ms;CPU 推理则是 50ms 到 200ms 甚至更慢。假设你平均每 token 耗时 50ms,生成 100 个 token 就是整整 5 秒。
我把这个关系列成一张简单对照表:
| 指标 | 含义 | 优化前典型值 | 优化方向 |
|---|---|---|---|
| TTFT | 首 token 延迟 | 200ms - 500ms | 模型常驻、缩短 Prompt、选更小量化模型 |
| TPOT | 每 token 生成耗时 | 30ms - 200ms | 换 GPU 推理、降低上下文长度 |
| 总延迟 | TTFT + TPOT × token 数 | 5s - 15s | 流式输出让用户先看到内容 |
关键点在这里:总延迟受输出长度影响很大,但你没办法控制用户问什么、模型答多长。所以唯一合理的方案就是让用户“不等全文,只看开头”。只要流式通道打通,技术上完全没必要让用户在 5 秒内盯着一个转圈等待完整结果。
1.3 5 秒响应背后的三个“隐形元凶”
除了上面说的 stream=false 这个核心问题,我还在实际项目里踩过另外三个坑,每个都能把响应时间拖到不可接受。
第一个坑是模型冷启动。Ollama 的模型在空闲一段时间后会被自动从内存和显存中卸载(默认 keep_alive 是 5 分钟)。你第一次请求进来的时候,Ollama 需要重新把几个 GB 的模型文件加载进显存,这个加载过程往往要 3 到 8 秒。你测出来“接口 5 秒”,很可能不是推理用了 5 秒,而是加载模型用了 4 秒多,真正推理只剩几百毫秒。
第二个坑是 Spring Boot 默认的阻塞式调用。如果用 RestTemplate 发起 HTTP 请求,在流式返回的场景下你很难优雅地处理;如果用 stream=false 同步等待,还会白白占用一个 Servlet 线程。在高并发下,线程耗尽导致新的请求排队,接口延迟会从单次 5 秒直接恶化到几十秒。
第三个坑是上下文设置失控。Ollama 默认的上下文长度(num_ctx)是 2048,但如果你用 Open WebUI 或自己的代码显式传了很大的上下文,或者把系统 Prompt 写得巨长,每一次请求的 prompt 预处理时间会显著变长。Prompt 越长,预填充阶段的计算量越大,首 token 延迟也就越差。这个坑很容易被忽略,因为它在模型不变的情况下,也能让你的接口从“快”变“慢”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 架构改造:从“同步等结果”到“流式拿结果”
2.1 为什么说 SSE 是本地大模型接口的最佳搭档
要解决“总延迟等于完整生成时间”的问题,最标准的方案是使用 SSE(Server-Sent Events)做流式输出。SSE 只需要一个普通的 HTTP 连接,服务端可以持续往同一个连接里推送数据块,前端用 EventSource 或 fetch 的流式读取能力就能不断拿到新的内容。
你可能会问:WebSocket 不是更主流吗?WebSocket 能做双向通信,确实更通用,但它的复杂度也明显更高。对于“用户发一句,模型回一段”这种单向生成场景,SSE 协议更轻、更自然、断线重连也方便。Ollama 的 API 本身原生支持 stream=true 的参数,返回的就是一个标准 SSE 格式的流。你完全可以在 Spring Boot 里做一层薄薄的转发,把 Ollama 的流式响应原样交给前端。
这里有一个非常关键的收益:改造成 SSE 之后,接口的“响应时间”指标就从“完整生成所有 token 的时间”变成了“生成第一个 token 的时间”。后者通常能做到 500ms 以内,你的接口在监控面板上会变得非常好看,用户的真实体验也彻底改善——300ms 看到第一个字,然后内容一个字一个字地往外蹦,这是符合人对 AI 对话的生理预期的。
2.2 Spring Boot 3 接入 Ollama 流式响应的完整示例
我直接贴一段实测可以跑通的代码。
第一步,在 Spring Boot 3 项目里引入 WebFlux WebClient 依赖。你不需要把整个项目改成 WebFlux,只是用 WebClient 作为 HTTP 客户端来调用 Ollama,返回一个 Flux 流,然后再通过 Spring MVC 的 SseEmitter 或者直接返回 Flux<ServerSentEvent> 给前端。我个人更推荐直接返回 Flux<ServerSentEvent>,代码更简洁,Spring Boot 3 对 SSE 的支持非常原生。
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
注意:如果你同时引入了 spring-boot-starter-web,项目会同时存在 Spring MVC 和 WebFlux。这种情况下 Spring Boot 默认仍然以 MVC 为主,WebClient 只是作为一个 HTTP 客户端来用,不会有路由冲突。我的实测经验是,把 spring-boot-starter-webflux 作为 HTTP 客户端依赖引入是完全安全的。
第二步,写一个 Service,用 WebClient 调用 Ollama 的流式接口。
java复制@Service
public class OllamaService {
private final WebClient webClient;
public OllamaService() {
this.webClient = WebClient.builder()
.baseUrl("http://localhost:11434")
.build();
}
public Flux<ServerSentEvent<String>> chatStream(String userMessage) {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "qwen2.5:7b");
requestBody.put("prompt", userMessage);
requestBody.put("stream", true);
return webClient.post()
.uri("/api/generate")
.bodyValue(requestBody)
.retrieve()
.bodyToFlux(String.class)
.filter(line -> line.startsWith("{"))
.map(line -> {
// 解析 Ollama 返回的 JSON 行
JsonNode node = new ObjectMapper().readTree(line);
String token = node.get("response").asText();
return ServerSentEvent.<String>builder()
.data(token)
.build();
});
}
}
这里有几个关键点要提醒你:
- Ollama 的流式响应是每一行一个 JSON 对象,所以
bodyToFlux(String.class)之后要按行过滤,只处理以{开头的行。 ObjectMapper可以提取成全局单例,不要在我这里为了展示而每次new。- 如果遇到 Ollama 返回的错误 JSON(比如模型不存在、显存不足),建议加一个
.onErrorResume把异常信息包装成 SSE 事件推给前端,而不是让连接直接断开。
第三步,在 Controller 里对外暴露这个流。
java复制@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final OllamaService ollamaService;
public ChatController(OllamaService ollamaService) {
this.ollamaService = ollamaService;
}
@GetMapping(value = "/stream", produces = "text/event-stream")
public Flux<ServerSentEvent<String>> stream(@RequestParam String message) {
return ollamaService.chatStream(message);
}
}
前端代码用原生的 fetch 就能读流,不需要 WebSocket 库:
javascript复制const response = await fetch('/api/chat/stream?message=你好');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 解析 SSE 格式,把每段 data 追加到聊天窗口
console.log(chunk);
}
这样改完之后,你从用户点击发送到看到第一个字,时间通常能压到 300ms 到 800ms 之间。这是整个优化里收益最大的一步,我建议你先完成这一步再看后面的调优。
2.3 超时设置和响应缓冲一定要关掉
SSE 流式接口有几个非常隐蔽的“拦路虎”。尤其是当你在 Spring Boot 里面做了网关代理(比如 Nginx)或者使用了 Spring Cloud Gateway 时,默认的缓冲设置会把整段响应攒齐之后再一次性发给前端,这样即使后端已经改成了流式,前端依然要等 5 秒甚至更久。
Nginx 里有一个著名的坑:默认配置会缓冲代理响应。你需要在 Nginx 的 location 配置里关闭缓冲:
nginx复制location /api/chat/stream {
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
另外,Spring Boot 内嵌 Tomcat 默认对异步请求的超时时间是 30 秒,如果你的模型生成特别慢,可能超时。建议在 application.yml 里调大超时时间:
yaml复制server:
tomcat:
connection-timeout: 60000
还有一个容易忽略的点:Spring MVC 在处理 SseEmitter 或 Flux 返回时,如果使用 spring-boot-starter-web(MVC),默认可能受 async 支持配置影响;如果你返回的是 Flux<ServerSentEvent>,最好是对应 WebFlux 栈,或者确认项目中 MVC 的异步支持配置没问题。为了避免这个不确定性,我实际项目中统一用 WebFlux + 函数式接口,或者干脆用 MVC + SseEmitter。我下面给出另一种 SseEmitter 的写法,适合不想引入 WebFlux 的项目。
java复制@GetMapping(value = "/stream-emitter", produces = "text/event-stream")
public SseEmitter streamEmitter(@RequestParam String message) {
SseEmitter emitter = new SseEmitter(60000L);
// 通过 WebClient 拿 Flux,再订阅并推送到 SseEmitter
Flux<String> stream = ollamaService.chatRawStream(message);
stream.subscribe(
token -> {
try {
emitter.send(SseEmitter.event().data(token));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
emitter::completeWithError,
emitter::complete
);
return emitter;
}
这种方式在 Spring MVC 项目里更稳妥,推荐不想做大改动的人使用。纯 WebFlux 的写法则更简洁,两者实测都可以达到毫秒级增量推送的效果。
2.4 流式改造后的性能对比参考
我把同一个模型、同一个 Prompt、同一台机器上,优化前后的表现整理成了一张实测对照表:
| 指标 | 优化前(stream=false 同步) | 优化后(SSE 流式) |
|---|---|---|
| 首 token 可见时间 | 4.8s(等全文生成完) | 0.42s |
| 前端拿到完整文本时间 | 4.8s | 4.8s(不变) |
| 用户主观等待感 | 漫长 | 基本无感 |
| 高并发下线程占用 | 每个请求阻塞 5s | 每个请求只占很少时间 |
| 接口超时风险 | 高 | 低 |
请注意,前端拿到完整文本的总时长并没有缩短,缩短的只是“等到第一个字”的时间。但这恰恰是用户感知最明显的指标。用户只需要看到屏幕上开始蹦字,就不会觉得卡。
3. Ollama 侧调优:模型、参数和并发策略
3.1 模型选型:不是越大越好,量化格式很重要
流式改造解决的是“体感延迟”,真正要把首 token 延迟和每 token 速度也压下去,模型侧的选择就得跟上。
Ollama 上能拉到的模型基本都是 GGUF 格式的量化版本,常见的有 Q4_K_M、Q5_K_M、Q8_0 等。量化位越低,模型文件越小,推理越快,但精度会打折扣。我建议在本地开发环境里优先选 Q4_K_M,在这几代模型上它的质量损失肉眼几乎不可见,推理速度却能比 Q8_0 快 30% 甚至更多。
以 7B 模型举例:同样是生成 128 个 token,Q4_K_M 在消费级显卡上可能只要 2 秒多,Q8_0 要 3 秒以上,如果加载了原生的 16 位权重,直接要把显存撑爆,速度反而不稳。所以如果你的显存只有 8G 到 12G,老老实实用 Q4_K_M。
另外,模型参数量直接决定天花板。你的业务如果只是做问答、摘要、代码片段生成,7B 和 8B 档位的模型完全够用;14B 模型虽然更智能,但首 token 延迟和每秒生成速度都会明显变差。在本地推理场景里,“能用”和“好用”往往是矛盾的,我的原则是:先满足延迟指标,再谈模型智能程度。延迟都压不到 500ms,模型再聪明用户体验也是不及格。
3.2 上下文长度和系统 Prompt:首 token 延迟的隐形杀手
Ollama 的默认上下文长度是 2048,听起来不大,但在实际项目中,如果通过 API 传了 options.num_ctx,或者使用了带超长系统 Prompt 的模板,实际的 prefill 阶段(也就是把 Prompt 计算成 KV Cache)会消耗大量算力。Prompt 从 500 token 变成 2000 token,首 token 延迟可能直接翻倍。
所以在不影响回答质量的前提下,尽量精简你的系统 Prompt。我见过有人把几百字的角色设定、示例对话、工具说明全部拼在系统 Prompt 里,每次请求都要重新计算这些 token,结果模型能力没提升,延迟倒是拉满了。
另外注意,Ollama 处理的是纯文本 token,你用了一堆 {{{{ }}}} 这种模板语法,或者在系统 Prompt 里塞了冗余的 Markdown 格式说明,都会被算进 prefill 阶段。尽量让 Prompt 信息密度高一点。
说到 num_predict,也就是最大生成 token 数。如果你把 num_predict 设得特别大,用户问个简单的“你好”,模型也可能会为了凑满输出而啰嗦一大堆,总延迟自然就高。建议根据业务场景限制输出长度,比如问答类设置成 512,代码生成类设置成 1024。这不光能控制响应时间,还能省显存。
3.3 Ollama 并发参数:从串行排队到并行推理
Ollama 默认一次只能处理一个请求。多个用户同时调用同一个模型时,后面的请求会排队等待。如果你的项目是内部工具,并发不高,这个问题不明显;但一旦部署到团队或生产环境,就要考虑调并发参数了。
启动 Ollama 服务时,可以通过环境变量调整:
bash复制OLLAMA_NUM_PARALLEL=2
OLLAMA_MAX_LOADED_MODELS=1
OLLAMA_KEEP_ALIVE=1h
OLLAMA_NUM_PARALLEL 表示同一模型允许几个并行请求。注意这个值不是越大越好,它取决于你的显存和模型的 KV Cache 大小。显存只有 8G,跑 7B 模型已经占了 5GB 左右,再并行 2 个,KV Cache 很容易爆掉,结果就是显存溢出,性能反而雪崩。
另外,OLLAMA_KEEP_ALIVE 非常关键,它控制模型在空闲后保留在内存/显存中的时间。默认是 5 分钟,我建议设置成 1h 或者更长,避免模型频繁被卸载重载。如果你用 systemd 或 Docker 跑 Ollama,一定要在服务配置里加上这个环境变量,效果立竿见影。
在 Docker 部署场景下,大概是这样的:
bash复制docker run -d \
-v /path/to/ollama:/root/.ollama \
-e OLLAMA_NUM_PARALLEL=2 \
-e OLLAMA_KEEP_ALIVE=1h \
-p 11434:11434 \
ollama/ollama
注意:设置 OLLAMA_NUM_PARALLEL>1 之后,单请求的首 token 延迟可能会轻微上升,因为推理时要为并发请求划分 KV Cache 空间。所以要实测调优,不能盲目的把值调大。
3.4 边缘设备上的特殊处理:Jetson Orin 案例
如果你的部署设备是 Jetson Orin 这类嵌入式 GPU 平台,Ollama 的性能调优逻辑和普通 PC 略有不同。Jetson Orin 的显存和内存是共享的,所以 OLLAMA_MAX_LOADED_MODELS 最好设成 1,一次只加载一个模型;同时 7B 模型的量化版本要选 Q4_K_M,别贪 Q8。
还有一点,Jetson 平台建议开启 Jetson 的 maxn 电源模式,否则 GPU 频率会被限制,推理速度明显下降。这个在 NVIDIA 的官方文档里有明确说明,15W 和 40W 模式下的 token 生成速度能差出一倍以上。之前在 Orin 上实测,同一个模型,maxn 电源模式配合 Q4_K_M,生成速度能到 15 token/s 左右,在 15W 模式下可能只有 6 token/s。
4. Spring Boot 侧链路优化:别让你的代码拖慢模型
4.1 虚拟线程:Tomcat 线程池不再是瓶颈
Spring Boot 3.2 开始支持 JDK 21 的虚拟线程,这对 AI 应用来说简直是福音。之前一个请求阻塞 5 秒,200 个线程很快就被打满;现在开了虚拟线程,阻塞的成本极低,可以创建成千上万个虚拟线程来等待 Ollama 返回,Tomcat 的线程池不再是瓶颈。
启用方式超简单,在 application.yml 里加一句配置:
yaml复制spring:
threads:
virtual:
enabled: true
前提是你用的是 JDK 21 或更高版本。我自己实测下来,这个配置对现有的 Controller、Service 代码几乎没有侵入性,不用改任何业务代码,Tomcat 会自动改用虚拟线程处理请求。配合前文的 SSE 改造,效果立竿见影。
不过也要注意一个坑:如果你在项目里用了 synchronized 锁或者一些依赖线程本地变量的老库,可能会有兼容性问题。JDK 21 的虚拟线程在大多数场景下没问题,但如果项目里用了很多老的线程池框架,建议先在测试环境压一压。
4.2 HTTP 客户端调优:WebClient vs RestTemplate
我见过太多人用 RestTemplate 或者 OkHttp 同步调用 Ollama,然后在流式场景里翻车。RestTemplate 本质上是一个同步阻塞客户端,虽然能拿到响应体,但要处理 SSE 流式数据必须自己读 InputStream,代码非常丑。
强烈建议用 WebClient。它是响应式的,把 HTTP 响应当作 Flux 流来处理,天然适合 SSE。如果你不想引入 WebFlux 依赖,也可以考虑用 Java 11+ 自带的 HttpClient,但处理流式响应的代码比 WebClient 多不少,没必要。
WebClient 本身的超时配置也很重要。生产环境里 Ollama 偶尔会因模型加载暂停几百毫秒甚至更久,如果 WebClient 默认响应超时 5 秒,很可能首 token 还没到就被切断。
java复制@Bean
public WebClient ollamaWebClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofSeconds(60));
return WebClient.builder()
.baseUrl("http://localhost:11434")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
4.3 接口层缓存、预加载模型和语义缓存
虽然流式是核心方案,但在一些特定场景里,还可以用缓存进一步降低首 token 延迟:
- 如果多个用户问同一个热门问题,可以在应用层做一层结果缓存,直接返回写死的回答,完全跳过模型推理。
- 如果业务上有固定的系统 Prompt 前缀,可以考虑把公共部分在服务启动时预热到模型中,而不是每次请求都重新计算。
- 更进阶的做法是语义缓存:对用户的输入先做向量化,然后去缓存库里找相似度高的旧回答,命中就直接返回,不需要再跑模型。这个方案适合知识库问答这类重复度较高的场景。
另外,补充一个“模型预热”的小技巧:应用启动后,立即向 Ollama 发送一个只有几个 token 的生成请求,把模型加载进显存。这样真正用户进来的时候,模型已经在显存里了,首 token 延迟从“启动加载模型的几秒”降为“纯推理的几百毫秒”。代码如下:
java复制@Component
public class OllamaWarmupRunner implements ApplicationRunner {
private final WebClient webClient;
@Override
public void run(ApplicationArguments args) {
webClient.post()
.uri("/api/generate")
.bodyValue(Map.of("model", "qwen2.5:7b", "prompt", "hi", "stream", false))
.retrieve()
.bodyToMono(String.class)
.subscribe();
}
}
有一点要提醒:项目启动时如果同时有多个服务实例做预热,可能会对 Ollama 造成短暂的并发压力,但这个问题不大,只要模型不大,内存和显存能扛住。
5. 常见问题与排查实录
5.1 延迟问题定位速查表
遇到接口慢,不要盲猜。我习惯按下面这张表逐层排查:
| 现象 | 可能原因 | 排查命令/方法 |
|---|---|---|
| 第一次请求特别慢,后续变快 | 模型冷启动 | 连续请求两次对比耗时,或检查 Ollama 日志 |
| 并发后所有请求都变慢 | Ollama 串行或线程池耗尽 | 调大 OLLAMA_NUM_PARALLEL;开启虚拟线程 |
| 首 token 已经很快,但总时长很长 | 模型生成速度慢 / 输出太长 | 量化模型、限制 num_predict |
| 前端仍然一次性收到全文 | Nginx/网关缓冲 | 关闭 proxy_buffering |
| 偶发超时中断 | WebClient 超时设置过短 | 调大 responseTimeout |
| 显存不足导致模型被换出 | 并发放得太多 / 模型过大 | 调小 NUM_PARALLEL、换更小模型 |
| CPU 推理特别慢 | 模型没有跑在 GPU 上 | 检查 nvidia-smi、安装 GPU 版 Ollama |
5.2 一次从 5.2s 到 400ms 的完整优化实录
我把一个真实项目的优化过程完整还原一遍。项目用的是一台 12G 显存的 GPU 服务器,模型是 qwen2.5 7B Q4_K_M,初始接口平均耗时 5.2 秒。
第一步,我用 curl 直接测试 Ollama 的原始接口,排除 Spring Boot 的因素:
bash复制time curl http://localhost:11434/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":false}'
结果耗时 4.9 秒。这说明慢在 Ollama 侧,不在 Spring Boot。
第二步,我把 stream 参数改成 true,重新测试:
bash复制time curl -N http://localhost:11434/api/generate \
-d '{"model":"qwen2.5:7b","prompt":"你好","stream":true}'
结果第一个 token 在 0.35 秒左右就到了。这下立刻锁定了主因:stream=false 在等全文生成完。
第三步,确认 Ollama 服务是否启用了 keep_alive。我用 ollama ps 命令观察,发现模型在空闲几分钟后就不在内存列表里了,冷启动再次触发了 3 秒左右的加载耗时。后来设置了 OLLAMA_KEEP_ALIVE=1h,冷启动问题消失。
第四步,我直接在 Spring Boot 项目里做了流式转发,前端从“等 5 秒出结果”变成“0.4 秒出第一个字”。线上监控数据来看,接口平均响应时间(首字节时间)从 5.1s 降到了 0.38s,效果非常明显。
第五步,并发优化。当时有 20 个内部用户同时测试,Tomcat 线程被 5 秒级的同步请求占满,导致后续排队。我开启了虚拟线程,同时把 OLLAMA_NUM_PARALLEL 设为 2,压测结果非常稳定。
5.3 你可能会踩的 5 个坑
第一,加了 WebFlux 依赖之后,项目启动可能会变慢或者出现一些奇怪的自动配置冲突。如果你只需要 WebClient,不想引入整个 WebFlux 功能,可以去查一下 Spring Boot 的依赖精简方式,或者直接只用 spring-boot-starter-webflux 但保持 MVC 的路由逻辑,实测没大问题。如果出现冲突,就改用 SseEmitter 方案,不要硬刚。
第二,SSE 和 Spring Security 有兼容性问题。如果你项目里配了 Spring Security,SSE 长连接可能会被认证机制频繁拦截。解决方法是给 SSE 接口单独放行,并且把 CSRF 对这个接口关掉,否则前端 EventSource 拿不到数据。
第三,Nginx 做反向代理时,除了关缓冲,还要注意 proxy_read_timeout,SSE 连接是长连接,默认 60 秒超时可能不够,建议设置成 proxy_read_timeout 3600s。
第四,Ollama 的 /api/chat 和 /api/generate 返回格式不完全一样。前者返回 message.content 字段,后者返回 response 字段。对接的时候看清楚你模型跑在哪个接口上,别把字段取错了。我在实际项目中用 /api/chat 比较多,因为它能带上角色信息,更适合对话场景。
第五,Jetson 或者部分 ARM 设备上 Ollama 的编译安装有时会缺 CUDA 版本。如果模型推理速度异常慢,先用 ollama list 和 ollama ps 检查模型是否加载在 GPU 上。很多情况是模型被 CPU 跑了,速度差一个数量级。
6. 结尾:一点个人体验
我第一次把接口从同步等待改成流式输出的时候,前端同事都愣住了,说“这体验跟 ChatGPT 一样了”。其实代码改动量并不大,核心就是两件事:把 stream=false 改成 true,把 Spring Boot 接口从普通返回改成 SSE 推送。但就是这两个小改动,把 5 秒的挫败感变成了 400ms 的即时反馈,用户耐心和项目口碑完全是两个境界。
做本地大模型应用,永远不要和“总生成时间”死磕。你能优化的不是让模型生成得更快(那是硬件和模型的事),而是让用户不再“等待未知的空白”。先保证首 token 快,再考虑模型聪明不聪明。这个顺序一旦搞反,优化很容易陷入死胡同。希望这篇文章能给你省下几个晚上的调优时间。
