提案人:@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 容量实验。
提案人:@ying123ww · 导师:@yuanlehome · 状态:待评审
官方任务:No.7「不可变 LoRA 版本的在线发布、会话绑定与安全回收」
首版支持配置
--lora-adapter-mode --fully-async --use-agentic-rollout,新增显式开关--enable-versioned-lora-publication。n=1,支持工具轮次、重试及临时 abort/resume。max_loras_per_batch=3、max_loaded_loras=3。受管 adapter 加载后保持 pinned,直到物理卸载;首版不和第三方动态 adapter 混用。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. 现有能力与要改的地方
lora_adapter_sync.py、device_direct.py、SGLang patchDeviceDirectBackend.update_weights_for_rollout()_SessionRecord能跨工具轮次存在,但没有 LoRA bindinglora_pathsession/service.py、pipeline/runtime.pylora_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 -.->|"操作结果"| PUBLoRAVersionRegistry_SessionRecord中保存 binding,负责首次绑定、IR dispatch 和 Session close lifecycle。lora_path,不替请求改版本。Registry 是一个共享的单写者 Ray actor。它只保存状态和做状态转换,不在状态更新过程中等待网络、NCCL、probe 或 unload;这些实际操作都由 Publisher / Reclaimer 在外面完成。
受管 adapter 只能走本文定义的 load/cleanup/reclaim 流程,普通 overwrite/unload 和隐式 reload 不能直接修改它们。
3. 版本发布
3.1 版本身份和 snapshot
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 路径执行:
beginreceive_bucketendREADY_LOCAL。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"]当 E0/E1 都 READY 后,Publisher 调用
commit(B, attempt_token, expected_default_revision)。commit 前,Registry 会确认 E0/E1 的 READY 都属于当前这次发布,并且没有因为引擎重启或失败而失效;确认后才切换 default:所以,Session 绑定哪个 LoRA 版本由 Relax 的 LoRAVersionRegistry 里的default控制。 Registry commit 之前首次生成的 Session 绑定 A,commit 之后首次生成的 Session 绑定 B;SGLang 只按请求里显式携带的 lora_path 执行对应版本。
3.4 失败情况
如果遇到以下失败情况:
ABORTED;E0/E1 都完成 cleanup 后进入FAILED_CLEAN,再释放 B 占用的逻辑容量。旧 default 不变。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,不清缓存、不强制结束旧会话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"]如果 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因此 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。RelaxRadixTreeMiddleware首版保持关闭,因为它自己的 token/logprob 缓存还没有 LoRA version namespace;SGLang 的 KV cache 则保持开启。7. 验证与交付
7.1 状态和故障测试
S_mid=A、default 不变;commit 后S_new=B,旧 Session 的续写、retry、resume 仍然是 A。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"]实验中 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. 实施计划
begin / receive_bucket / end / READY / commit,补齐失败 cleanup 和幂等 unload,同时保证旧路径回归通过。