Ollama CPU 推理大提示词优化实测报告(细致化数据版)
测试日期:2026-08-08
关键词:Ollama / llama.cpp / CPU 推理 / 大上下文 / KV Cache 量化 / Prompt Cache
数据来源:真实服务器实测,全部原始测量数据附后
摘要(TL;DR)
结论一(反直觉) :CPU 推理下开启 KV Cache 量化(q8_0)是严重负优化------同一提示词预填充从 84 秒恶化到 208 秒,慢 2.5 倍。GPU 场景的优化手段不能照搬到 CPU。
结论二:手动设置线程数(32 / 64)与默认无差异------预填充瓶颈是内存带宽而非核心数,SMT 虚核收益互相抵消。
结论三(正解) :将 OLLAMA_KEEP_ALIVE 从默认 5 分钟拉长至 24 小时,让模型常驻内存,llama.cpp 的 Prompt Cache 对相同前缀提示词实现缓存命中 99.99%:11,055 token 的预填充从 84 秒降至 0.05 秒,请求总耗时 1.6 秒。
适用场景:固定系统提示词 + 固定文档上下文 + 尾部变化问题(RAG / 长文档问答 / 多轮对话的标准形态)。
一、测试环境
| 项目 | 参数 |
|---|---|
| CPU | AMD Ryzen Threadripper PRO 9975WX(Zen5) |
| 核心 | 32 核 / 64 线程,单 NUMA 节点 |
| 内存 | 128 GB DDR5(实测 125 GiB),交换分区 8 GiB |
| GPU | 96 GB 显存 NVIDIA 专业卡一块(未参与本模型推理,仅系统显示占用) |
| OS | Linux(systemd 管理服务) |
| 推理引擎 | Ollama 0.30.8(内置 llama.cpp) |
| 模型 | MoE 架构 GGUF 模型:总参数 29.9B / 激活约 3B,Q4_K_M 量化,文件 31.16 GB |
| 上下文窗口 | 202,752 tokens(启动参数 -c 202752) |
| GPU offload | 0 层(-ngl 0,纯 CPU 推理) |
| 其他 llama.cpp 参数 | --mlock --no-mmap --flash-attn auto -b 2048 -ub 2048 -np 1 |
| 服务监听 | 0.0.0.0:11434,OpenAI 兼容端点 /v1/chat/completions |
二、问题现象
客户端通过 /v1/chat/completions 提交 18,730 token 的大提示词(系统提示 + 文档上下文 + 问题)后:
- CPU 占用飙至 28 核满负荷(约 2800%),持续 3 分 14 秒;
- 期间客户端一个 token 都不返回,表现与"死循环/卡死"无异;
- 预填充完成后才开始生成,生成阶段 17.06 tok/s(CPU 水平)。
诊断结论 :不是死循环。Ollama 日志显示 prompt processing 进度持续推进(progress 0.11 → 0.99),说明引擎正在执行预填充(prefill) ------逐 token 消化提示词。预填充阶段本就不产生任何输出,大提示词 + CPU 推理 = 数分钟"假死"。
关键日志证据(基线,18,730 tokens)
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 2048, progress = 0.11, t = 7.02 s / 291.56 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 4096, progress = 0.22, t = 16.56 s / 247.29 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 6144, progress = 0.33, t = 28.89 s / 212.67 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 8192, progress = 0.44, t = 44.14 s / 185.57 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 10240, progress = 0.55, t = 62.41 s / 164.08 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 12288, progress = 0.66, t = 83.51 s / 147.14 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 14336, progress = 0.77, t = 107.39 s / 133.49 tokens per second
slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 16384, progress = 0.87, t = 134.02 s / 122.25 tokens per second
slot print_timing: id 0 | task 0 | prompt eval time = 10963 ms / 187 tokens (17.06 tok/s) ← 预填充完成后进入生成
slot print_timing: id 0 | task 0 | stop processing: n_tokens = 18916, truncated = 0 ← 正常结束,未截断
三、实验设计
控制变量法 :固定提示词(19,125 字符中文 ≈ 11,055 tokens),固定生成上限(max_tokens=32,只测预填充阶段),每组只改一个变量;每次修改后 systemctl daemon-reload && systemctl restart ollama,冷却 45 秒排除热降频干扰(Threadripper 满载后 boost 频率回落,已验证冷却后频率恢复 3.9~5.3 GHz)。
| 实验组 | 变更项 | 对照目的 |
|---|---|---|
| 基线(改前现场) | 调用方真实请求 18,730 tokens,默认配置(f16 KV、自动线程) | 建立参考曲线 |
| A-1 | OLLAMA_KV_CACHE_TYPE=q8_0 + OLLAMA_NUM_THREADS=64 |
KV 量化 + 全线程 |
| A-2 | OLLAMA_KV_CACHE_TYPE=q8_0 + OLLAMA_NUM_THREADS=32 |
KV 量化 + 物理核 |
| A-3 | OLLAMA_KV_CACHE_TYPE=q8_0 + 默认线程 |
KV 量化单独作用 |
| B | f16 KV(默认)+ 默认线程 | 回归基线,与 A 组对照 |
| C | f16 KV + OLLAMA_KEEP_ALIVE=24h,同提示词第二次请求 |
Prompt Cache 验证 |
四、完整测量数据
4.1 预填充速度轨迹对比表(tok/s,按已处理 token 数)
| 已处理 tokens | 基线 18.7K (f16) | A-1 q8+64线程 | A-2 q8+32线程 | A-3 q8+默认 | B f16 默认 |
|---|---|---|---|---|---|
| 2,048 | 291.56 | 172.84 | 172.45 | 169.92 | 290.17 |
| 4,096 | 247.29 | 116.90 | 117.17 | 117.32 | 247.33 |
| 6,144 | 212.67 | 89.16 | 89.30 | 89.28 | 212.67 |
| 8,192 | 185.57 | 71.57 | 71.50 | 71.46 | 184.85 |
| 10,240 | 164.08 | 60.12 | 60.03 | 59.99 | 163.94 |
| 12,288 | 147.14 | --- | --- | --- | --- |
| 14,336 | 133.49 | --- | --- | --- | --- |
| 16,384 | 122.25 | --- | --- | --- | --- |
| 总耗时(11,055 tokens) | --- | 197.4 s | 207.8 s | 207.8 s | 84.1 s |
注:A-1/A-2/A-3 三组轨迹逐项几乎一致(差异 <2%),说明线程设置对预填充无影响;A 组整体比 B 组慢约 2.5 倍,唯一变量是 KV 量化 → q8_0 是负优化元凶。
4.2 关键单点对比(10,240 tokens 处)
q8_0: prompt eval = 170.57 s / 10240 tokens (60.03 tok/s)
f16 : prompt eval = 62.46 s / 10240 tokens (163.94 tok/s)
差 = 2.73 倍
4.3 Prompt Cache 验证(C 组,决定性数据)
第一轮(模型冷加载后首个请求,全量预填充):
prompt eval = 71451.11 ms / 11055 tokens (154.72 tok/s) → 请求总耗时 84.1 s
第二轮(相同提示词,Keep-Alive 生效,缓存命中):
cached n_tokens = 11054, memory_seq_rm [11054, end) ← 跳过 11054/11055 个 token
prompt eval = 47.99 ms / 1 tokens ← 仅计算尾部新增 1 token
→ 请求总耗时 1.6 s
| 指标 | 第一轮 | 第二轮 | 提升 |
|---|---|---|---|
| 预填充耗时 | 71,451 ms | 48 ms | 99.93% ↓ |
| 请求总耗时(含 32 token 生成) | 84.1 s | 1.6 s | 98.1% ↓ |
| 缓存命中率 | 0% | 11,054 / 11,055 = 99.99% | --- |
4.4 生成阶段速度(供参考)
| 场景 | 生成速度 |
|---|---|
| 长文本生成(187 tokens 实测) | 17.06 tok/s |
| 短回复(32 tokens 实测) | 22.67 tok/s |
五、机理分析
5.1 为什么预填充随上下文衰减(291 → 122 tok/s)
- 注意力计算量平方级增长:每处理一个新 token 需与前面全部 KV 做注意力运算,上下文 2,048 → 16,384,计算量增 8 倍;
- 内存带宽是 CPU 推理的第一瓶颈:权重 31 GB + 持续增长的 KV 缓存全部走内存总线;
- 纯 CPU 推理(
-ngl 0)时上述开销完全由内存子系统承担,核心数再多也救不了。
5.2 为什么 q8 KV 在 CPU 上是负优化
| 维度 | GPU | CPU(本实验) |
|---|---|---|
| 内存带宽 | 极高(~1 TB/s 级) | 有限(百 GB/s 级) |
| KV 量化收益 | 省带宽 → 明显加速 | 省下的带宽有限 |
| dequant 开销 | 并行计算单元消化 | 每次注意力计算都要反量化,开销 ≥ 收益 |
| 实测结果 | (未测) | 慢 2.73 倍 |
结论 :KV Cache 量化(OLLAMA_KV_CACHE_TYPE=q8_0)是为 GPU 显存/带宽场景设计的手段,CPU 上反量化开销吃掉了全部带宽收益还倒贴。CPU 推理保持默认 f16 KV。
5.3 为什么线程数无效
预填充受内存带宽约束而非计算核心数约束;Threadripper 的 SMT 虚核(64 逻辑核)共享同一内存带宽,多线程争抢时收益互相抵消。Ollama/llama.cpp 自动检测的线程数已经是最优。
5.4 为什么 Keep-Alive + Prompt Cache 是质变
llama.cpp 会缓存已计算前缀的 KV 状态(cached n_tokens);请求前缀一致时直接复用,只对增量部分做预填充。默认 OLLAMA_KEEP_ALIVE=5min 时模型频繁卸载,缓存每次归零(基线日志 cached n_tokens = 0);拉长到 24h 后模型常驻,缓存跨请求存活------这正是 RAG 场景的标准形态:固定 system + 固定文档 + 尾部变化问题。
六、结论与最佳实践
| 优先级 | 手段 | 收益 | 备注 |
|---|---|---|---|
| ★★★ | OLLAMA_KEEP_ALIVE=24h(模型常驻) |
同前缀请求预填充提速 99.9% | 前提:调用方提示词前缀稳定 |
| ★★★ | 提示词结构化:前缀固定、变更放尾部 | 激活 Prompt Cache 的前提 | 调用方可改造时 |
| ★★☆ | 保持 f16 KV(不开 OLLAMA_KV_CACHE_TYPE) |
避免 2.7 倍退化 | CPU 推理 |
| ★★☆ | 保持默认线程(不设 OLLAMA_NUM_THREADS) |
避免无效操作 | 自动检测即最优 |
| ☆☆☆ | KV 量化 / 线程调优(CPU 场景) | 负收益 / 零收益 | 实测 |
使用前提与边界
- Prompt Cache 收益的前提是提示词前缀一致:若每次提示词完全随机(无公共前缀),缓存不命中,回到全量预填充(CPU 物理极限:11K token ≈ 84 s);
- 模型常驻增加约 31 GB 内存占用(本机 128 GB 无压力;小内存机器需权衡);
- 生成阶段(decoding)不受上述优化影响,CPU 上约 17~23 tok/s。
排查口诀
遇到"CPU 拉满但模型不输出":先看日志中
prompt processing的progress是否推进------在推进 = 预填充(正常,等它完成);停滞 = 才是真卡死。
附录 A:测试方法(可复现)
- 生成固定长度中文提示词(19,125 字符 ≈ 11,055 tokens);
- POST
/v1/chat/completions,max_tokens=32隔离生成阶段,仅测预填充:
bash
curl -s -X POST http://<host>:11434/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"<model>","messages":[{"role":"user","content":"<11K-token-text>"}],"max_tokens":32,"stream":false}'
- 每次修改配置:
systemctl daemon-reload && systemctl restart ollama,冷却 45 s 消除热降频; - 从
journalctl -u ollama提取prompt processing轨迹与cached n_tokens; - 对照组每项只改一个变量。
附录 B:配置变更记录(systemd drop-in)
ini
# /etc/systemd/system/ollama.service.d/override.conf(最终生效版)
[Service]
Environment="OLLAMA_KV_CACHE_TYPE=q8_0" # ❌ 已移除(实测负优化 2.7x)
Environment="OLLAMA_NUM_THREADS=64" # ❌ 已移除(实测无差异)
Environment="OLLAMA_KEEP_ALIVE=24h" # ✅ 保留(Prompt Cache 收益来源)
修改流程:先备份原 drop-in 目录 → 追加 Environment 行(勿覆盖原 OLLAMA_MODELS/OLLAMA_HOST/OLLAMA_ORIGINS)→ daemon-reload → restart → 基准复测。
免责声明:本文数据为单机单次实测(每配置一组基准,轨迹逐点记录),个体差异以复测为准。测试期间系统负载 33 → 1.4(清理异常任务后),冷却 45 秒后频率恢复正常(3.9~5.3 GHz)再测量。