先说结论:在 Apple Silicon 的机器上,把 SSD 当成 KV 缓存的后备存储,是让 32GB 内存跑 30B 级别模型并撑住上万 token 上下文的性价比方案。我这几个月一直在折腾 oMLX 的分层 KV 缓存,拿它当 Claude Code 的本地后端,从 8K 上下文被 OOM 追着跑,到现在稳定挂在 32K 上下文做多文件改造,中间踩了不少坑。这篇文章就是把整套方案、参数逻辑和坑位完整摊开,适合也想在本地跑编程助手的 Mac 用户,尤其是内存卡在 16GB 到 64GB 之间、想榨干统一内存最后一滴的人。
先说清楚一个容易混淆的概念:这里说的"SSD 当内存用",不是把系统虚拟内存开大那么粗暴,而是专门针对 Transformer 推理的 KV 缓存做多级分层。模型权重是静态的,量化一次就固定占那点内存,真正的内存黑洞是随着对话不断增长的 KV 缓存。oMLX 的思路是把 KV 缓存按热度分层,热数据留在统一内存里给 GPU 直接访问,冷数据序列化到 NVMe SSD,等模型真正需要某个早期 token 时再按需读回。这套逻辑和 CPU 的 L1/L2/主存分级如出一辙,只不过把最底层换成了硬盘。
这篇文章适合三类人:一是想在本地跑 Claude Code 但又没有 128GB 大内存机器的开发者;二是已经装了 oMLX 但遇到 OOM 或者长上下文性能骤降的玩家;三是单纯对 MLX 生态的推理优化感兴趣,想理解 KV 缓存 offload 原理的人。我会把为什么这样做、参数怎么调、实际性能如何、坑在哪里全部讲透。
1. 先搞清楚一个问题:KV 缓存为什么非要吃内存
1.1 KV 缓存是推理时“算出来的记忆”,不是模型文件的一部分
很多人对大模型内存占用的理解停留在"模型多大就占多少内存",实际上模型权重只是一部分。Transformer 解码时,每生成一个 token,都需要让这个 token 和之前所有 token 做注意力计算。为了不重复计算历史 token 的 Key 和 Value,推理框架会把它们缓存在内存里,这就是 KV 缓存(Key-Value Cache)。
打个比方:权重文件像是你买的实体书,买回来就一直放在书架上,位置固定。KV 缓存则是你边读边在书页边缘做的笔记,每翻一页就多写几行,书读得越多笔记越厚。你不可能把整本字典都塞进口袋,但笔记写到某个程度,口袋也装不下了。
KV 缓存的大小和上下文长度是严格线性关系。计算公式是:
KV 缓存大小 = 序列长度 × 层数 × KV 头数 × 注意力头维度 × 2(K 和 V) × 每元素字节数以我手头常用的一个 32 层、8 组 KV 头、head dim 128、FP16 存储的模型为例,每个 token 的 KV 大概是:
32 × 8 × 128 × 2 × 2 = 131,072 字节 ≈ 128KB也就是说每多处理 1000 个 token,缓存就增加 128MB。跑到 100K 上下文就是 12.8GB,这个数字已经超过很多人的空余内存了。如果模型层数更多、KV 头更大,这个数字还会更夸张。
1.2 量化解决了权重问题,却解决不了 KV 缓存问题
4bit 量化能把模型权重压缩到原来的四分之一甚至更小,这是 32GB 内存能跑 30B 模型的前提。但很多人忽略了一个事实:KV 缓存通常仍然以 FP16 或 FP8 存储。原因很简单,KV 缓存的精度直接影响注意力分数的计算质量,激进量化会明显影响生成质量,特别是在长上下文场景下。
所以你会看到一个常见的尴尬局面:模型权重量化后只占 15GB 左右,可上下文一旦拉长,KV 缓存瞬间又吃掉了 8GB 甚至 16GB。32GB 统一内存的 Mac,实际能分给推理的内存可能只有 20 多GB,权重加缓存一叠加,分分钟触顶。
早期我试过用系统自带的 swap 来解决这个问题。macOS 确实会在物理内存不够时把页面交换到 SSD 上,但那是操作系统层面的统一页面调度,粒度是 4KB 内存页,完全不理解哪些数据是模型推理马上要用的、哪些是可以慢慢回读的。结果就是整个进程被拖得极慢,Claude Code 发一条消息要等很久才出第一个字。
1.3 内存装不下的溢出物:SSD offload 要解决的真实问题
既然内存确实放不下,又不能指望系统 swap 的随机调度,那就在推理框架层面做显式的、可预测的换入换出。这需要回答三个问题:
- 哪些 KV 可以放 SSD?
- 什么时候从 SSD 读回?
- 内存空间不够时优先丢弃/写出哪些?
答案都指向同一个东西:访问局部性。大模型对话中,系统提示词、工具定义、最近几轮对话是高频访问的,属于热数据。早期的历史上下文、长篇文档中被模型"扫过"的部分,后续生成时很少再被精确引用,属于冷数据。冷数据放 SSD,热数据留内存,同时用 LRU 策略维护内存中的 KV 子集,这就是分层 KV 缓存的全部逻辑。
oMLX 把这套逻辑做成了可直接配置的开关,不需要自己写算法。但前提是你要理解每个参数的含义,不然默认配置可能并不适合你的硬件和任务。
2. oMLX 为什么适合当本地推理后端:选型对比
2.1 MLX 生态的独特优势:Apple Silicon 统一内存
在 Mac 上做本地推理,绕不开 MLX。它和 CUDA 生态最大的区别在于统一内存架构,CPU 和 GPU 共享同一块物理内存,不需要显存和内存之间的拷贝。这意味着模型权重放在统一内存里,GPU 可以直接访问,省掉了 PCIe 传输的延迟和复杂度。
这个特性对 KV 缓存分层特别友好。传统 GPU 服务器上做 KV offload 到 CPU 内存,往往要经过 PCIe 拷贝,带宽再高也有浪费。而 MLX 上内存和显存就是同一个东西,SSD 换入换入的数据直接落在可以被 GPU 访问的统一内存区域,路径短、开销小。
oMLX 就是基于 MLX 的本地推理服务,专门把模型权重加载、KV 缓存管理、API 服务这几件事整合在一起。相比自己写预测代码,它能省掉大量工程适配工作。
2.2 oMLX、ollama、llama.cpp、vLLM 的取舍
我并不是没试过其他方案。在这件事上花了不少时间,简单列一下对比:
| 方案 | 底层框架 | KV 缓存 offload 支持 | 适合场景 | 主要问题 |
|---|---|---|---|---|
| oMLX | MLX | 显式分层,支持 SSD 缓存 | Mac 本机/局域网推理服务 | macOS 限定生态 |
| ollama | llama.cpp | 依赖系统 swap,控制粒度过粗 | 快速跑模型 | 长上下文性能衰减明显 |
| llama.cpp server | ggml | 有 --mlock 和部分 offload 策略 | 跨平台调试 | 配置相对原始,需要自己调参 |
| vLLM | CUDA | 支持 prefix caching 等高级特性 | 服务器多卡 | 对个人 Mac 重,概念多 |
对我来说,决定用 oMLX 的核心理由不是它功能最多,而是它把"分层 KV 缓存"做成了明确的功能模块。ollama 跑长上下文时会悄悄退化成系统 swap,整个进程看着还活着,但每生成一个 token 都要卡顿几十秒。oMLX 的 SSD 缓存路径、内存阈值、预加载策略都是显式可配的,出了问题也容易诊断。
2.3 分层 KV 缓存的设计:从热到冷的完整链路
oMLX 的分层策略大致分三层:
- L1:统一内存常驻层,放系统提示词、工具定义、最近 N 轮对话的 KV。
- L2:CPU 可寻址内存层,放较旧但可能还会被引用的 token,默认不太常用。
- L3:SSD 缓存层,放已被 LRU 策略判定为冷的 KV 分片,按会话 ID 和 token 区间组织成文件。
实际推理时,GPU 计算注意力分数只会读取 L1 层的 KV。如果某个历史 token 的 KV 不在 L1 而在 L3,框架会先把它读回 L1 再继续计算。这个过程对上层不可见,Claude Code 感觉不出来,只是那个 token 位置附近的生成速度会慢一些。
这个设计最妙的地方在于:上下文长度不再被物理内存硬性限制。你可以把 80K 上下文跑在 32GB 内存上,代价只是早期 token 被访问时偶尔多几百毫秒延迟。对 Claude Code 这类工具场景完全可接受——它一次请求通常会批量生成几百上千个 token,KV 缓存的读写次数摊到整个回复周期里,影响很小。
3. 手把手配置:从加载模型到跑通缓存分层
3.1 安装 oMLX 与模型加载
我假设你已经装好了支持 MLX 的 Python 环境。oMLX 本身是 Python 包,我用的是 uv 来管理,避免污染系统环境。
uv tool install omlx装完之后启动服务,我这里的实际启动命令长这样:
omlx serve --model mlx-community/Qwen3-32B-4bit \ --hierarchical-kv-cache \ --kv-ssd-path ~/.omlx/kv-ssd \ --kv-mem-threshold 8G \ --port 8080参数说明一下:
--hierarchical-kv-cache:开启分层缓存,不开就没法把 KV 写到 SSD。--kv-ssd-path:指定 SSD 缓存文件存放目录。务必放到 NVMe 盘,系统盘或外置盘都行,但速度直接影响冷 KV 的回读体验。--kv-mem-threshold:L1 内存层允许给 KV 缓存的上限。超过这个值,LRU 就把最旧的 KV 分片写进 SSD。设置多大取决于你机器总内存。--port:服务端口,Claude Code 要通过 HTTP 连这里。
模型仓库选mlx-community下转好的 4bit 量化版即可,这类模型是社区用官方转换脚本转出来的,兼容性比较好。我试过 Qwen3-8B 和 Qwen3-32B-4bit,都能正常加载,后者在 32GB 内存下需要分层缓存加持才能跑长上下文。
3.2 分层缓存参数详解:不是每台机器都用同一套配置
核心参数就那几个,但调法有讲究。
--kv-mem-threshold是最关键的一个。设太大,L1 缓存吃光内存,OS 开始 swap,整个服务变卡;设太小,SSD 回读太频繁,生成速度受影响。我的经验是:总内存减去模型权重量化大小,再减去操作系统和常用软件的开销,剩余空间的三分之二给 L1。32GB 机器跑 32B-4bit 模型,权重约 18GB,系统占 6GB 左右,剩下 8GB,给 KV 阈值 5GB 左右比较稳。
SSD 缓存目录要注意文件系统。macOS 自带 APFS 没问题,但如果用外置 exFAT 盘,小文件读写性能会很难看。我踩过一次,后来老老实实把缓存目录放在内置 SSD 上。
还有一个容易被忽略的参数是并发请求数。Claude Code 偶尔会并行发多个子代理请求,如果并发数太高,多个请求同时抢占 L1 内存,缓存颠簸会非常严重,表现为响应时快时慢。我一般限制最大并发 4,宁可让请求排队,也不要互相挤兑。
3.3 用 API 把 oMLX 接到 Claude Code
oMLX 启动后,本地就是一个 OpenAI/Anthropic 兼容的 API 服务。Claude Code 的接入方式我在社区里看到不少文章写,但很多都绕了弯路。其实就用环境变量指定 base URL 和 token 就行:
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080" export ANTHROPIC_AUTH_TOKEN="local-test-token" claude --model Qwen3-32B-4bit注意ANTHROPIC_AUTH_TOKEN这里随便填一个非空字符串即可,oMLX 本地服务不会真的校验 token,但 Claude Code 需要看到一个值才肯发请求。--model参数后面跟的是 oMLX 实际加载的模型名,要和启动时的一致。
跑通之后先做一个最小验证,我不会直接丢复杂的编程任务。最简单的方式:
echo "用三句话解释什么是 KV 缓存" | claude -p如果这一步通了,再打开交互模式做真实编码任务。
4. 实测:不同配置下的实际体验与性能数据
4.1 开启分层缓存前后的对比
我在 32GB 内存的 Mac 上做的对比测试,模型固定为 Qwen3-32B-4bit,启用分层缓存后把 SSD 缓存路径放在内置 NVMe 盘上。结果非常直观:
| 配置 | 最大稳定上下文 | 首 token 延迟 | 平均生成速度 | 早期 token 回读延迟 |
|---|---|---|---|---|
| 不开分层,靠系统 swap | 约 8K,超过即卡死 | 5-10 秒 | 2-3 tok/s | 不可用 |
| 开分层,L1 阈值 5GB | 32K 稳定运行 | 0.8-1.5 秒 | 12-18 tok/s | 150-300ms |
| 开分层,但缓存放外置 USB SSD | 32K 稳定运行 | 0.8-1.5 秒 | 12-18 tok/s | 500ms 以上 |
关键发现是:开启分层缓存后,常规生成速度几乎不受影响,因为当前 token 的注意力计算只涉及最近的上下文,这部分 KV 大概率还在 L1。只有当你让模型"回忆"很久之前的对话内容时,才会触发冷 KV 回读,出现一次可感知的停顿。
4.2 上下文长度对性能的影响曲线
我把上下文长度从 8K 增加到 64K,每一步都观察服务是否还能正常返回,以及生成的稳定性。测试发现:
- 8K-16K:毫无压力,和不开分层时速度一样。
- 16K-32K:速度开始略有下降,但整体稳定。
- 32K-48K:出现过一次"上下文窗口耗尽"的报错,排查下来是 KV 缓存文件组织碎片化导致的索引膨胀,清理缓存目录后恢复。
- 超过 64K:模型本身的上下文窗口可能不够了,不是缓存层能解决的问题。
对于 Claude Code 实际开发场景,32K 上下文已经很够用了。一次典型的代码库改造会话,可能涉及读取十几个文件、多次修改和测试,总 token 消耗一般在 30K-80K 之间。有了分层缓存,本地模型终于能撑到完成一次完整任务而不是中途崩溃。
4.3 多轮对话中的分层命中率观察
我顺手统计了一次 40 分钟的真实编码会话,总上下文约 28K token,分层缓存的表现大概是:
- 系统提示词和工具定义约占 3K token,完全常驻 L1,命中率 100%。
- 最近 5 轮对话约占 6K token,大部分时间在 L1,偶尔被挤到 SSD 又读回。
- 前 20K token 的早期上下文被成功 offload 到 SSD,平均每个 token 大约被回读 0.3 次。
这个命中率说明大部分时间模型都在读最近的信息,SSD 层主要起着"兜底"作用。只要不频繁跳转上下文,整体体验接近纯内存运行。
5. 踩坑实录:分层缓存和 Claude Code 集成中最常见的几个问题
5.1 缓存目录写放大:NVMe 寿命与性能的平衡
我一开始把--kv-ssd-path直接指到了~/Library/Caches/omlx,跑了大概一周,发现缓存目录里堆积了好几万个分片文件。每次会话结束,旧的分片不会自动清理,磁盘占用肉眼可见地涨。
更让人担心的是写放大。LRU 策略把旧 KV 逐出到 SSD 时会写入文件,等这个 token 再次被访问时又从 SSD 读回并删除旧文件。频繁的写入删除对 NVMe 寿命确实有影响,虽然现代 SSD 写寿命很难被普通使用跑完,但没必要这么浪费。
解决方案是加启动参数限制 SSD 缓存最大占用,并定期清理。我会在启动命令里加--kv-ssd-max-size 40G,然后挂一个 crontab 每周清理一次超过一周的缓存分片。闲聊型会话的缓存留着也没意义,编程任务才需要保留会话缓存以便后续续上。
5.2 embedding 接口的兼容性:Claude Code 的隐藏依赖
这个问题是最隐蔽的。Claude Code 在特定功能(比如子代理的长期记忆)里会调用 embedding 接口生成向量表示。如果你的本地后端只有 chat 接口没有 embedding 接口,Claude Code 的某些功能会静默失败,表现是子代理总是"忘记"之前的内容。
排查链路是这样的:先是发现 Claude Code 的--subagent模式回复变得很短,像是完全没有记忆。然后我手动 curl oMLX 的 embedding 接口,返回 404。再查日志,发现 Claude Code 确实发过/v1/embeddings请求。
解决方式是同时加载一个 embedding 模型。oMLX 支持在配置里指定 embedding 模型:
omlx serve --model mlx-community/Qwen3-32B-4bit \ --embedding-model mlx-community/bge-m3-4bit \ --hierarchical-kv-cache \ --kv-ssd-path ~/.omlx/kv-ssd加上之后重启服务,用 curl 验证一下 embedding 接口能正常返回,问题就消失了。
5.3 工具调用格式不匹配:排查链路与修复
Claude Code 本质上是一个工具调用循环:它决定调用哪些工具、以什么参数调用,然后把结果反馈回模型继续推理。本地模型的工具调用能力参差不齐,和 Claude 官方模型差距明显。
我遇到的具体问题是:Qwen3-32B-4bit 在简单对话场景下没问题,但一旦工具定义超过 5 个,它有时候会返回非法的 JSON,导致 Claude Code 无法解析工具调用,表现为"模型在反复修改一个文件但没有实际动作"。
排查步骤:
- 开启 oMLX 的 debug 日志,看到底返回了什么。
- 用 curl 手动构造一个带工具定义的请求,逐字检查模型返回内容。
- 发现模型偶尔会把工具参数里的字段名换成小写变体,导致校验失败。
- 解决:精简工具定义,让工具数量不超过 5 个;同时把
--tool-call-format调整成更宽松的解析模式。
本质上这是模型能力问题,不是框架 bug。换更强壮的模型或者减少工具复杂度都能缓解。如果实在需要大量工具,建议用 Qwen3 系列里更大参数量的版本,或者等待更好的模型发布。
5.4 一个容易被误判的坑:KV 缓存截断导致的记忆错乱
有段时间我发现 Claude Code 在长会话里会出现"改完 A 文件,又按旧版本逻辑改 B 文件"的情况,像是模型失忆了。排查了很久才发现不是模型问题,而是 KV 缓存被截断了。
原因是某个请求的上下文长度超过了模型窗口,oMLX 的截断策略默认是丢弃最早的 token。如果丢弃的 token 里包含用户最初的需求描述,模型还能靠后续对话继续工作,但如果丢弃的是某个文件的旧内容,模型就会基于不完整的上下文做出错误判断。
解决方式有两个:一是把上下文窗口上限设置得保守一点,宁可多换入 SSD 也要保留完整的早期上下文;二是在 Claude Code 层面定期用--resume新开会话,不要无限制地让同一个会话膨胀。我后来两种一起用,记忆错乱的问题基本没再出现。
6. 进阶玩法:把 SSD 缓存调到最优
6.1 不同 SSD 等级下的配置策略
不是所有 SSD 都适合做 KV 缓存层。根据我的实测,可以分成三个等级:
| 存储类型 | 典型 4K 随机读性能 | 推荐配置 |
|---|---|---|
| 内置 NVMe(PCIe 4.0) | 60-90MB/s | 激进的 offload,L1 阈值可以调低 |
| 雷电 3 外置 NVMe | 30-50MB/s | 适合放缓存文件,日常使用可接受 |
| SATA SSD | 10-20MB/s | 尽量避免,除非上下文需求极高 |
| HDD | 1MB/s 左右 | 完全不可用,别试 |
原因在于 KV 缓存回读是典型的小文件随机访问,4K 随机读性能比顺序读重要得多。PCIe 4.0 NVMe 的随机读性能是 SATA SSD 的好几倍,回读延迟差距直接决定了长上下文体验的下限。
如果你的机器只有 SATA SSD,建议把--kv-mem-threshold调高,宁可牺牲并发度也不要频繁触发 SSD 回读。如果只有 HDD,那就别开分层缓存,老实按系统 swap 的节奏跑小模型。
6.2 按任务类型调整冷热阈值
不同任务的访问模式差异很大,我用过的两种典型调法:
代码库分析任务:特点是上下文特别长,包含大量文件内容,但"旧的被引用"的频率中等。这种情况下要把--kv-mem-threshold调低一些,比如 4GB,让更多 KV 尽早进 SSD,给新 token 留足空间,避免走到系统 swap。
日常多轮聊天任务:特点是每次回复都依赖最近几轮对话,早期内容几乎不会被精确引用。这种情况把 L1 阈值调高到 8GB,让最近对话尽可能常驻内存,SSD 层只作为兜底。
如果嫌手调麻烦,也可以观察 oMLX 日志里的命中率指标。命中率长期低于 80%,说明阈值设小了;如果 SSD 缓存几乎没被写入,说明阈值设大了,内存没用满。
6.3 oMLX 作为通用本地后端的更多玩法
Claude Code 不是 oMLX 唯一能接的客户端。只要支持 OpenAI 兼容 API 或 Anthropic 格式的编程工具、聊天客户端,理论上都能走同一个本地后端。我在实际工作中至少试过三条路:
一是接 Continue 这类 IDE 插件。配置方式类似,把 base URL 指向本地端口,让 IDE 里的补全和对话都走本地模型。分层缓存带来的长上下文能力对代码补全非常有用,模型能记住当前文件之前的内容,补全质量明显提升。
二是做局域网内的共享推理服务。在办公室一台 128GB 内存的 Mac mini 上启动 oMLX,其他同事的 Claude Code 通过环境变量指向它的局域网 IP。注意这种场景一定要在服务端加 token 校验,并且只在内网环境使用。
三是自己写脚本批量调用。oMLX 服务本质是 API,我写过一个简单的 Python 脚本,把一批文档喂给模型做摘要,利用长上下文缓存避免重复处理。这类批处理任务对缓存命中率的要求更高,合理排序输入顺序能明显减少 SSD 读回次数。
最后再分享一个小技巧:如果开会话时发现响应突然变慢,先看是不是 SSD 缓存目录满了。我遇到过两次类似情况,都是--kv-ssd-max-size没设,缓存文件把磁盘撑到 99%,系统整体 IO 开始阻塞。给缓存设上限、定期清理,比任何调参都管用。