Skip to content

【No.7】不可变 LoRA 版本的在线发布、会话绑定与安全回收 - RFC #369

Description

@ying123ww

提案人:@ying123ww · 导师:@yuanlehome · 状态:待评审

官方任务:No.7「不可变 LoRA 版本的在线发布、会话绑定与安全回收」


首版支持配置

维度 支持配置
模型与引擎 Dense text LoRA,使用 Qwen3 fixture;固定两个独立 SGLang 引擎 E0/E1,每个 TP=1、DP=1。
启用方式 --lora-adapter-mode --fully-async --use-agentic-rollout,新增显式开关 --enable-versioned-lora-publication。
部署与请求 固定 base 权重及 tokenizer/config;单 publisher、至多一个未决 publication attempt;单样本 n=1,支持工具轮次、重试及临时 abort/resume。
容量与驻留 最多两个受管逻辑版本;SGLang 配置 max_loras_per_batch=3、max_loaded_loras=3。受管 adapter 加载后保持 pinned,直到物理卸载;首版不和第三方动态 adapter 混用。
调度与缓存 关闭 speculative decoding、PD disaggregation、elastic scale-out、Relax RadixTreeMiddleware 和 scheduler overlap mode;SGLang KV cache 保持开启。

ps: SGLang 配置 3 个 LoRA slots,是因为两个受管 adapter 都要保持 pinned,同时运行时还需要保留一个可用 slot; Relax 只能同时管理两个逻辑版本。

摘要

核心做法是把 LoRA 的加载、发布和回收分开。新版本 B 可以先在两个目标引擎上准备,但只有 引擎 E0/E1 都确认可用后,LoRAVersionRegistry 才 commit B 并切换默认版本。Session 不在创建时选版本,而是在第一次生成时绑定一次;之后工具轮次、重试和续写都继续使用同一个 lora_path。因此旧 Session 可以继续跑 A,新 Session 开始使用 B,整个发布过程不需要全局暂停生成或清空 KV cache。回收也分两层:Relax 的 Session ref 决定什么时候可以开始回收,SGLang 原生 request refcount 决定什么时候真的可以物理卸载。

1. 现有能力与要改的地方

现有能力 / 行为 本次要补的能力 主要落点
已有 adapter export/gather 和 DCS/NCCL 传输,但更新时使用固定名称覆盖 从同一份冻结 snapshot 生成不可变版本,并支持候选版本分阶段加载 lora_adapter_sync.py、device_direct.py、SGLang patch
adapter 更新事务外围会 pause/flush 后续 adapter-only publication 不再停掉旧版本生成 DeviceDirectBackend.update_weights_for_rollout()
_SessionRecord 能跨工具轮次存在,但没有 LoRA binding 第一次生成时绑定版本,后续每个 backend request 都显式带上 lora_path session/service.py、pipeline/runtime.py
SGLang 已有 pinning、KV identity,以及 unregister/refcount/unload 直接复用这些机制,并补齐必要的幂等和 exactly-once release 问题 lora_registry.py、tokenizer_manager.py、tokenizer_control_mixin.py

总之,加入了一套 LoRA version lifecycle。

2. 组件怎么分工

flowchart TB
    SNAP["训练侧 snapshot<br/>tensors / config / digest"] --> PUB["DCS Publisher / Reclaimer"]
    PUB -->|"reserve / ready / commit<br/>claim / confirm reclaim"| REG["共享 LoRAVersionRegistry"]
    SESSION["Session 服务"] <-->|"bind / close"| REG
    SESSION -->|"exact lora_path"| ROUTE["Backend adapter / Router"]
    subgraph FLEET["固定目标引擎"]
        E0["SGLang E0"]
        E1["SGLang E1"]
    end
    PUB -->|"prepare / transfer / cleanup / reclaim"| FLEET
    ROUTE -->|"worker placement"| FLEET
    FLEET -.->|"操作结果"| PUB
Loading
组件 职责
Snapshot builder 产出本次发布使用的 tensor/config 副本和 digest;snapshot 一旦进入发布流程,就不再跟着 live 参数变化。
Publisher / Reclaimer 把候选版本发送到固定的 E0/E1,并收集 ready、cleanup 和 reclaim 结果。
LoRAVersionRegistry 保存版本状态、default、Session binding、逻辑容量和操作结果;只有它可以切换默认版本。
Session 服务 在 _SessionRecord 中保存 binding,负责首次绑定、IR dispatch 和 Session close lifecycle。
SGLang 解析 exact LoRA name,维护原生 request refcount、pinning、KV namespace,并完成物理卸载。
Router 选择最终 worker;只透传 lora_path,不替请求改版本。

Registry 是一个共享的单写者 Ray actor。它只保存状态和做状态转换,不在状态更新过程中等待网络、NCCL、probe 或 unload;这些实际操作都由 Publisher / Reclaimer 在外面完成。

受管 adapter 只能走本文定义的 load/cleanup/reclaim 流程,普通 overwrite/unload 和隐式 reload 不能直接修改它们。

3. 版本发布

3.1 版本身份和 snapshot

version_key = (deployment_epoch, version_id)
lora_name   = relax_policy_lora@<epoch>-<version_id>-<digest_prefix>
lora_path   = lora_name  # generate 中填写已注册的不可变名称

version_id 在第一次 reserve 前确定,之后不再变化。Registry 会在整个 deployment epoch 内记住 version_key -> digest:同一个版本、同一份内容的重复请求返回已有状态;同一个版本如果对应了不同内容,则返回 VERSION_CONFLICT。名称里的 digest prefix 只是方便识别,真正比较内容时使用完整 digest。

训练侧在一个确定的 snapshot point 调用现有 export_local_adapter/gather_full_adapter,得到不再和 live 参数共享存储的 CPU tensors 和 serving config。digest 对规范化 manifest 做 SHA-256。后面的 digest 计算和 DCS 传输都使用这同一份 snapshot。

3.2 引擎怎么准备候选版本

Registry 先为 B 占一个逻辑版本槽位,然后 Publisher 复用现有 DCS/NCCL 路径执行:

阶段 做什么 什么时候算完成
begin 固定 identity、attempt、engine incarnation、manifest 和 bucket plan,并检查本地资源 E0/E1 都返回 PREPARED 后才开始传数据。
receive_bucket 按固定顺序接收当前 bucket,并写入这个 attempt 自己的 stash source/device completion 和 E0/E1 的完成结果都拿到后,才进入下一 bucket。
end 校验完整 manifest,fresh load adapter,完成必要的 GPU preparation 和 exact-version probe(或等价检查) 返回包含 version、digest、attempt、incarnation 的 READY_LOCAL。
普通 Session 则只能通过 Registry 绑定已经 PUBLISHED 的版本。

3.3 什么时候切换 default

flowchart TB
    SNAPSHOT["冻结的 snapshot"] --> RESERVE["Registry 接受 B 的发布
并占用一个版本槽位"]
    RESERVE --> E0["E0 准备version B"]
    RESERVE --> E1["E1 准备version B"]
    E0 --> READY["E0 / E1 都确认 B 可用"]
    E1 --> READY
    READY --> COMMIT["Registry commit"]
    COMMIT --> PUBLISHED["A → RETIRED<br/>B → PUBLISHED<br/>default = B"]
Loading

当 E0/E1 都 READY 后,Publisher 调用 commit(B, attempt_token, expected_default_revision)。commit 前,Registry 会确认 E0/E1 的 READY 都属于当前这次发布,并且没有因为引擎重启或失败而失效;确认后才切换 default:

A: PUBLISHED -> RETIRED
B: LOADING   -> PUBLISHED
default      = B
default_revision += 1

所以,Session 绑定哪个 LoRA 版本由 Relax 的 LoRAVersionRegistry 里的default控制。 Registry commit 之前首次生成的 Session 绑定 A,commit 之后首次生成的 Session 绑定 B;SGLang 只按请求里显式携带的 lora_path 执行对应版本。

3.4 失败情况

如果遇到以下失败情况:

情况 怎么处理
同一操作重复请求 返回原操作的状态或结果,不重复 load、transfer 或 commit。已经 commit 的版本即使后来 RETIRED/RECLAIMED,也不会因为旧 commit 重试而重新成为 default。
commit 回复丢失 查询或重试同一个 commit;结果没确认前不清理 B,也不开始下一次 publication。
candidate 发布失败 Registry 将本次 attempt 标为 ABORTED;E0/E1 都完成 cleanup 后进入 FAILED_CLEAN,再释放 B 占用的逻辑容量。旧 default 不变。
某个目标的执行或 cleanup 结果无法确认 保留当前状态和容量,不继续新的 publication;可以清理已经确认安全的资源,但不能把未知状态当成已经释放。

3.4 Happy path:A 持续服务时发布 B

图中 A/B 是不可变 lora_name 的简写,生成请求省略 Router;实际 worker 仍由 Router 选择。

sequenceDiagram
    autonumber
    participant S as Session 服务
    participant P as DCS Publisher
    participant R as Registry
    participant E0 as SGLang E0
    participant E1 as SGLang E1
    Note over S,E1: 初始 default=A,A 在两个引擎就绪并 pinned
    S->>R: bind_first(S_old)
    R-->>S: A;持有 Session ref
    S->>E0: generate(lora_path=A)
    Note over S,E0: 旧请求持续执行
    P->>P: 冻结 snapshot B,计算 digest
    P->>R: reserve(B)
    R-->>P: attempt_token;逻辑容量占用 A/B
    P->>E0: begin(B)
    P->>E1: begin(B)
    E0-->>P: PREPARED
    E1-->>P: PREPARED
    Note over P,E1: 有序 buckets 传输;段间正常 prefill/decode 继续推进
    P->>E0: end(B)
    P->>E1: end(B)
    E0-->>P: READY_LOCAL(B)
    P->>R: record_ready(E0, B)
    S->>R: bind_first(S_mid)
    R-->>S: A;E1 尚未 ready
    E1-->>P: READY_LOCAL(B)
    P->>R: record_ready(E1, B)
    P->>R: commit(B, attempt, expected_revision)
    R->>R: A=RETIRED;B=PUBLISHED;default=B
    R-->>P: commit receipt
    S->>R: bind_first(S_new)
    R-->>S: B
    par 旧会话续写
        S->>E0: S_old / S_mid 工具轮次、retry、resume:lora_path=A
    and 新会话生成
        S->>E1: S_new:lora_path=B
    end
    Note over E0,E1: A/B 均保持 pinned,不清缓存、不强制结束旧会话
Loading

4. Session 绑定与请求 lifecycle

4.1 第一次生成时绑定

Session 创建时只登记为 OPEN,不立即选择 LoRA。第一次真正生成时调用 bind_first(session_key):如果还没有 binding,就读取当前 default 并为这个 Session 增加一份 Session ref;已经绑定过则直接返回原来的版本。session_key 带 deployment epoch 和 Session nonce,用来区分同名 Session 的不同 lifecycle。

binding 保存在 _SessionRecord,不属于某个单独 IR。第一次 binding 由 Session 自己持有一个共享 task,其它等待者通过 asyncio.shield() 等它完成,所以取消某个 IR 不会把整个 binding 一起取消,也不会让后续请求重新选择版本。binding 的回复如果丢了,重试同一个 Session 仍然返回第一次确定的版本。

4.2 后续生成、重试和版本缺失

binding 完成后,后续工具轮次、合法 retry 和临时 abort/resume 都继续使用同一个 lora_path,不再读取当前 default。只有真正重新执行一次 backend generation 时,才创建新的 backend attempt。

generation 的 retry 必须由 Agentic IR 层决定,底层 HTTP/Router 不能因为超时等原因自己偷偷重发。如果上一轮是否已经被后端接纳仍然无法确认,就不能直接再发一次。目标引擎缺少 Session 已绑定的 exact version 时直接返回版本错误。

4.3 Session close

临时 abort 不释放 Session ref。只有 Session 真正结束时,才由现有的 _finish_session_once 负责一次性收尾:

flowchart TB
    F["Session 进入 FINALIZING<br/>禁止新 dispatch"] --> C["Registry.begin_close<br/>拒绝迟到 open / bind"]
    C --> B["等待共享 binding task 收尾<br/>请求终止已发出的请求<br/>完成本地 runner 清理"]
    B --> R["close_and_release<br/>释放一次 Session ref<br/>保留 CLOSED"]
    R --> D["删除本地 SessionRecord"]
Loading

如果 close 比 open/bind 更早到,Registry 仍然保留 CLOSED 记录;重复 close 也不会重复减 Session ref。之前已经被 SGLang 接纳的请求是否真的结束,仍由 SGLang 自己的 request refcount 判断。

5. 容量与安全回收

5.1 什么时候可以回收 A

A 被 B 替换成 default 后,只是进入 RETIRED,并不会立刻卸载。只有当 session_refs == 0,Registry 才允许它进入 RECLAIMING 并发出稳定的 reclaim_id。

Reclaimer 随后同时请求 E0/E1 回收 A。每个引擎先 unregister exact name,阻止新的请求继续 acquire A;然后调用原生 wait_for_unload(lora_id),等已经在途的 request refcount 归零;最后才真正执行 physical unload。

A 会一直保持 pinned,直到物理卸载完成。 pinning 的作用是:在 Relax 还没允许 reclaim 之前,不让 SGLang 的 LRU 因为 pool 压力先把 A 淘汰掉。真正进入 reclaim 后,在途请求的安全性则由 SGLang 自己的 acquire/release 和 wait_for_unload 保证。

5.2 Happy path:释放 A 后才允许 C

逻辑容量为 2。只要一个版本还处在 LOADING、PUBLISHED、RETIRED、RECLAIMING,或者 ABORTED 但 cleanup 还没完成,它就继续占一个 slot。只有进入 FAILED_CLEAN 或 RECLAIMED 后,slot 才真正释放;历史状态本身不占容量。

sequenceDiagram
    autonumber
    participant S as Session 服务
    participant Q as Reclaimer / Publisher
    participant R as Registry
    participant E0 as SGLang E0
    participant E1 as SGLang E1
    Note over S,E1: A RETIRED,B PUBLISHED;B 流量持续运行
    Q->>R: reserve(C)
    R-->>Q: CAPACITY_ERROR;不发送 Begin/NCCL
    S->>S: 完成所有 A-bound Session 的关闭流程
    S->>R: close_and_release(最后一个 A Session)
    Q->>R: claim_reclaim(A)
    R-->>Q: reclaim_id;A=RECLAIMING,容量仍占用
    par E0 回收
        Q->>E0: reclaim(A, reclaim_id)
        E0->>E0: unregister;等待原生 refs=0;physical unload
        E0-->>Q: UNLOADED receipt
        Q->>R: confirm_reclaim(A, E0)
    and E1 回收
        Q->>E1: reclaim(A, reclaim_id)
        E1->>E1: unregister;等待原生 refs=0;physical unload
        E1-->>Q: UNLOADED receipt
        Q->>R: confirm_reclaim(A, E1)
    end
    R->>R: 双目标确认;A=RECLAIMED;释放逻辑容量
    Q->>R: reserve(C)
    R-->>Q: 接受 C;default 仍为 B
Loading

因此 A/B 已经占满时,C 会在 Registry 层直接拿到 CAPACITY_ERROR,不会开始 Begin 或 NCCL。

6. 接入现有 Relax 与 KV 隔离

启动时先按现有路径完成 base 同步,再用同一套 publication 协议建立第一个版本 A。A commit 之前没有可绑定的 LoRA,生成返回 NO_PUBLISHED_VERSION。bootstrap 阶段还不存在需要保护的旧 Session,因此可以继续沿用现有 pause/flush;A 发布以后,后续 adapter-only 更新不再 pause rollout、flush KV 或重复同步 base。

训练侧 actor_fwd/reference 原有的更新顺序继续保留。publication 成功、容量拒绝和失败都要让参与训练的 ranks 得到一致结果,或者明确结束本次事务,不能留下某些 rank 还在等 collective。

每次 fresh load 都会得到一个新的 engine-local lora_id;E0 和 E1 不需要拿到相同的 ID。Session 请求携带的是 Relax 管理的不可变 lora_path,由各个 SGLang 引擎在本地解析成自己的 lora_id。SGLang 当前会把 lora_id 纳入 prefix cache 的 identity,因此 A 和 B 会进入不同的 cache namespace,因此不会因为输入 prefix 相同而错误复用彼此的 KV。Relax RadixTreeMiddleware 首版保持关闭,因为它自己的 token/logprob 缓存还没有 LoRA version namespace;SGLang 的 KV cache 则保持开启。

7. 验证与交付

7.1 状态和故障测试

场景 应该看到什么
B 只有 E0 READY;随后 E0/E1 都 READY 并 commit 只有 E0 READY 时 S_mid=A、default 不变;commit 后 S_new=B,旧 Session 的续写、retry、resume 仍然是 A。
重复 publication 和内容冲突 同版本同内容不重复加载、不多占 slot、不重复切 default;同版本异内容始终返回 conflict。
单引擎失败、manifest 错误、ACK 丢失 旧 default 不变;commit 结果没确认时不清理 B;candidate 只有两边都清干净后才释放容量。
迟到控制请求和 cleanup 竞态 cleanup 先到、旧 attempt 的 Begin/READY/receive 晚到,都不能重新创建资源;重复 receive 不能多发一次 collective。
bind/close、取消/完成并发 一个 Session 只有一次 binding 和一份 Session ref;取消一个 waiter 不取消共享 binding task;CLOSED 不会被迟到 open/bind/response 重新打开。
SGLang 原生 request refcount r1/r2 同时使用 A,r1 走 500/503 结束后计数应从 2 变成 1;pre-dispatch fail、disconnect、abort 和重复 terminal 都只能 release 一次。
Session refs=0,但后端 refs>0 A 继续 pinned,不能完成 physical unload;等在途请求真正结束后才卸载。
capacity=2、重复 reclaim、迟到 unload 回执 C 在 Begin/NCCL 前被拒绝;每个 engine 只 unload 一次;E0/E1 都确认后 C 才能重新 reserve。
exact version 缺失、旁路管理、兼容性 缺失就报错,不 fallback 到 base/latest,也不 implicit reload;普通 unload/LRU 不能删掉受保护版本;关闭开关后旧路径保持原行为。

7.2 GPU 数值和 KV 验证

准备两份非零、结果能明显区分的 Qwen3 LoRA fixture A/B,并分别在独立干净的 engine process 中建立 A-only 和 B-only baseline。正式实验前固定模型与代码 revision、config、fixture digest、dtype/kernel、sampling 参数、完整 prefix token IDs、评分 token 和 logprob 容差 ε。先确认 baseline 自身稳定,再选择能稳定区分 A/B 的评分位置。

按照 §3.4 的场景比较 S_old/S_mid/S_new 的实际 logprob。工具轮次和 resume 使用各自完整 prefix 建 baseline,不能拿第一轮短 prompt 的结果代替,也不能比较两边自由生成后已经不同的 token 序列。E0/E1 都要覆盖。

KV 隔离做两个方向,而且两次实验使用独立的初始缓存状态:

flowchart LR
    A1["实验一:A 预热"] --> B1["请求 B<br/>比较 B cold baseline"] --> B2["重复 B<br/>确认同版本 cache hit"]
    B3["实验二:B 预热"] --> A2["请求 A<br/>比较 A cold baseline"] --> A3["重复 A<br/>确认同版本 cache hit"]
Loading

实验中 SGLang KV cache 始终开启、不 flush。除了数值结果,还要记录实际 cached tokens / namespace 命中情况,确认 A-warm 不会污染 B,同时 B 自己重复请求又确实能命中自己的 cache。

7.3 端到端在线实验

至少做一轮完整链路:从真实 Megatron LoRA model 出发,经现有 export/gather 得到冻结 snapshot,再通过 DCS/NCCL 送到 E0/E1,最终由真实 Session 使用这个版本生成。发布 B 时持续保留 A-bound 流量并持续加入新 Session;随后按 §5.2 验证 C 的容量拒绝和 A 的安全回收,再注入一次部分发布失败,确认 candidate 能清理干净且 A 的输出不受影响。

通过条件是:整个 publication 过程中没有全局 pause/flush/abort_all,也不会强制结束旧 Session;E0/E1 都能继续推进正常 scheduler/decode;正常 publication 不产生额外 request failure,受管逻辑版本数始终不超过 2。

正式运行前固定负载、bucket 上限、各阶段 deadline 和最大无进度间隔。报告记录 publication latency、request latency、p50/p95/p99、最大无进度间隔、失败数、版本占用和各阶段耗时

最终交付包括版本发布和 Session binding、安全回收及必要的 SGLang patch、可运行的故障测试、固定实验配置、fixture/baseline 和 GPU 验收报告。

8. 实施计划

阶段 主要工作
Phase 0 · 固定运行基线 固定 Relax/SGLang/patch,核对原生 refcount 的 acquire/unregister 和 exactly-once release、pinning、KV namespace;如果现有路径有问题,先完成最小修补和回归测试。
Phase 1 · Registry 与 Session 实现 Registry 状态机、first-bind、Session close 和 reclaim 编排,并通过确定性的故障/竞态测试。
Phase 2 · DCS 在线 publication 接入双引擎 begin / receive_bucket / end / READY / commit,补齐失败 cleanup 和幂等 unload,同时保证旧路径回归通过。
Phase 3 · GPU 验收 完成 A/B 数值、双向 KV、在线进度和 A/B/C 容量实验。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions