ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot 3 接入 Ollama:延迟从 5 秒优化到 500ms

Spring Boot 3 接入 Ollama:延迟从 5 秒优化到 500ms 1. 先拆延迟账5秒到底是模型慢还是架构偷懒1.1 一次Ollama调用从HTTP进入到token生成经历了什么项目接了个需求Spring Boot 3 服务要接 Ollama 跑本地大模型推理结果第一版接口响应稳定在5秒以上。团队第一反应基本都是换显卡但我花了几天把链路拆开之后发现问题不全在算力更在于我们一直在用同步接口等完整回复。先看一次典型的同步调用发生了什么。用户点击发送前端把请求打到 Spring Boot 接口Spring Boot 通过 HTTP 调用 Ollama 的/api/chatOllama 把 prompt 交给本地模型模型先做prefill也就是把整段输入文本编码成 KV Cache然后逐个生成 token最后把所有 token 拼成一段 JSON 返回到 Spring Boot再返回浏览器。在这个过程里Spring Boot 本身几乎不耗时耗时大头是模型推理。模型生成速度有一个很粗略的公式可以参考单token生成时间约等于模型每生成一个token需要读取的权重字节量除以显存带宽。我用 Qwen2.5 7B 的 Q4_K_M 量化版模型文件约4.7GBRTX 3060 的显存带宽约360GB/s理论极限可以到70 token/s左右但实际跑起来只有20到30 token/s。为什么差这么多因为实际生成还要做采样、softmax、KV Cache读写而且显存带宽不是时刻跑满加上任务本身有调度开销。按25 token/s算输出100个token就要4秒。所以如果你的接口是同步等待完整结果5秒以上是非常正常的物理结果。这不是 Spring Boot 代码写得不好也不是 Ollama 配置有问题而是模型推理本身的耗时摆在那里。1.2 降到500ms的真实目标首字节/首包不是完整回复很多人一看到500ms以内就以为要把完整回答压到500ms这个目标在本地小显卡上几乎不可能。7B模型即使再优化输出几百字也需要好几秒。真正应该优化的是用户从提交请求到看到第一个字的时间也就是TTFTTime To First Token。这跟去餐厅吃饭一样。你点完菜之后最焦虑的是厨师什么时候开始做我的菜而不是整顿饭什么时候吃完。如果服务员在30秒内先把第一道凉菜端上来你的等待焦虑就会大幅下降。大模型接口也是这个逻辑先用流式输出把第一段文本尽快送到前端后续的 token 再逐步滚动出现用户的感知就不是卡了5秒而是内容正在生成中。从产品体验层面来说200ms 之内用户基本无感知500ms 是能够接受的边界超过1秒就必须给 loading 状态。我们做本地大模型接口应该把 500ms 定义为首包返回时间而不是完整结果返回时间。这个目标才是可实现的。1.3 延迟预算表先把目标定明白再动手改代码我建议先把两种方案的目标量化出来方案模型加载prefill/TTFT生成100token用户感知首屏同步调用 streamfalse冷启动可能3-10秒约1秒约4秒完整结果回来后约5秒流式SSE streamtrue常驻后可忽略约600-900ms首包后持续输出约1秒内看到前几字语义缓存命中无需推理无需推理无需推理约100-200ms流式缓存混合无需推理命中时无 prefill无稳定500ms内这里有一个关键认知模型常驻之后冷启动加载时间可以忽略掉但 prefill 和生成是省不掉的。所以你要把优化重点放在三件事上让模型常驻、让首包早到、让高频问题根本不要走推理。2. Spring Boot 3 接 Ollama先选对调用姿势2.1 Ollama 暴露的是普通HTTP接口Ollama 本身是一个本地服务监听11434端口本质上就是一组 HTTP 接口。最常用的是这两个POST /api/chat聊天补全接口支持消息列表、模型参数、流式返回。POST /api/embed文本向量化接口返回 embedding做语义缓存时会用到。GET /api/tags列出本机已安装模型。一个最简单的非流式调用长这样curl http://127.0.0.1:11434/api/chat \ -H Content-Type: application/json \ -d { model: qwen2.5:7b, messages: [{role: user, content: 你好}], stream: false, options: { temperature: 0.7, num_predict: 256 } }分析这个请求可以发现Spring Boot 这层其实只是一个转发层。所以我们完全没必要引入特别重的依赖自己封装一个客户端反而更透明、更好排查问题。2.2 RestClient、WebClient 还是 Spring AISpring Boot 3.2 起官方推荐RestClient做同步 HTTP 调用它的 API 比 RestTemplate 舒服得多。但这里有个坑RestClient只能同步拿完整响应做不了流式逐行读取。所以如果你要用 SSE 流式输出必须上WebClient它是 Spring WebFlux 的响应式客户端。如果你不打算引入 WebFlux 全家桶可以单独引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency然后自己构建一个没有 WebFlux 业务侵入的 WebClientWebClient webClient WebClient.builder() .baseUrl(http://127.0.0.1:11434) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build();至于 Spring AI它确实提供了spring-ai-ollama-spring-boot-starter这类封装用起来非常省事ChatClient 一行就能调模型。但它现在是快速迭代阶段版本升级容易踩坑而且流式返回的细节封装了一层之后排查问题会更绕。我个人的习惯是项目不大就不要引入手动封装一个OllamaClient就够了。2.3 流式响应必须走对通道别掉进 blocking 陷阱很多人在 Spring MVC 里写流式接口方法声明是FluxString但内部调用 Ollama 却用了 RestClient 同步等待最后依然卡了几秒。这是典型的名义响应式实际阻塞。正确的做法是让方法返回Flux内部用 WebClient 把 Ollama 的text/event-stream响应转成数据流。我的最小封装如下public FluxChatStreamChunk chatStream(String model, ListMapString, String messages) { return webClient.post() .uri(/api/chat) .bodyValue(Map.of( model, model, messages, messages, stream, true, options, Map.of(temperature, 0.7, num_predict, 512) )) .retrieve() .bodyToFlux(ServerSentEvent.class) .filter(Objects::nonNull) .mapNotNull(event - { String data (String) event.data(); if (data null || data.isBlank()) return null; return parseChatChunk(data); }); }这里要注意Ollama 的/api/chat流式响应是data: {json}这种格式WebClient 用ServerSentEvent可以帮你把data:前缀去掉然后你再去解析 JSON。如果接口要在 Postman 或 Reqable 里调试看到的就是一行一行的事件流。3. 第一阶段优化SSE流式返回让首包率先进来3.1 服务端SSE接口从 String 拼装改成 Flux 推送改成流式之后Spring Boot 接口直接返回FluxChatStreamChunk并且指定produces MediaType.TEXT_EVENT_STREAM_VALUEPostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxChatStreamChunk chatStream(RequestBody ChatRequest request) { return ollamaClient.chatStream(request.model(), request.messages()); }前端拿到的不再是一整个 JSON而是一连串事件。这样用户看到的第一个字理论上就是 Ollama 生成第一个 token 的时间再加上网络传输时间。实测下来收益非常大。3.2 前端接入fetch ReadableStream 而不是 EventSource有经验的开发者第一反应是用EventSource但EventSource只支持 GET 请求而大模型聊天几乎都是 POST。所以这里要用fetch结合ReadableStream读流const resp await fetch(/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: qwen2.5:7b, messages }), }); const reader resp.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); const lines chunk.split(\n).filter(l l.startsWith(data: )); for (const line of lines) { const json JSON.parse(line.slice(6)); appendText(json.message.content); } }这里有个很容易踩的坑resp.body.getReader()拿到的字节流需要自己按\n拆行。因为数据是被分片传输的一个完整的data:行可能被拆到两个 chunk 里所以严谨做法是把decoder.decode(value, { stream: true })的返回值缓存起来拼接到 buffer 里再按行分割。否则会出现偶发的解析失败。3.3 网关缓冲关了吗这是SSE首包延迟的隐藏杀手代码都写对了本地测试也很快结果一上 Nginx 还是慢第一包要等两三秒。这个坑我在生产环境踩过Nginx 默认会缓冲后端响应。SSE 是长连接持续写如果proxy_buffering开着Nginx 会把后端发来的数据攒到一定量才转发给浏览器导致首包迟迟不出现。需要在反向代理配置里显式关闭location /chat/stream { proxy_pass http://spring-app:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; proxy_http_version 1.1; }尤其是proxy_buffering off这个配置不关掉的话Ollama 生成得再快也白搭。这个检查项应该排在所有性能排查的前面。4. 第二阶段优化常驻模型、KV缓存复用和Ollama参数4.1 模型常驻keep_alive 与预热请求流式接口改完之后首包可能还在800ms到1秒之间。再往下挖发现很大一部分时间浪费在模型加载上。Ollama 的行为是如果一个模型闲置时间过长会被卸载下一次请求又要重新从磁盘加载到显存。7B 模型加载到显存可能花3到10秒这个时间对用户来说是毁灭性的。解决办法是让模型常驻。推荐设置环境变量OLLAMA_KEEP_ALIVE24h OLLAMA_MAX_LOADED_MODELS1OLLAMA_KEEP_ALIVE控制模型在内存/显存中的驻留时间24h意味着至少一天内不卸载。OLLAMA_MAX_LOADED_MODELS1是避免同时加载多个模型防止显存不够导致模型被频繁换进换出。还有一个非常实用的预热技巧Spring Boot 启动完成后主动向 Ollama 发一个空请求或者非常短的消息让模型先加载到显存。比如Component public class OllamaWarmUpRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) { ollamaClient.chat(qwen2.5:7b, List.of(Map.of(role, user, content, hi))); } }预热请求发的消息越短越好目的只是触发模型加载。这样服务起来之后第一个真实用户的请求就不会再撞上几秒钟的冷启动。4.2 上下文复用prompt缓存原理与system prompt规范模型常驻之后首包还是不够快那就要看 prefill 了。prefill 的耗时与输入长度强相关。Ollama 有一个很有用的机制如果两次请求的 prompt 前缀一致它会复用之前算好的 KV Cache不需要重新编码整段前缀。这意味着如果你每个请求都带很长的 system prompt并且 system prompt 放在消息列表最前面且保持完全一致Ollama 可以缓存这部分计算结果后续请求只需要对新输入的部分做 prefill。实践上要注意不要每次动态拼接 system prompt不要在前缀位置插入当前时间等易变内容否则前缀一变缓存就失效。多轮对话场景尤其明显。第一次请求要编码全部历史消息耗时高后续请求只编码新消息速度会快很多。这也是为什么我建议把系统提示、沉默指令这些稳定内容固定放在最前面聊天记录顺序不要随意改。4.3 别盲目调高 num_ctx显存、prefill 与首字延迟的三角关系Ollama 的num_ctx控制上下文窗口大小默认 2048 还是 4096 取决于版本。很多人为了追新模型动辄设置成 128K但本地显存根本吃不消而且 prefill 速度拖慢首字延迟跟着飙升。因为上下文窗口越大KV Cache 占用显存越多能并行的 batch 也会受影响。我的建议是按实际业务场景设置 num_ctx。如果你的问答只需要单轮、长度不超过几千字设成 4096 或 8192 就够了。同时可以把num_batch调大一些比如 512让 prefill 阶段能够一次处理更多 token缩短 TTFT。综合参数示例options: { num_ctx: 8192, num_batch: 512, temperature: 0.7, num_predict: 512 }这些参数对首包延迟的影响比你换一张显卡更立竿见影。5. 第三阶段优化语义缓存和结果复用让高频问题不经推理5.1 哪些请求值得缓存流式输出和模型常驻解决问题的一部分但仍有个尴尬场景同一个高频问题比如客服场景里你们营业时间是几点每次都要让模型重新推理虽然首包快但用户还是要等几百毫秒而且 GPU 还在空转。认真分析业务会发现真正需要大模型的任务往往没有想象中那么多。很多请求是固定模板填充、短问题询问、内容总结而这些结果完全可以缓存。判断标准有三个问题是否重复或者是否可以用语义相似度命中。结果是否需要严格实时比如股市数据就不能缓存。模型参数是否固定如果temperature设置为 0结果确定性高非常适合缓存。5.2 一个简单可落地的语义缓存实现我做的方案是把用户输入编码成 embedding然后用向量相似度在缓存里找最近的历史答案。命中就直接返回完全不碰 Ollama 的生成接口。整体结构是nomic-embed-text做输入向量化Ollama 本地跑一个/api/embed就行。用 Caffeine 存历史答案键是 prompt hash值是答案内容。用内存里的 List 存向量每次新请求来了线性扫描 TopK数据量不大时完全够用。核心代码逻辑如下public OptionalChatResult searchCache(String normalizedPrompt, float[] queryVector) { return vectorStore.topK(queryVector, 3).stream() .filter(entry - cosineSimilarity(queryVector, entry.embedding()) 0.97) .findFirst() .map(entry - entry.result()); }阈值0.97需要根据自己的业务调。调太高命中率低调太低语义差很远的问题也会命中错误答案。我建议先用几十条真实问题跑一遍看相似度分布在哪个区间再确定阈值。向量化一个短问题的耗时通常在一两百毫秒所以语义缓存命中的总延迟在 200ms 以内轻松进入 500ms 目标。即使缓存没命中也只是多花一次 embedding 的耗时而已不会拖慢主流程。5.3 缓存之外还要有限流和降级缓存写完了要注意一个问题更容易把模型打满的是没被缓存的大量新问题。本地显卡并发能力有限如果突然涌来 20 个新问题Ollama 会排队每个请求的首包时间都会恶化。这时候要用信号量做并发控制。我在 Spring 层面控制最多同时 4 个推理请求进到 Ollama超过的请求先等待避免雪崩Service public class OllamaRateLimiter { private final Semaphore semaphore new Semaphore(4); public T T acquire(CallableT action) throws Exception { semaphore.acquire(); try { return action.call(); } finally { semaphore.release(); } } }配合业务降级策略服务过载时对缓存未命中的请求返回一个占位提示系统繁忙请稍后再试或者改成异步任务。对于本地大模型服务来说保活永远比追求极致准确率重要。6. Ollama侧部署与并发调优实操清单6.1 模型选型7B、8B、14B如何选量化到哪一档我在热搜词里看到很多人问ollama本地部署大模型哪个模型最佳这个问题没有一个统一答案但有一个相对稳妥的选择路径。任务类型推荐模型显存参考中文日常问答Qwen2.5 7B Instruct约5GB通用对话、摘要Llama 3.1 8B约5-6GB复杂推理、代码Qwen2.5 14B约10GB更强推理DeepSeek-R1-Distill-Qwen-7B约5-6GB从性价比角度看Qwen2.5 7B 或 Qwen3 8B 是很稳的选择。显存紧张就不要上 14B因为一旦放不下模型Ollama 会用 CPU 量化推理速度断崖式下跌反而比小模型更慢。量化方面优先选Q4_K_M。它是速度、显存占用和质量的平衡点Ollama 官方库很多模型默认就是这个量化等级。Q8质量更高但显存占用几乎翻倍推理速度下降对大多数业务收益并不明显。用ollama show qwen2.5:7b可以看到当前模型的参数量、量化方式和文件大小部署前一定要检查。6.2 环境变量与多实例负载均衡Ollama 服务本身可以调整的环境变量我整理了一份常用清单OLLAMA_KEEP_ALIVE24h OLLAMA_MAX_LOADED_MODELS1 OLLAMA_NUM_PARALLEL4 OLLAMA_FLASH_ATTENTION1 OLLAMA_HOST0.0.0.0OLLAMA_NUM_PARALLEL允许同一个模型同时处理多个请求但要注意并行度越高单个请求延迟越慢。它更适合允许慢一点但吞吐高的场景。如果业务对首包延迟敏感建议不要超过 4。如果用户量大一台机器确实扛不住可以考虑部署多个 Ollama 实例然后在 Spring Boot 侧做简单轮询。比如规划两个节点每个节点加载同一个模型用列表保存地址private final ListString ollamaBaseUrls List.of( http://ollama-1:11434, http://ollama-2:11434 ); private int index 0; private String nextUrl() { return ollamaBaseUrls.get((index) % ollamaBaseUrls.size()); }这样每个请求都从当前节点取一个整体吞吐翻倍单实例压力减轻延迟自然更稳定。注意两个节点要在模型和参数上保持一致否则用户会感觉到回复风格飘忽。6.3 模型下载慢/导入GGUF的落地经验热搜里还有一堆关于ollama下载太慢国内镜像源模型离线下载的问题。这个问题在部署阶段非常现实Ollama 拉取模型走的是官方分发通道国内网络环境经常不稳定。我推荐的做法是找一台网络顺畅的机器先把模型下载好或者从可信任的镜像站拿到模型文件然后通过内网传到目标机器。离线安装有两种方式直接把~/.ollama/models目录拷贝到目标机器对应目录。如果拿到的是 GGUF 文件可以写一个 Modelfile 导入FROM /data/models/mymodel.gguf然后执行ollama create mymodel -f Modelfile这样就不依赖外部下载通道了。模型文件通常 4GB 以上传输用内网 HTTP 服务或者 scp 都比反复重试官方下载可靠。生产环境建议把模型文件纳入部署资产管理而不是每次手工 pull。7. 压测复盘从5720ms到380ms的具体过程7.1 基线测试同步调用完全不可行我把整个优化过程完整记录下来。测试环境Spring Boot 3.3.2Ollama 0.3.xQwen2.5 7B Instruct Q4_K_M单张 RTX 3060 12GB。输入 prompt 约500字要求输出约100字。第一版代码用的是RestTemplate调/api/chat的streamfalse从浏览器看到接口返回完整耗时约5720ms。从日志拆解看到模型加载耗时约 800msprefill 约 1.2秒生成 100 个 token 约 3.5 秒。这个结果说明任何同步等全量文本的接口都注定无法满足 500ms 目标。7.2 每一段优化带来的真实收益第一阶段切到 SSE 流式输出后首包时间降到约 800ms。这个收益很大因为用户至少马上能看到文字在动。第二阶段设置OLLAMA_KEEP_ALIVE24h并加了启动预热模型不再频繁加载首包稳定在 600ms 左右。然后我把num_ctx从默认值下调到 8192num_batch设为 512首包进一步降到 500ms 上下。第三阶段加上语义缓存效果是最明显的。高频问题第一次未命中时走推理首包 500ms第二次起直接命中缓存整体响应在380ms附近。而且这个时间包含了 embedding 计算、向量相似度扫描、Caffeine 读取全程不碰 GPU 生成负载也能降下来。阶段用户感知首包完整回答同步调用基线5720ms5720ms切SSE流式约800ms看生成速度模型常驻参数调整约500ms看生成速度语义缓存命中约380ms380ms直接返回7.3 调优顺序建议先流式再常驻最后缓存最后分享一个调优顺序的排坑经验。如果让我重来一次我会先做 SSE 流式再做模型常驻和num_ctx调整最后才写语义缓存。原因很简单前两步是基础体验兜底任何请求都能受益语义缓存只对高频重复问题有效但它能让你在某些场景下做到肉眼可见的快。很多团队一上来就搞缓存、搞并发结果核心链路还是同步等待用户该卡还是卡。还有个小细节压测时不要只看平均响应时间要看 P95 和 P99。本地大模型服务最怕抖动一次模型换入换出就能把 P99 拉到几秒。我就是在压测脚本里加了冷启动检查连续请求 20 次看最后一次和首次的差异冷启动问题立刻暴露。这套组合拳打下来从5秒以上到稳定500ms内是完全可以实现的重点不是显卡多强而是你是否把首包时间和完整时间这两件事分开对待。
返回列表