不用加内存、不换显卡!把 Embedding 推理从 97 秒砍到 30 秒------Qwen3-Embedding-0.6B 纯 CPU 调优实录 原创
😫 痛点引入 :4 核小机器跑个 0.6B 的 embedding 模型,一条长文本要等 一百多秒 ?
内存常年顶满、swap 疯狂读写、CPU 明明闲着却慢得离谱?
网上一搜全是「vLLM + FP16 + FlashAttention」,可你这台机器连个独显都没有🤡?
⚡ 一句话结论 :把 Ollama 默认的
n_batch从 2048 改成 512 ,长文本(3961 token)耗时 97~109s → 27.6~30.6s ,提速约 2~3 倍 ,向量和原模型数值完全一致 (余弦相似度 1.0000000000)。全程没动权重、没改 ctx、没重新训练 ,客户端只改一个
model字段名。两行 Modelfile 的事儿 ✨📌 本文所有数字都是真机实测,命令、日志、digest 全部可复现,直接抄作业就行。
〇、先说结论(赶时间看这段就够)⚡
| 动的东西 | 值 | 效果 |
|---|---|---|
num_batch |
2048 → 512 | 长文本 97s → 30s |
| 容器内存限额 | 无限制 → 2048MB | 不再 OOM、不再拖垮整机 |
| KV cache 量化 q8_0 | 试了,已回滚 🚫 | 慢 50~60%,别碰 |
| ctx | 4096 不动 | 长文本不截断 |
| 模型权重 | 完全没动 | 向量余弦相似度 = 1.0 |
一句话:不是少算了,是算得更顺了 🎯
一、我的机器有多寒酸 💻
先交代家底,不然你没法判断能不能照抄:
| 项目 | 值 |
|---|---|
| 机器 | 云服务器一台,4 核 / 3.6 GiB 内存 / 无独显 |
| 系统 | OpenCloudOS 9.6 |
| 模型 | qwen3-embedding:0.6b,595.78M 参数 |
| 输出维度 | 固定 1024 维(只做向量化,不支持对话) |
| 量化档位 | Ollama 默认标签 Q8_0 |
| 权重实测 | 639 MB(609.5 MiB) |
| 推理栈 | Docker + ollama/ollama:latest(客户端 0.34.1) |
| 后端 runner | llama.cpp 架构的 llama-server |
| 用途 | 自建 Agent 的记忆检索(memory_search)文本分片向量化 |
3.6 GiB 内存、零显存------这就是全部家当。所有「加张卡就好了」的方案在这台机器上一律作废。
1.1 先记下权重 digest,很重要 🔑
bash
ollama show qwen3-embedding:0.6b
记下这串:
ac6da0dfba84a81fdbfbaf330198c33cd77c4cdfc53e8bc50eb581914a15621d
后面讲「换机器要不要重建向量索引」时全靠它,先存着 💾
1.2 模型架构参数(GGUF metadata 实测)📋
| 参数 | 值 |
|---|---|
| block_count(层数) | 28 |
| attention.head_count | 16 |
| attention.head_count_kv(GQA) | 8 |
| head_dim (K/V) | 128 |
| embedding_length | 1024 |
| feed_forward_length | 3072 |
| 训练上下文 | 32768 |
运行上下文(-c) |
4096 |
二、调优前有多惨 😵
不是「有点慢」,是「几乎不可用」:
bash
free -h
# 物理内存 available 一度只剩 69~113 MB
# Swap(zram 1.8G + swapfile 4G)占用 3.4~4.3 GB
vmstat 1
# si/so(换入/换出)达 2~4.7 万 KB/s
# 块设备读写 ~57 MB/s
# wa(IO 等待)27~29%,idle 62%
看到 wa 高、us(用户态 CPU)低,基本可以确诊 内存颠簸(thrashing):CPU 根本没在算东西,它在干等内存页从磁盘换回来 💤
累计换页量更能说明这不是偶发尖峰:
pswpin 65 GB
pswpout 57 GB
2.1 顺手抓到一个「白嫖怪」🕵️
排查期间还发现一件离谱事:对外网关(Nginx 反代到容器,端口已脱敏)没做鉴权 ,容器起来约 11.5 小时里,某个外部 IP(已脱敏为 X.X.X.X)发了 130 次 embeddings 请求(54 次成功、76 次 400),还夹着 2105 token 的长文本。
后果很直接:频繁请求不停给模型的 5 分钟 keep_alive 续命 → 模型常驻内存不释放 → 占死唯一的推理 slot → 我自己发的测试请求被拖到 180 秒超时。11.5 小时里,本机自己只成功调通了 1 次 🤡
⚠️ 你要是也把 Ollama 暴露到公网,一定加鉴权。第 8 节还会再提一次。
三、内存到底被谁吃了?🔍
先看总量:
bash
curl http://127.0.0.1:11434/api/ps
size 2.37 GB
llama-server RSS 1.33~1.56 GB
再看 runner 日志里的实际分配明细(这才是关键):
| 占用项 | 大小 | 说明 |
|---|---|---|
| model buffer(Q8_0 权重) | 603.87 MiB | mmap 加载权重 |
| KV cache buffer(f16,ctx 4096) | 448 MiB(K 224 + V 224) | 按 ctx 全额预分配 |
| compute buffer(n_batch=2048 预留) | 1209 MiB | 前向激活值工作区,最大的一块 🔥 |
| 合计热驻留峰值 | 约 1.6~1.73 GiB | 容器内实测 |
画出来长这样:
┌─────────────────────────────────────────────┐
│ compute buffer 1209 MiB ← 最大头!随 batch │
│ (前向激活值工作区) 变化,本次优化目标 │
├─────────────────────────────────────────────┤
│ model buffer 604 MiB ← 权重,mmap 进来的│
├─────────────────────────────────────────────┤
│ KV cache 448 MiB ← 按 ctx 预分配 │
└─────────────────────────────────────────────┘
预留合计 ≈ 2.26 GiB,实驻 ≈ 1.73 GiB
这张图就是全文的题眼 :大头是 compute buffer,而它是跟着 n_batch 走的。后面的优化方向就是从这儿长出来的 🎯
四、两个 99% 的人会搞混的概念 🧠
这俩搞混,后面全白搭。
4.1 「1024」和「4096」不是一回事 ❌
| 概念 | 含义 | 谁决定 | 变了会怎样 |
|---|---|---|---|
| 输出 1024 维 | 模型架构定死,任意长度输入最后都池化成同一个 1024 维向量 | 模型架构 | 换模型才变 |
| 输入 ctx 4096 token | 单次请求最多能读多少文本 | runner 参数 -c 4096 |
超限静默截断 |
⚠️ 最阴的坑在这儿 :超过 4096 token 的输入会被静默截断 ------照样返回 1024 维向量、照样 HTTP 200、一句报错都没有。
你的服务看起来岁月静好,实际上长文档后半段压根没进模型。等你发现检索结果不对劲,几十万条向量已经入库了 😇
4.2 KV Cache 对 embedding 基本是白给 💸
KV 缓存的价值在聊天解码:复用历史 token 的 K/V,避免每生成一个字都重算一遍前缀。
但 embedding 是一次性并行 prefill ,K/V 算一次、读一次、用完就扔------既不跨解码步骤复用,也不跨请求复用。所谓「缓存」,压根没存到东西。
真正浪费的不是计算,是 llama.cpp 按 -c 全额预分配的那块 buffer。验算一下:
2(K 和 V)× 28 层 × 8 个 KV 头 × 128 head_dim × 2 字节(f16)
= 112 KB / token
× 4096 token
≈ 448 MB
和日志里的 448 MiB(K 224 + V 224)分毫不差 ✅
这个验算很重要------它证明我们对内存结构的理解是对的,后面的判断才有底气。
4.3 插播:Ollama 的「赖着不走」机制 ⏰
模型算完不会立刻卸载 ,默认 keep_alive=5m:热驻留 5 分钟等下一个请求(省掉十几秒的重复加载)。期间持续占内存,连续来请求就一直续期。
空闲到期后自动卸载,内存回落到 Ollama 本体约 10 MB。
所以「我没在调它,怎么还占着内存」------正常的,别慌 😉
五、三次动手:一次兜底、一次翻车、一次命中 🔧
5.1 第一次:给容器上紧箍咒(cgroup 内存限额)🛡️
这是必须先做的一步:先把爆炸半径圈住,别让 OOM 把整机拖死。
compose 项目的 .env:
env
MEMORY_LIMIT=2048MB # 原本是 0MB,等于没限制
对应 cgroup v2 的 memory.max=2147483648(2 GiB),swap 上限 4 GiB。
这个 2G 不是拍脑袋定的,是试出来的:
bash
# 第一次:照搬另一台 NAS 的经验,设 1.5 GiB
# 结果:发 2725 token 长文本 → runner 直接被 OOM Kill
# HTTP 400 EOF,容器状态 OOMKilled:true 💀
# 第二次:放宽到 2 GiB
# 结果:3961 token(打满 ctx)请求成功,峰值 1.656 GiB ✅
结论 :短 chunk(比如 memory_search 那种几十 token 的)1.5g 就够;但只要你有长文档场景,2G 才是不截断输入的安全线。贴着 ctx 上限跑时峰值逼近 1.74G,卡在 2G 里刚刚好。
5.2 第二次:KV cache 量化 q8_0 ------ 翻车了 🚫
既然 KV cache 占着 448 MiB,很自然想量化掉它:
env
OLLAMA_KV_CACHE_TYPE=q8_0
KV buffer 确实从 448 MiB 降到 238 MiB,ctx 保持 4096 不变。然后实测:
| 指标 | f16(原) | q8_0(量化后) |
|---|---|---|
| KV buffer | 448 MiB | 238 MiB ✅ |
| 长文本余弦相似度 | 1.0(基准) | 0.99963543 ⚠️ |
| 短文本余弦相似度 | 1.0(基准) | 0.99961684 ⚠️ |
| 长文本耗时 | 97~109s | 157~159s 💀 |
| 总内存峰值 | 1.73 GB | 1.73 GB(持平甚至略升)❌ |
三个暴击:
- 有 3.7e-4 的真实量化偏差,不是浮点噪声能解释的;
- 长文本慢了 50~60%,两次复测结果稳定------纯 CPU 上 int8 KV 的反量化开销太高;
- 内存根本没降,因为大头是 1.2 GB 的 compute buffer,KV 只是零头。
省了 210 MiB,换来慢 60% + 掉精度。赔本买卖,环境变量删掉,回滚 f16 🔙
💡 这次翻车一点不亏。它反过来证明了「内存大头是 compute buffer 而非 KV」,直接把优化方向锁死在 batch 上。
5.3 第三次:把 batch 从 2048 砍到 512 ------ 命中 🎯
根因 :Ollama 默认 n_batch = n_ubatch = 2048(runner 参数 -b 2048 -ub 2048)。这个值是给 GPU 大批次准备的。
我这台 4 核纯 CPU,一次吞 2048 token 会连撞三枪:
- CPU 缓存命中率崩了 ------ 工作集远超 L2/L3
- 激活张量一次性铺开 ------ compute buffer 预留 1.2 GB
- 触发 swap 搬运 ------ CPU 干等磁盘,就是第 2 节看到的
wa高us低
做法 :不改权重、不改 ctx、不动接口 ,只把服务端内部批次上限从 2048 降到 512。前向计算按 512 token 一片串行跑,数学上和一次性算完全等价;单片激活工作区大幅缩小,也更贴合 CPU 的缓存层级。
⚠️ 前提:没有 OLLAMA_NUM_BATCH 这个环境变量
我实测过了,不存在 OLLAMA_NUM_BATCH。设完去读 n_batch,还是 2048(已验证并回滚)。
batch 只有两条路能传进去:
bash
# 路子一:每次请求的 options(每个客户端都得改,侵入性强)❌
curl ... -d '{"model":"...","input":"...","options":{"num_batch":512}}'
# 路子二:写进模型的 PARAMETER(服务端固化一次,客户端零改动)✅
为了让客户端零改动,选第二个:固化模型。
Modelfile:就两行 ✨
dockerfile
FROM qwen3-embedding:0.6b
PARAMETER num_batch 512
创建命令:
bash
docker cp Modelfile.cpu <你的容器名>:/tmp/
docker exec <你的容器名> \
ollama create qwen3-embedding:0.6b-cpu -f /tmp/Modelfile.cpu
两个你可能担心的点,我都验证过:
- ✅ 不会重复占 639 MB :创建时复用原权重层,日志会打
using existing layer sha256:06507c...,只新增一个几 KB 的参数层; - ✅ 容器重建不丢 :新模型 digest
21c08ccb4791e9ad...存在 bind mount 的 data 目录里。
六、数据说话 📊
测试输入:约 3961 token(基本打满 4096 ctx)的中文长文本,冷请求,对照组是原模型默认配置。
| 指标 | 优化前(batch 2048) | 优化后(batch 512) |
|---|---|---|
| 长文本耗时 | 97s / 109s | 27.6s / 29.8s / 30.6s ⚡ |
| 提速倍数 | 1× | 约 2~3× 🚀 |
| compute buffer 预留 | 1208.97 MiB | 302.24 MiB |
| KV buffer(f16,没动) | 448 MiB | 448 MiB |
| 内存峰值(物理驻留) | 1.73 GiB | 1.69~1.74 GiB |
| ctx 容量 | 4096(不截断) | 4096(不截断) |
| 向量余弦相似度 vs 原模型 | --- | 1.0000000000 🎯 |
| 输出维度 | 1024 | 1024 |
| 短文本耗时 | 2~5s | 没明显差异(本来就快) |
| OOM 风险(2g 限额内) | 长文本有风险 | 长文本安全 |
那次 51.5s 是系统抖动,不算数。
6.1 「内存没降多少,凭啥快 3 倍?」🤔
好问题,这俩不矛盾:
- 物理驻留峰值 差不多------该算的激活数据总量没变,小批次只是分时处理;
- 但预分配额度 从约 2.2 GB 降到约 1.3 GB,内存水位线大幅下降。配合 2G cgroup 限额,不再出现「预留 + 驻留」叠在一起触发大规模 swap 的情况;
真正的收益是消掉了 CPU 等 IO 的假空闲:小批次下计算和内存搬运更匹配,CPU 有效利用率上去了,端到端耗时自然断崖式下降。
一句话:不是少算了,是算得更顺了。
6.2 客户端要改什么?几乎不用改 ✨
- 只改一个字段:
model换成qwen3-embedding:0.6b-cpu。URL、请求格式、返回 JSON、向量维度全都不变; - 单次请求连接时长缩短约 2/3,大幅降低撞 60s 超时的概率 ;单 slot(
-np 1)排队时后面的请求也更快轮到; - 客户端 CPU、内存零额外开销(就收几 KB 的 1024 维数组)。
七、完整配置,直接抄 📋
7.1 docker-compose.yml
yaml
services:
ollama:
image: ollama/ollama:${VERSION}
deploy:
resources:
limits:
cpus: ${CPUS}
memory: ${MEMORY_LIMIT}
restart: unless-stopped
tty: true
ports:
- ${HOST_IP}:${OLLAMA_PORT}:11434
volumes:
- ${APP_PATH}/data:/root/.ollama
labels:
createdBy: "bt_apps"
networks:
- baota_net
networks:
baota_net:
external: true
7.2 .env 关键项
env
VERSION=latest # 当前 0.34.1,latest 会漂移,建议早点 pin 版本
HOST_IP=127.0.0.1 # 只绑本机!公网暴露走自己的 nginx 网关
OLLAMA_PORT=11434
CPUS=0 # 不限制 CPU 核数
MEMORY_LIMIT=2048MB # 2 GiB 限额(1.5g 跑长文本会 OOM)
7.3 runner 实际参数
bash
# 原模型
-c 4096 -np 1 -b 2048 -ub 2048
# 新的 -cpu 模型:继承上面,只把 batch 覆盖成 512
-c 4096 -np 1 -b 512 -ub 512
# 另:--flash-attn auto、--embedding、keep_alive 默认 5m
7.4 模型清单
| 模型名 | 说明 |
|---|---|
qwen3-embedding:0.6b |
原版,留着对照,batch=2048 |
qwen3-embedding:0.6b-cpu |
日常用这个 ,batch=512,digest 21c08ccb4791e9ad |
7.5 调用示例
bash
curl -X POST http://127.0.0.1:11434/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model":"qwen3-embedding:0.6b-cpu","input":"要向量化的文本"}'
Python(OpenAI SDK):
python
client.embeddings.create(
model="qwen3-embedding:0.6b-cpu",
input="要向量化的文本"
)
八、踩坑清单 ⚠️
1️⃣ 这次优化的本质 :改的是推理调度参数(batch 分片)+ 部署资源限额(cgroup) ,没训练、没微调、权重一个字节都没动(还是 Q8_0,新旧模型共用同一权重层)。所以语义能力和向量空间完全不变,同输入余弦相似度 = 1.0。
2️⃣ 别照搬 GPU 那套 :sentence-transformers FP16 / vLLM / GPTQ,在无独显 3.6G 内存的机器上根本跑不起来。纯 CPU 上 Q8_0 就是甜点档------INT8 能吃 AVX2/VNNI 指令加成,很多时候比 FP16 还快 🍬
3️⃣ 千万别靠砍 ctx 省内存 :把 ctx 降到 2048,长文本会被静默截断。这是最阴的省内存方式:服务不报错,你只会得到一堆「看着正常、实际残缺」的向量。本方案保持 4096,靠小批次解决性能。
📌 提醒:超过 4096 token(约 6000+ 汉字)照样截断。真要处理整篇长文档,得调大 ctx 并同步上调内存限额。
4️⃣ 内存瓶颈在 compute buffer,不在 KV cache :所以 KV q8 量化没收益(实测慢 50~60%)。另外 OLLAMA_NUM_BATCH 这个环境变量不存在,别再试了,我替你试过了。
5️⃣ 公网网关一定加鉴权:对外网关没鉴权,就会被刷请求 → 模型续命常驻 → 占死 slot(本文实打实发生过:11.5 小时被单个 IP 请求 130 次)。建议 Nginx 层加 Bearer Token,或者直接用防火墙限来源 IP 🔒
😌 好消息:Ollama 空闲时只占约 10 MB,平时完全没内存负担。
6️⃣ 换机器迁移的正确姿势 :换到另一台机器(比如家用 NAS,i5-6200U,容器限 1.5g/2 核),要在那台机器上同样执行一次 ollama create ,Modelfile 内容保持一样。迁之前先比对权重 digest 是不是 ac6da0df...,保证向量空间一致。
7️⃣ 什么时候要重建向量索引 🔑
| 情况 | 要不要重建 |
|---|---|
| 换访问地址 / 换服务器 / 换推理引擎,权重 digest 一致 | ✅ 不用,已入库向量全都能接着用 |
| 模型尺寸变了(输出维度变了) | ❌ 必须全量重建 |
| 权重 digest 变了 | ❌ 必须全量重建 |
九、5 步复现清单 🚀
你要是也在一台小内存无独显的机器上跑 embedding,按这个顺序来:
bash
# ① 记基线:权重 digest + 架构参数
ollama show qwen3-embedding:0.6b
# ② 看内存结构:找 compute buffer / KV buffer 的实际占用
docker logs <容器名> 2>&1 | grep -Ei "buffer|n_batch"
# ③ 先圈限额(长文本场景建议 2G,别让 OOM 拖垮整机)
# .env 里:MEMORY_LIMIT=2048MB
# ④ 固化小 batch 模型
cat > Modelfile.cpu <<'EOF'
FROM qwen3-embedding:0.6b
PARAMETER num_batch 512
EOF
docker cp Modelfile.cpu <容器名>:/tmp/
docker exec <容器名> ollama create qwen3-embedding:0.6b-cpu -f /tmp/Modelfile.cpu
# ⑤ 验证:同输入下新旧模型向量余弦相似度应为 1.0,顺手对比长文本耗时
调优的先后顺序(这个比参数本身重要):
先圈限额(防 OOM 拖垮整机)
↓
再看日志、拆内存,定位大头(别猜!)
↓
对着大头调参数
↓
每次改动都用「向量一致性 + 耗时」两个指标验收
本篇总结 📝
- 内存大头是 compute buffer 🔍:1209 MiB,跟着
n_batch变,不是 KV cache - batch 2048 → 512 ⚡:长文本 97s → 30s,提速 2~3 倍,向量数值完全一致
- KV q8 量化是坑 🚫:省 210 MiB,慢 60%,还掉精度,已回滚
OLLAMA_NUM_BATCH不存在 ❌:batch 只能走请求 options 或模型 PARAMETER- 两行 Modelfile 固化模型 ✨:复用原权重层,不占额外空间,客户端零改动
- 容器内存限 2G 🛡️:1.5g 跑长文本会 OOM,实测出来的安全线
- ctx 4096 别动 ⚠️:砍 ctx 会静默截断长文本,最阴的坑
- 公网必须加鉴权 🔒:不加就会被刷,模型常驻占死 slot(血泪教训)
- 权重 digest 一致就不用重建索引 💾:换机器换引擎都不怕,只有维度变或 digest 变才重建
最后唠两句 💬
这次最值得记的不是「2048 改成 512」这个数字,而是这个数字是怎么被找出来的:
- 只看「内存不够」,第一反应是砍 ctx → 结果静默截断长文本;
- 只盯「KV cache 占 448 MiB」,第一反应是量化它 → 结果慢了 60%;
- 只有把内存按 model / KV / compute 三项拆开,看见 compute buffer 才是那 1.2 GB ,才会想到它是跟着
n_batch走的,杠杆才找对。
资源受限的环境里做优化,先量化地定位,再动手,比在网上搜一堆「性能调优参数」挨个试有效得多 🎯
如果你的机器配置跟我差不多,欢迎直接拿第 9 节的清单试一遍,也欢迎评论区贴出你的实测数据------尤其是不同 CPU 型号、不同内存下的 batch 最优值,我猜这个数跟机器强相关,样本多了说不定能整出一张对照表 📊
觉得有用的话点个赞收个藏呗 ⭐
作者 :书源丶
发布平台 :CSDN
系列 :AI 大模型本地化实践
日期:2026-09-18
标签 :#Embedding #Ollama #CPU推理 #RAG #性能优化 #llama.cpp #Qwen3