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

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

<!--more-->

## 要实现的语义

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

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

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

```bash
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` 传入：

```json
{
  "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`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L606)）。

## 五个设计决策

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`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py)，调度器 `Scheduler` 中的扣费、退款和跳过逻辑（[`scheduler.py`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L606-L702)），以及引擎主循环 `EngineCoreProc` 空转时的等待方式（[`core.py`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/engine/core.py#L1616-L1626)）。

```mermaid
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-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py#L49-L57" >}}

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

- [`refund(user)`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py#L59-L61)：TAT 退回 1/rate，用于本步扣了费但最终没派发的请求。
- [`ready_at_ns(user)`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py#L63-L69)：`TAT - burst`，即下一次 `try_take` 能成功的最早时刻，用向上取整保证不早于边界。
- [`cleanup_idle_users()`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py#L71-L81)：每 256 步删掉 TAT 已经落后于当前时间的用户。这样的用户删掉后再出现，行为与保留状态完全一样，所以清理不改变语义。

[`_BURST_NS`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/output_rate.py#L9-L12) 固定为 100 ms。它必须覆盖至少一个引擎步：如果一步耗时超过 burst，用户在两步之间攒下的额度会过期，实际速率达不到配置值。对应的上界是：

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

### 用一个时间线看匀速

设 `limit = 10`（每 100 ms 一个），用户 A 有 4 个并发流，每一步前向耗时 30 ms：

```text
时间(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-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L615-L638" >}}

`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-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L640-L649" >}}

开启 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](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L1566-L1567)）。派发了的请求记为该用户最近一次被服务的流，供下一步轮转使用；没派发的退回名额：

{{< github-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L651-L659" >}}

```mermaid
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`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L768-L771)）：

- 它留在 `running` 里，保留 KV、输出历史和 worker 槽位，额度恢复后直接继续 decode，不重算。
- 它不计入活跃上限。running 循环拿“本步实际调度的 running 请求数”和 `max_num_active_reqs` 比较（[L778-L785](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L778-L785)）；waiting 循环判断能否接纳新请求时，先确认 running 总数没有达到 `max_num_running_reqs`，再把被跳过的请求从 running 数里减掉，和 `max_num_active_reqs` 比较（[L1074-L1079](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L1074-L1079)）。
- KV 不够需要抢占时，优先选被限速的请求作为牺牲者；PRIORITY 策略下只在同一优先级内优先（[L928-L944](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L928-L944)）。

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

同一用户、同一优先级的多个流共享一份额度，如果每步都按 `running` 原顺序扣费，排在前面的流总能抢到新释放的名额。[`_rotate_output_rate_requests`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L671-L687) 在每步开始时，把上一次拿到名额的流（`_output_rate_last_served`）之后的那个流转到前面。轮转只在同用户同优先级的请求之间交换位置，其他用户和优先级的相对顺序不变。

## 等待额度时不能阻塞引擎

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

{{< github-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/engine/core.py#L1616-L1626" >}}

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

{{< github-file url="https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/v1/core/sched/scheduler.py#L661-L669" >}}

这样设计有三个结果：

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

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

[`VllmConfig._verify_user_output_rate_config`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/config/vllm.py#L1414-L1473) 在配置阶段拒绝了很多组合。源码只给出报错，没有写原因；下表的原因是根据实现推断的，分成两类：一类会直接破坏记账，另一类是跳过、抢占和退款逻辑没有覆盖到。

| 被拒绝的配置 | 原因 |
| --- | --- |
| 投机解码 | 破坏记账：一步可能产出多个 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`](https://github.com/jimyag/vllm-project.vllm/blob/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee/vllm/entrypoints/generate/beam_search/utils.py#L22)），因为它的候选扩展不走“每步采样 1 个 Token”这条计费路径。Tensor parallel 不受影响，TP 发生在单个引擎内部，调度器只有一个。

## 从滚动窗口到 GCRA

补丁的第一版（[`b9dde61`](https://github.com/jimyag/vllm-project.vllm/commit/b9dde619230bf73cd74f7471bca35a685e8d5107)）用的是“任意 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`](https://github.com/jimyag/vllm-project.vllm/commit/36830f3dd02e78b0f1eac4462c39111bbb0dc7ee) 的提交说明，环境是 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 前向，用假时钟驱动限速器：

```bash
.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 调用说明](https://docs.pytest.org/en/stable/how-to/usage.html)。

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

