本地推理是为一个人打字设计的。Agent 不是那样跑的。
2026-08-30
English · 中文
llama.cpp 和 MLX 都很好,而且在一件事上比我们强:一个人、一个流、一个 问题。这篇文章不打算争那一格 —— 我们在 M5 Pro 上单流 prefill 是对手的 0.90×,7k 上下文单流更是 0.64×,输得很清楚。
但那不是 agent 的形状。Agent 的形状是:好几个同时在跑、每轮重发一大段 system prompt、不停调工具、偶尔塞一张图进来。 一旦切到这个形状,排序会 翻过来 —— 有几格甚至不是"慢",是开不起来。
Tempo9 是把服务器端那套东西(continuous batching、paged KV、异构执行) 搬到 Apple Silicon 上的一次尝试。
一、并发下,对手不是慢,是起不来
同一台 24 GB M5 Pro,同一个 GGUF 文件,同一个负载生成器。 effective tok/s(端到端,含 prefill):
| 上下文 · 并发 | Tempo9 | llama.cpp |
|---|---|---|
| ~1k · 1 路 | 848 / 899 | 660 / 566 |
| ~1k · 8 路 | 1294 / 1429 | 410 / 368 |
| ~2.4k · 8 路 | 1661 / 1735 | 无法服务 |
| ~7k · 8 路 | 3755 / 3753 | 无法服务 |
| ~7k · 1 路 | 2505 | 3884(我们输) |
最后一行留着,因为它是真的。但中间那两行是产品能不能用的分界: 2.4k 上下文 + 8 个并发,在这台机器上 llama-server 开不了服(加载期 Metal OOM)。
并发下引用用户真正感受到、且重复区间不重叠的 TTFT。四个 agent 共享 system prompt、各自做不同工作:
四个 Agent · 越短越好
四组测试里,Tempo9 最慢的一次都快于 llama.cpp 最快的一次
每格跑两到三轮,每轮 12 个请求;横轴为首 token 等待时间。
每轮 12 个请求,所以报多轮区间而不是倍数。10k 不是刻意构造的压力测试, 大致就是 OpenClaw 每轮重发的 system prompt 尺寸。
这里不比较并发 decode 吞吐:五轮重复实验里,Tempo9 每请求 decode 中位数 仍有 18–33% 的离散,关掉投机也没有消失。在找到未控制变量之前,这个数字 不适合拿来比较引擎。
二、声明 128K 上下文,不用按上限预付 KV
KV + 工作内存 · 越低越好
声明 128K 上下文,Tempo9 启动时仍只占约 0.55 GB
刻度为 0–4 GB;测的是填入上下文前的启动分配。
机制不是"我们更省",是KV 放在哪里:对手的 KV 是 Metal wired buffer, 和 16 GB 权重抢同一份 GPU wired 预算;我们的 KV 分页在 CPU 侧、按需触碰, 不占那份预算。
说清楚它不是什么:这量的是声明容量的预付成本,不是"填满 128K token 后内存也不涨"。真装进去的 token 当然要占内存 —— 分页只是让它按需 付费,而不是开机就按最坏情况预留。
三、工具调用有一个可以核对的分数
这是我们最想强调的一格,因为本地推理圈几乎没人公布过。
用官方 BFCL v4 评测器,打我们自己的 OpenAI 端点(也就是说 HTTP、 chat template、工具调用解析这一整条真实链路都在测量范围内):
查看分类明细
| BFCL v4 分类 | Tempo9 |
|---|---|
| simple (Python) | 95.25% |
| multiple | 94.50% |
| parallel | 89.00% |
| parallel multiple | 83.50% |
| Non-Live irrelevance | 84.17% |
对 agent 开发者来说,"能不能稳定地把工具调对"比 tok/s 重要得多,而这个 问题在本地模型圈基本靠感觉。
而且这一格是我们跑砸了才有的。 第一次跑官方评测器,multiple 只有
24%。不是模型不行 —— 报错逐条指着同一件事:
Incorrect type for parameter 'side2'. Expected type integer, got str.
Parameter value: '4'
模型的 XML 方言 <parameter=side1>5</parameter> 不携带类型,我们的服务端
就把 "5" 原样发出去了。任何做严格校验的工具都会拒绝。修法是按工具
schema 里声明的类型转换 —— 不是猜:声明为 string 的参数即使长得像数字
也必须保持字符串,否则订单号和电话号会被静默转成整数。
这次解析器修复把分数从 24% 拉到 93.50%;上表引用的后续全量重测为 94.50%。
更值得说的是为什么我们自己之前没发现:我们有一套自研判分器,它给 字符串参数加了一层宽松的重解析,于是同一批输出它报 85.5%。那层重解析在 做 A/B 时是无害的(两臂同等对待),但它把一个真实缺陷从视野里抹掉了, 抹掉的时长正好等于缺陷存在的时长。会把 bug 归一化掉的仪器,测的是另一 个程序。
四、三套协议,改一行地址就能接
一个服务端同时说 OpenAI、Anthropic、Ollama。
tempo9 --gguf <model.gguf> --port 11435
# OpenAI 客户端 base_url = http://127.0.0.1:11435/v1
# Anthropic 客户端 base_url = http://127.0.0.1:11435 (/v1/messages)
原生 /v1/messages 的意思是:用 Anthropic SDK 写的 agent 可以直接指
过来,不需要转接层。默认端口 11435 紧邻 Ollama 的 11434,方便并存切换。
五、看图的时候聊天不卡
不是"支持多模态",是视觉塔和 LLM 能同时跑而不互相饿死。
纯文本基线 · 同一张图 · 只改视觉计算位置
带一张图时,视觉计算放到 NPU,LLM 解码快 2.8×
不带图是纯文本基线。处理同一张图时,把视觉计算放到 NPU,可以把 GPU 留给 LLM 解码。
这里必须说反直觉的一半:塔自己在 NPU 上更慢,3931 vs 462 ms/图,慢 8.5 倍。价值不在塔快,在它不占 GPU —— 塔在 NPU 跑时 GPU 功率只有 0.35 W,约等于空载。
The NPU path is slower in isolation, but faster at the system level.
争用仍然存在(NPU 那条路要吃 CPU 的一半,和宿主侧的路由、采样撞车), 所以结论是"没那么糟",不是"免费"。
没做成的
发布页通常只写好消息。这一节写我们试过、测过、然后被数字否掉的东西 —— 因为在这个领域,"作者会公布自己被证伪的假设"本身就是一种可信度。
投机解码在散文上不赚钱。 这个 GGUF 自带 MTP 头,逐位置接受率也健康 (位置 1 = 0.818)。但最优深度只做到 11.03 ms/提交,而我们不开投机的 基线就是 11.4 ms/步 —— 净收益约等于零。llama.cpp 开 MTP 有 +18%, 因为他们的基线比我们慢 30%。投机解码赚的是"把慢基线藏起来"的钱, 基线已经快的一方没有这笔钱可赚。
结构化输出上倒是有 +13%(k=4 到 104.9 tok/s,单流过 100),但它会让贪心 输出随投机深度确定性地改变 —— 而"关掉温度就能复现"是很多人拿来做测试和 缓存的性质。我们在 BFCL 2501 例上量过:开投机精度没有可测出的变化 (73.0% vs 73.2%,McNemar p = 0.557),但 1.9% 的调用逐字节不同。 所以它是一个显式开关,不是默认值。
MoE 专家分组是负的。 −10 ~ −17%。
tile 大小已经在最优点。 我们一度认为 MoE 的矩阵乘在 tile 填充上有 浪费,扫了一遍:auto 480 ms、bm32 490、bm64 516、bm16 588。自动规则 已经挑到最好的了,那个"看起来很有道理"的假设是错的。
归约优化让内核慢了 9%。 我们判断 GDN 的行内核受限于串行归约的延迟, 把两次归约折成一次深度 —— 结果慢了 9%。前提就是错的:它贴着的是发射 吞吐,不是深度。但那次失败给归约网络定了价(占该内核 16%),从而把 下一步准确指向了真正的地方:同一个 head 的 128 个 simdgroup 各自把 q/k 重读了一遍,占 51%。改成一个 simdgroup 管 4 行之后,内核快 25%,端到端 prefill 快 7%,而且输出逐字节一致。
冷启动的权重驻留只值一次 180 ms。 我们一度以为"Mac 上放一会儿再问 就变慢"是个大问题,量下来:同进程内重复 prefill 是 2055 / 1875 / 1872 / 1871 ms,30 秒间隔不会让它变冷。
口径
- 机器:Apple M5 Pro,24 GB 统一内存,macOS 默认配置。
iogpu.wired_limit_mb = 0(未提高) —— 抬高它是系统级改动、非默认 用户体验,而且对所有引擎一视同仁;本机实测抬到 22000 会触发 SoC watchdog 硬复位。 - 模型:Qwen3.5-35B-A3B q3km GGUF,15.98 GiB,两侧读同一个文件。 视觉塔 Qwen3-VL-4B(Core ML,bucket 4096)。
- llama.cpp:b10307 (fc3f10b),源码构建,Metal+BLAS。
- BFCL:官方
bfcl-eval,single_turn全套 3641 例,经 HTTP 打我们 自己的/v1/chat/completions。官方 overall 一栏含 multi-turn / web_search / memory 三组,我们只跑了 single_turn,所以只引用分组分数, 不引用 overall。 - 各图的负载定义、脚本、原始数据与未采用的假设,见
results_p4b_boundary.md/results_p6_mtp.md/results_p7_power.md/results_p13_serving_retest.md/results_p16_spec_variance.md/results_p8_effective.md。