做推理服务这几年,我改过的权重文件加起来不到二十个,但改过的启动参数、调度配置和缓存策略,保守估计上千次。这个比例不是我懒,是现实逼出来的:2026 年真正能把首字延迟压下去的杠杆,绝大多数都不在权重里。首字延迟,也就是大家常说的 TTFT(Time To First Token),从请求进网关到你屏幕上蹦出第一个字,中间塞着排队、模板渲染、分词、prefill 计算、首步解码、采样、序列化、代理转发七八个环节,而权重只跟其中一两个环节沾边。这篇东西就聊这件事:为什么"0 行权重改动"能换来 77% 的首字延迟下降,这些改动具体发生在哪一层,以及我在实操里踩过的坑。不管你是刚接手推理服务的新人,还是已经在调 vLLM、llama.cpp 这类后端的同学,下面的内容都能直接拿去用。
1. 先把账算清楚:TTFT 里的时间到底花在哪
很多人一提"推理提速"就条件反射地想到换更小的模型、做量化、剪枝,这属于只盯着一棵树忘了整片林子。要让首字延迟下降,第一步是把这条时间线切开,看清楚每一段各占多少毫秒。我在自己的测试环境里(单卡 80G 显存,8B 级别稠密模型,16K 上下文)做过一次完整打点,一个 2400 token 的请求,基线端到端 TTFT 是 1830 毫秒,而其中真正属于"模型前向计算"的部分不到 900 毫秒。剩下那一半,全在权重之外。
1.1 Prefill 和 Decode 是两条完全不同的曲线
要理解 TTFT,得先把 prefill 和 decode 分开。Prefill 是把整段 prompt 一次性喂进去,计算注意力并填充 KV Cache,这个过程是大矩阵乘密集型的,瓶颈在算力,属于"把一整本书一口气读完并做笔记"。Decode 是每次只吐一个 token,每一步都要把全部权重和已经积累的 KV Cache 重新读一遍,瓶颈在显存带宽,属于"逐个字抄写但每写一个字都要翻一次词典"。
这两件事的优化手段几乎不重叠。TTFT 只跟 prefill 和排队有关,而 TPOT(每个输出 token 的耗时,也叫 ITL)跟 decode 有关。你如果把权重从 FP16 压到 INT4,decode 阶段的访存量降到四分之一,TPOT 会明显好转,但 TTFT 的收益往往只有百分之十几——因为 prefill 的耗时里有相当一部分来自注意力的平方复杂度,这部分不会因为权重变小而缩短。我见过太多团队花了三个月做权重量化,吞吐上去了,首字延迟几乎没动,然后被业务方追着问为什么"变快了但感觉没变快"。
1.2 被忽略的排队时间和传输时间
把 TTFT 的组成拆开,最容易被低估的是两个东西:排队和传输。
先说排队。单请求打点的时候你看不到它,但只要并发上来,排队时间会迅速变成大头。我在并发 32 的压测里见过排队占到 TTFT 的 45%。原因很直白:请求到达后先进调度队列,调度器要等当前这一轮 batch 跑完才能把它塞进去;如果前一批里有几个 8K 长 prompt 正在 prefill,后面的短请求就得干等,这就是典型的队头阻塞。这一段的耗时跟权重一点关系都没有,纯粹是调度策略决定的。
再说传输。这部分更隐蔽,而且经常是"客户端测出来比服务端慢 300 毫秒"的元凶。默认配置下,反向代理会把 SSE 流式响应攒在缓冲区里,等攒够一定大小或者超时才往下发,业务方看到的首字延迟里就凭空多了一段缓冲时间。还有 gzip,对 SSE 这种长连接流式响应做压缩,会把本来可以逐块发出的数据卡住。这三百毫秒你就算把模型换成世界最小的也优化不掉,因为它压根不在 GPU 里发生。
还有一段更少人注意的:模板渲染和分词。Chat 模板通常是 Jinja 渲染的,长 system prompt 加上多轮历史,渲染一次几十毫秒很正常;分词器如果是 Python 实现,长 prompt 又要几十毫秒,而且受 GIL 影响不容易并行。这两段加起来在长 prompt 场景下能吃掉你一百多毫秒。
1.3 为什么"动权重"这条路越来越不划算
我不是说权重侧没有优化空间,而是说它的投入产出比在快速衰减,原因有三个。
第一是收益结构不对。权重侧的手段主要改善 decode 的访存瓶颈,对 prefill 和排队几乎无能为力,而 TTFT 恰恰主要由后两者决定。你做了一堆量化,用户的感受是"输出变快了",但"第一个字还是等那么久"。
第二是周期长、风险高。蒸馏和剪枝要重新训练,周期按周甚至按月算;量化要跑完整的质量评测,还要过业务方的回归集。权重是资产,一旦发布就被下游按哈希锁死,你改一次,整条链路都要重新验证一遍。
第三是回滚成本。权重改坏了,回滚意味着重新加载几十 G 的模型、重新预热、重新发版。而调度参数、缓存策略、内核后端这些"运行时资产",改完当天生效,出问题一条命令回滚,还能顺手做 A/B 对比。所以我的原则很简单:能在运行时解决的,绝不碰权重。这也正好解释了标题里那句话——77% 的收益,来自一堆根本不改模型数值的开关。
1.4 该盯哪些指标,别只盯平均值
聊优化之前先把度量这件事定下来。只有平均值等于自欺欺人。我一般看这么一组:
| 指标 | 含义 | 我的关注点 |
|---|---|---|
| TTFT p50 | 一半请求的首字延迟 | 反映常规体验 |
| TTFT p95 / p99 | 长尾请求的首字延迟 | 决定用户投诉量 |
| TPOT / ITL | 每输出 token 耗时 | 和 TTFT 分开优化 |
| 排队时长 | 请求进队到开始 prefill | 判断调度是否合理 |
| KV Cache 使用率 | 显存里 KV 占用比例 | 接近 100% 就会开始抢占 |
| 抢占次数 | preemption 计数 | 大于 0 说明并发超了 |
| goodput | 满足 SLO 的吞吐 | 比裸吞吐更有意义 |
这里有个反常识的点:裸吞吐和首字延迟经常是互相打架的。你把并发上限开大,吞吐数据好看,但排队变长,p99 的 TTFT 直接爆掉。真正该盯的是 goodput——在"TTFT 小于某个阈值"这个约束下的有效吞吐。我吃过这个亏,早期为了冲吞吐把并发上限调到 256,结果线上 p99 首字延迟从 800 毫秒涨到 4 秒,用户端表现就是"发了消息半天不出字",最后只能把并发降回来,吞吐其实并没损失多少。
2. 权重之外的五个抓手:从调度到内存的完整拆解
把账算清楚之后,优化方向就清楚了:凡是发生在"请求到达"到"prefill 结束"之间的环节,都是我的目标。下面这五层是我实际调优时的检查顺序,从便宜到贵排列,越靠前的性价比越高。它们的共同点是:一行权重都不用动。
2.1 调度层:连续批处理与 chunked prefill
调度层是收益最大也最容易被忽视的一层。老式的静态批处理是"攒一批请求,跑完一起返回",一个 batch 里最慢的那个决定所有人的等待时间。连续批处理(iteration-level scheduling)改成每生成一个 token 就重新组一次 batch,新来的请求随时能插进去,短请求不用等长请求跑完。这一改动对 TTFT 的影响是数量级的。
在此之上还有 chunked prefill,中文一般叫分块预填充。它的思路是把一个超长的 prefill 切成若干小块,比如 8K 的 prompt 切成每块 512 token,让这些小块和 decode 步骤交替执行。好处有两个:一是长 prompt 不会长时间霸占 GPU,后面的短请求能及时插队;二是显存峰值更平滑。代价是长请求自己的首字延迟可能略微变长,但整体 p95/p99 会明显下降。这笔买卖在真实流量里非常划算,因为长 prompt 通常是少数,短请求是多数。
控制这个行为的核心参数是"单轮 batch 里最多塞多少个 token"。我的经验值是从 2048 起步,如果你发现 decode 的 TPOT 被拖慢了,说明块太大;如果发现 prefill 效率低、GPU 利用率上不去,说明块太小。这个参数没有普适最优解,必须拿你自己的 prompt 长度分布去扫一遍。
2.2 前缀缓存:把重复的 prefill 直接抹掉
如果说调度层是省时间,前缀缓存就是直接把时间删掉。原理非常朴素:如果两个请求的前面一段 token 完全一样,那这段的 KV Cache 可以直接复用,不用重算。
真实业务里前缀重复率高得惊人。系统提示词、角色设定、few-shot 示例、工具定义、RAG 的固定指令模板,这些东西在同一个产品里往往一模一样,长度动辄一两千 token。我这次实测的场景里,系统提示部分有 1200 多个 token,开启前缀缓存后,这部分直接从 prefill 里消失,TTFT 掉了 430 毫秒。
但这里有个巨坑,我必须单独拎出来说:前缀缓存要求前缀逐 token 完全一致,一个空格、一个换行、一个模板渲染出来的多余 token 都会导致缓存失效。最常见的错误写法是把时间戳、请求 ID、会话 ID 塞在 system prompt 的开头,比如"当前时间是 2026-03-15 14:22:31,请根据以下规则回答……"。这一句直接让所有请求的前缀都不一样,命中率归零,缓存形同虚设,而且你在监控面板上根本看不出异常——GPU 该忙还是忙,只是白忙。
正确的做法是把所有动态内容挪到 prompt 的末尾,让前面那 1200 token 保持完全静态。如果确实需要时间信息,就放在用户输入那一段里,或者用工具调用的方式注入。
2.3 KV Cache:分页、量化与生命周期管理
KV Cache 是推理服务里最值得花心思的一块显存。它有两重身份:既是计算中间结果,也是并发容量的天花板。
先说分页。早期的实现给每个请求预留一段连续显存,长度按最大可能长度算,结果实际用了一半,另一半浪费掉,还产生大量碎片。分页方案把 KV Cache 切成固定大小的块,用类似虚拟内存的方式按需分配,浪费率能从百分之六七十降到个位数。这个改动的直接效果不是单请求变快,而是同样显存能塞下更多并发,排队时间下降,TTFT 跟着下降——典型的"绕道优化"。
再说量化。KV Cache 用 FP8 存储,显存占用直接减半,容量翻倍。这同样是绕道降延迟:并发能力上去了,队列短了,首字自然快。不过 KV 量化对输出质量的影响比权重量化更敏感,尤其是在长上下文场景下,误差会在注意力里被放大。我的做法是先在业务回归集上跑一遍对比,确认质量差异在可接受范围内再上线,而且要开一个小流量灰度。
最后是生命周期。这里有个容易被忽略的参数:显存预留比例。设得太高容易 OOM,设得太低会导致运行中的请求频繁被抢占,KV 块被换出到 CPU 内存再换回来,延迟直接起飞。我一般让 KV Cache 使用率稳定在 80% 到 90% 之间,同时盯紧抢占计数,只要不为零就说明并发上限该往下调了。
2.4 计算图与内核:CUDA Graph、后端选择与采样开销
这一层是纯工程活,收益稳定但需要耐心。
CUDA Graph 的思路是把一连串 kernel launch 录制成一张图,之后一次提交全部执行。decode 阶段每生成一个 token 要启动几十个 kernel,启动开销累积起来相当可观,用 CUDA Graph 之后这部分基本消失。它对 TTFT 也有贡献,因为 prefill 末尾和第一步 decode 的 kernel 启动开销同样被省掉了。注意不同 batch size 需要不同的图,所以框架一般会准备一组分段图,这也是为什么你在压测时会看到某些批量下性能突然跳变。
注意力后端的选择同样关键。同一张卡、同一个模型,换一个注意力实现,TTFT 差百分之十几很正常,因为不同实现针对的序列长度、head 维度、数据类型各不相同。我一般的做法是把候选后端列出来,用自己真实的 prompt 长度分布(比如短、中、长三档)各测一遍,选 p95 最好的那个,而不是看社区推荐的默认值。
采样环节也值得看一眼。词表动辄十几万,top-p 采样要做排序和累积求和,几毫秒到几十毫秒都有可能。如果你的业务不需要极端多样的输出,适当限制候选集、换更高效的采样实现,能省掉一部分首字时间。
这里顺便说个反直觉的结论:投机解码对 TTFT 帮助很有限。它的核心价值在 decode 阶段,用小模型先猜几个 token 再由大模型验证,能提高 TPOT。但 prefill 阶段它帮不上忙,而且草稿模型本身还有开销。我实测下来,开投机解码后 TTFT 甚至略有上升,但 TPOT 好了不少。所以如果你的 SLO 是首字延迟,别把预算花在这里。
2.5 内存分层与卸载:offload 动的到底是什么
这一节专门回应一个高频疑问:把权重 offload 到内存,算不算"改权重"?
答案很明确:不算。权重的数值一个比特都没变,变的是它存放在哪里、以及被读取的路径。量化和剪枝是改内容,offload 和 mmap 是改位置,这是两件性质完全不同的事,优化手段和风险也完全不同。
具体到场景,offload 通常是"显存不够、但还想跑起来"的妥协方案。把一部分层放到主机内存,计算时通过总线搬进显存,搬运带宽是硬瓶颈。结果就是首字延迟显著变差——你可能获得了"能跑"的能力,但代价是慢。所以在延迟敏感的场景里,offload 是保底手段而不是加速手段。
稍微例外的一种组合是:权重常驻显存,只把 KV Cache 的一小部分卸载到主机内存。这种情况下权重路径没变,被牺牲的是长上下文下 KV 的读写速度,TTFT 会有小幅增加,但换来了更长的上下文支持。这种取舍是否值得,取决于你的业务是"短 prompt 高并发"还是"长文档低频次"。
3. 一次真实调优:从 1830 毫秒压到 420 毫秒
上面讲的是方法论,这一节把过程完整还原一遍。数据来自我自己的测试环境,你要照抄参数之前请记住:所有绝对数字都跟硬件、模型、prompt 分布强相关,只有"优化顺序"和"排查思路"是可以直接搬走的。
3.1 基线怎么测才可信
先把测量这件事做对,否则后面所有对比都是幻觉。我给自己定了五条规矩。
第一条,客户端测,不要在服务端内部打点。服务端日志里的 TTFT 往往不含网络和代理,业务方感受到的是客户端那个数。第二条,必须流式测,只看第一个 chunk 到达的时刻,不要等整个响应结束。第三条,prompt 长度要固定成几档,我用 512、2400、8000 三档,每档跑 200 个请求,输出都只取 1 个 token,因为我们只关心首字。第四条,冷启动和预热后分开记录,第一请求永远特别慢,别混进统计。第五条,并发要固定,我测 1、8、32 三个档位,因为不同并发下瓶颈会换人。
下面是我用的测量脚本,很简单,核心就一句:记录第一个数据块到达的时间。
import asyncio, time, statistics import httpx URL = "http://127.0.0.1:8000/v1/chat/completions" PROMPT = "..." # 固定内容,长度按档位准备 CONCURRENCY = 32 ROUNDS = 200 async def one_call(client): payload = { "model": "local-model", "messages": [{"role": "user", "content": PROMPT}], "max_tokens": 1, # 只要首字 "stream": True, "temperature": 0, } t0 = time.perf_counter() async with client.stream("POST", URL, json=payload) as resp: async for line in resp.aiter_lines(): if line.startswith("data:") and "DONE" not in line: return (time.perf_counter() - t0) * 1000 return None async def main(): limits = httpx.Limits(max_connections=CONCURRENCY * 2) async with httpx.AsyncClient(timeout=120, limits=limits) as client: # 预热,别把冷启动算进去 for _ in range(10): await one_call(client) samples = [] sem = asyncio.Semaphore(CONCURRENCY) async def worker(): async with sem: v = await one_call(client) if v: samples.append(v) tasks = [asyncio.create_task(worker()) for _ in range(ROUNDS)] await asyncio.gather(*tasks) samples.sort() p = lambda q: samples[min(int(len(samples) * q), len(samples) - 1)] print(f"p50={p(0.50):.0f}ms p95={p(0.95):.0f}ms p99={p(0.99):.0f}ms") asyncio.run(main())基线数据:并发 32、prompt 2400 token 时,p50 是 1830 毫秒,p95 是 2640 毫秒。GPU 利用率只有 62%,这个数字本身就是线索——如果你的 GPU 没跑满但用户还在等,那时间一定花在 GPU 之外。
3.2 第一轮:传输层和模板层
第一轮我一共只动了配置文件,没碰任何服务端逻辑,收益 230 毫秒加 60 毫秒加 50 毫秒。
先在反向代理上关掉响应缓冲。这一条我强烈建议所有做流式服务的同学检查,因为默认值几乎一定是"开",而它对 SSE 的伤害巨大。
location /v1/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; # 关键:别攒缓冲区 proxy_cache off; gzip off; # 流式响应别压缩 chunked_transfer_encoding on; proxy_read_timeout 300s; }改完之后,客户端 p50 直接掉了 120 毫秒。这 120 毫秒我原本以为是模型慢,其实是网关在等缓冲区。
然后是模板渲染。我把渲染逻辑从"每个请求现场拼 Jinja"改成"静态部分预先渲染成字符串常量的哈希键,命中就直接取"。同时所有动态内容全部后移到消息末尾。改完省了 60 毫秒,更重要的是这为后面的前缀缓存铺了路——如果模板每次都渲染出细微不同的字符串,前缀缓存永远打不中。
最后是分词。把 Python 分词器换成 Rust 实现的版本,长 prompt 场景下省了 50 毫秒。这个改动在短 prompt 上几乎看不出差别,所以如果你只有一个短 prompt 场景,可以跳过。
3.3 第二轮:调度与缓存
第二轮开始碰服务端参数,这也是收益最大的一轮,总共省了 740 毫秒。下面是我最终用的启动参数,每一项后面都写了理由。
vllm serve ./models/local-8b \ --served-model-name local-model \ --max-model-len 16384 \ --gpu-memory-utilization 0.88 \ --enable-prefix-caching \ --enable-chunked-prefill \ --max-num-batched-tokens 2048 \ --max-num-seqs 64 \ --disable-log-requests几个参数逐个说。显存利用率设 0.88 而不是 0.95,是因为留一点余量给临时 buffer,避免高峰期被抢占,实战里稳定性比那 7% 的容量重要得多。分块预填充的块大小设 2048,是我把 1024、2048、4096 各扫一遍之后的结果:1024 时 prefill 效率太低,4096 时 decode 被明显拖慢,2048 是这一档的甜点。最大并发序列数设 64,是配合"p99 TTFT 小于 600 毫秒"这个 SLO 调出来的,不是越大越好。
前缀缓存打开之后,我做了一件事验证它真的生效了:发两个完全一样的请求,看第二次是不是明显更快。第一次 1830 毫秒,第二次 1380 毫秒,说明那 1200 token 的系统提示确实被复用了。如果你测出来两次一样快,那一定是前缀没对齐,回去检查动态内容有没有混在前面。
3.4 第三轮:KV Cache 与内核
第三轮改的是数据精度和内核选择,省了 440 毫秒。KV Cache 切到 FP8 之后,容量翻倍,同样显存能挂的并发从 24 涨到 47,排队时长肉眼可见地缩短。注意力后端我从默认实现换成了一个针对长序列更友好的实现,这一项单独贡献了大约 120 毫秒。
顺手把 CUDA Graph 打开,decode 阶段每步的启动开销被吃掉,首步也随之受益,这一项省了 180 毫秒左右。采样那边我把候选集做了限制,省了几十毫秒。
这里提醒一句:KV 量化上线前一定要跑质量回归。我在一个文档问答场景里测过,短问题几乎无差异,但超过 8K 上下文之后,答案里的数字引用偶发出错。这种问题在压测里根本看不出来,只有业务评测集能抓到。
3.5 收益对照表与归因说明
把三轮的收益合起来,就是下面这张表。p50 从 1830 毫秒降到 420 毫秒,降幅 77%,和标题里的数字对上了。
| 阶段 | 改动内容 | p50 变化 | 是否改权重 |
|---|---|---|---|
| 基线 | 默认配置 | 1830ms | - |
| 第一轮 | 关闭代理缓冲、直通 SSE | -120ms | 否 |
| 第一轮 | 模板预渲染、动态内容后移 | -60ms | 否 |
| 第一轮 | 换 Rust 分词器 | -50ms | 否 |
| 第二轮 | 前缀缓存命中 1200 token | -430ms | 否 |
| 第二轮 | 分块预填充与调度调参 | -310ms | 否 |
| 第三轮 | KV Cache FP8 | -160ms | 否 |
| 第三轮 | 注意力后端替换 | -120ms | 否 |
| 第三轮 | CUDA Graph 与采样优化 | -160ms | 否 |
| 合计 | - | 1830ms 到 420ms | 全部为否 |
必须说清楚一件事:这些收益不是严格可加的。前缀缓存省下的 430 毫秒里,有一部分和分块预填充重叠;KV 量化的收益高度依赖并发水平,如果你本来就是低并发场景,它可能一点用都没有。这张表更大的价值在于给出优先级:先把传输层和模板层这种"零成本、马上见效"的活干完,再动调度,最后才是精度和内核。
4. 权重那一侧:下载、校验、格式与 offload 的边界
说完"权重之外",还是得把"权重之内"的事情聊清楚,因为最容易混淆的恰恰是这条边界。很多团队在排查延迟问题时,把属于运行时的问题误判成权重问题,白白折腾了好几周。
4.1 预训练权重下载与完整性校验
先说下载。做视觉任务的同学经常需要拿预训练权重做微调起点,检测、分割、自监督骨干各有各的权重文件,命名和许可各不相同,下载来源也五花八门。我的习惯是:只在官方仓库或官方发布的镜像渠道获取,下载后立刻做哈希校验,并且把哈希值和来源链接一起写进项目文档。
为什么这么较真?因为权重文件往往是几十 G 的分片,下载中断、断点续传、缓存目录软链接这几件事组合起来,很容易出现"文件大小看起来对、加载时缺了几个张量"的情况。更麻烦的是有些框架加载时会静默忽略缺失的键,你训练半天发现某一层是随机初始化的,而日志里只有一行不显眼的警告。
我的校验流程大概是三步。第一步,核对分片数量与索引文件是否一致,分片索引里列了多少个文件,磁盘上就该有多少个。第二步,全量哈希校验。第三步,用严格模式加载一遍,确认没有缺失或多余的键。
# 1. 分片数量是否与索引一致 python - <<'PY' import json, os idx = json.load(open("model.safetensors.index.json")) files = sorted(set(idx["weight_map"].values())) missing = [f for f in files if not os.path.exists(f)] print("分片数:", len(files), "缺失:", missing) PY # 2. 哈希校验(前提是你有一份可靠的清单) sha256sum -c weights.sha256 # 3. 严格加载,有任何键不匹配立刻报错 python - <<'PY' from safetensors import safe_open import glob for f in sorted(glob.glob("*.safetensors")): with safe_open(f, framework="pt") as st: print(f, len(st.keys())) PY这一步花你十分钟,能省掉后面几天甚至几周的排查。我见过最惨的一次是某团队用了一份不完整的骨干权重做微调,训练损失曲线"看起来还行",但下游评测一直上不去,最后发现问题出在下载环节,前后浪费了两周。
4.2 量化改了数值,offload 只改了位置
这是全篇我最想讲清楚的一个区分。同样是"让模型跑得动",下面两类手段的性质完全不同。
| 手段 | 动的是什么 | 是否改变权重数值 | 对 TTFT 的典型影响 |
|---|---|---|---|
| 权重量化 | 数值精度 | 是 | 主要改善 TPOT,TTFT 有限 |
| 知识蒸馏 | 模型结构和数值 | 是 | 明显,但要重训 |
| 结构化剪枝 | 参数量 | 是 | 明显,但要重训和评测 |
| 权重 offload | 存放位置 | 否 | 通常变差 |
| 权重 mmap | 读取路径 | 否 | 冷启动变差,热态持平 |
| 显存布局调整 | 内存排布 | 否 | 间接,靠并发容量 |
| 内核替换 | 计算实现 | 否 | 明显,尤其是长序列 |
这张表基本能解释为什么标题说"提速发生在权重之外"。真正改数值的手段,一定伴随着训练、评测、发版、回滚这一整套重流程;而不改数值的手段,只需要改配置和代码,当天验证当天上线。
还有一个细节值得说:量化并不总是让 TTFT 变快。INT4 权重在某些后端需要反量化,反量化本身是额外计算;如果这个开销大于访存节省的部分,prefill 反而更慢。所以量化对 TTFT 的影响方向不确定,必须实测。这也是为什么我把量化排在优化顺序的最后面。
4.3 llama.cpp 里那些和"权重"有关的开关
用 llama.cpp 的同学经常会被一堆参数绕晕,我把自己常用的几个列一下,并且标注它到底动了什么。
llama-server -m ./models/model-Q4_K_M.gguf \ -ngl 99 \ --no-mmap \ --mlock \ -c 8192 \ -b 2048 \ -ub 512 \ --flash-attn \ --host 127.0.0.1 --port 8080-ngl决定多少层放到显存里,这个参数改的是"权重放哪",不改数值。放得越多,矩阵乘跑在更快的显存上,首字越快;但显存不够就只能往下调,代价是变慢。
--no-mmap和--mlock是一对组合拳。默认情况下权重用内存映射的方式加载,好处是启动快、内存占用按需增长,坏处是第一次访问某个页会触发缺页中断,从磁盘读进来,冷启动的第一个请求可能慢好几秒。关掉 mmap 并锁住内存,可以把权重一次性读进物理内存常驻,代价是启动变慢、内存占用顶格。做延迟敏感服务,我通常选择后者,并且在服务启动后主动发几个预热请求把页面踩热。
-b和-ub这两个参数特别值得关注,因为很多人压根没用过。前者是逻辑批大小,后者是物理微批大小,后者直接决定 prompt 处理阶段一次算多少 token。把微批调大,prompt 处理速度会明显提升,首字延迟跟着改善;调太大则显存峰值上去,可能 OOM。我在 8K 上下文下从默认值调到 512,长 prompt 的处理速度提升了三成左右。这两个参数同样不改权重,纯粹是计算调度。
--flash-attn这类开关是换内核实现,也不改权重。注意不同版本里这个参数的名字和取值形式会变,用之前先看一眼帮助输出,别照着旧博客抄。
5. 常见问题与排查技巧实录
优化做多了会发现,绝大部分"玄学延迟"都能归纳到少数几个原因上。我把自己的排查速查表和踩过的坑整理如下。
5.1 常见问题速查表
| 现象 | 最可能的原因 | 处理方向 |
|---|---|---|
| TTFT 高但 GPU 利用率低 | 排队或传输层耗时 | 查代理缓冲、查调度队列深度 |
| 首字很慢但日志显示 prefill 很快 | 客户端到服务端的传输被缓冲 | 关 gzip、关 proxy_buffering |
| 冷启动第一个请求特别慢 | 权重缺页、内核自动调优 | 预热请求、关 mmap、锁内存 |
| 并发一上来 p99 就爆 | 抢占、KV 不足、无准入控制 | 降并发上限、扩 KV、加优先级 |
| 两次相同请求速度差异大 | 前缀缓存没命中 | 检查动态内容是否混在前缀里 |
| p50 正常但 p99 极差 | 长 prompt 队头阻塞 | 开分块预填充、分离长短队列 |
| 长上下文下答案质量波动 | KV 量化误差 | 关 KV 量化或降量化等级 |
这张表我贴在工位上,每次接到延迟相关的反馈,先按顺序过一遍,能解决八成问题。
5.2 三个我踩过的坑
第一个坑,把时间戳放进了 system prompt 开头。这个错误的破坏力我到现在还印象深刻。当时我信心满满地开了前缀缓存,指标面板上缓存命中率是 0,我却花了半天排查配置项,最后发现是模板里一句"当前时间:{{ now }}"把所有请求的前缀都变成了独一无二的字符串。改法很简单,把那句话挪到用户消息里,命中率立刻上到 92%,TTFT 掉了四百多毫秒。从此我养成了一个习惯:任何写进 system prompt 的内容,都要问一句"它对所有请求都一样吗"。
第二个坑,以为换了量化模型就能解决首字延迟。有一段时间我执着于把模型从 FP16 换到 INT8,折腾了两周,TPOT 好了百分之三十,TTFT 只好了百分之八。业务方的反馈是"打字快了,但还是等半天才出第一个字"。那次之后我才彻底想明白,TTFT 的主要矛盾在排队和 prefill,跟权重精度关系不大。省下的这两周,如果拿去调调度参数,收益会大得多。
第三个坑,显存利用率设得太激进。我一度把显存利用率开到 0.96,想把并发容量榨到极限,压测数据确实好看。结果线上流量一有波峰就开始抢占,KV 块被换出又换回,p99 首字延迟从 900 毫秒飙到 5 秒。后来降到 0.88 并加了并发准入,稳定性立刻回来了,而平均吞吐只降了不到百分之五。这个教训告诉我:容量参数的甜点区往往不在最大值,而在略低于最大值的地方。
5.3 一条可以照抄的排查顺序
最后把我常用的排查顺序写出来,遇到"首字延迟变高"的反馈,我会按这个顺序往下走,通常前三步就能定位。
第一步,客户端和服务端分别打点。如果客户端比服务端慢 300 毫秒以上,问题在传输层,直接去查反向代理配置和压缩设置。第二步,在服务端内部把时间线切开,分成排队、prefill、首步 decode、序列化四段,看哪一段异常。这一步需要框架暴露细粒度指标,如果没有,就临时加日志。第三步,看 KV Cache 使用率和抢占计数,这两个数字能立刻告诉你并发是不是超了。第四步,同一请求连发两次,比较耗时差异,判断前缀缓存是否生效。第五步,用一个长度固定的极简 prompt 做对照实验,如果极简 prompt 很快、真实 prompt 很慢,问题就在 prompt 结构和模板上,而不是计算。
按这个顺序走,基本不会跑偏到"去改权重"这条远路上。
我在实际项目里的体会是,推理优化这件事,越靠近用户的那一层越容易被忽略,但收益往往越大。反向代理的一个配置项值 120 毫秒,模板里一句话的位置值 430 毫秒,而花两周做的权重精度转换可能只有几十毫秒。下次再遇到首字延迟的指标报警,先把 GPU 放一边,从请求进入网关的那一刻开始往后捋,你会发现问题大多不在显存里,而在那些你从来没打开看过的配置文件里。