Prhub

#52144 [Test] Add pause/resume E2E tests

原始 PR 作者 floatlibai 合并时间 2026-08-18 14:46 文件变更 3 提交数 5 评论 14 代码增减 +582 / -0

执行摘要

新增 pause/resume 全链路 E2E 测试,双模 MRV1/MRV2 覆盖

RFC #45585 指出 vLLM 作为 RL 主流 rollout 引擎,其 scheduler gating(pause/resume)和 weight-transfer 协议等 RL 专属代码面长期没有专用 CI 覆盖,且已出现多起线上 bug(如 #45326、#45518、#44972 等)。此前 #45586 曾将这些行为测试嵌套在 sleep/wake 生命周期套件中,依赖固定延时和宽泛的 liveness 断言,时序不稳定。本 PR 把 pause/resume 独立成模块,用流式请求同步真实的生成进度替代定时等待,强化可观察的 API 契约。

值得精读。重点学习两处设计:一是用流式请求 + 线程事件同步生成进度来替代固定延时,解决“200-lie”下时序断言不稳定的难题;二是通过模块级 server fixture 配合 autouse 状态恢复,在 N 个用例与双 MRV 模式下复用服务进程,兼顾覆盖率与运行时间。由于该 PR 是纯测试补充,阅读时建议结合 RFC #45585 的矩阵规划与 #45586 的原方案对比,理解为何要把 pause/resume 从 sleep/wake 大套件中独立出来。

讨论亮点

核心讨论集中在三处:

  • 结构建议(Ronald1995):review 评论指出 it's better to move these method to conftest.py,认为流式生成、线程同步等辅助方法应下沉到共享 conftest,随后 floatlibai 回复 done 并在最终提交中完成重构,形成现在的 conftest.py + test_pause_resume.py 分层。
  • CI 接入确认(aoshen02):aoshen02 询问是否需要加到 Buildkite workflow,floatlibai 说明 .buildkite/test_areas/entrypoints.yaml 中的 entrypoints-integration-api-server 已覆盖 tests/entrypoints/serve 目录,无需额外配置,aoshen02 表示认同并触发 /ci run
  • 后续行为契约缺口(floatlibai 自述):floatlibai 在评论中主动指出 pause 后提交的新请求在 abort 模式下的期望行为需要更新,并提到 #51476 是后续两周内计划实现的 RFC,两个议题相关联,属于未完全闭合的待办。

实现拆解

实现分三步:

  1. 新增共享测试基础设施(conftest.py):定义 server 上下文管理器,以子进程方式拉起带 VLLM_SERVER_DEV_MODE=1 的开发路由服务,轮询 /health 直到就绪;提供 poll_until 轮询助手以应对 sleep/wake/pause 接口的 “200-lie” 问题;定义 genokStreamResultstream_completionstart_stream 等生成与流式 HTTP 助手;并封装 pauseresumecompletion_with_cache_detailsgolden_output 等接口调用工具。

  2. 编写 pause/resume 生命周期测试(test_pause_resume.py):使用模块级 server_url fixture,通过 parameterize 的 use_v2 在 MRV1 与 MRV2 两种模型运行器下各启动一次服务;restore_unpaused_state autouse fixture 在每条用例前后强制 resume 以恢复状态。四个测试分别验证:多轮 pause/resume 的幂等性与 is_paused 状态;非法 mode 返回 400 且不影响原有状态;abort/wait/keep 三种模式下 in-flight 请求、暂停期间新请求及恢复后行为的差异;clear_cache 对 golden output 与 prefix cache 命中控制。

  3. 目录结构配套:新增 state_transitions/__init__.py 空文件,将 RL 测试按路由分区(state_transitions、info_retrieval、consistency),与 RFC #45585 规划的目录布局对齐。

配套方面,测试依赖 VLLM_USE_V2_MODEL_RUNNER 环境变量切换模型运行器,并通过 --enable-prefix-caching--enable-prompt-tokens-details 暴露 cache 与 tokens 明细,属于纯测试配置,不涉及 schema 或部署改动。

文件 模块 状态 重要度
tests/entrypoints/serve/dev/rlhf/conftest.py 测试夹具 added 7.76
tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py 生命周期测试 added 7.34
tests/entrypoints/serve/dev/rlhf/state_transitions/__init__.py 测试目录 added 2.71

关键符号

server poll_until start_stream stream_completion pause resume golden_output test_state_and_idempotency_across_cycles test_invalid_mode_preserves_state test_mode_request_lifecycle test_clear_cache_preserves_output_and_controls_prefix_cache

关键源码片段

tests/entrypoints/serve/dev/rlhf/conftest.py test-coverage

RL 生命周期测试套件的共享基础设施,提供 server 启动、轮询、流式生成、pause/resume HTTP 助手,是整套测试可运行与防 flaky 的关键。

def poll_until(
    predicate: Callable[[], bool],
    timeout: float = 10.0,
    interval: float = 0.5,
) -> bool:
    """轮询 predicate 直到为 True 或超时。    针对 vLLM sleep/wake 的 200-lie 现象:
    HTTP 接口可能在底层操作完成前就返回 200,
    所以需要此助手来验证操作后的真实状态,而不是信任 200 即完成。
    """
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            if predicate():
                return True
        except Exception:
            pass # 忽略瞬时错误,继续轮询
        time.sleep(interval)
    return False
​
​
def start_stream(url: str, max_tokens: int) -> tuple[StreamResult, threading.Thread]:
    """启动一个流式生成请求,并等待其真正产出首个 token。    这里用 started 事件同步生成进度,而不是固定 sleep,
    从而避免慢机器上因延时不足导致请求尚未开始生成就暂停。
    若请求在 pause 前就已结束或启动失败,先 abort 再 resume 清理现场。
    """
    result = StreamResult()
    thread = threading.Thread(
        target=stream_completion,
        args=(url, result, max_tokens),
    )
    thread.start()
    started = result.started.wait(timeout=10)
    if not started or result.done.is_set():
        # 清理异常状态:先终止未完成请求,再恢复引擎
        pause(url, mode="abort")
        resume(url)
        thread.join(timeout=10)
    assert started, "request did not start generating"
    assert not result.done.is_set(), "request completed before it could be paused"
    return result, thread
tests/entrypoints/serve/dev/rlhf/state_transitions/test_pause_resume.py test-coverage

核心测试文件,定义 MRV1/MRV2 双模 fixture 与四个 P0/P1 用例,直接验证 pause/resume 的 API 契约与请求生命周期。

@pytest.mark.parametrize(
    ("mode", "max_tokens", "inflight_finish_reason"),
    [
        pytest.param("abort", 256, "abort", id="abort"),
        pytest.param("wait", 256, "length", id="wait"),
        pytest.param("keep", 256, "length", id="keep"),
    ],
)
def test_mode_request_lifecycle(
    self,
    server_url,
    mode,
    max_tokens,
    inflight_finish_reason,
):
    # 先启动一个 in-flight 流式请求,并等它产出文本再 pause
    inflight, inflight_thread = start_stream(server_url, max_tokens)
    new_result: dict[str, Any] = {}
    new_done = threading.Event()
​
    def _new_request():
        # 在 pause 期间尝试提交的新请求,记录结果并置事件
        new_result["response"] = gen(server_url, max_tokens=4, timeout=60)
        new_done.set()
​
    new_thread = threading.Thread(target=_new_request)
    try:
        assert pause(server_url, mode=mode) == 200
        assert is_paused(server_url)
​
        if mode in ("abort", "wait"):
            # abort 和 wait 都会让 in-flight 请求立即结束
            assert inflight.done.is_set()
        else:
            # keep 模式冻结生成进度,chunk 数量不应继续增长
            chunks_after_pause = len(inflight.chunks)
            assert not inflight.done.wait(timeout=5)
            assert len(inflight.chunks) == chunks_after_pause, (
                "in-flight request continued generating in keep mode"
            )
​
        # 暂停期间提交的新请求必须保持 pending
        new_thread.start()
        assert not new_done.wait(timeout=0.3), (
            "new request completed while generation was paused"
        )
    finally:
        # 无论断言是否失败都先恢复,避免污染后续用例
        assert resume(server_url) == 200
        inflight_thread.join(timeout=30)
        if new_thread.ident is not None:
            new_thread.join(timeout=30)
​
    # 恢复后:旧请求按模式以预期 finish_reason 收尾,新请求正常完成
    assert not inflight_thread.is_alive()
    assert inflight.error is None
    assert inflight.finish_reason == inflight_finish_reason
    assert not new_thread.is_alive()
    assert ok(new_result.get("response"))

评论区精华

把流式与生命周期辅助方法下沉到 conftest.py 设计

Ronald1995 在 review 中建议:it's better to move these method to conftest.py,认为 StreamResult、start_stream 等应作为共享夹具供后续 RL 测试模块复用。

结论:floatlibai 回复 done,并在最后一次 commit 中完成 conftest 重构与 import 调整。 · 已解决

是否需要显式接入 Buildkite question

aoshen02 提出 Maybe we should add it to buildkite workflow to enable it,担心新目录不会被现有 CI 拾取。

结论:floatlibai 说明 entrypoints-integration-api-server 已按目录 glob 覆盖 tests/entrypoints/serve,无需额外改动;aoshen02 表示 I see 并 LGTM。 · 已解决

abort 模式下暂停后新请求的期望行为需要更新 question

floatlibai 在回应 aoshen02 提到的 RFC #51476 时自述:As for this issue, I think we need to update the abort testcase behavior for requests submitted after the pause,暗示现有 abort 场景对暂停后提交请求的断言可能需随新 RFC 调整。

结论:未在 PR 内落地,属于留待 #51476 推进时处理的后续事项。 · 待处理

风险与影响

主要风险集中在测试本身:

  • 时序敏感性start_stream 依赖线程事件等待生成首个 token,test_mode_request_lifecycle 用 0.3 秒断言新请求在暂停期间不完成,在慢 CI 或高负载机器上可能产生 flaky。
  • 资源占用与端口冲突server 默认绑定固定端口 8770,若多个测试模块并行可能冲突;每次启动服务约 37 秒,整套测试约 110 秒,会拉长 entrypoints 相关 CI 时长。
  • 外部依赖:默认使用 Qwen/Qwen3-0.6B 真实权重,离线或模型缓存缺失环境下 setup 会失败(有 dummy_weights 选项但当前用例未启用)。
  • 契约变更风险:评论中提及的 abort 模式下暂停后新请求行为尚未更新断言,若实际服务行为与预期不一致,可能掩盖真实 bug 或导致用例过时。
    • 无生产代码改动,不存在对推理路径、性能或安全的直接影响。

影响范围仅限测试目录 tests/entrypoints/serve/dev/rlhf/,对 vLLM 生产代码零影响。对团队而言,该 PR 补上了 RFC #45585 中 RL CI 矩阵的 P0/P1 关键空白,为 sleep/pause 相关的回归(如请求绕过调度器挂起、重复 pause 幂等性、clear_cache 语义)提供自动化防护。对下游 RL 框架(verl、OpenRLHF 等)而言,pause/resume 行为契约得到更明确的验证保障。MRV1/MRV2 双覆盖也让模型运行器迁移期的行为差异可被持续观测。

测试时序敏感可能 flaky 固定端口 8770 存在并行冲突风险 依赖真实模型权重下载 abort 契约断言待随新 RFC 更新 CI 时长增加约 110 秒

关联 Issue

#45585 [RFC] RL CI Matrix for vLLM: Behavioral + Physical + Protocol Coverage

完整报告

参与讨论