把请求抽象成 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 会从单请求推理,真正迈向多请求推理引擎。