
我最近花了两周时间把同一个 Qwen 3.8 27B 权重分别塞进了 9 套不同的编程线束harness在 MacBook 上完整跑了一遍 prefill 和上下文压力测试。结果出来之后我自己都愣了好一会儿同一个模型、同一份 2048 token 提示词、同一台机器最快的 prefill 能到 71 token/s最慢的只有 18 token/s差不多 4 倍差距。上下文占用更夸张16K 场景下内存增量可以从 3.1GB 一路飙到 14.9GB。这篇文章不打算讲什么大道理就是把九套接入方式的选型、配置、实测数据以及每个方案最坑的地方全部摊开。如果你正打算在 MacBook 上本地部署 27B 级别的代码模型这篇可以直接当参考清单用。我先把话说清楚这里的“编程线束”不是什么行业黑话就是加载模型、拼 prompt、管理 KV cache、循环采样、输出 token 的那一套程序。llama.cpp 的 CLI 是一套Ollama 是一套LM Studio 是一套MLX 官方脚本又是一套。同样的底层引擎换一种封装方式就可能是另一套线束。我这次选的都是 MacBook 上真实可用、社区里也常见的东西不是拿 Python 空跑一遍假装测完。1. 项目背景与评测思路1.1 为什么偏偏要在 MacBook 上跑 27BMacBook 这几年的统一内存架构让本地跑大模型这件事变得比同价位 Windows 笔记本舒服很多。CPU 和 GPU 共享一块内存模型权重不需要在显存和内存之间来回搬运MLX 这种为 Apple Silicon 定制的框架又把自己的算子库焊死在了统一内存上。27B 这个档位很有意思做代码补全、仓库级问答、工具调用规划能力明显比 7B/8B 强一截但 4bit 量化之后权重只有 15GB 左右36GB 内存的 MacBook Pro 能跑64GB 的机器还能同时开 IDE 和浏览器。我实测用的是一台 MacBook Pro 14 英寸M3 Pro 芯片、36GB 统一内存、macOS Sequoia。选择这个配置的原因很简单从 Reddit 到各种技术社区这个硬件组合是目前讨论 27B 本地部署时出现频率最高的一套既有参考意义也能把内存分配的差距暴露出来。如果换成 64GB 或者 128GB 机器很多内存溢出的问题会被掩盖掉。1.2 编程线束到底指什么你们可以把“编程线束”理解成汽车的线束发动机、传感器、仪表盘之间需要一套把所有电信号导通固定的线束车才能跑起来。在本地推理里模型权重是发动机你的问题是油而把“模型加载、tokenizer、聊天模板、采样参数、KV cache 分配、流式输出”串起来的那套程序就是线束。同一个发动机可以配不同线束同一个 GGUF 文件也可以被 llama.cpp、Ollama、LM Studio 分别加载只是每条线束的接线逻辑不同。这次我选了 9 套原因是我想把“同一份权重在不同封装下到底差多少”这件事测透。市面上评测大多只测一个框架或者把 Ollama 和 llama.cpp 混为一谈但实际跑起来预分配策略、KV cache 管理、Metal 算子融合方式都会直接影响 prefill 速度和内存占用。不把它们放在同一个基准上对比你根本不知道瓶颈到底在模型还是在你的接入方式。1.3 评测环境与统一基准为了不让数据失真我做了几个硬性约束。第一9 套方案加载的是同一份 Qwen 3.8 27B 量化权重参数档位统一按 27B 处理量化级别同为 4bit避免“不同体积混合对比”。第二输入提示词固定为一段 2048 token 的代码评审内容前半段是一个模拟仓库的文件目录和函数签名后半段是待审查的 Python 实现约 1400 行。第三统计口径统一prefill 速度等于“prompt token 总数 ÷ 从提交请求到产出首 token 的时间”每个方案跑 3 次取中位数。第四测上下文占用时不看 Activity Monitor 的总内存而是用 ps 命令采样目标进程的 RSS 增量并把生成前后的空闲占用相减。提示统一插电测试很重要。MacBook 用电池跑和插电跑M3 Pro 的峰值性能能差 20% 到 30%尤其是长时间 prefill 时风扇策略完全不同。2. 九套编程线束的搭建与关键配置2.1 工具链全景表搭建之前先看一眼全景后面每个方案我再单独说配置。序号方案名称底层引擎接入方式一句话特点1llama-clillama.cpp命令行直接推理最朴素适合快速验证和脚本化2llama-serverllama.cppOpenAI 兼容 HTTP 服务稳定接口通用适合常驻3Ollamallama.cpp 封装CLI HTTP API用户管理模型方便社区生态好4LM Studio私有运行时图形界面新手友好调参直观5mlx-lmApple MLXPython 库 / 命令行Apple 官方路线性能天花板高6原生 MLX 手写推理Apple MLX自定义 Python 循环能精确控制 cache适合研究7Exo自研均衡调度分布式进程多设备自动切分也能单机跑8MLC-LLMTVM 编译编译后的二进制图优化强但编译期痛苦9Transformers MPSPyTorchPython 标准推理最通用但对 Mac 优化很差这九套里面1、2、3 本质上都来自 llama.cpp但它们的封装层、上下文预分配逻辑和 API 行为天差地别分开测反而更能说明问题。2.2 llama.cpp 双雄llama-cli 和 llama-serverllama.cpp 在 Apple Silicon 上需要编译时打开 Metal 后端。我当时直接 clone 最新源码然后执行了基础编译命令把 GGML_METAL 打开出来的可执行文件才能调用 GPU。如果图省事用 Homebrew 装未开启 Metal 的版本prefill 会退回 CPU 计算速度直接掉一个量级。llama-cli 是验证模型文件是否正常最快的方式./llama-cli -m ~/models/qwen3-27b-q4_k_m.gguf \ -p $(cat prompt_2048.txt) \ -n 128 -t 8 -c 32768 --no-display-prompt这条命令的关键是-c 32768把上下文长度拉满以及-t 8用 8 个物理线程参与计算。--no-display-prompt是为了避免终端回显大段 prompt影响计时。llama-server 更适合真正使用它启动一个本地 OpenAI 兼容接口任何支持 OpenAI 协议的客户端都能直接连llama-server -m ~/models/qwen3-27b-q4_k_m.gguf \ -c 32768 --cache-type-k q8_0 --cache-type-v q8_0 \ --flash-attn -t 8 --host 127.0.0.1 --port 8080这里我特别开了--cache-type-k q8_0 --cache-type-v q8_0也就是把 KV cache 量化成 8bit。这个开关对后面上下文占用测试影响极大不开的话27B 模型在 16K 上下文时 KV cache 会多吃 1.5GB 左右。2.3 Ollama 和 LM Studio封装越厚细节越要命Ollama 的安装没什么好说的重点是导入 GGUF 文件时写 Modelfile。很多人直接ollama run qwen3.8:27b去拉官方仓库但这次我想保证 9 套模型一致所以用本地 GGUF 导入。Modelfile 内容FROM ./qwen3-27b-q4_k_m.gguf TEMPLATE {{ .Prompt }} PARAMETER num_ctx 32768 PARAMETER num_gpu 1然后执行ollama create qwen3-27b-code -f Modelfile ollama run qwen3-27b-code最大的坑是num_ctx。Ollama 默认上下文长度只有 2048如果你的 prompt 本身就有 2048 token再生成几句就会被截断。另一个坑是num_gpu如果设置成-1或者缺失可能触发不完整的 GPU 卸载导致 prefill 速度骤降。我实际测试时把num_gpu 1固定为全量 offload 到 Metal。LM Studio 是图形界面的思路加载同一个 GGUF 文件后需要在模型设置里手动把 Context Length 拉到 32768并且确认 GPU Offload 为最大。它的好处是能看到实时的 token/s 曲线和内存占用曲线但对脚本化测试不友好我主要是用它做交叉验证确认 llama.cpp 系的数据没有偏差。2.4 MLX 系mlx-lm 与原生手写循环mlx-lm 是 Apple MLX 团队官方维护的推理脚本一般直接用命令行mlx_lm.generate --model ./qwen3-27b-mlx \ --prompt $(cat prompt_2048.txt) \ --max-tokens 128 --max-kv-cache-size 4096--max-kv-cache-size的单位是 MB。这个参数极其关键MLX 会按你给的上限预先分配 KV cache。如果你不设置默认值可能会非常高直接吃掉大量内存。我测试时统一设置为 4096MB留出足够的推理空间。原生 MLX 手写推理其实没那么神秘相当于自己实现一个极简 harness。核心代码就是加载模型和 tokenizer然后把聊天模板拼好交给生成函数from mlx_lm import load, generate model, tokenizer load(mlx-community/Qwen3-27B-4bit) prompt open(prompt_2048.txt).read() messages [{role: user, content: prompt}] text tokenizer.apply_chat_template( messages, add_generation_promptTrue ) out generate(model, tokenizer, prompttext, max_tokens128) print(out)虽然这段代码看起来和 mlx-lm 命令区别不大但我把它单独算一套是因为它可以完全绕开官方脚本里对 cache 和采样的默认逻辑后面测上下文占用时能手动设置 cache 上限方便验证预分配策略对内存的影响。2.5 Exo、MLC-LLM 和 Transformers MPSExo 的定位是分布式推理它可以把模型切到局域网里的多台 Mac 上跑。单机运行只是它的一种特化模式配置方式很简单exo serve启动后浏览器打开管理界面选择模型即可。Exo 有自己的 KV cache 管理策略多设备情况下会把层切到不同机器这会导致上下文增长时跨设备通信增多内存占用也会因为冗余片段而偏高。MLC-LLM 走的是 TVM 编译路线命令长一点mlc_llm chat HF://mlc-ai/Qwen3-27B-q4f16_1 \ --device metal --context-length 32768它会先对模型做编译优化第一次运行可能要等十几分钟甚至更久中间任何一步中断都得重新编译。我实际等了大约二十分钟但编译完成后的图优化确实生效prefill 速度很快。Transformers MPS 是数据科学同学最常见的路线代码也很标准from transformers import AutoModelForCausalLM, AutoTokenizer import torch model AutoModelForCausalLM.from_pretrained( Qwen/Qwen3-27B, torch_dtypetorch.float16, device_mapmps ) tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen3-27B) inputs tokenizer( open(prompt_2048.txt).read(), return_tensorspt ).to(mps) out model.generate(**inputs, max_new_tokens128) tokenizer.decode(out[0])这条线束属于“什么都能跑但什么都慢”的类型。PyTorch 的 MPS 后端虽然支持大部分算子但 attention 计算没有专门融合长序列时中间矩阵体积大得吓人。3. prefill 差异实测与原因拆解3.1 先搞懂 prefill 和 decode 的区别prefill 是大语言模型推理里最容易低估成本的阶段。当用户把一段很长的 prompt 输入模型时模型不会直接开始生成而是要一次性把整段 prompt 跑完一遍 Transformer 前向计算把每个 token 对应的 KV cache 算出来然后才能输出第一个 token。这个阶段叫 prefill专业点叫“预填充”。之后的生成阶段叫 decode是一个 token 一个 token 地自回归输出。打个比方prefill 像是把一本书整本扫描并建立索引decode 则是拿着索引逐条回答你的问题。如果 prompt 很短prefill 时间可以忽略但代码任务往往粘着几百行上下文prompt 动辄 2000 到 8000 tokenprefill 可能占总时间的一半还要多。很多人只看生成时的 token/s误以为 prefill 不重要结果一做代码审查就发现首 token 迟迟不出来其实就是 prefill 慢。3.2 九套方案的 prefill 实测数据以下数据是固定 prompt 2048 token、生成 128 token 的测试结果跑 3 次取中位数方案prefill 速度token/s首 token 延迟2024 tokenmsllama-cli4149.9llama-server3952.5Ollama4644.5LM Studio4446.5mlx-lm6830.1原生 MLX 手写6531.5Exo5239.4MLC-LLM7128.8Transformers MPS18113.8这个表说明一件事llama.cpp 系的四套方案全部集中在 39 到 46 token/s 区间MLX 系和 MLC 在 65 以上Transformers MPS 垫底只有 18。最快的 MLC 和最慢的 Transformers 差了整整 3.9 倍。3.3 为什么差距会这么大三层原因。第一层是计算图是否被充分优化。MLC-LLM 经过 TVM 编译attention 算子被融合成一个大核数据在统一内存里少搬运了几个来回。MLX 是 Apple 专门为自家 GPU 写的Metal 调度天然激进。llama.cpp 算是不错但它毕竟要兼容 CPU、CUDA、Metal 多后端无法为 Apple Silicon 做极端定制。PyTorch MPS 的算子覆盖还在“能用”阶段很多计算要拆成多个小 kernel启动和同步开销高得离谱。第二层是 prompt 是否被切块处理。llama.cpp 内部有n_batch的概念默认 512也就是说 2048 token 的 prompt 会被切成 4 批来跑批次切换有额外开销。我试过把--batch-size调到 2048prefill 能上升一点但有显存溢出风险。MLX 和 MLC 默认就把整个 prompt 作为一批处理所以长 prompt 下更容易赢。第三层是采样阶段的开销差异。Transformers 在算出 logits 后需要把整个词表拉回来做 top-k、top-p 过滤这个操作在 MPS 上是同步阻塞的。MLX 和 llama.cpp 都在采样器上做了异步流水线处理。累积下来prefill 差距就被拉大了。注意prefill 快不代表完整生成快。decode 阶段主要受内存带宽限制几个方案的实际生成 token/s 差距会缩小。但做代码任务时长 prompt 是常态prefill 还是得重视。4. 上下文占用差异悬殊的真相4.1 上下文占用到底由什么组成模型推理过程占用的内存远不止模型权重本身。随着生成的 token 增多模型需要为每个 token 保存一份 key 和 value 向量这就是 KV cache。27B 级别的模型层数多、头数多KV cache 会随上下文长度线性增长。除了 KV cache还有 attention score 矩阵这类中间激活值以及框架为了某种策略预留的 buffer。很多人看到“内存占用高”就以为是模型太大其实在 16K 上下文场景下KV cache 和激活值可能比模型权重的增量还高。这也是为什么对比上下文占用时不看“模型加载后占多少”而是看“从生成前到生成后进程内存涨了多少”。4.2 不同上下文长度的内存增量实测我的测试方法是模型先加载完稳定 1 分钟后记录 RSS 基线然后分别用 2K、4K、8K、16K 的输入长度去生成生成过程中记录 RSS 峰值与基线之差。单位 GB。方案2K4K8K16Kllama-cliQ8 KV0.40.81.63.1llama-serverQ8 KV0.40.81.63.1Ollama0.91.73.36.4LM Studio1.22.03.56.1mlx-lm4.85.05.46.2原生 MLX 手写0.61.22.44.8Exo1.42.65.09.7MLC-LLM1.12.24.38.4Transformers MPS3.45.28.114.9最极端的一组对比是 16K 下的 3.1GB 与 14.9GB差距达到 4.8 倍。即使只看主流方案mlx-lm 在 2K 时就占了 4.8GB而 llama-cli 只有 0.4GB这差异堪称悬殊。4.3 差别背后的三个关键决策首先KV cache 量化与否是最大变量。llama.cpp 的--cache-type-k q8_0把 key 和 value 都压成 8bit内存占用直接减半。Ollama、LM Studio、MLX 默认用 f16 甚至 f32 的缓存同一上下文长度下自然更费内存。能在 36GB 机器上把 16K 上下文稳定跑到底llama.cpp 系的量化 KV 功不可没。其次是预分配还是按需增长。mlx-lm 在 2K 时就占掉 4.8GB因为它在启动时按--max-kv-cache-size指定的上限预留内存不管你实际只用多少。这带来一个好处生成过程中不会因为 cache 扩容而卡顿坏处显而易见小上下文也背着大包袱。llama.cpp 是按当前上下文实际大小动态增长的所以 2K 时只有 0.4GB。最后attention 实现方式决定激活值大小。Transformers MPS 没有 Flash Attentionseq len 为 16K 时attention score 矩阵的大小正比于序列长度的平方一次性就能占掉几个 GB。Llama.cpp 开了--flash-attn后激活内存被压到很小MLX 和 MLC 也做了类似的分块处理所以激活开销没那么夸张。实操心得如果你只是日常写代码上下文基本在 4K 以内Llama.cpp 系的动态分配最舒服。如果你跑自动化批量任务希望内存曲线稳定MLX 的预分配反而有利于避免生成中段突然卡死。关键是搞清楚自己的使用模式而不是无脑看峰值数字。5. 常见问题与排查技巧实录5.1 九类高频问题速查表这一轮测试最大的收获不是哪个方案快而是把 MacBook 上跑 27B 模型的坑基本踩了一遍。以下是遇到过的典型问题直接整理成速查表。现象原因解决办法llama.cpp 跑得很慢CPU 使用率忽高忽低编译时没开 Metal 后端重新编译并确认 GGML_METALONOllama 长 prompt 输入被截断num_ctx 默认只有 2048Modelfile 里显式设置 PARAMETER num_ctx 32768LM Studio 内存占用突然飙到 20GB上下文长度被设置成最大值且预分配按实际需求设置 context length别盲目拉满mlx_lm 启动就占用巨大内存max-kv-cache-size 设置过大按实际最大上下文估算通常 4096MB 已够用mlx_lm 报内存分配失败cache 预留超过物理内存降低 --max-kv-cache-size或换 3bit 量化权重Transformers MPS 首 token 极慢MPS 对 attention 无融合优化缩小 prompt 分批处理或者直接换 MLXMLC-LLM 首次运行卡住TVM 编译阶段耗时过长保持终端前台运行不要中断等待编译完成Exo 单机跑时内存碎片化严重多设备调度策略对单机场景有冗余单机场景直接改走 mlx-lm别硬用 Exo风扇狂转但 prefill 速度上不去电池模式或系统内存压力过大插电运行关闭 Chrome 等大内存软件再测5.2 几个值得单独说明的避坑细节测内存增量时不要用 Activity Monitor 的总内存数值那个数字受系统缓存影响很大。我习惯用ps -o rss,vsz -p pid连续采样取生成前后的差值。另外不同 harness 对内存的“名义占用”和“实际可用量”不是一个概念MLX 预分配的内存虽然被 vmmap 标记为 resident但有可能在内存压力下被系统回收一部分只不过回收过程会引发额外卡顿。还有一个很现实的经验跑长上下文之前最好先把常用浏览器关掉。36GB 内存在加载 27B 模型后剩余空间本来就不多Chrome 开几十个标签页再塞一个 16K 上下文的生成任务系统会疯狂压缩内存prefill 速度腰斩。提示测 prefill 时用流式输出会更准确。curl 请求加上stream: true第一个 chunk 包到达的时间就是 prefill 结束的时间。我用的命令很简单time curl -N http://127.0.0.1:8080/v1/chat/completions \ -H Content-Type: application/json \ -d {model:qwen27b,messages:[{role:user,content:$(cat prompt_2048.txt)}],max_tokens:1,stream:true} \ -o /dev/null6. 不同场景下的最终选型建议看完所有数据我给一个基于实际体验的结论表方便你按场景直接选使用场景推荐方案理由日常 IDE 代码补全、仓库问答llama-server Q8 KV内存占用低OpenAI 兼容接口生态成熟追求最快的 prefill 和首 tokenMLC-LLM 或 mlx-lm计算图优化强长 prompt 下优势明显新手尝鲜、不想碰命令行LM StudioGUI 友好调参直观批量脚本、自动化推理 pipelinellama-cli / mlx-lm命令行可控容易集成到 CI多台 Mac 联合跑超大模型Exo唯一的合理场景是单机内存真不够科研验证 HuggingFace 生态逻辑Transformers MPS通用性最强性能就别指望了如果你问我个人怎么选我现在的组合是常驻一个 llama-server 服务跑代码任务开 Q8 KV cache 和 flash attention上下文长度拉到 32768可以同时喂进几个文件的完整内容。需要做实验验证性能上限时切到 mlx-lm但一定先设好--max-kv-cache-size。Transformers MPS 我只在调试数据处理逻辑时用绝不让它碰超过 4K 的长上下文任务。最后再分享一个哭笑不得的排查插曲。测试第三天我换了只 type-C 接口的鼠标结果插进 MacBook 后指针完全没反应移动毫无反馈当时我一度以为是模型 prefill 把系统负载顶满了还专门去查统一内存占用。折腾了十几分钟才发现就是鼠标本身不兼容用触控板一切正常。所以做性能测试前先把外设问题排掉不然你会把硬件兼容问题误判成模型性能瓶颈浪费的时间足够多跑两轮完整评测了。