本地模型一报超时,第一反应通常是「模型太慢」。真拆开看,超时很少是算不完,多半是等不及:等模型加载、等一整段上下文 prefill、等前面的请求腾出槽位。模型只是最后那个背锅的。
先把「超时」拆成四段
一次请求从发出到吐字,耗时至少分成四块,任何一块都能触发超时。
- 1客户端发请求30 秒或 60 秒的超时,从这一刻起算
- 2模型加载冷启动要把权重读进内存,几十秒很常见
- 3prefill整段上下文先算一遍,提示越长越久
- 4decode逐 token 生成,这才是通常说的 tok/s
- 5回传结果流式输出还是等整段算完,体感差很多
这四段的调参方向完全不同。加载慢,该看模型格式和存储;prefill 慢,该砍上下文、查缓存命中;decode 慢,才是量化、显存带宽和层数分配的问题;排队慢,往往是并发槽位和常驻模型数量没设对。
把 time-to-first-token 和 tok/s 分开记,是排查里最省事的一步。很多人把「首字十几秒」和「每秒几个 token」混成一个「慢」,然后开始换模型,换完还是慢。
客户端底下,多半是同一个引擎
Ollama、LM Studio 这类客户端,把模型下载、量化选择、显卡探测、显存不够时自动卸载、OpenAI 兼容接口都包好了。多数情况下,底下跑的就是 llama.cpp;LM Studio 另外提供 MLX 后端,但 llama.cpp 仍是常见路径。日常聊天、单机试用,这层壳省下的时间远大于它藏起来的问题。
代价是默认值不透明。上下文开多大、并发几个槽、模型空闲多久卸载、日志落在哪,都不在你手里。出问题时,你调的是壳的参数,不是引擎的参数;有些参数壳根本没暴露,只能等版本更新。
知道这件事之后,排查思路会变:与其在界面上反复改数字,不如用同一个模型文件直接起一个引擎,把变量收窄。
直连要跑通的三步
第一步是拿到能用的二进制。自己编译就确认后端真的启用了:Metal、CUDA、ROCm 各有各的开关,编译日志里没有对应后端,后面速度再调也白搭。
第二步是选 GGUF。量化档位别一步压到底,先用在质量和体积之间比较平衡的档;需要多模态再额外配投影文件,纯文本用不上。
第三步是起服务,先小上下文跑通,再往上加。大致是这样:
llama-server -m model.gguf -c 8192 -ngl 99 --host 127.0.0.1 --port 8080-ngl 控制放上显卡的层数,-c 是上下文长度。不同版本的参数名会变,拿不准就翻 --help,别照抄半年前的命令。
起来之后,任何支持 OpenAI 兼容接口的客户端都能接,地址填 127.0.0.1:8080/v1,本机回环地址。这里有个容易忽略的点:别顺手改成 0.0.0.0。本地服务默认没有鉴权,扔到公网等于把显卡借给陌生人,至少加一层反向代理和访问控制。
非对称 KV 量化,省的是上下文那块内存
权重是固定的,KV cache 是随 token 数涨的。它的粗略算法是:
每 token 字节数可以粗略写成 2 × 层数 × KV 头数 × 每头维度 × 每元素字节数。
前面的 2 是 K 和 V 各一份。上下文翻倍,KV 这块差不多也翻倍,长上下文最先撞的往往不是算力,是内存。
所谓非对称量化,就是 K 和 V 不用同一个精度。常见做法是 K 留 Q8,V 压到 Q4:K 参与注意力打分,量化误差会改变排序;V 是加权之后的内容,误差更多表现为内容糙一点,容错高一些。这个组合不是万能钥匙,但比 K、V 一起压到很低要稳。对应到 llama.cpp,是 --cache-type-k q8_0 --cache-type-v q4_0 这类写法;版本不同参数名会变,先翻 --help。
判断有没有效,别只看「能不能起来」。把上下文从 8K 逐档加到 32K,记录每一档的内存占用、首字延迟和 decode 速度,找到开始 swap 的那一档,往前退一档用。掉进 swap 之后,速度不是打折,是换了一个数量级。
53 tok/s 和 262K,别当成两个承诺
M1 Max 上 53 tok/s 这个数,说的是 decode,而且通常有前提:短上下文、单人、模型已经加载好。262K 是 -c 的配置值,只说明能开这么大,不说明这个长度下速度、内存和质量还成立。
同一档硬件,换一套量化、换一个上下文长度,跑出来的数字能差一个数量级。看到单点数字,先问三件事:上下文多长、并发多少、模型是不是热的。这三个条件不写清楚,tok/s 没有比较意义。
长上下文还有第二道坎:塞得进去,不等于模型还能用住中间的信息。拿一段长文档做定位测试,比看参数表有用。真要做 Agent,工具调用会把上下文越滚越长,每次追加结果都要重新 prefill 新增部分;缓存命中做得好只算增量,做不好就整段重算,体感完全不一样。
拆壳的账,要提前算
直连的好处很实在:版本能固定,参数能调到底,长上下文、多卡分配、MoE 层下放这些都有抓手,日志也在自己手里。出问题能定位到具体阶段,而不是对着一个「请求超时」猜。
成本同样实在。编译工具链、驱动版本、升级时参数改名、模型文件管理、并发队列、鉴权、反向代理、崩溃重启、监控,以前壳帮你做的,现在都要自己补。一个人折腾和给团队用,是两种工作量。
所以别把拆壳当信仰。只是想让本地模型能聊天,壳挺好;在做 Agent、长上下文、多人共用,或者需要固定版本复现问题,拆到引擎层才值得。
更稳的折中是:平时用壳,排障时用同一个 GGUF 直接起服务,拿同一段提示对比首字延迟、prefill 速度和 decode 速度。差在哪一段,一目了然。
超时排查到最后,真正有用的不是「哪个客户端更快」,而是把加载、prefill、排队、decode 分开计量。壳可以留,但要知道它替你做了什么;数字可以看,但要问它是在什么条件下测的。
本地推理最贵的从来不是电费,是在一个不透明的默认值上反复试错的时间。