07. 一个请求的全链路
前六篇一直盯着 GPU 上的计算。这一篇把镜头拉远,跟着资料助手的一次请求走完全程:从 HTTP 请求到达,经过对话模板、分词、调度、Prefill、Decode 循环、采样、反分词,直到最后一个字推送回浏览器。读完你应该能画出这条链路,指出每个环节在 CPU 还是 GPU 上运行、可能卡在哪里,并理解温度、top-p 这些采样参数在哪一步起作用。第三部分的每一项技术,都会在这条链路上找到自己的位置。
先走一遍
按顺序,一个请求会经过这些环节:
| 环节 | 做什么 | 在哪里运行 | 可能的瓶颈 |
|---|---|---|---|
| API 入口 | 接收 HTTP 请求,校验参数和权限 | CPU | 连接数、请求解析 |
| 对话模板 | 把多轮消息拼成模型认识的格式 | CPU | 模板写错会直接影响质量 |
| 分词 | 文本切成 token 编号 | CPU | 超长输入、高并发时占用 CPU |
| 调度 | 决定这一步哪些请求参与计算 | CPU | 请求很多时调度本身耗时 |
| Prefill | 并行处理全部输入,建立 KV Cache | GPU,计算受限 | 输入很长时拉高 TTFT |
| Decode 循环 | 每步生成一个 token,读全部 KV | GPU,访存受限 | 显存带宽,决定 TPOT |
| 采样 | 按参数从概率分布中选出 token | GPU,较轻 | 复杂约束会增加开销 |
| 停止判断 | 检查结束符、停止词、长度上限 | CPU | 很少成为瓶颈 |
| 反分词与推送 | token 拼回文字,流式发给客户端 | CPU | 与主循环争抢 CPU |
下面用一个只有几个 token 的教学例子,一步步看请求的状态和 KV Cache 怎样变化:
逐步观察一个请求教学 token,不是真实分词
- 1排队与分词
- 2Prefill
- 3Decode 1
- 4Decode 2
- 5停止与释放
输入年假怎么申请?
输出待生成待生成待生成
输入已转换为 4 个教学 token;尚未进行模型计算。
活跃 KV:0 个位置。刚采样出的 token,要在下一次前向中才建立自己的 KV。
1 / 5
对话模板:模型看到的不是你发的原文
调用推理服务时,你通常发送的是一组消息:
json
[
{"role": "system", "content": "你是公司资料助手,只根据提供的制度回答。"},
{"role": "user", "content": "年假怎么申请?"}
]模型并不理解 JSON。服务端会用这个模型专属的对话模板(chat template),把消息拼成一段带特殊标记的文本,标出哪段是系统提示、哪段是用户、从哪里开始轮到助手回答。不同模型的模板不同,模板通常和模型文件一起发布。
模板用错了,模型可能答非所问,或者停不下来。这类问题看起来像模型质量差,实际上出在推理服务的配置上。排查回答质量问题时,先确认实际送进模型的文本是什么。
分词和反分词:CPU 上的工作
拼好的文本由分词器切成 token。分词跑在 CPU 上,一般很快,但输入有几万个 token、同时又有大量请求时,它也会占用可观的 CPU 时间。
生成出来的 token 要反过来拼回文字,叫反分词(detokenize)。流式输出时这一步要增量进行,而且有个细节:一个汉字在 UTF-8 编码里占 3 个字节,可能被切在两个 token 里。反分词时必须等字节凑齐了再输出,否则用户会看到乱码。
GPU 优化到位以后,这些 CPU 环节反而可能成为新的瓶颈:GPU 算完一步,要等 CPU 准备好下一步的输入才能继续。所以 vLLM 的 V1 版本把 API 服务、分词和反分词放在独立的进程里,与负责调度和 GPU 执行的引擎核心并行运行,避免它们拖慢主循环。
调度与执行:GPU 主循环
请求分好词以后,进入推理引擎的等待队列。引擎的核心是一个循环,每一圈叫一个 step:
- 调度器看一眼当前所有请求:哪些在等待,哪些正在生成,显存还剩多少,然后选出这一步要参与计算的请求,以及每个请求这一步处理多少 token。
- 执行器把这些请求的输入整理成张量,连同每个请求的 KV Cache 位置信息,一起送进模型做一次前向计算。新请求在这一步做 Prefill,老请求做一步 Decode。
- 模型为每个请求的最后一个位置输出一组分数,交给采样器选出下一个 token。
- 新 token 追加到各自的请求里,检查是否该停止;没停的进入下一圈。
这个循环每秒要跑几十到上百圈。第三部分讲的分页、连续批处理、前缀缓存、分块预填充,都是在改进这个循环的某一步。
采样:从概率到 token
模型每一步输出的是词表上每个 token 的分数(logits)。怎样从中选出一个,由请求里的采样参数决定。
贪心(greedy):直接选分数最高的那个。结果最稳定,但容易重复,缺少变化。请求里把 temperature 设为 0,通常就是贪心。
温度(temperature):先把分数除以温度,再换算成概率,然后按概率随机抽取。
text
概率ᵢ ∝ exp(分数ᵢ ÷ 温度)温度小于 1,高分和低分的差距被拉大,抽取更保守;温度大于 1,分布被拉平,低概率的 token 也有机会被选中,输出更多样,也更容易出错。资料助手这类要求准确的场景,温度通常设得很低。
top-k:只在分数最高的 k 个 token 里抽取。top-p(核采样):按概率从高到低累加,只在累计概率达到 p 的那一小组 token 里抽取。它们的作用都是砍掉长尾里那些不靠谱的候选。
还有一些参数在采样之前修改分数:重复惩罚降低已经出现过的 token 的分数;结构化输出(比如必须输出合法 JSON)会把不符合语法的 token 的分数直接设为负无穷。
关于确定性要注意一点:即使温度为 0,同一个问题在服务端多次请求,结果也可能不完全相同。因为请求和别的请求凑在不同的批里时,GPU 上的计算顺序会变化,浮点数相加的顺序不同,结果会有极小的差异,偶尔会让某一步选出不同的 token。需要严格可复现时,要用引擎专门提供的确定性模式,并接受它带来的性能代价。
停止、流式返回与取消
每生成一个 token,引擎都要判断是否停止:遇到模型的结束符,命中请求里指定的停止词,或者达到 max_tokens 上限。返回结果里通常带一个结束原因(如 stop 或 length),用来区分“正常说完”和“被长度截断”。被截断的回答,应用层要当作不完整的结果处理。
流式输出通常用 SSE(Server-Sent Events)协议,每生成一小段就推送一次。前面说过,一次推送可能包含多个 token,测 TPOT 时不能按推送次数计算。
用户中途关掉页面时,取消信号要一路传进引擎:从等待队列中移除请求,或者在下一步把它从批中拿掉,并释放它的 KV Cache。如果取消没有传到引擎,GPU 会继续为一个已经没人看的请求生成内容,白白占着显存和算力。高峰期大量用户刷新页面时,这个问题会放大。
每个环节由后面哪一篇展开
| 链路上的问题 | 对应技术 | 章节 |
|---|---|---|
| KV Cache 怎样分配而不浪费 | PagedAttention | 第 8 篇 |
| 调度器怎样挑选请求、显存不够怎么办 | 连续批处理、抢占 | 第 9 篇 |
| 同样的系统提示和资料能否少算 | 前缀缓存 | 第 10 篇 |
| 长输入的 Prefill 怎样不卡住别人 | 分块预填充 | 第 11 篇 |
| 一次前向内部怎样更快 | 注意力算子、CUDA Graph | 第 12 篇 |
| 每一步要搬的数据能否更少 | 量化 | 第 14 篇 |
| 一步能否推进多个 token | 投机解码 | 第 15 篇 |
一个请求要经过 API 入口、对话模板、分词、调度、Prefill、Decode 循环、采样、停止判断和反分词,其中只有中间几步在 GPU 上运行。对话模板决定模型看到什么,采样参数决定怎样从概率里选出 token,取消要一直传到引擎才能释放资源。
第二部分到这里结束。我们已经知道瓶颈在哪里:Decode 访存受限,KV Cache 随人数和长度增长,CPU 环节也可能拖慢 GPU。第三部分开始逐个拆解推理引擎的核心技术,第一个是让 vLLM 成名的 PagedAttention。
