【大模型推理】小白教程:vllm 异步接口

cpp 复制代码
async def async_generate(model, prompt, sampling_params):
    request_id = random_uuid()
    result_generator = model.generate(prompt=prompt, sampling_params=sampling_params, request_id=request_id)
    output = None
    async for request_output in result_generator:
        output = request_output
    assert output is not None
    return output

好的,我们来详细拆解 async for request_output in result_generator: 这一行代码的执行过程,并用一个具体的例子来说明。

这行代码虽然看起来像一个普通的 for 循环,但因为有了 async 关键字,它的内部机制完全不同,核心在于**"暂停"与"恢复"**。

三个关键角色

为了理解这个过程,你必须想象有三个角色在协同工作:

  1. 你的代码 (The async_generate 函数):它的目标是拿到最终的生成结果。
  2. Asyncio 事件循环 (The Event Loop):它是一个总调度员。当你的代码需要等待时,它会接管控制权,去处理其他任务,并在等待的事情完成后,再回来唤醒你的代码。
  3. vLLM 引擎 (The vLLM Engine):它是一个在后台独立工作的工人,负责接收请求、在 GPU 上执行繁重的计算任务,并产出结果(token)。

详细执行过程(以你的脚本为例)

在你的脚本中,SamplingParams 设置了 output_kind=RequestOutputKind.FINAL_ONLY。这是一个非常重要的细节,意味着 vLLM 引擎只会在整个文本序列生成完毕后,才返回一次最终结果

我们用一个简单的例子来走一遍流程。

例子:

  • prompt: "中国的首都是"
  • sampling_params: max_tokens=5, output_kind=RequestOutputKind.FINAL_ONLY
  • vLLM 引擎最终会生成文本:"北京,一座美丽的城市。"
流程开始
  1. 调用 model.generate

    • 你的代码执行 result_generator = model.generate(...)
    • vLLM 引擎立即接收到这个请求,将其放入待处理队列,然后立刻 返回一个 result_generator 对象(一个异步生成器,可以理解成一个"取餐凭证")。
    • 关键点 :你的代码没有被阻塞,它拿到了"凭证",继续向下执行。
  2. 进入 async for 循环(第一次,也是唯一一次迭代)

    • 代码执行到 async for request_output in result_generator:
    • 它向 result_generator(凭证)询问:"请给我下一个结果。"
    • result_generator 内部知道,结果需要从 vLLM 引擎获取,而引擎还在后台处理(可能在处理其他用户的请求,或者正在为你的请求预热)。这个结果不是立即可用的
    • 因此,result_generator 触发了一个 await(等待)操作。
  3. "暂停"与控制权交接

    • 因为 await 被触发,async_generate 函数的执行在这里**被"暂停"**了。
    • 程序的控制权被交还给 Asyncio 事件循环
  4. 事件循环的工作时间(并发的核心)

    • 现在事件循环是自由的!它不会闲着。它会检查:
      • 还有没有其他 async 函数也处于等待状态?
      • 有没有新的网络请求进来了?
      • 有没有其他的 async_generate 任务可以启动?
    • 与此同时,在后台,vLLM 引擎和 GPU 正在努力工作 。它可能把 "中国的首都是" 和其他几十个请求打包成一个大批次(batch),在 GPU 上高效地计算,并生成了完整的文本 "北京,一座美丽的城市。"
    • 因为你设置了 FINAL_ONLY,vLLM 会等到全部生成完毕。
  5. 结果就绪,发出信号

    • vLLM 引擎生成完整个句子后,它将最终结果打包成一个 RequestOutput 对象。
    • 然后,它通知事件循环:"嘿,之前那个'凭证'对应的结果已经准备好了!"
  6. "恢复"与循环体执行

    • 事件循环收到信号后,立刻找到之前被"暂停"的 async_generate 函数,并在它暂停的地方**"恢复"**它的执行。
    • await 等待结束,result_generator 成功地交出了那个最终的 RequestOutput 对象。
    • 这个对象被赋值给 request_output 变量。
  7. 执行循环体

    • 现在,循环体 output = request_output 开始执行。
    • 本地变量 output 被赋值为那个包含了所有生成信息的 RequestOutput 对象。
  8. 循环结束

    • 循环尝试去获取下一个结果。
    • result_generator 因为是 FINAL_ONLY 模式,它知道自己只会产出一个结果。所以它告诉循环:"没有更多的结果了。"
    • async for 循环正常结束。
  9. 函数返回

    • async_generate 函数继续执行循环之后的代码,最终 return output

如果不是 FINAL_ONLY 会怎样?(流式输出)

如果你的 sampling_params 没有设置 output_kind(默认为流式),那么这个循环的行为会略有不同,更能体现"流"的特性。

假设 vLLM 逐个 token 生成 "北京"

  • 第一次迭代 : vLLM 生成了 "北"。它通知事件循环。你的循环被唤醒,request_output 是一个包含 text="北" 的对象,output 被赋值。然后循环再次 await,你的函数再次暂停。
  • 第二次迭代 : vLLM 生成了 "京"。它通知事件循环。你的循环被唤醒,request_output 是一个包含 text="北京" 的对象(vLLM 通常会返回到当前为止的完整文本),output 被更新为这个新对象。然后再次暂停。

在这种模式下,output = request_output 这行代码会被反复执行output 变量的值会被不断地用最新的、更长的生成结果所覆盖 。直到最后一次迭代,output 中保存的才是最终的完整结果。

这就是为什么即使在流式模式下,这段代码最后也能得到完整输出的原因。你的脚本使用 FINAL_ONLY 只是让这个过程更直接:循环只执行一次,直接拿到最终结果

假设两个请求调用了async_generate, ,流式, 举例说明执行过程,说明两个请求等待的过程?

好的,这是一个绝佳的场景,能够完美地展示 asyncio 和 vLLM 异步引擎的协同工作威力。

我们假设有两个并发的请求,都调用了 async_generate,并且都是流式输出(streaming)模式。

场景设置

  1. 并发任务 : 在 run_test 函数中,asyncio.gather 会同时启动两个 async_generate 任务。我们称它们为 任务A任务B

    python 复制代码
    # run_test 函数中的核心代码
    tasks = [
        async_generate(model, "深度学习是什么?", sampling_params_A),  # 任务A
        async_generate(model, "介绍一下长城。", sampling_params_B)     # 任务B
    ]
    ret = await asyncio.gather(*tasks)
  2. 流式输出 : sampling_params 不设置 output_kind,或设置为 RequestOutputKind.STREAMING

  3. vLLM 引擎的批处理 (Batching): vLLM 引擎足够智能,它会把这两个请求打包成一个批次(batch)送入 GPU 进行计算,而不是一个接一个地处理。

详细执行过程:一场"交谊舞"

任务A任务B事件循环 想象成三个舞者,vLLM 引擎是乐队。


第 1 步:任务同时启动 (T0时刻)

  • asyncio.gather 同时调用了 async_generate 两次。
  • 任务A 执行 result_generator_A = model.generate(...)。vLLM 引擎接收请求,立即返回一个"凭证 A"。
  • 任务B 执行 result_generator_B = model.generate(...)。vLLM 引擎接收请求,立即返回一个"凭证 B"。
  • vLLM 引擎的后台队列中现在有两个待处理的请求。

第 2 步:两个任务同时进入等待 (T1时刻)

  • 任务A 进入 async for request_output in result_generator_A: 循环。它向"凭证 A"索要第一个 token。由于 token 还没生成,任务A 暂停 ,并将控制权交还给事件循环
  • 事件循环 发现还有任务要跑,于是切换到任务B
  • 任务B 进入 async for request_output in result_generator_B: 循环。它向"凭证 B"索要第一个 token。同样,token 也没好,任务B也暂停 ,将控制权交还给事件循环

关键点: 此刻,任务A 和 任务B 都处于"暂停/等待"状态。事件循环掌握着控制权,它现在唯一能做的就是等待 vLLM 引擎的消息。


第 3 步:vLLM 引擎工作与第一个结果的到来 (T2时刻)

  • 在后台,vLLM 引擎将请求A和请求B打包,送入 GPU。
  • GPU 开始并行计算。假设请求B的第一个 token ("长") 先计算完成。
  • vLLM 引擎将这个结果(RequestOutput 对象,包含 "长")标记为"就绪",并通知事件循环:"凭证B有新消息了!"

第 4 步:任务B被唤醒 (T3时刻)

  • 事件循环 收到通知,立即去唤醒任务B
  • 任务B 从它暂停的地方恢复执行。async for 循环成功地从"凭证 B"拿到了包含 "长"request_output
  • 任务B 的循环体执行 output = request_output。现在它的 output 变量是 ("长", ...)
  • 循环体执行完毕后,async for 再次向"凭证 B"索要下一个 token。由于下一个 token 还没好,任务B再次暂停 ,控制权交还给事件循环

第 5 步:任务A的结果到来 (T4时刻)

  • GPU 继续计算,现在请求A的第一个 token ("深") 计算完成了。
  • vLLM 引擎通知事件循环:"凭证A有新消息了!"

第 6 步:任务A被唤醒 (T5时刻)

  • 事件循环 收到通知,唤醒任务A
  • 任务A 恢复执行,从"凭证 A"拿到了包含 "深"request_output
  • 任务A 的循环体执行 output = request_output。现在它的 output 变量是 ("深", ...)
  • 循环结束后,任务A再次暂停,等待下一个 token。

第 7 步:交错执行,直到结束

这个"等待 -> 结果就绪 -> 唤醒 -> 执行 -> 再次等待"的过程会不断地、交错地发生在任务A和任务B上。

  • 可能接下来 vLLM 又生成了请求A的第二个 token ("度")。事件循环就会唤醒任务A。
  • 然后又生成了请求B的第二个 token ("城")。事件循环就会唤醒任务B。
  • ... 这个过程就像两个舞者轮流跳舞,而不是一个跳完整支舞另一个再开始。

两个请求的等待过程总结:

  1. 非阻塞等待:两个请求的"等待"都不是死等。当一个任务在等待时,另一个任务可以被执行(如果它有事可做),或者事件循环可以处理其他事情。
  2. 交错恢复:哪个请求的下一个 token 先被 vLLM 引擎生成出来,事件循环就优先唤醒哪个任务。它们的执行是**交错(interleaved)**的,而不是平行的。
  3. 共享资源:两个任务共享了 CPU(通过事件循环分时复用)和 GPU(通过 vLLM 的批处理)。这极大地提高了硬件的利用率。

最终 ,当一个请求(比如任务A)的所有 token 都生成完毕,"凭证 A"会告诉 async for 循环"没有更多内容了",任务A的循环会正常退出,async_generate 函数返回最终的 output。当所有任务都完成后,asyncio.gather 会收集所有结果并最终返回。

这个模型完美地解释了为什么异步 I/O 对于处理大量并发的网络请求或像 LLM 推理这样的计算密集型任务如此高效。

任务同时启动 (T0时刻)? 是两个协程, 是并发执行还是同时执行?

你问到了一个非常精确且关键的点!"并发执行"是更准确的描述,而不是"同时执行"

让我们来澄清 asyncio.gather 是如何启动协程的。

并发 (Concurrency) vs. 并行 (Parallelism)

这两个词在日常用语中经常混用,但在计算机科学中,它们有严格的区别:

  • 并行 (Parallelism)真正地同时做多件事情。这需要多个独立的执行单元,比如多个 CPU 核心或多个计算机。如果两个任务在物理上完全相同的时刻都在向前推进,那就是并行。

    • 例子 :多进程(multiprocessing)可以在多核 CPU 上实现并行。两个进程可以分别在两个不同的核心上同时运行。
  • 并发 (Concurrency)看起来像同时 在做多件事情,但实际上可能是在单个执行单元(如单个 CPU 核心)上通过快速切换任务来实现的。当一个任务因为等待(如等待网络数据、等待文件读写、等待 GPU 计算结果)而暂停时,执行单元会立刻切换到另一个可运行的任务上。

    • 例子asyncio 在单线程中管理多个协程,就是典型的并发。

asyncio.gather 的行为是并发

回到你的问题:asyncio.gather 启动两个协程时,发生的是并发

以下是更详细的步骤:

  1. 创建任务对象 :当你调用 asyncio.gather(coro1, coro2) 时,asyncio 会把你的协程(coroutine)coro1coro2 分别包装成 Task 对象。Task 对象是 asyncio 事件循环可以调度和管理的基本单元。

  2. 放入事件循环队列 :这两个 Task 对象会被放入事件循环的"可运行"队列中。

  3. 启动第一个任务 :事件循环从队列中取出第一个任务(比如 任务A)并开始执行它。

  4. 任务A的执行与暂停

    • 任务A 开始执行 async_generate 函数的代码。
    • 它执行到 result_generator_A = model.generate(...)。这个调用是同步的,会立即返回一个异步生成器对象。
    • 然后它执行到 async for ... 循环,在这里它第一次遇到 await(隐式地等待异步生成器的下一个值)。
    • 此时,由于 vLLM 引擎需要时间来处理,结果不是立即可用的。任务A 暂停,并将控制权交还给事件循环。
  5. 启动第二个任务

    • 事件循环现在是自由的!它检查"可运行"队列,发现了 任务B
    • 事件循环开始执行任务B。
    • 任务B 同样执行 async_generate,直到它也遇到 await 并暂停,将控制权再次交还给事件循环。

所以,"同时启动"这个说法的真正含义是:

在宏观的用户感知层面,这两个任务几乎是同时开始 的。它们的启动之间只隔了第一个任务运行到第一个 await 点所需的那几微秒或几毫秒的时间。从效果上看,它们都已提交给系统并进入了"正在处理"的状态。

但在微观的执行层面,在一个单线程的事件循环中,它们是交错执行的。CPU 在任何一个精确的时间点上,只在执行这两个任务中的一个。

总结

  • 是并发执行,不是并行执行。
  • asyncio.gather 确保所有任务都被事件循环接管,并尽快开始执行。
  • 每个任务会一直运行,直到遇到一个 await 表达式,且等待的事情尚未完成。此时它会暂停,让出 CPU,以便事件循环可以运行其他任务。
  • 这种"运行-暂停-切换"的模式发生得非常快,给人一种所有任务都在同时推进的错觉,这就是并发的魔力。
相关推荐
背心2块钱包邮1 小时前
第24节——手搓一个“ChatGPT”
人工智能·python·深度学习·自然语言处理·transformer
e***19351 小时前
MySQL-触发器(TRIGGER)
android·数据库·mysql
炒毛豆1 小时前
vue3+ant design vue实现表单验证失败后,自动滚动到失败的位置(scrollToField)
前端·javascript·vue.js
Aiden121211 小时前
Mysql主从复制与读写分离
数据库·mysql
databook1 小时前
Manim v0.19.1 发布啦!三大新特性让动画制作更丝滑
后端·python·动效
IT_陈寒1 小时前
Vite 5个隐藏功能大揭秘:90%的开发者都不知道这些提速技巧!
前端·人工智能·后端
云边有个稻草人1 小时前
国产化数据库标杆!金仓以五大技术优势,筑牢电力行业数字基石
数据库·金仓数据库·kes
fruge1 小时前
Vue3 实战避坑:10 个 Composition API 高频错误及修复方案
前端·javascript·vue.js