在 Mac Studio M5 Ultra 上跑本地大模型与定制 Splash 推理服务的实践记录
目录
声明:本文包含 AI 辅助创作。
TL;DR 这篇博客记录了我过去一个半月在配备 256 GB 统一内存的 Mac Studio(M5 Ultra,64 核 GPU)上本地部署百亿激活级混合注意力大模型,并把它打磨成日常驱动 Claude Code 与 Codex 长上下文编码服务的全过程。文章按真实的探索与演进顺序展开,先算清楚这台机器相比顶配 80 核版本及英伟达旗舰显卡的真实性能定位,再复盘模型量化与投机解码选型里几次反直觉的实测结果(包括为什么体积小近三成的 Q5_K_M 反而比 Q8_0 慢两成),接着讲怎么排查十几万长上下文缓存归零的原因并靠十来行 C++ 改动切换到 Splash 引擎实现吞吐翻倍,随后介绍通过引擎内埋点回放砍掉一半动态内存预算的过程,最后聊聊自研三进程 API 网关、无感模型切换与三次核对监控统计口径的细节。
前言
九月下旬的时候,我新配了一台 Mac Studio(Mac17,15,搭载 30 核 CPU、64 核 GPU 的 Apple M5 Ultra 芯片与 256 GB 统一内存)。买这台机器的目标从第一天起就很明确,我没打算拿它在网页对话框里跑几句简单的日常闲聊,核心诉求是把它当成重度编码 Agent(Claude Code、Codex 以及其他多步自动化框架)的本地长上下文推理后端,用来跑动辄十万到二十万 token 的多轮代码阅读与工具调用任务。
真正把机器摆上桌开始跑测试之前,我先让 Claude Code 写了几个底层探测小工具摸清硬件边界。在 macOS 27.0 上,系统默认分配给 Metal GPU 的最大推荐工作集(recommendedMaxWorkingSetSize)已经提高到了物理内存的 87%,也就是 222.7 GiB(比网上很多旧教程按 75% 算出来的数值足足多了 31 GiB,照抄旧教程手动改内核参数反而会把可用显存改小)。用 4 GiB 的 fp16 数组实测 GPU 矩阵乘向量带宽,稳定在 1,014 GB/s。
我也专门拉了 llama.cpp 官方同口径基准(Llama 2 7B Q4_0),横向对比了这台 64 核 M5 Ultra、顶配 80 核版本以及英伟达消费级显卡的真实水平。客观地讲,Apple Silicon 的算力短板在预填充(Prefill)阶段非常明显,本机预填充速度只有单张 RTX 4090 或 RTX 5090 的约三分之一;受内存带宽主导的生成解码(Decode)速度则与 RTX 4090 持平,约为 RTX 5090 的六成;至于贵出一万多元的 80 核 M5 Ultra,因为内存控制器带宽与 64 核版本完全相同,生成速度几乎没有差别(差距不到 1%),预填充也只快了 13%。
这台机器真正不可替代的地方在于 256 GB 的高速统一内存容量。一个总参数约 180B 的 8-bit 量化稀疏混合专家(MoE)模型,单权重文件就有 175 GiB,再加上二十多万上下文的键值缓存,单张 32 GB 的 RTX 5090 甚至 96 GB 的专业卡都装不下,硬凑多卡工作站动辄要花十多万元还会受制于主板总线切分。算清楚这笔账之后,我们接下来的全部工作就是围绕这 222.7 GiB 可用统一内存和 1,014 GB/s 带宽,把速度、缓存命中率和日常使用体验尽可能调到最好。
模型与量化选型里的反直觉实测
选定混合注意力 MoE 与跑满 262K 上下文
在 256 GB 统一内存的边界内挑主力编码模型,经过几轮筛选和试跑,我们最终锁定了 Qwen3.8-Flash-Next-Uncensored(原生支持 262,144 上下文)。它的架构非常有代表性,总参数规模约 180B,但包含一张约 51.2B 按行查表的词级嵌入表(PLE),48 层主干网络由 36 层固定状态大小的门控线性注意力(GDN)和 12 层带块稀疏索引器(QSA)的全注意力组成,每生成一个 token 只从 512 个专家里激活 10 个(约 6B 激活参数)。
按内存带宽做一次简单算术就能看出它对 Apple Silicon 有多友好。如果是传统的 27B 稠密模型,跑 Q8_0 量化每生成一个 token 都要把 29 GB 权重完整读一遍,理论带宽上限只有 35 tok/s 左右;而 Flash-Next 的 Q8_0 版本虽然文件总大小有 175 GiB,但每个 token 实际参与计算读取的激活权重只有 7.28 GB。同期我们还评估过 753B 总参的 2-bit 超低比特模型以及 320B 的 GLM-5.3-Flash,前者量化损失过大,后者则因为部分注意力头尺寸触发了 CPU 回退且拒答率偏高而被淘汰。
9 月 24 日模型选定当天,我起初在 Unsloth Studio 里拉起了按理说很契合苹果芯片的 MLX 格式,结果刚把 Claude Code 和 Codex 真连上去发请求,就遇到了 503 No GGUF model loaded 和 400 Bad Request 报错。顺着本地 ~/.unsloth/studio/ 的 Python 源码查下去才发现,Studio 在 Python 3.13 环境下遇到了异步生成器取消作用域的兼容缺陷,直接关掉了 MLX 后端的 /v1/messages 和流式 /v1/responses 接口。
于是切回基于 llama-server 的 GGUF Q8_0 权重(外加 mmproj F16 多模态视觉组件)。紧接着又碰到一个怪事,明明内存绰绰有余,面板却提示超出统一内存预算,自动把 262K 上下文砍成了可怜的 8,192。顺着 llama_cpp.py 往下追查发现,Studio 更新自带的 llama.cpp 二进制包时,漏掉了 llama-fit-params 探测程序的可执行权限(权限位只有 -rw-r--r--)。探测静默失败后,外层估算逻辑把原本只驻留在 CPU mmap 里按行查表的 51.29 GB 嵌入表也一股脑算进了 GPU 显存账上。给那个二进制补上 chmod +x 并刷新缓存后,262,144 满血上下文顺利跑通。
两次推翻线性外推与闲置槽位跨度污染
基础推理跑通时,Studio 默认模式下的短输入生成速度只有 50.5 tok/s,到了 9.8 万上下文更是掉到了 30.5 tok/s。为了提速,我们在 9 月 25 日手动接入了 Flash-Next 配套的 Multi-Token Prediction(MTP)投机解码草稿权重,测试每次起草 token 数 n=2、n=3 与 n=4 的真实收益。
这一轮测试里发生过两次有意思的误判。为了省掉几轮长上下文压测时间,AI 先后两次尝试用线性公式做纸面推算。第一次测完短上下文后,它按单步增加耗时反推,判断 9.8 万长上下文下 n=3 会比 n=2 慢;实际跑下来,9.8 万上下文下 n=3 跑出了 46.6 tok/s,比 n=2(43.0 tok/s)快了 8.4%。看到 n=3 更快之后,AI 又改用新的线性模型拟合,预测 n=4 能进一步涨到 50 tok/s 左右并准备收工。
我在会话里问了一句「另外 4 确定不测么」,干脆把 n=4 也一起跑完。实测数据出来,第二次线性外推又失效了,n=4 在短输入(67.5 对比 72.7 tok/s)、2.7 万上下文和 9.8 万上下文下全线倒退。拆解单步验证耗时才看清楚,GPU 批量计算根本不成线性关系,第 3 个草稿 token 每步只多花约 6 ms,而第 4 个草稿 token 恰好跨过了 Metal 计算核的分块边界,单步耗时一下子多跳了 10~12 ms,把多接受草稿省下来的步数全抵消了。
紧接着跑完最后一轮压测时,我听到桌上的 Mac Studio 风扇突然高速转了起来,随口问了一句会不会有温控降频导致的测试误差。写两个 C 小程序直读苹果 IOKit 温度传感器和 SMC 风扇转速核对后,证实满载期间芯片最高温度只有 73~75 °C,系统热状态始终是正常的 nominal,排除了过热降频。
那为什么同一组 2.7 万上下文测试,放在 9.8 万长测试后面跑就会莫名其妙慢 11%~20%?顺着这个异常深挖 llama-server 的槽位源码,才揪出了统一键值缓存(--kv-unified)里的跨度污染机制。4 个并发槽位共用同一个 KV 缓冲池,底层确定注意力扫描范围的函数直接取整个缓冲池里已占用单元的最大编号(used_max_p1)。前一个 9.8 万上下文请求跑完留在空闲槽位里还没清空时,后一个 2.7 万请求从内存缓存恢复前缀,数据块就会被分配在 9.8 万单元的高地址后面;哪怕随后空闲槽位被清空,当前请求自己的块编号依然停在 12.5 万的位置上,导致每一步注意力计算都要空扫十几万个被掩码屏蔽的空白单元。摸清这一层机制后,我们在基准脚本每轮测试前都加上了显式擦除槽位的接口调用,测量误差随即消失。
体积更小的 Q5_K_M 为什么反而比 Q8_0 慢两成
10 月 4 日那天,我看到社区里有体积小了近三成的 Q5_K_M 量化版本(总体积约 133 GB),按常规直觉觉得权重文件小这么多,每次从内存读取的数据量更少,生成速度应该会快不少,于是下载回来跟正在用的 Q8_0 同台对比。
实测结果完全出乎意料。在同样开启 MTP n=3 投机解码的条件下,体积更小的 Q5_K_M 在短上下文、2.7 万和 9.8 万上下文下,生成速度反而比 Q8_0 慢了 16%~29%。
为什么每步少读将近三成字节,速度反而倒退了两成?原因就出在「投机解码微批量验证」与「Metal 解量化开销」的叠加效应上。如果不开启投机解码、每次只逐个生成 1 个 token,瓶颈确实主要在内存读取带宽;但开启 MTP n=3 之后,主模型每一步要同时并行验证 4 个 token,计算形态从矩阵乘向量变成了小批量矩阵乘。Q8_0 的量化块结构极其规整,GPU 内核可以直接对齐读取计算,一步处理 4 个 token 只需 36.6 ms;而 Q5_K_M 采用复杂的高低位分拆超级块结构,在 4 token 微批量下,Metal 着色器花在按位解包与权重还原上的算力开销被明显放大,单步耗时涨到了 43.8 ms。与此同时,逐位置对数概率比对还显示 Q5_K_M 在中文长段落上出现了 10.8% 的首选词分歧。看完这组数据,我就删掉了 Q5_K_M,确定继续用 Q8_0。
排查长上下文缓存失效与转向 Splash 引擎
差一个 Token 引发的十分钟级全量重算
10 月 6 日到 10 月 8 日,我把调好参数的 llama-server 正式接给 Claude Code(开启 /goal 跑长程自主任务)以及另一套日常使用的编码 Agent 框架实战压测。刚跑起来半天就遇到了一个非常折磨人的问题,只要任务上下文累积到七八万乃至二十万 token,几乎每一轮对话都要卡住三四分钟重新做全量预填充。后台日志统计显示,下午短短三个小时的 84 次请求里,光重新预填充就耗掉了 155 分钟,缓存复用率跌到了可怜的 3%。
明明本地配了充足的内存提示词缓存,多轮对话每轮也只新增几百个工具返回 token,为什么每次都要从头算起?AI 起初猜测是参数偏小,建议把上下文长度设成 393,216、再去调槽位相似度并把检查点数量加到 9 个。但 393,216 连模型本身的 262K 上限都超了,显然没找对方向。考虑到会话里包含未公开的工作内容,我们约定排查时不读取请求正文文本,专门写了只统计 token ID 数组与长度分布的脚本去定位原因。
在不碰任何明文的前提下,抓包比对脚本先后定位出了两处很隐蔽的跨层缓存失效机制。
第一处机制出在「动态注入标签」与「循环层检查点边界」的 1 个 token 错位上。当时客户端挂载了 113 个 MCP 工具,光是开头的系统提示词与工具定义就占了 4 万多 token;而客户端每发出一轮新请求,都会在第一条用户消息正前方动态更新一句剩余 token 计数标签(<total_tokens>... tokens left</total_tokens>)。由于 Flash-Next 包含 36 层无法按位置随意截断回退的线性循环层,llama-server 只能在固定的消息边界建立状态恢复检查点。抓包数据显示,那条动态变化的计数标签导致前后两轮请求的最长公共前缀停在了位置 50,412,而最近的一个可用状态检查点恰好建在紧随其后的位置 50,413。就因为刚好差了这 1 个 token 跨不过检查点,29 次请求里有 26 次被迫退化回 n_past = 0,把前面五万多 token 连同整段对话全部重算一遍。
第二处机制出在「上下文压缩请求抢槽」与「多模态计数低估」的连环冲突上。在另一套编码 Agent 框架里,会话涨到 20.1 万 token 触发自动摘要压缩时,客户端会先发一条 3.1 万 token 的压缩请求,紧接着再发一条 20.1 万 token 的主对话恢复请求。因为两条请求共享了开头 2.9 万 token 的系统提示词,前缀相似度算出来是 0.153,刚好越过了 llama-server 默认的 0.10 槽位匹配阈值。结果 20.1 万的主请求直接抢走了刚刚跑完 3.1 万压缩请求的活跃槽位,根本没去内存缓存池里读取早就存好的 20 万完整状态(把 --slot-prompt-similarity 调高到 0.5 并扩大内存缓存池后,20 万上下文恢复耗时立刻从 3 分钟降到了 0.63 秒)。与此同时,我们还顺着日志查出客户端自己的 token 估算器把单张占 2,600 视觉 token 的截图按 JSON 短字符串算成了几十个 token,中文也按 4 个字符折算 1 个 token 严重低估,导致每次触发压缩只裁掉位置 29,141 后面的零头,把后面的十几万缓存反复改写报废。
十七行代码接入 Splash 与速度翻倍
把 llama-server 的槽位参数理顺之后,单流稳态解码速度大约维持在 58~68 tok/s(四并发总吞吐上限约 101.4 tok/s)。到了 10 月 21 日下午,我注意到了专门面向 Apple Silicon Metal 编写的开源推理引擎 incoai/splash(1.4.0 版本),它原生实现了 Flash-Next 的 QSA 块稀疏注意力、基于块哈希的 KV 前缀树缓存,还配套提供了 0.6B 参数的块扩散投机解码草稿模型 DFlash2。
为了不影响手头正在跑的编码任务,我们先在不中断现有服务的前提下做完了架构评估与源码下载。等到准备加载本地现成的 175 GiB Q8_0 权重时,启动预检报了一个硬错误,Splash 1.4.0 强制要求模型里 24 个稀疏注意力索引器张量(indexer.k_proj.weight 与 indexer.q_proj.weight)必须是 F32 或 BF16 类型,直接拒收了我们 GGUF 文件里的 Q8_0 索引器权重。
摆在面前的有两条路,要么重新转换并落盘一份近两百 GB 的权重文件,要么直接改 Splash 的 C++ 权重加载器。衡量了一下磁盘空间和耗时,改加载器显然省事得多,于是我们先评估了源码改动范围。
翻看 Splash 源码发现,引擎内部其实现成写好了 Q8_0 到 F32 的反量化函数 DequantizeToFloat32,只是在 Python 预检规则和 GgufImage.cpp 的索引器加载分支里卡死了类型白名单。这 24 个张量加起来总共只有约 1,966 万个参数,加载时在内存里反量化成 F32 只会多占 55.08 MiB 内存,连 175 GiB 总权重的零头都算不上。Codex 只在 2 个核心文件里新增 11 行、删改 6 行核心代码,跑通了包含 MTL_SHADER_VALIDATION=1 逐字节比对在内的 1,319 项单元测试。
停掉旧服务切到新引擎后,只花了 21.65 秒 就把主权重、PLE 表和视觉组件全部装入 Metal 内存。效果非常明显,预填充速度从原先的不到 1,000 tok/s 跃升到了 2,785~3,883 tok/s;单流生成速度首测即达 93.6 tok/s,日常编码任务稳定在 100~108 tok/s(DFlash2 草稿接受率稳定在 52%~54%,每步验证平均产出 3.14 个 token);三路并发下的纯输出总吞吐推高到了 150.9~174.8 tok/s,块哈希前缀缓存让多轮 Agent 任务的整体命中率长期稳在 92%~95%。
内存与磁盘缓存精算
关停百 GB 级临时盘写入与果断回退无效改动
Splash 跑起来当天深夜十一点多,我扫了一眼系统活动监视器,突然发现进程启动才几个小时,磁盘累计写入量就已经涨到了六十多 GB。查了一下进程打开的文件句柄才发现,Splash 默认会在系统 /var/folders/ 临时目录下创建两个解除目录链接的匿名临时文件做 32 GiB 滚动键值缓存,高强度跑一天下来累计读盘高达 490 GB、写盘 130 GB,而且进程一退出就全部销毁。
既然机器有 256 GB 统一内存,完全没必要拿内置 SSD 的擦写寿命去换进程退出就销毁的临时缓存。我们当晚就把启动参数切到了 --max-cache-disk 0 纯内存缓存模式,先把总内存预算设到 212 GiB(其中权重与固定运行缓冲占 178.97 GiB,留出 33.03 GiB 动态缓存池),磁盘缓存读写随即归零。
这期间还做过一次小实验并及时做了回退。我注意到以前用 llama.cpp 连续卸载再加载同一个模型时,第二次加载能直接命中 macOS 的操作系统文件缓存不用读盘,而 Splash 每次重载还是会重新读盘 20 秒。查源码发现 Splash 的 WeightSource.cpp 打开权重文件时显式设置了 F_NOCACHE 标志绕过系统页缓存,并且每次都要重新分块读入私有的 Metal 缓冲区。我们试着加了一个允许保留系统文件缓存的开关跑了一天,结果观察发现,在 256 GB 的机器上把 195 GiB 以上的 Metal 缓冲区锁定之后,操作系统早就把百来 GB 的文件页缓存挤出去了,下次重载照样得从磁盘重读。确认没有实际收益后,就把这个开关代码完整回退了,没在仓库里留无用分支。
引擎内埋点回放五百次请求,砍掉一半动态缓存预算
切到 212 GiB 纯内存预算的第二天凌晨三点,我看着占满的内存产生了一个新疑问。动态缓存池分到了整整 33 GiB,这里面除了常规的注意力键值页,还存着混合架构特有的「可恢复请求状态」(48 层里的 36 层 GDN 线性注意力无法靠键值页倒推历史,每个恢复快照都要固定占用 113.25 MiB 内存,其中 FP32 循环状态矩阵占 108 MiB)。
我在会话里跟 Codex 聊了我的直觉,Agent 任务一路往前滚动推进,这 33 GiB 缓存里会不会绝大多数快照都只被下一轮访问过一两次就再也没用过了?如果是这样,哪怕砍掉一半甚至二十几 GB,缓存命中率可能也不会掉多少,却能给系统和日常同时打开的 IDE、浏览器腾出十几 GB 物理内存。不过直觉归直觉,到底缩减到多少合适,还是得进 C++ 引擎做一次埋点统计,看真实数据怎么分布。
我们在不记录任何请求文本的前提下,往 Splash 的 C++ 缓存管理器里加了一层轻量生命周期轨迹采集器,录下了白天高强度工作时的 543 次真实准入请求,并写了一个独立的离线回放器。为了确保回放器没有算错,我们先拿原预算回放了一遍,确认全部 543 次请求的缓存恢复位置与真实引擎 100% 逐条吻合,随后才按每 1 GiB 一档扫描了从 9 GiB 到 33 GiB 共 25 档容量下的真实命中率曲线。
回放数据一半验证了我的直觉,另一半也及时打消了我原本想砍到只剩几个 GB 的激进念头。在录下的 149 个循环状态大快照里,确实有 120 个只被复用过 1 次,白白占掉了 13.27 GiB;但另一边,KV 前缀树里有 81% 的字节被复用了至少 3 次以上,如果真把动态预算猛砍到 9 GiB,未命中的预填充 token 量会暴增 44%~46%。对照着完整的 25 档折算表,从 33 GiB(命中率 92.13%)一路降到 16 GiB(命中率 90.05%),整体吞吐折损不到一成,却能一口气释放出 17 GiB 系统内存。
据此我们把动态缓存预算对半调到了 16 GiB(对应总预算 195 GiB),并把这份实测容量预估表做进了前端的加载模型弹窗里。新配置连续实跑 9.5 小时、完成 3,569 次请求且实测命中率稳定在 91.50% 之后,就把引擎里那 1,400 行临时埋点代码和 3.61 GiB 轨迹日志一并清理掉了。
打磨日常服务体验与较真每一个监控指标
三进程解耦、优雅卸载心跳保活与生命周期治理
把一个本地推理引擎真正当成全天候基础设施来用,光模型跑得快还不够,改界面不能断流,换模型不能让正在工作的 Agent 报 502 崩溃,双击启动也不能在桌面上留一个关不掉的黑框终端。为此,我们在 Splash 原生服务外面自研了一套严格解耦的三进程架构。
Claude Code / Codex / 局域网客户端
│ (HTTP / SSE, Bearer API Key, 默认 :8888)
▼
splash_webui.control (API 调度网关, :8888 / 管理 :18084)
├─ 256 深度 FIFO 队列 / 1~4 动态并发控制 / SQLite 30 天明细与长期聚合
├─ 优雅卸载挂起队列 + 每 15 秒 ": keep-alive" SSE 注释帧防断连
└─ 独立会话拉起与身份核验管理 ──▶ Splash 原生推理引擎 (:18081, 195 GiB Metal)
▲
│ (本机管理转发)
splash_webui.app (React + MUI 监控与管理控制台, :18082)
三个进程各司其职,前端静态资源与页面服务跑在 18082 端口,核心的鉴权与 FIFO 排队,还有指标落库和模型生命周期管理跑在独立的 control 网关进程里,底层 18081 端口的原生 Splash 进程只管专心推理。这样平时改前端界面或重启页面服务时,占着近两百 GB 内存的模型进程纹丝不动,正在生成的流也不会中断。期间还排掉了一个 Python 标准库在 macOS 上的经典阻塞问题,标准库 HTTPServer 绑定端口时会默认调用 socket.getfqdn() 做反向主机名解析,在特定网络环境下会卡死好几秒导致测试超时,我们重写了 NumericHTTPServer 直接保留数字 IP,彻底消除了绑定阻塞。
针对切模型或调参数时上游 Agent 容易断连报错的痛点,我们做了一套「优雅卸载」机制。点击优雅卸载后,网关会放行已经在生成中的请求直到自然结束,同时把所有排队中和新到达的请求全部挂起(队列上限扩到 256 个,最长等待放宽到 24 小时)。为了防止 Claude Code、Codex 或中间反向代理在等待模型重载的几十秒里因为收不到字节而主动超时断开连接,网关对照 WHATWG 的 Server-Sent Events 规范,每隔 15 秒 向挂起的流式连接发送一行标准的 : keep-alive 注释帧。客户端解析器会自动忽略注释行,既保住了 TCP 长连接不断开,又绝对不会污染首字延迟(TTFT)和 token 计数;等新模型加载就绪,挂起的请求按先来后到顺序无缝恢复推进。
在桌面启动与退出体验上我们也磨了两轮。最初双击 .command 脚本启动后,即便用 Python 分离了进程会话,macOS 桌面层依然把后台服务归属在「终端」应用名下,关掉终端点停止后台运行就会把模型一起杀掉。我们干脆把它改造成两个用户图形会话级的 launchd 服务(RunAtLoad 与 KeepAlive 均设为 false),双击脚本通过 launchctl 拉起后终端窗口即可干净退出。后来日常用久了,发现侧边栏早期为了热更新页面设计的「结束 Web UI(保留网关和模型运行)」按钮很容易让人误以为已经退出了服务,结果后台还静默占着近 200 GiB 内存,于是又在 11 月初把它改造成了按顺序先停模型再停网关,最后关闭页面的「退出 Splash」按钮,如果模型进程在收到信号 30 秒内没停稳,页面会保留并明确报错,绝不留下后台残留进程(十月下旬查系统空闲内存异常占用时,还真在后台抓出过 1,966 个因为某逆向插件信号处理死锁而残留的孤儿进程,清理后释放了 16.5 GiB 物理内存和 38 GiB 交换空间)。
三次核对吞吐统计口径与验证长上下文零衰减
有了可视化控制台(早期手写原生页面时曾把整段 CSS 和 JS 挤在一行里且单文件破千行,后来统一重构成了 React + MUI,并配上了 Prettier 与 ESLint,外加 Ruff 和单源码文件不超过 800 行的 CI 检查),我们对面板上每一个监控指标的真实性做了三次核查。
在界面细节上,面对十几万 token 的长请求,最让人焦虑的就是不知道它到底卡死了还是正在预填充。我们接入原生 /status 每秒轮询,在请求编号旁边实时画出预填充百分比进度条,一旦进度到达 100% 进入解码阶段就立刻隐藏进度条减少视觉干扰,同时按并发上限固定保留 4 行槽位高度,删掉了早期标题栏上只有开发者才看得懂的采样间隔说明。
围绕吞吐率(TPS)与延迟的数字口径,我们先后纠正和验证了三件事。
第一次是看到两条流并发生成时,面板上实时速度偶尔同时跳出 106.7 和 104.7 tok/s(合计破两百),我好奇是不是并发计数翻倍算错了。对线上跑着的流做了 75 秒独立采样核对后,证实 Splash 合批验证时确实存在 1 秒窗口爆出 216 tokens(214.5 tok/s)的瞬时峰值,但也查清了持续双流的稳态总吞吐其实在 150.3~163.4 tok/s 之间。
第二次是接着核对面板上的「整体平均 TPS」算法,一查代码发现初版只是把近期完成请求的各自 TPS 做了简单算术平均。这种算法在多并发与混合负载下是一笔糊涂账,两条流重叠的时间被算了两遍,某条流正在解码时如果撞上另一条长请求进来做预填充抢占了 GPU,算出来的解码速度又会被明显拉低。于是我们把统计口径改成了严格的纯输出墙钟吞吐,按 1 / 2 / 3 / 4 条活跃生成流分别建桶,利用引擎暴露的累计预填充批次数(prefill_batches)做过滤,只有相邻两次采样之间没有任何预填充批次增长、也没有请求进出切换的纯粹解码窗口才纳入统计,用窗口内所有流的总增量 token 除以单份墙钟时间。同一天我们还把首字延迟拆成了「含排队」与「不含排队」两套指标,并在底部请求明细表里把每一次请求的排队时长、预填充耗时与解码耗时拆成独立列展示,按国内主流云端 API 价格(输入未命中 1 元与缓存命中 0.1 元,输出按 3 元每百万 token 计算)加了一块人民币费用折算面板,跑一天下来相当于省了几十块钱云端账单一眼就能看到。
第三次核查则验证了混合架构最漂亮的一项特性。跑了十几个小时攒够各长度分桶数据后,我看着「上下文与吞吐」图表里那条从 30k 到 160k 几乎呈水平直线的单流折线图有点怀疑。按以前跑 llama.cpp 的经验,上下文拉长之后解码速度明明会明显往下掉,为什么现在长短上下文全稳在 100~108 tok/s 附近?是不是分桶统计逻辑写错了?
我们直接调出数据库里过去 16 个小时的原始请求明细做了核对,筛出 1,537 条全程没有任何并发重叠的单流已完成请求(上下文跨度从 25,951 一直覆盖到 159,734 token)做回归分析。算出来的上下文长度与解码速度相关系数只有 0.026(扣除各小时波动后为 -0.010),其中输出超过 1,000 token 的 407 条长生成请求按时长加权平均速度为 103.7 tok/s,跟图表汇总的 103.6 tok/s 分毫不差(同时也看出了逐请求算术平均会算成 119.9 tok/s、虚高 11% 的统计偏差)。数据实打实地印证了架构原理,48 层里有 36 层是固定状态开销的 GDN 线性层,剩下 12 层全注意力又由 QSA 稀疏索引器固定只挑出最相关的块参与计算,因此在 16 万 token 跨度内每步解码的访存与计算量几乎恒定,长上下文生成速度确实没有衰减。
折腾了一个半月,这套跑在 Mac Studio 上的本地推理服务现在每天都在后台安静地运行各类编码与自动化任务,风扇平时就维持在听不见声音的 1,000~1,800 rpm,重度跑一天下来缓存命中率稳定在九成以上。后面如果有更合适的新一代开源混合架构编码模型放出来,再切上去试试看效果。

