axon-inference 模型热更新(0.6.0 P0 Stage 6 收口)¶
适用版本:
axon-inferencev0.6.0+ 状态:已实现(Stage 6 收口) 上游 plan:docs/superpowers/plans/2026-07-18-axon-quant-0.6.0.md工作流 C
ModelHotReloader 提供不中断推理服务的模型权重热更新能力,核心目标:
- 零停机:reload 期间旧 session 仍可服务推理请求,只在
replace_session瞬间阻塞(< 1ms) - 原子性:新 session 构建失败时,旧 session 保持不变
- 可观测:每次 reload 触发
watch::Sender+ Python 端可选回调,记录版本号 + sha256 校验和
设计核心:两步原子路径¶
reload()
│
├─ ① compute_sha256(path) — 算新文件校验和(预校验)
│
├─ ② backend.read() — 拿**只读**锁
│ .build_session(path) — 在 backend 上下文中预构造新 session
│ ─→ 失败:立刻返回 Err,旧 session 不动
│
├─ ③ backend.write() — 拿**写**锁
│ .replace_session(new) — 原子替换
│ ─→ 失败:返回 Err,旧 session 仍生效
│
└─ ④ version.fetch_add(1) — 版本号 +1 + 通知所有订阅者
关键约束:build_session 阶段持有只读锁,旧 session 仍可并发推理;replace_session 是 &mut self 瞬间操作,只阻塞当前写者(无 IO)。
Trait 抽象¶
pub trait InferenceEngine: Send + Sync {
fn load(&mut self, path: &Path) -> Result<(), InferenceError>;
fn infer(&self, observation: &Observation) -> Result<Action, InferenceError>;
fn infer_batch(&self, observations: &[Observation]) -> Result<Vec<Action>, InferenceError>;
/// 在 backend 上下文中预构造新 session(不替换当前 session)
fn build_session(&self, path: &Path)
-> Result<Box<dyn Any + Send + Sync>, InferenceError>;
/// 原子替换当前 backend 的 session
fn replace_session(&mut self, new_session: Box<dyn Any + Send + Sync>)
-> Result<(), InferenceError>;
}
各 backend build_session / replace_session 实现:
| Backend | build_session 返回 | replace_session downcast | 备注 |
|---|---|---|---|
OnnxBackend | Box<ort::session::Session>(已 commit) | Box<ort::session::Session> | 真预构造,可独立完成 IO |
TchBackend | Box<tch::CModule>(已 load) | Box<tch::CModule> | 同上 |
CandleBackend | Box<CandleReloadState { path }> | Box<CandleReloadState> → self.load(&state.path) | Candle 模型与 self.device / self.config 紧绑定,无法独立构造;走"包 path,replace 时再 load" 模式 |
Python 绑定¶
构造¶
from axon_quant import InferenceEngine, ModelConfig, Device, InferenceBackend, ModelHotReloader
cfg = ModelConfig(
path="/models/policy_v1.onnx",
backend=InferenceBackend.Onnx,
device=Device.cpu(),
input_shape=(1, 64, 128),
output_dim=3,
)
eng = InferenceEngine(cfg)
eng.load("/models/policy_v1.onnx") # 先 load,再传 reloader
reloader = ModelHotReloader(eng) # Stage 6 收口后**真实现**,不再 RuntimeError
assert reloader.version() == 0
assert reloader.model_path() == "/models/policy_v1.onnx"
手动 reload¶
# 假设训练进程产出了新权重 /models/policy_v2.onnx
# 业务侧复制覆盖 /models/policy_v1.onnx(用 `os.replace` 原子替换)
import os
os.replace("/models/policy_v2.onnx", "/models/policy_v1.onnx")
# 触发热更新
new_version = reloader.reload()
assert new_version == 1
订阅回调¶
events = []
def on_reload(path, version):
events.append((path, version))
print(f"model {path} reloaded to v{version}")
reloader.subscribe(on_reload)
# ... 触发 reload ...
assert len(events) == 1
assert events[0] == ("/models/policy_v1.onnx", 1)
reloader.unsubscribe() # 取消订阅
watch::Receiver Rust 端订阅¶
use axon_inference::hot_reload::ModelHotReloader;
let mut rx = reloader.subscribe(); // watch::Receiver<u64>
tokio::spawn(async move {
while rx.changed().await.is_ok() {
let v = *rx.borrow();
tracing::info!(version = v, "model hot reloaded");
}
});
注意事项¶
build_session失败不污染状态:算 sha256 / 加载 ONNX 失败 → 立刻Err,旧 session 不动。replace_session类型不匹配返回Backend错误:传错Box<dyn Any>类型 → 后端返回明确的Onnx(...)/Tch(...)/Candle(...)错误信息(包含期望的类型名),便于排查。CandleBackend阻塞时间最长:因为replace_session内部走self.load(&state.path)完整重建,大模型可能 1-3 秒;Onnx/Tch 因预构造完成,替换瞬间。- Python 端不暴露
build_session/replace_session:仅ModelHotReloader.reload触发整条流程,避免误用导致状态不一致。 - 真实 ONNX 模型加载需自行准备:
test_inference_e2e.py默认不测真模型(避免 CI 拉模型),仅验证 Rust 单元测试覆盖 + Python API 协议(版本号、回调、路径一致)。
验收¶
- Rust 单元测试:
cargo test -p axon-inference --features "onnx candle-backend python" --lib57/57 通过 - Python E2E:
pytest python/tests/test_inference_e2e.py -v包含: test_reloader_new_succeeds_with_engine:验证__new__成功 +version() == 0+model_path非空test_reloader_subscribe_and_unsubscribe:验证subscribe/unsubscribe/has_callback三件套test_reloader_model_path_matches_engine:验证 path 一致- 旧 Stage 6 stub 测试
test_reloader_new_returns_runtime_error_in_stage6已删除(对应 Rust 侧reloader_new_returns_runtime_error改为reloader_new_succeeds_with_engine)