jimyag's Blog

如何在 vLLM 中按用户限制输出 Token 速度:派发时扣费、GCRA 匀速与性能瓶颈

最后更新:

同一个 vLLM 引擎同时服务多个用户时,一个用户开很多并发流,就能占住大部分 decode 位置,其他用户的首 Token 和吐字速度都会被拖慢。API 网关可以按请求数或按 Token 总量限流,但网关看不到“这一步模型要为谁采样一个 Token”。要限制的是生成速度,判断点只能放在调度器里。

本文以一个基于 vLLM v0.32.0(上游提交 5df63a7)的实验补丁为例,说明在 V1 调度器里按用户限速要怎么实现。补丁在 jimyag/vllm-project.vllm 的 feat/user-output-rate-limit 分支上,尚未合入上游。文中的代码都引用提交 36830f3,GPU 数字来自该提交说明。行为描述以源码为依据;凡是由源码推出、没有实测的结论,会在文中注明。

要实现的语义

补丁对外的承诺可以归纳为五条:

  1. 只限制输出 Token,不计 prompt Token。
  2. 同一用户的所有并发请求共享一份额度,额度单位是“每秒 N 个 Token”,输出在时间上均匀分布,不在某一秒开头一次花完。
  3. 额度用完时请求暂停,等额度恢复后继续生成。不截断回答,不返回错误。
  4. 一个用户被限速时,其他用户照常推进。
  5. 同步调度和异步调度(async_scheduling)都支持。

启动方式如下,--default-user-output-tokens-per-second 不设置时功能整体关闭,调度器行为与上游一致:

1
2
3
4
5
vllm serve <text-decoder-model> \
  --max-num-seqs 64 \
  --max-num-active-seqs 16 \
  --default-user-output-tokens-per-second 100 \
  --user-output-tokens-per-second '{"user-a":100,"user-b":500}'

这条命令中的参数是:

  • serve <text-decoder-model>:启动推理服务,尖括号内是模型名称或路径的占位符,执行时需要替换。
  • --max-num-seqs 64(maximum number of sequences,最大序列数):在这份补丁中限制常驻请求数,也就是 worker 槽位数。
  • --max-num-active-seqs 16(active sequences,活跃序列):限制每步实际参与 batch 的请求数;小于常驻上限时,被限速请求才有空间把活跃位置让给其他用户。
  • --default-user-output-tokens-per-second 100:启用按用户限速,未单独配置的每个用户默认获得 100 tok/s 额度,同一用户的所有请求共享这份额度。
  • --user-output-tokens-per-second '...':用 JSON 对象覆盖指定用户的限额;这里 user-a 为 100 tok/s,user-b 为 500 tok/s,额度按用户汇总,不按请求分别计算。

这组容量和限速语义以本文补丁为准,不能直接套到其他版本的上游 vLLM。英文用于拆读参数名,不是另一组可执行选项。

用户身份通过 vllm_xargs.output_rate_user 传入:

1
2
3
4
5
6
7
{
  "model": "<served-model-name>",
  "messages": [{"role": "user", "content": "Explain continuous batching."}],
  "max_tokens": 256,
  "stream": true,
  "vllm_xargs": {"output_rate_user": "user-a"}
}

这个字段不做认证,OpenAI 协议里的 user 字段也不参与限速。vLLM 端口不能直接暴露给不可信客户端,网关要把调用方自带的 output_rate_user 覆盖成认证后的身份。没带这个字段的请求共用 __anonymous__ 这一份额度(_output_rate_user)。

五个设计决策

  1. 限速点放在哪里。网关只能事后按已经收到的 Token 拖慢转发,这时 GPU 已经算完,batch 位置也没有让给别人。所以判断点放在调度器:每一步决定“给谁算”之前先查额度。
  2. 什么时候扣费。在派发一个会采样的步时扣 1 个名额,不等模型结果回来结算。这样调度器不需要上一步的结果,可以开异步调度。
  3. 用什么算法。每个用户一个 GCRA(Generic Cell Rate Algorithm)状态,只存一个“理论到达时间”,输出按 1/N 秒的间隔匀速放行,允许 100 ms 的突发。
  4. 额度用完时怎么等。不能在 schedule() 里 sleep,否则引擎收不到新请求和 abort。等待放到引擎主循环,挂在输入队列上,超时取“下次额度恢复的时间”和 50 ms 中较小的那个。
  5. 被限速的请求占着位置怎么办。它留在 running 里,保留 KV 和 worker 槽位,但本步被跳过,而且不计入 max_num_active_seqs,活跃位置可以给别的用户;KV 不够时它优先被抢占。

整体结构

改动集中在三处:独立的限速器 OutputRateLimiter,调度器 Scheduler 中的扣费、退款和跳过逻辑(scheduler.py),以及引擎主循环 EngineCoreProc 空转时的等待方式(core.py)。

  flowchart LR
    GW["可信网关<br/>写入 output_rate_user"] --> API["OpenAI 兼容服务<br/>vllm_xargs → SamplingParams.extra_args"]
    API --> IQ["EngineCore input_queue"]
    IQ --> LOOP["EngineCore 主循环"]
    LOOP -->|"schedule()"| SCH["Scheduler"]
    SCH <-->|"try_take / refund / ready_at_ns"| LIM["OutputRateLimiter<br/>每用户一个 TAT"]
    SCH -->|"SchedulerOutput"| EXE["Model Executor<br/>前向 + 采样"]
    EXE -->|"ModelRunnerOutput"| UPD["update_from_output()<br/>不访问限速器"]
    LOOP -->|"本步未执行模型"| WAIT["input_queue.get(timeout)<br/>最多 50 ms"]
    WAIT --> LOOP

限速器只负责记账,不做排队和公平性决策。谁先拿额度、拿不到时怎么处理,都由调度器决定。

限速器:GCRA

GCRA 可以看成没有显式令牌数的令牌桶。每个用户只存一个理论到达时间 TAT:下一个 Token 按匀速“应该”在什么时候发出。放行条件是 TAT 最多领先当前时间 burst,放行后 TAT 前进 1/rate。

正在加载 GitHub 文件…

try_take 把所有时间都乘以该用户的 rate 存储,“前进 1/rate 秒”就变成“加 1 秒”,全是整数运算,没有舍入误差。其余三个方法:

  • refund(user):TAT 退回 1/rate,用于本步扣了费但最终没派发的请求。
  • ready_at_ns(user):TAT - burst,即下一次 try_take 能成功的最早时刻,用向上取整保证不早于边界。
  • cleanup_idle_users():每 256 步删掉 TAT 已经落后于当前时间的用户。这样的用户删掉后再出现,行为与保留状态完全一样,所以清理不改变语义。

_BURST_NS 固定为 100 ms。它必须覆盖至少一个引擎步:如果一步耗时超过 burst,用户在两步之间攒下的额度会过期,实际速率达不到配置值。对应的上界是:

1
任意 W 秒窗口内的输出数 ≤ (W + 0.1) × limit + 1

用一个时间线看匀速

设 limit = 10(每 100 ms 一个),用户 A 有 4 个并发流,每一步前向耗时 30 ms:

1
2
3
时间(ms)  0    30   60   90   120  150  180  210  240  270  300
TAT 领先  0→200 170  140  110  80→180 150 120 90→190 160 130 100→200
放行      2 个  -    -    -    1    -    -    1    -    -    1

t=0 时 TAT 等于当前时间,可以连放两个:第一个把 TAT 推到 100 ms,领先量 100 ms 仍不超过 burst;第二个推到 200 ms。此后每次领先量回落到 100 ms 以内就放行一个,大约每 3 步一个 Token,由调度器在 A 的 4 个流之间轮转分配(见后文“被限速的请求怎样让出位置”)。这张表是按 try_take 的规则手算的,不是实测轨迹。

与“1 秒滚动窗口内最多 N 个”相比,GCRA 不会在窗口开头一次放完 N 个、然后整秒停住。由此推断,被限速的用户再多,batch 大小也不会以 1 秒为周期大幅振荡。

扣费时机:派发时扣,不结算

V1 调度器不区分 prefill 和 decode 阶段,每一步只决定“给每个请求再算多少个 Token”。一步会不会采样,取决于算完之后是否到达已知序列的末尾:

正在加载 GitHub 文件…

num_computed_tokens + num_new_tokens < request.num_tokens 表示这一步算完还没到序列末尾、不会采样,直接放行;_output_rate_charged 里已有的请求本步扣过,不重复扣。由此得到三种情况:decode 每一步扣 1 个;prefill 中间的 chunk 不扣;prefill 最后一个 chunk 会采样出首 Token,扣 1 个。异步调度下 num_computed_tokens 已经包含上一步的占位 Token,判断条件不变。扣费失败时把该用户的 ready_at_ns 并入 _output_rate_wakeup_ns,供引擎决定空转时等多久。

扣费失败后,_limit_output_rate_tokens 决定这一步还能做多少:

正在加载 GitHub 文件…

开启 chunked prefill 时,prefill 可以先推进到倒数第二个位置,把采样位置留到有额度时再算;没开 chunked prefill 的场景(包括非因果的 PrefixLM 注意力)不能把 prefill 切开,整个 prefill 只能等。

派发时扣出去的名额不再按模型结果结算。update_from_output() 不碰限速器,调度器不需要等上一步返回,就能调度下一步。代价是两种情况下会多扣:

  • 客户端在这一步取消,但这一步已经派发,照样计费。
  • 异步调度下,一个请求在第 N 步命中 stop,调度器在知道结果之前已经派发了第 N+1 步,这一步的名额不退。

两者都是多扣不少扣,符合“上限”语义。EOS、隐藏的 reasoning Token、tool-call Token 也都计费,即使响应解析器不把它们显示成正文。

唯一的退款点

扣费发生在调度循环中间,后面还可能失败:KV 分配不到、请求被抢占。这些请求最终不在 num_scheduled_tokens 里,schedule() 末尾统一退款。refund 在调度器里只有这一个调用点( L1566-L1567)。派发了的请求记为该用户最近一次被服务的流,供下一步轮转使用;没派发的退回名额:

正在加载 GitHub 文件…
  sequenceDiagram
    participant L as EngineCore 主循环
    participant S as Scheduler
    participant B as OutputRateLimiter
    participant M as Model Executor

    L->>S: schedule()
    S->>B: try_take(user)(仅会采样的请求)
    B-->>S: True / False
    rect rgb(255, 243, 224)
    note over S: KV 分配失败或被抢占,<br/>没进 num_scheduled_tokens
    S->>B: refund(user)
    end
    S-->>L: SchedulerOutput
    L->>M: execute_model()(异步调度下可与下一次 schedule 重叠)
    M-->>L: ModelRunnerOutput
    L->>S: update_from_output()(不访问限速器)

被限速的请求怎样让出位置

额度用完的请求如果继续占着活跃位置,其他用户的请求补不进来,“一个用户被限速时其他用户照常推进”就落空了。这里涉及两个容量参数:命令行的 --max-num-active-seqs 在调度器里是 max_num_active_reqs,表示每步实际参与 batch 的请求上限;--max-num-seqs 在调度器里是 max_num_running_reqs,表示常驻请求上限,也就是 worker 槽位数。

补丁没有引入新的请求状态,而是在一次 schedule() 内部记下“被限速跳过”的 running 请求(output_rate_throttled):

  • 它留在 running 里,保留 KV、输出历史和 worker 槽位,额度恢复后直接继续 decode,不重算。
  • 它不计入活跃上限。running 循环拿“本步实际调度的 running 请求数”和 max_num_active_reqs 比较(L778-L785);waiting 循环判断能否接纳新请求时,先确认 running 总数没有达到 max_num_running_reqs,再把被跳过的请求从 running 数里减掉,和 max_num_active_reqs 比较(L1074-L1079)。
  • KV 不够需要抢占时,优先选被限速的请求作为牺牲者;PRIORITY 策略下只在同一优先级内优先(L928-L944)。

因此只有 --max-num-active-seqs 小于 --max-num-seqs 时,被限速的请求才能把位置让给别人;两者相等时,常驻槽位一满,新用户就进不来。常驻请求越多,占用的 KV 越多,这两个值要按模型和负载实测来定。

同一用户、同一优先级的多个流共享一份额度,如果每步都按 running 原顺序扣费,排在前面的流总能抢到新释放的名额。_rotate_output_rate_requests 在每步开始时,把上一次拿到名额的流(_output_rate_last_served)之后的那个流转到前面。轮转只在同用户同优先级的请求之间交换位置,其他用户和优先级的相对顺序不变。

等待额度时不能阻塞引擎

最直接的做法是在 schedule() 里 sleep 到额度恢复,但这样引擎收不到新请求,也处理不了 abort。补丁把等待放到引擎主循环里,挂在输入队列上:

正在加载 GitHub 文件…

等待时长来自调度器的 get_output_rate_wait_seconds,返回 min(50 ms, 本步被拒用户中最早的 ready_at - now):

正在加载 GitHub 文件…

这样设计有三个结果:

  • 只有整步都没有执行模型时才会等待。只要还有别的用户在推进,引擎就不会因为某个用户被限速而停。
  • 新请求和 abort 会立即唤醒等待。
  • 超时上限 50 ms,让调度器能及时发现其他异步就绪的请求,例如结构化输出 grammar 编译完成。

为什么限制了这么多运行模式

VllmConfig._verify_user_output_rate_config 在配置阶段拒绝了很多组合。源码只给出报错,没有写原因;下表的原因是根据实现推断的,分成两类:一类会直接破坏记账,另一类是跳过、抢占和退款逻辑没有覆盖到。

被拒绝的配置原因
投机解码破坏记账:一步可能产出多个 Token,而每个请求每步只扣 1 个名额
data_parallel_size > 1破坏记账:每个 DP rank 有自己的调度器和限速器,同一用户会拿到多份额度
pipeline_parallel_size > 1未覆盖:多个 micro-batch 同时在途,调度和抢占路径没有针对它验证
多模态、encoder-decoder、hybrid、attention-free、diffusion未覆盖:跳过、抢占和 prefill 切分只按标准 attention decoder 验证过
KV/EC/aux connector、KV offload、自定义 scheduler_cls未覆盖:它们改变 KV 生命周期或调度器实现

Beam search 在服务入口被拒绝(validate_beam_search_output_rate),因为它的候选扩展不走“每步采样 1 个 Token”这条计费路径。Tensor parallel 不受影响,TP 发生在单个引擎内部,调度器只有一个。

从滚动窗口到 GCRA

补丁的第一版(b9dde61)用的是“任意 1 秒滚动窗口内最多 N 个”:采样前预留,模型返回后按实际采样数结算;额度用完的请求移到单独的 WAITING_FOR_OUTPUT_RATE 状态停放。这个设计有三个问题:

  • 结算依赖上一步的结果,只能同步调度,GPU 每步都要等 CPU 做完 update_from_output() 和 schedule()。
  • 滚动窗口允许在窗口开头一次花完 N 个名额,然后整秒停住,被限速的用户多时 batch 大小会以 1 秒为周期振荡。
  • 停放需要新的请求状态,以及恢复选择、让位和驱逐逻辑,每步还要对停放队列排序和扫描。

改成派发时扣费后不再需要结算,异步调度可以打开;GCRA 让输出匀速;被限速的请求留在 running 里,只是本步不计入活跃上限,停放相关的状态机全部删掉。代价在“扣费时机”一节已经列出:取消的那一步和异步 lookahead 的那一步不退款,上界也从严格的 N 放宽到 (W + 0.1) × N + 1。

实测数据

以下数字来自 36830f3 的提交说明,环境是 RTX 4090、Qwen3-8B,在 v0.32.0 镜像上挂载补丁文件运行。速率在客户端测量:每个流用 ignore_eos 生成 200 个 Token,按每个用户从第一个 Token 到最后一个 Token 的时间跨度计算。

  • 限额 20 tok/s 的单个用户,开 1 个流或 4 个流,同步和异步调度下都测得 20.1–20.2 tok/s;同时跑一个限额 100 tok/s 的用户,测得 102 tok/s。略高于限额符合 GCRA 的上界:开头的 100 ms 突发可以多放 0.1 × limit + 1 个 Token。
  • 限额不触发时,吞吐与上游的差距在不同 GPU 之间的波动范围内(约 4%)。
  • 同样在限额不触发的条件下,异步调度的吞吐是 1227–1271 tok/s,旧版只能同步调度,吞吐是 1175 tok/s。

客户端测量会受到 HTTP 缓冲和网络合并的影响,这些数字说明的是长时间平均速率,不能证明 Token 之间的间隔。

性能瓶颈在哪里

下面按影响面从大到小排列,都是由源码推出的,没有单独测量。“改进方向”是作者的建议,补丁没有实现。

被限速的请求仍然在 running 里被逐个扫描

被跳过的请求留在 running 里,running 循环每遇到一个都要走一遍 _limit_output_rate_tokens:从 extra_args 解析用户、调用 try_take,失败后再调用 ready_at_ns。每步开始还要做一次 _rotate_output_rate_requests,按用户和优先级给 running 分组。两者都是 O(R),R 是 running 的长度,上限是 --max-num-seqs。旧版对停放队列的排序和让位扫描已经去掉,剩下的只是线性扫描。

开启异步调度后,这部分 CPU 时间可以和 GPU 执行重叠,不会直接变成 GPU 空闲。R 在几十以内时可以忽略;为了给其他用户腾位置把 --max-num-seqs 调得很大时,可以用 py-spy 看 schedule 的耗时确认。

改进方向:把用户身份在请求创建时解析一次缓存到 Request 上;同一步内某个用户已经被拒过,直接跳过它的其余请求,不再调用 try_take。

活跃上限生效时按列表顺序服务

活跃上限生效时,running 循环调度满 max_num_active_reqs 个就 break,后面的请求要等前面的请求结束或被限速跳过。轮转只发生在同用户同优先级的请求之间,不同用户之间没有轮转。被限速的请求让出了位置,但位置给谁取决于 running 的列表顺序,而不是哪个用户等得更久。

被限速的请求占用 KV

被跳过的请求保留全部 KV block,活跃 batch 能用的 KV 相应减少。KV 紧张时它们优先被抢占,恢复时要重新准入,释放的块若仍在 prefix cache 中可以命中,否则要重算整个 prefix。限速越严、被跳过的请求越多、上下文越长,这项成本越高。

首 Token 多出的调度步

开启 chunked prefill 且输出额度用完时,prefill 停在倒数第二个位置,首 Token 要等有额度时再单独跑一步。这一步只有 1 个位置,形状和 decode 一样。

单引擎上限

限速要求 data_parallel_size=1,并要求同一用户固定路由到同一个引擎。一个大用户的额度只能由一个引擎提供,横向扩展只能靠多个独立引擎加网关路由。

部署时要注意的边界

  • 限速状态是引擎内的内存状态。重启会清零,同一用户分到两个引擎会得到两份额度。网关必须把同一用户固定路由到同一个引擎;迁移用户时,要先确认旧引擎上的在途工作已经结束或已保守等待,只改负载均衡路由不够。
  • 额度是上限,不是吞吐保证。给某个用户更高的额度,不会让模型本身 decode 得更快。对外承诺高额度档位之前,要先实测单流速度和负载下的 Token 间隔。
  • 限速不替代排队。max_num_queued_reqs 满时仍会返回 503,排队策略由外部网关负责。
  • gpu_memory_utilization 不是算力配额。需要在不同服务档位之间做性能隔离时,把它们放在不同的 GPU 组上。

如何验证

补丁的测试在 CPU 上驱动真实的调度器和 KV manager,用受控的 model runner 结果代替 GPU 前向,用假时钟驱动限速器:

1
2
3
.venv/bin/python -m pytest tests/v1/core/test_output_rate.py -q
.venv/bin/python -m pytest tests/v1/core/test_scheduler.py -k output_rate -q
.venv/bin/python -m pytest tests/test_config.py -k user_output_rate -q
  • .venv/bin/python:使用项目虚拟环境中的 Python,确保加载该环境安装的依赖。
  • -m pytest(module,模块):由这个 Python 运行 pytest 模块;这里的 -m 属于 Python,不是 pytest 的 marker 筛选参数。
  • tests/...py:只收集指定文件中的测试。
  • -k output_rate、-k user_output_rate(keyword,关键词助记):按名称表达式筛选匹配的测试,避免运行同一文件里的无关用例。
  • -q / --quiet(quiet,安静):减少 pytest 的输出。

筛选和输出参数见 pytest 调用说明。

这些测试只能证明调度和记账逻辑,不能证明 GPU 上的实际吐字节奏。上线前还需要在 GPU 上覆盖:A、B 两个用户各开多个并发流,长 prompt,prefix cache 命中,短回答,EOS,客户端取消,并核对最终的 completion_tokens 总数;同时确认回答完整、被限速的请求能恢复、未限速的用户不受影响。Token 间隔要在引擎侧按生成事件观察;客户端测量的局限见“实测数据”一节。

#VLLM #LLM #推理 #调度 #限流