流式输出和异步 Engine:让推理引擎真正像一个在线服务

前面几篇文章里,我们已经把 mini vLLM 的核心执行链路搭了起来。

一个请求进入系统后,会被抽象成 Sequence。Scheduler 负责决定哪些 Sequence 做 prefill,哪些 Sequence 做 decode。ModelRunner 负责执行模型,Sampler 负责从 logits 中选出下一个 token。Sequence 自己维护 prompt tokens、generated tokens、KV cache、status 和 stop reason。

到这里为止,系统已经有了一个推理引擎的雏形。

但它还不像一个真正的在线服务。

原因很简单:用户不会希望等模型把整段回答都生成完之后才看到结果。对于 LLM 应用来说,流式输出几乎是默认体验。用户发出请求后,模型只要生成了第一个 token,就应该尽快返回。后面的 token 也应该边生成边返回,而不是全部攒到最后。

这一篇我们要做的事情,就是把前面的 engine step 循环改造成支持流式输出和异步请求的形式。

这一步看起来像是在做 API 层,但它其实会反过来影响整个推理引擎的设计。因为一旦支持流式输出,engine 就不能再把 generate 当成一个封闭函数。它必须变成一个可以持续推进、持续产生增量结果、同时处理多个请求的状态机。

从一次性返回到增量返回

先看最朴素的接口。

text = engine.generate(prompt)
return text

这种接口非常符合普通函数调用的直觉。输入一个 prompt,返回一段 text。

但是对于 LLM 推理来说,它隐藏了一个问题:生成是一个逐 token 发生的过程。

模型不是一次性生成完整文本,而是每一步生成一个 token。既然 token 是一个个产生的,服务层就没有必要等到全部结束再返回。

更合理的接口应该像这样:

for output in engine.generate_stream(prompt):
    yield output.text

也就是说,engine 每生成一点新内容,就把这部分内容交给调用方。

从系统角度看,这个变化非常关键。

一次性返回的 generate 可以把内部所有过程包起来,外部只关心最后结果。

流式返回则要求 engine 暴露中间结果。每一轮 step 产生了哪些 token,哪些请求有新增输出,哪些请求结束了,都需要变成结构化事件返回给上层。

所以我们需要重新思考 engine 的输出。

前面我们已经提到过 RequestOutput 这个概念。它不应该只是最终结果,而应该能表示每一轮的增量输出。

它可以包含这些信息:

class RequestOutput:
    request_id: str
    text: str
    token_ids: list[int]
    finished: bool
    stop_reason: StopReason | None

对于非流式请求,服务层可以把每一轮的 text 拼起来,等 finished 后一次性返回。

对于流式请求,服务层可以把每一轮的 text 立即发给客户端。

这说明一个好的推理引擎不应该把“流式”和“非流式”写成两套完全不同的生成逻辑。

底层都应该基于同一个 step 循环。

区别只在于上层如何消费这些增量输出。

Engine.step 是整个系统的心跳

流式输出的基础是 Engine.step()

在前面的文章里,我们已经大概设计过它的职责。现在可以把它放到更重要的位置来看。

每调用一次 step(),engine 就向前推进一小步。

它会让 Scheduler 做一次调度,让 ModelRunner 执行一次模型,让 Sampler 采样新 token,然后把新 token 写回 Sequence。最后,它会返回这一轮产生的 RequestOutput。

这个过程可以理解成推理引擎的心跳。

outputs = engine.step()

一次 step 不代表一个请求完成。

一次 step 只代表系统中某些请求向前推进了一步。

这和普通 generate 函数有本质区别。

普通 generate 的控制权属于单个请求。它会一直循环,直到这个请求结束。

engine step 的控制权属于系统。每一轮系统决定推进哪些请求,推进多少,然后把本轮新增结果返回。

这正是 continuous batching 能成立的前提。

如果我们把生成写成这样:

while not sequence.is_finished():
    run_one_sequence(sequence)

那么一个请求会长时间占据执行流程,其他请求很难插进来。

但如果我们写成这样:

while engine.has_unfinished_requests():
    outputs = engine.step()
    handle(outputs)

系统每一轮都有机会重新调度所有请求。新请求可以进入,老请求可以继续,完成请求可以退出。

流式输出就是从这个 step 循环自然长出来的。

每一轮 step 产生新 token,服务层就把新 token 发出去。

为什么需要异步 Engine

如果只是写一个本地 demo,同步循环就够了。

request_id = engine.add_request(prompt)

while not engine.is_finished(request_id):
    outputs = engine.step()
    print(outputs)

但在线服务不一样。

服务端会同时面对很多客户端连接。每个连接都可能在等待自己的流式输出。与此同时,新请求还会不断到来,老请求可能取消,某些连接可能断开。

如果所有逻辑都写成同步阻塞形式,很快会遇到几个问题。

第一个问题是,请求接入和模型执行会互相阻塞。

当某个接口正在等待一个请求生成完成时,服务端还需要继续接收其他请求。不能因为一个请求还在生成,就让整个服务卡住。

第二个问题是,流式输出本身是一个异步过程。

服务端需要不断把增量 token 写到网络连接里。写网络可能有等待,客户端也可能断开。如果这些等待阻塞了推理主循环,就会影响其他请求。

第三个问题是,engine step 应该持续运行。

只要系统里有未完成请求,engine 就应该不断推进它们。它不应该完全依赖某个 HTTP handler 自己循环调用模型。否则多个请求之间就很难共享同一个调度器。

所以我们需要把 engine 设计成一种异步结构。

一个常见的思路是:推理引擎内部有一个后台循环不断执行 step,而每个用户请求通过队列接收自己的输出。

注意这里的“后台循环”不是让文章里的代码去做什么神秘的后台任务,而是指服务进程内部的事件循环结构。它仍然是程序当前运行的一部分,只是和单个请求 handler 解耦。

可以想象成这样:

HTTP 请求进入
创建 Sequence
把 Sequence 加入 Scheduler
为这个 request_id 创建一个输出队列

Engine loop 持续执行 step
每轮 step 产生 RequestOutput
根据 request_id 把输出放进对应队列

HTTP streaming handler 从队列读取输出
读到一个 token 就返回一个 token
读到 finished 就结束连接

这样一来,模型执行由统一的 engine loop 控制,请求输出由各自的 streaming handler 消费。

这就是异步 engine 的核心思想。

用队列连接 Engine 和客户端

支持流式输出时,最自然的抽象是每个请求一个输出队列。

当用户提交请求时,系统会创建一个 request_id,同时创建一个队列。

request_id = engine.add_request(prompt, sampling_params)
output_queue = output_queues[request_id]

engine 每轮 step 后,可能会产生多个请求的输出。

outputs = engine.step()

这些 outputs 需要按照 request_id 分发到不同队列。

for output in outputs:
    queue = output_queues[output.request_id]
    await queue.put(output)

然后 HTTP 层只需要消费自己对应的队列。

while True:
    output = await queue.get()
    yield output.text

    if output.finished:
        break

这个结构看起来简单,但它非常重要,因为它把两个节奏不同的系统解耦了。

模型执行有自己的节奏。

客户端读取也有自己的节奏。

模型可能很快生成 token,但网络发送可能慢。

客户端可能中途断开。

如果二者直接耦合,任何一个客户端的问题都可能影响整个推理循环。

用队列隔开之后,engine 只负责生产输出,HTTP handler 只负责消费输出。

当然,这里会出现一个新的问题:如果客户端消费太慢,队列会不会越来越大?

答案是会。

所以真实系统还需要 backpressure,也就是反压机制。

最简单的做法是给队列设置最大长度。如果某个客户端长期不消费,队列满了,服务端可以取消这个请求,释放它的 KV cache 和调度资源。

这说明流式输出不只是“yield token”这么简单。它还会牵涉请求取消、资源释放和慢客户端处理。

请求取消不是边缘情况

在普通脚本里,请求一旦开始,就默认会跑到结束。

但在线服务里,请求取消非常常见。

用户可能关闭页面。

浏览器可能断开连接。

上游服务可能超时。

用户可能点击停止生成。

这些都会导致一个正在 running 的 Sequence 不再需要继续 decode。

如果系统没有及时处理取消请求,就会浪费大量资源。

因为这个 Sequence 仍然占着 KV cache,仍然可能被 Scheduler 调度,仍然在消耗 GPU 时间,但它的输出已经没有任何客户端接收了。

所以异步 engine 必须支持取消。

从设计上看,取消不应该直接粗暴地删除对象。更合理的方式是给 Sequence 设置状态。

sequence.status = FINISHED
sequence.stop_reason = CANCELLED

然后在下一轮调度或状态更新时,Scheduler 会把它从 running 队列移除,cache manager 会释放它占用的资源。

当前阶段我们还没有实现真正的 block manager,但从抽象上必须保留这个动作。

取消请求的完整路径应该是:

客户端断开或主动停止
服务层通知 engine abort request
engine 标记对应 Sequence 为 cancelled
Scheduler 不再调度该 Sequence
资源管理模块释放该 Sequence 的 KV cache
输出队列收到 finished 事件
流式连接结束

这也是为什么 stop reason 要单独保存。

EOSMAX_TOKENSCANCELLED 看起来都表示结束,但它们的系统含义不同。

EOS 是模型自然结束。

MAX_TOKENS 是参数限制结束。

CANCELLED 是外部中断结束。

对于服务监控来说,这些必须区分。

流式输出里的文本边界问题

前面我们一直说“每生成一个 token 就返回一个 token”,但真实实现里还有一个容易忽略的问题:token 不等于可读文本片段。

Tokenizer 的 token 可能不是完整字符,也可能带有前缀空格,还可能涉及多字节字符。对于中文、emoji、特殊符号,逐 token decode 有时会产生不完整或不稳定的文本。

例如,某些 tokenizer 的一个 token 可能只对应某个字节片段。直接每个 token 都 decode 并返回,可能会出现乱码或重复文本。

所以流式输出一般需要一个小的 detokenization 层。

它负责把 token ids 转成稳定的增量文本。

概念上可以这样理解:

Sequence 维护 generated_token_ids
Detokenizer 维护已经返回到哪个字符位置
每轮拿新的 token ids 做 decode
只返回本轮新增的文本片段

一个简单策略是每次 decode 当前完整 generated tokens,然后和上一次已经返回的文本做差分。

full_text = tokenizer.decode(sequence.generated_token_ids)
delta_text = full_text[len(previous_text):]
previous_text = full_text

这个方法简单,但不一定最高效。

它的好处是容易保证文本增量不会乱。

后面优化时,可以改成更精细的增量 detokenizer。

这件事也提醒我们:推理系统不只是模型 forward。用户最终看到的是文本,而不是 token id。token 到文本的转换也是在线服务链路的一部分。

Streaming API 应该返回什么

当我们把 engine 接到 HTTP 服务时,流式输出通常会使用 SSE,也就是 Server Sent Events。

接口形式可以类似:

POST /generate

请求体包含:

{
  "prompt": "Explain KV cache in LLM inference.",
  "max_tokens": 128,
  "temperature": 0.8,
  "stream": true
}

如果 stream 为 false,服务端可以等请求结束后返回完整文本。

如果 stream 为 true,服务端会持续返回事件。

每个事件可以包含:

{
  "request_id": "req_001",
  "text": "KV",
  "finished": false
}

最后一个事件可以是:

{
  "request_id": "req_001",
  "text": "",
  "finished": true,
  "stop_reason": "eos"
}

这里有一个设计取舍:每个流式事件要不要返回 token id?

对于调试和高级用户来说,token id 很有用。

但对于普通 API 来说,返回文本就够了。

mini vLLM 可以先返回 text、finished、stop_reason。token ids 可以作为可选字段保留。

更重要的是,API 层不应该直接暴露内部 Sequence 对象。Sequence 是引擎内部状态,RequestOutput 才是对外输出协议。

内部对象和外部协议分开,系统才容易演进。

异步架构下的 Engine 生命周期

支持异步之后,Engine 的生命周期也需要重新设计。

它不再只是一个被 generate 函数临时创建的对象,而是服务进程中的长期对象。

它会持续维护这些状态:

Scheduler 中的 waiting 和 running 请求
每个 request_id 对应的 Sequence
每个 request_id 对应的输出队列
模型和 tokenizer
ModelRunner
Sampler
后续还会有 KV cache manager

服务启动时,Engine 初始化模型。

请求到达时,Engine 创建 Sequence 并加入 Scheduler。

Engine loop 持续 step。

请求结束时,Engine 清理 Sequence、输出队列和相关资源。

这个结构和本地脚本的最大区别是:Engine 变成了一个长期运行的服务对象。

这也带来一些工程问题。

例如,Engine loop 空闲时要不要一直跑?

如果没有请求,持续空转会浪费 CPU。可以在没有未完成请求时 sleep 一小段时间,或者使用事件通知机制,有新请求加入时唤醒 engine loop。

再比如,add_request 和 engine step 可能同时访问 Scheduler 队列。

这意味着需要考虑并发安全。简单版本可以把所有修改都放在同一个 asyncio event loop 中,减少锁的复杂度。更复杂的版本可以使用线程安全队列或显式锁。

再比如,模型执行是 GPU 上的重任务。它可能是同步调用。如果直接在 async handler 里调用阻塞 GPU 执行,可能会阻塞事件循环。更稳妥的方式是让 engine loop 统一负责模型执行,HTTP handler 只负责提交请求和读取结果。

这些问题看起来偏工程,但它们正是“从一个 demo 变成一个服务”的关键差别。

这一篇之后,mini vLLM 变成了什么

在这一篇之前,我们的系统更像一个内部推理循环。

它能管理 Sequence,也能用 Scheduler 调度 prefill 和 decode。

但用户还不能像调用服务一样使用它。

引入流式输出和异步 engine 后,系统形态发生了变化。

它开始像一个真正的 LLM serving 系统。

请求可以随时进来。

每个请求都有自己的输出队列。

Engine 统一调度所有请求。

每一轮 step 产生增量输出。

客户端可以边生成边接收。

客户端断开后,系统可以取消请求并释放资源。

这一步的意义不只是加了一个 HTTP API。

它让前面设计的 Sequence、Scheduler、RequestOutput 都真正串起来了。

Sequence 让请求可以被逐步推进。

Scheduler 让多个请求可以共享推理循环。

RequestOutput 让每一轮生成结果可以被结构化返回。

异步队列让模型执行和客户端消费解耦。

这些组合在一起,才形成了一个在线推理服务的基本形态。

小结

这一篇我们把 mini vLLM 从内部推理循环推进到了在线服务形态。

流式输出要求 engine 不再只返回最终文本,而是每轮返回增量输出。异步 engine 要把模型执行和 HTTP 请求处理解耦,让多个客户端可以同时等待自己的输出,而推理主循环仍然由统一的 Scheduler 控制。

这里最重要的思想是:

底层只有一个 step 循环
流式和非流式只是消费输出的方式不同

对于非流式请求,服务层可以收集所有 RequestOutput,最后拼成完整文本。

对于流式请求,服务层可以把每一轮新增文本立即返回。

这套结构还自然引出了几个服务端必须处理的问题:慢客户端、请求取消、输出队列、stop reason、detokenization 和资源释放。

到这里,mini vLLM 已经具备了推理服务的基本骨架。

不过,它还有一个很大的问题没有解决:KV cache 的显存管理仍然很粗糙。

当前阶段,我们仍然可以依赖框架的 past_key_values。但在高并发场景下,每个请求的 KV cache 都会动态增长和释放。如果还是用连续内存方式管理,就会遇到显存浪费和碎片化问题。

下一篇文章,我们会进入整个系列最核心的部分之一:KV cache 显存管理。

我们会讨论为什么 vLLM 要引入类似操作系统分页的思想,为什么 PagedAttention 能显著改善 KV cache 管理,以及在实现 PagedAttention 之前,我们应该先怎样设计一个 block manager。