问一句,回答像打字一样逐字冒出来。这不是界面在装效果——模型本身就是一次前向只生成一个 token,循环若干次才凑成一句话。理解这个循环,才能解释首字慢、长文贵、并发时快时慢这些现象。
这篇把一次推理拆成两段:先把输入读一遍,再一个 token 一个 token 往外写;然后讲 token、KV 缓存、采样参数和流式输出,最后用 curl 跑一次。
1. 两个阶段:prefill 与 decode
| 阶段 | 做什么 | 并行度 | 主要瓶颈 |
|---|---|---|---|
| prefill | 把整段输入做一次前向,算出每层的 KV | 高,所有输入 token 并行 | 算力(矩阵乘) |
| decode | 每步只算 1 个新 token,产出下一个 | 低,逐 token 串行 | 显存带宽(读权重与 KV) |
prefill 是"大块计算",时间大致随输入长度增长;decode 是"每步读一遍模型权重",时间与生成长度成正比,与输入长度关系较小。所以输入 4,000 token、输出 100 token 的请求,可能大部分时间花在 prefill;输入 50、输出 2,000 的请求,绝大部分时间在 decode。
这也解释了两个指标为什么要分开:首字延迟(TTFT)主要受 prefill 与排队影响,输出速度(每 token 时间)主要受 decode 影响。
2. token 不是字,也不是词
| 文本 | 大约 token 数(按分词器) | 说明 |
|---|---|---|
| 一个常用汉字 | 1(中文词表较全的模型) | 也可能被拆成 2 个字节级 token |
| 一个英文单词 | 1–2 | 长词、专名常被拆开 |
| 一段 JSON | 结构符号各占 token | {、"、, 都是独立 token |
| emoji / 生僻字 | 多个 | 字节级回退(byte fallback)会拆成多段 |
分词器(tokenizer)决定切法,同一句话在不同模型上 token 数可能差一截。成本、上下文占用、max_tokens 都按 token 算,不按字符算——用字符数估成本,中文场景常低估,代码/JSON 场景常高估。要准确就调分词器接口或 tiktoken 一类库按模型核对。
3. 上下文窗口与 KV 缓存
生成第 n 个 token 时,模型要"看到"前面所有 token 的中间结果,这些结果就是 KV 缓存。它的大小与「层数 × KV 头数 × 头维度 × 序列长度」成正比,且每个并发请求各占一份:
KV 字节 ≈ 2 × 层数 × KV头数 × 头维度 × 序列长度 × 精度字节数
# 数值按模型 config.json 与引擎参数核对,这里只给量级关系
| 现象 | 机制 | 工程含义 |
|---|---|---|
| 上下文越长越慢 | decode 每步要读更长的 KV | 长上下文的首字与每 token 时间都上升 |
| 上下文越长越贵 | KV 占用显存,挤掉并发槽位 | 并发数下降,排队变长 |
| 前缀重复可加速 | 前缀缓存复用相同开头的 KV | 固定的 system prompt 值得放最前 |
| 塞满后质量下降 | 关键信息被稀释,注意力分散 | 不是"越多越好" |
上下文窗口是硬上限,超了直接报错或被截断;KV 缓存是软成本,超了会 OOM 或触发换出、抢占,表现为吞吐骤降。
4. 采样参数:三个旋钮改的是同一件事
模型每步输出的是整个词表的概率分布,采样就是从分布里挑一个 token。
| 参数 | 作用 | 调大的效果 | 典型用法 |
|---|---|---|---|
temperature |
对 logits 缩放,改变分布陡峭程度 | 更随机、更多样 | 创意写作调高,抽取/分类调低 |
top_p |
保留累计概率达到 p 的最小集合 | 候选更多 | 通用 0.9 左右 |
top_k |
只保留概率最高的 k 个 | 候选更多 | 需要硬性限制候选时用 |
三者叠加生效:先按 top_k/top_p 截断候选集,再按 temperature 调整后的分布采样。同时把 top_p 和 top_k 调大,随机性会叠加;一个调高一个调低,效果互相抵消,很难预测。实践上固定其中两个、只动一个。
temperature=0 通常退化为贪心解码,但不等于完全确定:浮点归约顺序、批处理组合、并行策略、MoE 路由都可能让同一输入产生不同输出。要可复现,还得固定 seed(若引擎支持)与版本。
5. max_tokens、停止词与截断
{
"model": "MODEL_NAME",
"messages": [{"role": "user", "content": "用三句话解释 KV 缓存"}],
"max_tokens": 256,
"temperature": 0.2,
"stop": ["\n\n用户:", "###"]
}
| 参数 | 管什么 | 不管什么 |
|---|---|---|
max_tokens |
生成上限,触顶即停 | 不保证句子完整 |
stop |
命中字符串就停,该串不返回 | 不是"过滤",只是截断点 |
| 上下文窗口 | 输入 + 输出总长上限 | 不控制单次输出长度 |
截断的判定要看返回的 finish_reason:stop 是正常停止(命中停止词或自然结束),length 是达到 max_tokens。看到 length 就该知道答案可能被切在半句,重试要放大上限或压缩输入。
停止词写错很常见:写 "。" 会让模型每个句号就停;写带空格的 "\n\n" 在流式输出里可能永远匹配不到,因为分块边界可能落在两个 \n 之间。
6. 连续批处理:吞吐与单请求延迟的交换
静态批处理要等最长的那条生成完才能收批,短请求被长请求拖住(木桶效应)。连续批处理(continuous batching)在每个 decode 步重新组批:谁生成完谁退出,空位立刻补进新请求。
| 方式 | 吞吐 | 单请求延迟 | GPU 利用率 |
|---|---|---|---|
| 单请求串行 | 低 | 最低 | 低 |
| 静态批 | 高 | 受批内最长请求拖累 | 较高 |
| 连续批处理 | 最高 | 排队 + 与批内其他请求共享算力 | 高 |
所以"服务器吞吐上去了,我的请求反而变慢"是正常现象:你的 token 在和其他请求抢同一个 decode 步的时间片。要稳定单请求延迟,就得限流、隔离池或降低批上限。
7. 量化:把权重压小,换显存与带宽
| 精度 | 权重位宽 | 显存占用(相对 FP16) | 精度影响 |
|---|---|---|---|
| FP16/BF16 | 16 | 1.0 | 基线 |
| INT8 | 8 | 约一半 | 通常较小,长文任务更敏感 |
| INT4(GPTQ/AWQ 一类) | 4 | 约四分之一 | 视模型与校准集,可能明显退化 |
| KV 量化 | 8/4 | 只压缓存,不压权重 | 长上下文收益明显 |
decode 的瓶颈是显存带宽,权重越小每步读得越少,所以量化对输出速度的收益往往比对算力更直接。代价是精度:数学、长链推理、结构化输出对量化更敏感,要按任务实测而不是看宣传。
8. 流式输出:SSE 让"看起来快"
流式走的是 Server-Sent Events:服务端保持一个 HTTP 响应不结束,按 data: {...} 逐条推事件,最后一条通常是 data: [DONE]。
curl -N -s https://API_HOST/v1/chat/completions \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"model":"MODEL_NAME","stream":true,
"messages":[{"role":"user","content":"数到五"}],
"max_tokens":64,"temperature":0}'
data: {"choices":[{"delta":{"content":"1"}}]}
data: {"choices":[{"delta":{"content":"、"}}]}
data: [DONE]
关键差别:总耗时不变,感知等待变短。非流式要等全部 token 生成完才返回,用户盯着空白;流式在第一个 token 就出字。如果后端在流式之外还做了缓冲(网关聚合、压缩中间件),用户看到的仍是"一次性蹦出来"。
9. 同一模型,不同部署为什么表现不同
| 变量 | 影响 |
|---|---|
| 推理框架(vLLM / TensorRT-LLM / llama.cpp) | 调度策略、kernel、批处理实现不同 |
| 并行策略(TP/PP/EP) | 跨卡通信开销,TP 越大单步延迟未必越低 |
| 批上限与调度参数 | 吞吐与延迟的直接权衡 |
| 量化与 KV 缓存策略 | 显存占用、可支持的上下文长度 |
| 硬件与互联 | 带宽决定 decode 速度上限 |
所以"同一个模型,A 平台比 B 平台快"通常是部署差异,不是模型差异。比较时要固定输入输出长度、并发数、量化精度,并分别看 TTFT 与每 token 时间。
10. 最小示例:观察三件事
# 1) 流式:观察首字时间与逐 token 到达
curl -N -s https://API_HOST/v1/chat/completions -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"model":"MODEL_NAME","stream":true,"messages":[{"role":"user","content":"写一句诗"}],"max_tokens":64}' \
| while IFS= read -r line; do printf '%s %s\n' "$(date +%s.%N)" "$line"; done
# 2) 改 temperature:同一 prompt 请求三次,比较输出差异
# 3) 改 max_tokens=8:看 finish_reason 是否变成 length
第 1 步的时间戳能直接量出 TTFT 与相邻 token 间隔;第 2 步验证随机性(temperature=0 也可能不完全一致);第 3 步验证截断判定。三个观察都不需要压测工具。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 首字很慢、出字很快 | prefill 长或排队久 | 压缩输入、减少并发、查 TTFT |
| 出字很慢、首字正常 | decode 受带宽/批影响 | 降量化精度、减批、查每 token 时间 |
| 输出半句就停 | 命中 max_tokens 或停止词 |
看 finish_reason,调上限/停止词 |
| 同样输入输出不一样 | 采样随机 + 浮点/批处理差异 | 降温度、固定 seed、固定版本 |
| 流式却一次性出现 | 网关缓冲/压缩 | 关闭中间层缓冲,curl -N 验证 |
| 长上下文报错 | 超窗口或 KV 显存不足 | 截断输入、减少并发 |
12. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 固定格式抽取 | 约束解码 / 结构化输出 | 靠采样参数保证不了格式 |
| 超长文档问答 | 检索 + 分段 | 硬塞上下文成本高、质量降 |
| 极低延迟要求 | 小模型 + 少输出 + 就近部署 | 大模型 decode 有物理下限 |
| 高吞吐离线任务 | 大批处理 + 放宽延迟 | 与交互式请求分开池 |
推理优化的顺序是:先减输入与输出 token,再谈框架与量化,最后才是换硬件。多数"慢"来自不必要的上下文和过长的输出。
13. 自测
- prefill 与 decode 的瓶颈分别是什么?
- 为什么上下文越长,每 token 的时间和显存都上升?
temperature与top_p叠加时会怎样?finish_reason=length说明什么,该怎么处理?- 连续批处理为什么提高吞吐却可能增加单请求延迟?
- 流式输出让总耗时变短了吗?