ARTICLE / ai

本地LLM延迟优化实录:缓存会撒谎,慢的从来不是推理

本地 LLM 延迟优化实录:缓存会撒谎,慢的从来不是推理

一句话结论:**本地推理链路的两个"慢",都不是模型慢。**一个是任务路由场景下前缀缓存"看起来没生效"——官方指标 prompt_eval_count 每次都报全量 token,但墙钟从 8.94s 收敛到 0.39s,指标在撒谎;另一个是语音助手首句要等 13-15 秒——根因是 23GB 模型闲置超时被卸载后的冷加载,热态下大模型首 token 只要 0.07s。修复都不换模型:把变量后置让前缀逐字节命中,用流式首句即播 + 常驻保活把可听延迟压到 2 秒。

这是同一天在同一台 Apple Silicon 工作站上完成的两条独立优化线:一条在优化任务路由的吞吐(多 agent 分发场景),一条在优化语音对话的手感(首句可听延迟)。两条线最后指向了同一个工程判断——本地 LLM 的延迟问题,八成不在推理本身,而在你跟推理引擎之间的那一层。

一、任务路由场景:前缀缓存到底生效没有

1.1 问题

一个本地任务路由系统要给不同 agent(bash 执行、代码生成、文档写作、独立配置档)分发任务。每条 prompt 都长这样:

  • 前面是几百到一千 token 的系统指令 + 输出格式说明(固定不变)
  • 后面是具体的任务描述(每次不同)

本地 Ollama 推理不算快,云端 API 则按全量 token 计费。前缀缓存(prefix caching)是两边共同的解法:各家引擎(Ollama 的 slot KV 续用、DeepSeek 的 context caching 等)都支持对请求前缀逐字节命中的部分跳过重新计算,命中部分按约 1/10 的代价处理——对云端是真金白银,对本地是实打实的墙钟。

问题是:**怎么确认它真的生效了?**这比看起来难。

1.2 第一个坑:官方指标在撒谎

最直觉的验证方法是看 Ollama 返回的 prompt_eval_count(prompt 部分实际计算的 token 数)。如果缓存命中,这个数应该显著小于全量。

实测结果(固定 1073 token 模板头,同前缀连跑):

跑次prompt_eval_count墙钟
第 1 次10733.81s
第 2 次10733.09s
第 3 次10733.05s

这个表的关键就在中间列:计数字段每次都报全量 1073,看起来缓存从没命中过;但墙钟从 3.81s 一路收敛到 3.05s,引擎内部显然跳过了重复前缀的计算。结论是:prompt_eval_count 对 slot KV 续用的反映不完整,不能当缓存命中指标。判断缓存是否生效,唯一可靠的依据是同一 prompt 连跑的墙钟差。别拿 API 返回的计数字段当裁判——它可能是缓存感知的,也可能不是,不同引擎行为不同,但墙钟不会骗人。

1.3 第二个坑:变量放错位置,缓存整段作废

更隐蔽的坑在 prompt 模板本身。很多模板长这样:

你是{agent_name}任务执行器。
{task}
输出格式:...(几百 token 的格式说明)

变量 {agent_name} 在最前面,每次换 agent 前缀就变——从第一个不同的字节起,后面全部无法命中,那一千 token 的格式说明每次都白算一遍。修复原则一句话:模板头(系统指令 + 输出格式)固定在最前,一切可变内容(任务描述、agent 名)后置。

修复后的结构:

(系统指令 + 输出格式说明,全部固定,逐字节不变)
TASK:
{task}

验证方式不是"看起来对",而是一个逐字节断言:

assert render_template(t, task="A").split("TASK:")[0] == render_template(t, task="B").split("TASK:")[0]

用两个不同任务渲染模板,断言 TASK: 之前的部分逐字节相同。这个断言看起来多余——谁会故意改模板头?但现实中模板头被悄悄改掉的方式很多:格式说明里嵌了一个日期变量、某次重构把字段顺序换了、日志组件在系统指令里插了时间戳。逐字节断言把这个隐性契约显式化了,模板一变测试立刻红。

1.4 实测数据

修复后(同前缀、1073 token 模板头):

场景墙钟序列解读
同一 prompt 连跑 3 次3.81s → 3.09s → 3.05s首跑暖 slot,之后收敛
多轮对话追加历史8.94s → 1.97s → 0.39s历史前缀越长,命中越多,加速比越大

多轮对话这条曲线值得单独看:第一轮 8.94 秒是全量计算;第二轮历史翻倍但只要 1.97 秒;第三轮 0.39 秒——前缀缓存的价值随会话长度递增,这正是 agent 场景(长系统提示 + 滚动历史)最该吃到但最常错过的红利。

1.5 云端同理:一个没能完成验证的诚实记录

云端 API(DeepSeek context caching 等)同样按前缀命中、命中部分约 1/10 价计费。本次尝试验证 prompt_cache_hit_tokens 字段时遇到 HTTP 402 Payment Required——验证缓存命中这件事本身要花钱,账号余额不足,该字段的实测值留待充值后补充。这里如实记下,不编数字。

另一个云端特有的坑:模型代际切换会让历史缓存整体作废(缓存键空间变化),这是一次性的隐性成本事件,用户侧几乎从不审计它。

二、语音链路:首句 13 秒到 2 秒

2.1 问题

本地语音助手链路是 STT → Ollama → TTS。用户念完一句话,到耳朵里听到回答,要等 8-12 秒,冷的时候 13-15 秒。直觉归因是"模型太慢"——23GB 的模型在本地跑,慢很合理?

2.2 根因定位:慢的不是推理

逐段计时之后,图景完全不同:

环节耗时判定
语音 TTS 首次合成1-3s次要
LLM 热态首 token0.07s不是瓶颈
LLM 热态整句0.2s不是瓶颈
23GB 模型冷加载主导项真凶

热态下大模型首 token 只要 0.07 秒、整句 0.2 秒——推理快到可以忽略。真正吃掉十几秒的是模型冷加载:Ollama 的 keep_alive 机制下,模型闲置超过设定时长会被自动卸载;下一次请求到达时,引擎要先把这个 23GB 的权重重新载入内存才能开始推理。语音助手这种"用一下放半天"的使用模式,恰好每次都撞上冷加载。

这个定位过程本身是本文最想传达的方法:先分段计时,再谈优化。如果直接从"换更快的模型"开始,后面会看到那条路是死路。

2.3 被实测否决的方案:换小模型

优化手册第一条通常是"换小模型"。实测把这条路堵死了:

候选模型首 token 延迟
原模型 qwen3.6-nothink(23GB)热态 0.07s
ornith 小模型8-9s
Qwen3.5-9B8-9s

小模型热态首 token 反而要 8-9 秒——量级上完全不可用。原因待核实(可能是量化档/加载路径差异),但实测结论明确:不换。这条负结果值得记录,因为它挡住了后续所有"再试试更小的模型"的弯路。负结果也是结果。

2.4 三招修复

第一招:流式首句即播。把 ollama_chat 从"生成完整回复再朗读"改为流式(stream: true),缓冲区里一出现第一个句号就立即把这句送 TTS 合成播报,不等全文生成完。用户听到第一句时,后面的内容还在生成。感知延迟从"整段生成时间"变成"第一句生成时间 + TTS 首次合成时间"。

**第二招:模型常驻。**keep_alive 从 30 分钟拉到 60 分钟,另起一个保活线程每 10 分钟空 ping 一次模型端点,让权重永驻内存。冷加载场景直接消失。

第三招(被否决的那条):换小模型——见上节。

2.5 前后对比

状态首句可听延迟
优化前(冷态)~13-15s
优化后(热态 + 流式首句即播)~2s
冷加载场景保活后不再出现

剩余延迟构成也如实交代:STT 每段 2-3 秒 + TTS 首次合成 1-3 秒,LLM 部分已经退出瓶颈名单。下一档优化目标在 STT 侧,不在 LLM 侧。

2.6 一个容易被忽略的配置位置

keep_alive 不是服务端配置文件里的全局项,而是每次请求 JSON 的 options 字段里设置的:

{"model": "...", "messages": [...], "keep_alive": "60m"}

这意味着如果你的调用方有多个(比如两个脚本都调同一个 Ollama 实例),只要有一方没设 keep_alive,它触发的那次卸载就会波及所有调用方。多调用方共享一个推理实例时,keep_alive 策略必须全局对齐。

三、两条线合起来的三条工程判断

判断一:先测量归因,再动优化

两个案例的根因都不在最初怀疑的地方。任务路由的"缓存没生效"其实是指标口径问题;语音链路的"模型慢"其实是冷加载。没有分段计时的归因,优化就是在错误的方向上花力气——语音案例里"换小模型"这条路如果没有实测否决,会浪费大量时间且引入质量倒退。

判断二:指标会撒谎,墙钟不会

prompt_eval_count 报全量不代表缓存没命中;反过来,某些引擎的 cache hit 字段也可能因为实现细节报乐观值。跨引擎的通用裁判只有墙钟差。设计验证实验时,先问"这个指标是怎么算出来的",再决定信不信它。

判断三:隐性契约要用断言锁死

前缀缓存依赖"模板头逐字节不变"这个隐性契约。契约本身没有编译器帮你检查——唯一的安全网是把它写成显式断言(两个任务渲染结果的前缀逐字节相等),让任何破坏契约的改动在测试阶段就爆红。凡是依赖"某段内容必须逐字节稳定"的优化(前缀缓存、HTTP ETag、增量同步),都值得这一层防护。

四、局限与边界

  • 环境口径:Apple Silicon 工作站(大统一内存),Ollama 本地部署,模型 qwen3.6-nothink(23GB),均为单机实测,未在 CUDA 环境复测。
  • 前缀缓存数据为固定 1073 token 模板头 + 同前缀连跑/多轮追加两类场景,未覆盖并发多 slot 竞争、不同模型混载的场景。
  • 云端 prompt_cache_hit_tokens 因账号余额不足(HTTP 402)未完成实测验证,文中云端结论基于各厂商公开文档口径,待核实。
  • 小模型首 token 8-9s 的异常原因未深挖(疑似加载路径/量化差异),本文只采信"实测否决"这一层结论。
  • 保活常驻会持续占用内存(大内存工作站无压力),小内存机器需要权衡常驻与冷加载的取舍。

五、给读者的实操建议

  1. 优化前先分段计时:LLM 应用里"慢"的归因候选至少有五个(模型推理、冷加载、网络、前处理、输出端),先定位再动手,别从换模型开始。
  2. 验证前缀缓存用墙钟差:同一 prompt 连跑三次看时间收敛,不要信单个 API 计数字段。
  3. 模板变量一律后置:系统指令 + 输出格式固定在头部,任务内容放尾部,并加逐字节断言。
  4. 间歇使用的模型配保活:keep_alive 拉长 + 定时空 ping,把冷加载从路径上消灭,比任何推理优化都立竿见影。
  5. 流式 + 首句即播:语音/对话类应用里,感知延迟的杀手锏是"让用户尽早听到第一个字",而不是"让整段更快"。
  6. 负结果也写下来:换小模型被实测否决这条,挡住了后面所有同类弯路——没有记录的负结果会被人再踩一遍。

数据来源:2026-09-24 同日两条实测记录(任务路由前缀缓存优化、语音链路冷加载优化),单机 Apple Silicon + Ollama 环境,墙钟均为真实测量值。云端缓存计费口径来自各厂商公开文档(待实测复核)。