2902 字
约 9 分钟
21
S23-B06 · 推理入门:模型怎么把 token 一个个吐出来

问一句,回答像打字一样逐字冒出来。这不是界面在装效果——模型本身就是一次前向只生成一个 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. 自测

  1. prefill 与 decode 的瓶颈分别是什么?
  2. 为什么上下文越长,每 token 的时间和显存都上升?
  3. temperature 与 top_p 叠加时会怎样?
  4. finish_reason=length 说明什么,该怎么处理?
  5. 连续批处理为什么提高吞吐却可能增加单请求延迟?
  6. 流式输出让总耗时变短了吗?
S23-B06 · 推理入门:模型怎么把 token 一个个吐出来
https://www.clxhxhhr.top/posts/6147/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。