把请求抽象成 Sequence:从单请求 generate 到推理引擎状态机
1. 这一篇要解决什么问题
前面几篇文章里,我们已经走过了三个阶段。
第一篇,我们从整体上看了一个 LLM 请求在推理系统里的生命周期。
第二篇,我们手写了一个最小版本的 generate,理解了一个 token 是如何被生成出来的。
第三篇,我们引入了 KV cache,把生成过程拆成了 prefill 和 decode 两个阶段。
到目前为止,我们的代码大概还停留在“单请求脚本”的状态。
它可能维护着这些变量:
input_ids
prompt_length
past_key_values
next_token_id
generated_ids
sampling_params
在一个简单 demo 里,这样写没有问题。
但如果我们要实现 mini vLLM,这种写法很快就会失控。
因为真正的推理引擎里,同时会有很多请求存在。每个请求都有自己的 prompt、生成结果、采样参数、KV cache、状态和停止条件。
有的请求还在 waiting 队列。
有的请求已经完成 prefill,正在 running 队列里做 decode。
有的请求已经达到 EOS 或 max_tokens,需要进入 finished 状态。
如果这些信息都散落在循环变量里,我们就很难做调度,也很难做资源管理。
所以这一篇要做一件看起来不复杂,但非常重要的事:
把一个请求的生成状态抽象成 Sequence。
从这一篇开始,我们不再把生成看成一个函数调用,而是把它看成一个可以被推理引擎逐步推进的状态对象。
2. 为什么叫 Sequence
在 LLM 推理里,一个请求本质上就是一段不断增长的 token 序列。
它开始时只有 prompt tokens。
模型每生成一个新 token,这个序列就变长一点。
直到遇到停止条件,序列停止增长。
所以我们可以把它抽象成一个 Sequence。
一个 Sequence 至少要回答这些问题:
这个请求的 prompt tokens 是什么
已经生成了哪些 tokens
当前应该输入模型的 token 是什么
已经生成了多少个新 token
是否已经完成 prefill
是否已经结束
为什么结束
它使用什么采样参数
它持有哪些 KV cache
这些信息合在一起,构成了一个请求在推理系统中的完整状态。
可以这样理解:
Sequence = token 序列 + 推理状态 + 采样配置 + 缓存引用
后面的 scheduler 不会直接操作一堆零散变量,而是操作一个个 Sequence。
这就是工程抽象的意义。
3. Request 和 Sequence 有什么区别
这里有一个容易混淆的点:Request 和 Sequence 是不是同一个东西?
在最简单的系统里,它们可以暂时一一对应。
一个用户请求进来,系统创建一个 Sequence。
但从设计上,它们最好区分开。
Request 更接近 API 层概念,表示用户发来的原始请求。
它通常包含:
request_id
prompt
sampling params
arrival time
streaming flag
Sequence 更接近引擎层概念,表示模型推理时维护的一条 token 序列。
它通常包含:
prompt token ids
generated token ids
current status
KV cache
last token
stop reason
为什么要区分?
因为未来一个 Request 不一定只对应一个 Sequence。
例如,用户要求 n = 4,也就是同一个 prompt 生成 4 个不同结果。这时一个 Request 就可能对应多个 Sequence。
再比如 beam search,一个请求会在生成过程中分裂成多个候选序列。
所以更合理的关系是:
Request 是用户请求
Sequence 是模型正在生成的一条序列
在我们的 mini vLLM 初版里,可以先让一个 Request 对应一个 Sequence。但概念上要提前分清楚,这样后面扩展不会别扭。
4. Sequence 需要维护哪些字段
一个最小的 Sequence 可以包含这些字段。
class Sequence:
seq_id: str
prompt_token_ids: list[int]
generated_token_ids: list[int]
sampling_params: SamplingParams
status: SequenceStatus
stop_reason: StopReason | None
past_key_values: Any
这只是概念结构,不是最终完整代码。
其中 prompt_token_ids 保存用户输入的 token。
generated_token_ids 保存模型新生成的 token。
sampling_params 保存 temperature、top k、top p、max tokens 等采样参数。
status 表示当前状态,例如 waiting、running、finished。
stop_reason 表示请求为什么结束,例如遇到 EOS、达到 max tokens、被用户取消。
past_key_values 暂时保存框架提供的 KV cache。后面实现 block manager 后,这个字段会被替换成更可控的 cache block 信息。
这个结构看起来很简单,但它把生成过程中的关键状态都收拢到一个对象里了。
5. SequenceStatus:请求生命周期的状态机
接下来,我们需要定义 Sequence 的状态。
在第一版 mini vLLM 里,三个状态就够用了:
class SequenceStatus:
WAITING = "waiting"
RUNNING = "running"
FINISHED = "finished"
这三个状态对应推理引擎中的三类队列。
WAITING 表示请求已经进入系统,但还没有完成 prefill。
RUNNING 表示请求已经完成 prefill,后续会持续参与 decode。
FINISHED 表示请求已经结束,不再参与调度。
状态变化大概是这样的:
WAITING
进入 prefill
RUNNING
多轮 decode
FINISHED
更具体一点:
用户请求进入系统
创建 Sequence
Sequence.status = waiting
调度器选择它做 prefill
prefill 完成并采样出第一个 token
Sequence.status = running
每轮 decode 追加一个新 token
如果未结束,继续保持 running
达到停止条件
Sequence.status = finished
释放相关资源
这就是一个 Sequence 的生命周期。
后面实现 scheduler 时,我们会围绕这三个状态维护三个队列:
waiting queue
running queue
finished list
6. StopReason:为什么结束也很重要
很多简单 demo 只关心“是否结束”,但推理服务里还需要知道“为什么结束”。
一个 Sequence 可能因为这些原因结束:
遇到 EOS token
达到 max_new_tokens
命中 stop words
用户主动取消
模型执行出错
服务端超时
所以我们可以定义一个简单的 stop reason:
class StopReason:
EOS = "eos"
MAX_TOKENS = "max_tokens"
STOP_WORD = "stop_word"
CANCELLED = "cancelled"
为什么 stop reason 重要?
因为它会影响最终返回给用户的信息,也会影响服务端监控。
例如:
大部分请求都因为 max_tokens 结束
说明用户可能经常被截断,需要调大默认输出长度
大量请求因为 timeout 结束
说明系统负载可能过高,调度策略需要优化
大量请求因为 cancel 结束
说明流式输出场景下,用户中途断开的比例较高
一个好的推理系统不能只生成文本,也要能解释请求是如何结束的。
7. Sequence 如何追加 token
每次模型生成一个新 token,Sequence 都要更新自己的内部状态。
关键动作大概是:
sequence.append_token(next_token_id)
这个方法内部要做几件事:
把 next_token_id 加入 generated_token_ids
更新最后一个 token
更新生成长度
判断是否达到 max tokens
判断是否遇到 EOS
必要时设置 stop_reason
必要时把状态改成 finished
这一步的意义在于,engine 不应该到处手写这些判断逻辑。
engine 只负责驱动流程。
Sequence 自己负责维护自身状态。
这样代码会更清晰:
next_token = sampler.sample(logits)
sequence.append_token(next_token)
而不是在 engine 里散落大量判断:
generated_ids.append(next_token)
if next_token == eos_token_id:
status = finished
if len(generated_ids) >= max_new_tokens:
status = finished
后面当停止条件变复杂时,封装的价值会更明显。
8. prompt tokens 和 generated tokens 为什么要分开
有人可能会问:为什么不直接维护一个完整的 token 列表?
例如:
all_token_ids = prompt_token_ids + generated_token_ids
这样也可以,但最好还是把 prompt 和 generated 分开保存。
原因有三个。
第一,返回结果时,通常只需要返回新生成的部分。
如果 prompt 和 generated 混在一起,每次都要根据 prompt 长度切片。
第二,统计更清晰。
prompt 长度和生成长度是两个不同指标。prefill 成本主要和 prompt 长度有关,decode 成本主要和生成长度有关。
第三,调度会用到它们。
后面 scheduler 做 token budget 时,需要知道某个 waiting 请求的 prompt 长度,也需要知道 running 请求当前已经生成了多少 token。
所以更合理的结构是:
prompt_token_ids: 用户输入部分
generated_token_ids: 模型生成部分
需要完整上下文时,再临时拼接:
sequence.get_token_ids()
这个方法可以返回:
prompt tokens + generated tokens
9. last_token_id:decode 阶段真正需要的输入
第三篇里我们提到过一个细节:
decode 阶段每次只输入最新 token。
所以 Sequence 需要很方便地提供“最后一个 token”。
可以设计一个方法:
sequence.get_last_token_id()
在 prefill 刚结束时,最后一个 token 是 prefill 阶段采样出来的第一个生成 token。
在后续 decode 中,每一轮都会追加一个新 token。这个新 token 就是下一轮 decode 的输入。
流程可以理解成:
prefill 输入 prompt
采样出 token_1
Sequence 保存 token_1
decode 第 1 轮输入 token_1
采样出 token_2
Sequence 保存 token_2
decode 第 2 轮输入 token_2
采样出 token_3
Sequence 保存 token_3
所以对于 running 状态的 Sequence,调度器和 model runner 通常只需要取它的 last token,而不是完整上下文。
完整上下文已经体现在 KV cache 里了。
10. KV cache 暂时怎么放
当前阶段,我们还没有实现自己的 block manager,也没有实现 PagedAttention。
所以可以先把 Hugging Face 返回的 past_key_values 放在 Sequence 里。
sequence.past_key_values = outputs.past_key_values
prefill 后,Sequence 拿到第一份 cache。
decode 后,Sequence 更新 cache。
outputs = model(
input_ids=last_token_id,
past_key_values=sequence.past_key_values,
use_cache=True,
)
sequence.past_key_values = outputs.past_key_values
这个版本不是最终形态,但它足够帮助我们理解“每个请求拥有自己的 KV cache”。
后面做 block manager 时,Sequence 不再直接保存完整 past_key_values,而是保存类似这样的信息:
block table
current length
allocated blocks
也就是说,当前阶段的 past_key_values 是一个过渡设计。
它让我们先把引擎状态抽象清楚。
11. Sequence 和 prefill 的关系
当一个 Sequence 处于 waiting 状态时,它还没有完成 prefill。
调度器选择它之后,model runner 会拿它的 prompt tokens 做一次 forward。
prefill 完成后会发生三件事:
保存 KV cache
根据最后一个 logits 采样出第一个 token
把 Sequence 状态改成 running
伪代码可以写成:
outputs = model(prompt_token_ids, use_cache=True)
sequence.past_key_values = outputs.past_key_values
next_token = sampler.sample(outputs.logits[:, -1, :])
sequence.append_token(next_token)
sequence.status = RUNNING
注意这里的状态变化。
prefill 不是只生成 cache,它还会生成第一个输出 token。
这意味着一个请求从 waiting 进入 running 时,通常已经有了第一个 generated token。
这也解释了为什么流式输出里,prefill 完成后就可以向用户返回第一个 token。
12. Sequence 和 decode 的关系
当一个 Sequence 处于 running 状态时,它后续参与的就是 decode。
decode 阶段不再输入完整 prompt,而是输入 Sequence 的 last token。
last_token = sequence.get_last_token_id()
outputs = model(
input_ids=last_token,
past_key_values=sequence.past_key_values,
use_cache=True,
)
然后继续更新:
更新 KV cache
采样 next token
追加到 generated tokens
判断是否结束
如果还没结束,Sequence 保持 running。
如果结束,Sequence 进入 finished。
从调度器视角看,它不需要关心具体生成了什么文本,只需要知道:
这个 Sequence 是否还能继续参与下一轮 decode
这就是状态对象的价值。
13. SequenceGroup:为未来扩展留一个位置
虽然 mini vLLM 第一版可以让一个 Request 对应一个 Sequence,但我们可以提前引入一个概念:SequenceGroup。
一个 SequenceGroup 表示同一个用户请求下的一组 Sequence。
在最简单情况下:
一个 SequenceGroup 只有一个 Sequence
未来如果支持多个输出,比如用户设置 n = 4,那么一个 SequenceGroup 就会包含 4 个 Sequence。
如果支持 beam search,那么一个 SequenceGroup 也会维护多个候选 Sequence。
为什么不等以后需要再加?
因为 scheduler 后面调度的对象更接近 SequenceGroup,而不是裸 Sequence。
原因是多个 Sequence 可能属于同一个用户请求,它们共享一些信息:
request_id
arrival time
sampling params
streaming flag
priority
在第一版里,我们可以先弱化这个概念,只在文章里点出来。真正实现时,可以先用单 Sequence 版本,等调度器稳定后再引入 SequenceGroup。
14. 输出也需要抽象
除了输入状态,我们还需要考虑输出。
每一步 engine 执行完,可能会产生一些增量输出。
例如:
request_id
新生成的 token id
新生成的文本片段
是否结束
结束原因
可以抽象成一个简单的输出对象:
class RequestOutput:
request_id: str
text: str
token_ids: list[int]
finished: bool
stop_reason: StopReason | None
有了这个对象,engine 的接口就可以变得很清楚:
outputs = engine.step()
每次 step 返回一批 RequestOutput。
对于非流式调用,服务层可以把所有输出攒起来,最后一次性返回。
对于流式调用,服务层可以把每一步的新增文本片段立即返回给客户端。
这也是为什么我们要从这一篇开始设计状态对象。
一个推理引擎不只是“生成字符串”,它需要在每一步都能返回结构化结果。
15. 从函数式 generate 到状态机 engine
现在我们可以重新理解前几篇的变化。
第二篇里的 generate 是一个函数:
text = generate(prompt)
这个函数会一直执行,直到请求结束。
但推理引擎更适合写成状态机:
engine.add_request(prompt)
while engine.has_unfinished_requests():
outputs = engine.step()
这两种写法的差异非常大。
函数式 generate 的控制权在单个请求手里。
状态机 engine 的控制权在系统手里。
为什么这重要?
因为系统要同时管理很多请求。
如果每个请求都自己跑完一个完整 generate,那么就很难实现 continuous batching。
而如果每个请求都只是一个 Sequence,engine 每次 step 推进它们一点点,那么调度器就可以灵活地决定:
哪些 waiting 请求进入 prefill
哪些 running 请求继续 decode
哪些 finished 请求释放资源
这就是从 demo 走向推理系统的关键转变。
引入 Sequence 之后,mini vLLM 的核心对象会变得更清楚。
API 层收到用户请求。
Tokenizer 把 prompt 转成 token ids。
Engine 创建 Sequence。
Sequence 进入 waiting 状态。
Scheduler 选择 waiting Sequence 做 prefill。
prefill 完成后,Sequence 进入 running 状态。
running Sequence 每一轮 decode 都会追加新 token。
达到停止条件后,Sequence 进入 finished 状态。
最后,engine 返回完整输出,并释放相关资源。
可以把整个过程理解成:
文本请求
变成
token 序列
变成
Sequence 状态对象
被 engine.step 不断推进
最终变成
RequestOutput
这个抽象打通之后,后面实现 scheduler 就顺理成章了。
16. 小结
这一篇我们做了一个重要的工程抽象:把请求的生成过程封装成 Sequence。
Sequence 不只是 token 列表,它还维护了推理系统真正关心的状态:
prompt tokens
generated tokens
sampling params
KV cache
status
stop reason
last token
有了 Sequence 之后,我们就可以把生成过程从一个封闭的 generate 函数,改造成一个可以被 engine 逐步推进的状态机。
这一步看起来不像性能优化,但它非常关键。
因为后面的 scheduler、continuous batching、KV cache block manager 和 PagedAttention,都需要建立在清晰的请求状态抽象之上。
下一篇文章,我们会正式实现第一个调度器。
它会维护 waiting、running 和 finished 三类请求,并在每一轮 step 中决定哪些请求做 prefill,哪些请求做 decode。
到那时,mini vLLM 会从单请求推理,真正迈向多请求推理引擎。