Qwen3-Embedding-0.6B 纯 CPU

不用加内存、不换显卡!把 Embedding 推理从 97 秒砍到 30 秒------Qwen3-Embedding-0.6B 纯 CPU 调优实录 原创

😫 痛点引入 :4 核小机器跑个 0.6B 的 embedding 模型,一条长文本要等 一百多秒

内存常年顶满、swap 疯狂读写、CPU 明明闲着却慢得离谱?

网上一搜全是「vLLM + FP16 + FlashAttention」,可你这台机器连个独显都没有🤡?

一句话结论 :把 Ollama 默认的 n_batch2048 改成 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(持平甚至略升)❌

三个暴击:

  1. 3.7e-4 的真实量化偏差,不是浮点噪声能解释的;
  2. 长文本慢了 50~60%,两次复测结果稳定------纯 CPU 上 int8 KV 的反量化开销太高;
  3. 内存根本没降,因为大头是 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 会连撞三枪:

  1. CPU 缓存命中率崩了 ------ 工作集远超 L2/L3
  2. 激活张量一次性铺开 ------ compute buffer 预留 1.2 GB
  3. 触发 swap 搬运 ------ CPU 干等磁盘,就是第 2 节看到的 waus

做法不改权重、不改 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
提速倍数 约 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 拖垮整机)
    ↓
再看日志、拆内存,定位大头(别猜!)
    ↓
对着大头调参数
    ↓
每次改动都用「向量一致性 + 耗时」两个指标验收

本篇总结 📝

  1. 内存大头是 compute buffer 🔍:1209 MiB,跟着 n_batch 变,不是 KV cache
  2. batch 2048 → 512 ⚡:长文本 97s → 30s,提速 2~3 倍,向量数值完全一致
  3. KV q8 量化是坑 🚫:省 210 MiB,慢 60%,还掉精度,已回滚
  4. OLLAMA_NUM_BATCH 不存在 ❌:batch 只能走请求 options 或模型 PARAMETER
  5. 两行 Modelfile 固化模型 ✨:复用原权重层,不占额外空间,客户端零改动
  6. 容器内存限 2G 🛡️:1.5g 跑长文本会 OOM,实测出来的安全线
  7. ctx 4096 别动 ⚠️:砍 ctx 会静默截断长文本,最阴的坑
  8. 公网必须加鉴权 🔒:不加就会被刷,模型常驻占死 slot(血泪教训)
  9. 权重 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

相关推荐
IPdodo_1 小时前
住宅 IP 与数据中心 IP 到底差在哪:用 RDAP、BGP 和多源证据做工程判定
网络·网络安全·代理模式·ip
AKA__Zas1 小时前
跨网段主机的文件传输过程
java·网络·网络协议·学习方法
Shadow(⊙o⊙)1 小时前
OTOL设计模式 One Thread One Loop
服务器·网络·设计模式
xiaoye-duck1 小时前
《Linux 网络编程》深入理解 IP 协议(一):网络层基础与 IP 协议头详解
linux·网络·ip
ao-weilai1 小时前
Linux网络编程:Linux Socket TCP
linux·服务器·网络
是个西兰花1 小时前
UDP套接字编程
运维·服务器·网络
菜根Sec5 小时前
没钱考网络安全证书了
网络·网络安全·信息安全·信息安全工程师·网络安全公司
筼筜10 小时前
【系统架构设计师】通信系统架构设计理论与实践
网络
小程序设计11 小时前
基于IPv6的校园网设计与过渡技术仿真
网络·安全