第 16 章 引擎切换与停机边界
MoE 大模型低成本部署框架第 16 章 引擎切换与停机边界
CHAPTER 16

第 16 章 引擎切换与停机边界

14 分钟阅读 · 3,261 字

本书反复说"改引擎要停服"。这一章把这句话变成可执行的东西:切换工具怎么设计、
一次切换实际停多久、失败会怎样、以及为什么这套系统做不到零停机
。
素材:SSEStraBed/deploy/switch_engine.py(1084 行)、deploy/README-switch.md、
~/logs/switch-engine.log(417 行,2026-10-17 起)、~/deploy/logs/supervisord.log。


16.1 工具在哪

~/SSEStraBed/deploy/switch-engine          # shell 薄包装:exec python3 .../switch_engine.py "$@"
~/SSEStraBed/deploy/switch_engine.py       # 真正的实现(1084 行,2026-10-18 最后修改)
~/SSEStraBed/deploy/README-switch.md       # 运维手册(结构、流程、常见问题、检查清单)
~/SSEStraBed/切换引擎用户手册.md            # 面向使用者的版本
~/logs/switch-engine.log                   # 每次操作的操作日志(北京时间前缀)

子命令一览(main() 里的 argparse,:974-1040):

命令 作用 是否动生产
status / profiles 看 supervisor、两条 lane、代理的健康与引擎身份 只读
init-profiles 生成 legacy / ssebed 两套 profile(含从原配置派生 lane 配置) 只写文件
build <profile> CMake+Ninja 构建 build/strata,递增构建号、写 BUILD.json 不停服
switch <profile> [--lanes b,a] --yes 按 lane 滚动切换 停被切的那条 lane
rollback --yes 恢复 active-<lane>.env.prev(没有 .prev 就删 env 回到 legacy 默认) 停被切的那条
dev build / test / run / rollback / status / cycle 改完代码后的开发流程(见 16.9) build/test 不停服;run、test --gpu 停一条

默认不动手:switch / rollback / dev run 不带 --yes 时只打印计划并 return 0;
--dry-run 连计划里的动作都写成 [dry-run] ...。这是脚本层对"停服须先经用户同意"红线的实现。

16.2 四条设计目标(docstring 原文,:12-27)

1. **不改 supervisor.conf**(平台维护,只读):supervisor 仍然执行
   /home/cloud/Strata/run-iq3_xxs-{a,b}.sh,这两个脚本被改成薄分发器,
   从 ~/deploy/engines/active-{a,b}.env 读取本次要跑的引擎。
2. **按 lane 滚动切换**:A=8091/GPU0,B=8092/GPU1。一次只停一条 lane,
   另一条继续服务;健康检查通过后再切下一条。
3. **失败自动回滚**:切换前保存 active env;启动后等 /health + 一次
   1-token 冒烟请求;任何一步失败就恢复旧 env、重启旧引擎。
4. **配置与密钥留在 deploy/engines/**(0700/0600,不进 git),脚本本身不含任何密钥。

第 4 条不是口号:profiles/ssebed/strata-*.json 里含明文 api_key,权限 0600,
deploy/engines/ 在父仓库 .gitignore 里;脚本从不打印 key,status 只显示路径与引擎名
(README-switch.md §5)。本书同样只写"配置含明文 key"这个事实,不抄值。

16.3 一条 lane 的切换七步(switch_lane(),:676-728)

预检:binary / server_py / config 三个必须存在,否则 SystemExit
  ↓
drain(port, 30s)          # 轮询 /v1/status 的 activity.in_flight 与 concurrency.serving,都归零才走
  ↓
supervisorctl stop strata-{lane}     # 等端口释放(wait_port_closed,35s);没释放 → RuntimeError
  ↓
write_env(profile, lane)  # 原子写 active-{lane}.env(先 copy2 旧文件到 .env.prev)
  ↓
supervisorctl start strata-{lane}    # wait_supervisor_running(30s):确认不是 FATAL/BACKOFF
  ↓
wait_health(port, 900s)   # 每 2s 轮询 /health 且 /v1/status.loaded==true;期间把引擎日志新增行回显到终端
  ↓
smoke(port, config)       # 1-token 非流式 /v1/chat/completions(model 取自该 lane 配置),必须有 choices
  ↓
任何一步抛异常 → stop → 等端口释放 → 恢复 .prev(没有 .prev 就删 env)→ start → 再等健康 → 重新抛出

默认参数(argparse):

参数 默认 语义
--lanes b,a 默认先切普道 B、后切快道 A(把风险先放在非交互车道上)
--timeout 900 s 等健康/加载的上限
--drain-timeout 30 s 排空在途请求的上限
--no-smoke 关 可以跳过冒烟(不建议)

⚠️ 文档与代码不一致:README-switch.md §3 写"轮询 /health + /v1/status.loaded(默认 360s)",
代码里 --timeout 默认是 900s。以代码为准;手册没跟着改。

两个设计细节值得抄走:

  1. wait_health 带 follow_log(从引擎配置里解析出日志路径,:303),等待期间把引擎新增行回显到终端——
    注释原话:让"漫长的专家 arena 加载显示它的逐层进度,而不是只有 waiting lane 心跳"。
  2. smoke 判的是"能真正生成",不是"进程活着":/health 在模型还没加载完时也可能返回 ok,
    所以就绪判据是 status==ok 且 loaded is True(loaded is None 兼容旧接口,:361)。

16.4 为什么做不到零停机

README-switch.md §4 原文(这是本项目停机边界的权威表述):

  • GPU 只有两张,且已被两个生产实例占满。新引擎启动要重新加载模型/专家
    (1–8 分钟(冷启动),双实例更长),同一张卡上无法同时跑新旧两个引擎。
  • 因此能做到的是:一次只停一条 lane,另一条继续服务;代理的 upstreams 有两条,
    route_upstream 按模型 ID + 会话粘性选路。
  • 切 B 时默认模型(qwen3.8-flash → 8092)、切 A 时 qwen3.8-flash-fast(→ 8091)
    本来会直接 502。

这条待办已于 2026-10-21 落地(rag-proxy/rag_proxy.py 的 _proxy()):

  • except urllib.error.URLError 分支里,只要还没给客户端写过一个字节(sent 标记),
    就调用 failover_upstream(当前上游) 找一条活着的 lane 重试一次(failover=False 防循环,
    upstream_alive() 用 TCP connect + 2 秒缓存,避免每请求都探)。
  • 计数器 upstream_failover;日志一行 upstream X 不可达(…),改投 Y。
  • 实测两条证据:隔离实例(8099,上游指向死端口 9)→ 自动改投 8091 并正常返回;
    切 B 的窗口里向 qwen3.8-flash 发请求 → 日志 改投 http://127.0.0.1:8091、1.16 s 返回。
  • 边界:只在「连接阶段失败」生效;已经写出响应头之后的中断(读流中途断)无法改投,仍按原样断开。
    这也意味着 failover 不能替代滚动切换:它是兜底,不是零停机方案。

drain 的真实局限(10-17 20:41 的实测):

[2026-10-17 20:41:39]   draining lane 8091: in_flight=0 serving=4     ← 连续 15 次,serving 一直是 4
[2026-10-17 20:42:09] WARN: lane 8091 在 30s 内未完全 drain,继续停止(在途请求会被取消)

in_flight=0 但 serving=4 说明四条正在流式生成的长会话不会在 30 秒内归零——
drain 只能等短请求,长会话必然被砍。脚本对此只警告不阻塞。结论:切换前必须通知用户,
不能指望 drain 把在途对话保下来。

16.5 双 profile 与"这次构建到底生效了没有"

engine=0.1.38 这个语义版本 legacy 与 ssebed 完全相同,无法判断切换/重编是否真的生效
(ENGINE-VERSION-FIELD-2026-10-18.md §1)。于是加了构建身份三件套:

机制 位置 谁写
build/build-number.txt 每次成功 build +1(原子替换,_next_build_number :565) switch_engine.py / Strata/setup.py(legacy 侧)
build/BUILD.json {engine, version, build, built_at, sha256, size, mtime}(_write_build_manifest :588) 同上;必须在二进制同目录,serve 的 StrataEngine 从这里读
engine_version / engine_build /health、/v1/status、OpenAI/Anthropic 响应(流式在首末 chunk / message_start) serve
  • 可读串格式 <name>-<version>-b<build>;没有 BUILD.json 的旧二进制回退成 <name>-<version>-sha.<8位>。
  • 旧字段 /v1/status.engine = "0.1.38" 保留兼容;判断新构建一律看 engine_version。
  • dev status 会比对二进制 mtime 与源码 mtime,stale=true 就提示该重编(_build_info :528)。

2026-10-20 01:43(北京)当场读两条 lane 的 /v1/status(只读 GET,非压测):

lane engine_version engine_build.build built_at
A(8091,快道) ssebed-0.1.38-b1 1 2026-10-18 23:59:40
B(8092,普道) legacy-0.1.38-b0 0 2026-10-07 18:53:38

→ 两条 lane 的引擎确实是两套二进制、两个构建,可当场验证;B 的引擎二进制还是 10-07 的。

2026-10-21 13:53 起的现状(上表已过期,留作对照):两条 lane 均为 ssebed-0.1.38-b5
(sha256 4e5ce24d…,--mtp-varlen),A 当天上午已切到 b5(为做 TTFT 对照曾短暂换回 b1,13:13 再切回 b5)、B 13:52 完成切换;/metrics.engine 的
build=5 与 /v1/status.engine_version 一致。A 道 engines 目录同名的 build/build-number.txt 不再反映
真实构建(grep 仍会看到 b1),以 engine_version 与 BUILD.json 为准。
(ENGINE-VERSION-FIELD 报告 §8 当时写"B 的 /v1/status 没有 engine_version"——
10-19 18:58 重启 B 之后该字段已出现,这条结论已过期。)

16.6 停机代价:实测数字

事件 时间(北京) 时长 出处
supervisorctl stop strata-a(SIGTERM 到 exit 0) 2026-10-18 22:45:11 → 22:45:19 8 s(10-19 那次 7 s) supervisord.log(UTC 14:45:11→14:45:19 / 10:57:28→10:57:35)
10-17 首次切 A:start → ready 20:28:31 → 20:34:33 6 分 02 秒 switch-engine.log :290,318
10-17 第二次切 A 20:44:35 → 20:49:30 4 分 55 秒 同上 :377,401
10-18 部署 engine_version 字段(重启 A) 22:45:11 → 22:51:47 6 分 36 秒 ENGINE-VERSION-FIELD-2026-10-18.md §8
手册口径 — 1–8 分钟(README §4);设计目标里另写 1–3 分钟 README-switch.md §4
引擎重启后模型载入(单卡、共享盘 I/O) 2026-10-04 6–13 分钟 Strata-本Pod部署全景.md §6.1
kill -9 引擎后自动恢复 2026-10-04 418 s POD-ENV-NOTES.md :111
网关重启(restart rag-proxy) 2026-10-19 秒级(stop→spawn→RUNNING 共 5.3 s,= startsecs=5) supervisord.log 08:41:42→08:41:47
A 道重启(build 1 对照实验) 2026-10-21 13:13 起 8 分 24 秒(arena 40 GiB @ 0.08 GiB/s) SSEStraBed/logs/strata-iq3_xxs-a.log
A 道重启(回 build 5) 2026-10-21 13:31 起 9 分 07 秒(0.07 GiB/s:宿主 216 GiB 已用 187,page cache 被清空) 同上
B 道切换 build 5 2026-10-21 13:46 → 13:5x 约 9 分钟(switch_engine drain 30 s → stop → start → health) ~/logs/switch-b-build5.log

2026-10-21 的启示:冷启动时长不是常数,它跟着宿主的页缓存走。
同一天同一条 lane,第一次 8:24、第二次 9:07;arena 读盘速率只有 0.07–0.09 GiB/s。
所以「1–8 分钟」这个区间要按 [8, 10] 分钟做计划,而且任何重启都要先确认 failover 可用。

口径提醒:手册的"1–3 分钟"是设计期望,本机三次实测都在 5–7 分钟,
10-04 的单卡口径甚至到 13 分钟——排维护窗口请按 6–13 分钟留,不要按 1–3 分钟留。

16.7 真实记录:三次切换尝试与两次生产重启

2026-10-17(首次把 A 切到 ssebed):

时刻(北京) 发生了什么
20:27:02–20:27:34 build ssebed 成功
20:27:03 removed the new env (original fallback restored) —— 一次 rollback 把 env 删掉,回到 legacy 默认
20:28:31 → 20:34:34 switch lane A → ready → smoke ok → proxy health: ok upstreams={8091: ok, 8092: ok}
20:36:40 / 20:36:44 两次 rollback lane A 都是 [dry-run](打印计划后返回,未执行)——--yes 闸门按设计生效
20:41:39 → 20:42:09 再次切 A:drain 30s 超时(serving=4),强停
20:44:35 → 20:49:31 切 A 成功,ready + smoke ok;active-a.env 头部注释即 “written by switch_engine.py at 2026-10-17 20:44:35”(15 章 §15.3)

2026-10-19(窗口守卫事故当天的两次重启,均经用户点名批准):

supervisord 记录(UTC → 北京) 事件
05:07:00 → 13:07 restart rag-proxy(pid 3319174)
05:56:39 → 13:56 restart rag-proxy(pid 3344478)
08:41:42 → 16:41:42 restart rag-proxy(pid 3426352),5.3 s 后 RUNNING
10:57:28–10:57:50 → 18:57:28–18:57:50 restart strata-a(pid 3479908)
10:58:08–10:58:29 → 18:58:08–18:58:29 restart strata-b(pid 3480349)

⚠️ 口径冲突(不静默选):CONTEXT-COUNT-BILLING-2026-10-19.md 的落地记录写
“rag-proxy 已重启,pid 3426352,2026-10-19 15:33 北京”,而 supervisord 自己的记录是
同一 pid 在 16:41:42 北京 生成;该报告又写两通道"18:52–18:55 重启",supervisord 记录是
18:57–18:58。pid 一致、时间差 5–68 分钟。rag_proxy.py 的 mtime 是 15:28 北京,
推测报告里的 15:33 记的是改代码完成的时刻而非进程重启时刻。
进程事件以 supervisord.log 为准(它是 UTC,需 +8;对照验证:10-18 那次
14:45:11 UTC 与报告"22:45:11 北京"完全吻合)。

16.8 崩溃自愈,以及"RUNNING ≠ 可用"

supervisord 侧(15 章 §15.2):autorestart=true、startretries=3、startsecs=15、
stopasgroup/killasgroup=true。引擎被 kill -9 后 supervisor 会重新拉起,
实测 418 秒后恢复服务(含模型重载)。

但 entered RUNNING state 不等于能服务:日志里 strata-a ... entered RUNNING (stayed up for > than 15 seconds) 只说明进程活过 15 秒,模型此时通常还在加载。
判断"这条 lane 真的能用"要看 /v1/status.loaded == true(switch_engine.py 正是这么判的),
或看网关 /health 的 upstreams 明细。

故障域:dual 布局下 A/B 是两个独立进程与两张独立 GPU(11 章),一条崩了另一条不受影响;
但网关不会 failover(16.4),所以崩掉那条 lane 对应的模型 ID 会直接报错给用户。

16.9 改完代码之后的开发流程(dev 子命令)

README-switch.md §3.5 的推荐序列(命令原文):

switch-engine dev build ssebed                    # 只 build build/strata,不动生产
switch-engine dev test  ssebed                    # build-tests/ + ctest 跑 CPU 回归,不占 GPU
switch-engine dev run   ssebed --lane b --yes     # 只切 B 上真引擎跑测试;A 继续服务
switch-engine dev rollback --lane b --yes         # 切回
switch-engine dev cycle ssebed --lane b --yes     # build + CPU 测试 + 切换,一行走完
switch-engine dev status ssebed                   # 构建 hash / mtime / 是否 stale

要点:

  • dev build / dev test 不停任何生产进程;测试默认只跑 CPU 回归(10-17 实测一次跑 10 个
    CPU 目标:suffix_drafter_test、controller_test、draft_policy_test、conv_cache_test、
    coupled_draft_test、bf16_bits_test、file_expert_source_test、expert_profile_save_test、
    gguf_reader_test、gguf_split_test,随后 python -m unittest serve.test_server)。
  • dev test --gpu --lane b --yes 是维护动作:它会停掉 B、在 GPU1 上跑完整 ctest、再按当前 profile 重启 B。
  • 构建用 taskset -c 16-31,刻意避开生产引擎钉住的 0-15 / 38-53(README-switch.md §2)——
    这条是 11 章钉核纪律在构建侧的延伸:编译这种 CPU 密集活不能踩生产核。
  • 构建日志 ~/logs/ssebed-build.log;测试构建日志 ~/logs/ssebed-tests-build.log。
  • CMake 命令是固定的(日志里可见):-G Ninja -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=89 -DSTRATA_GGML_DIR=third_party/llama.cpp(sm_89 = 4090)。

16.10 停机窗口检查清单(手册 §7,逐条)

  • [ ] 用户已同意停服/重启窗口(红线,见 17 章)
  • [ ] status 两条 lane 都 healthy
  • [ ] build 成功、build/strata 存在
  • [ ] 低峰期;或已部署代理 failover(尚未部署)
  • [ ] 先切 B,跑一轮 bench/smoke.py / 手工请求
  • [ ] 再切 A
  • [ ] 两条都 healthy 后,检查 /concurrency 与一次真实对话
  • [ ] 有问题立即 rollback --yes

脚本还会在切换前显式警告影响面(:901-903):

WARN: 快道 A(8091 / *-fast)会停机 30-90s;真实用户会受影响
WARN: 普道 B(8092 / 默认模型)会停机 30-90s;默认用户会受影响

这两行的 “30-90s” 与 16.6 的实测 5–7 分钟不符(脚本低估了冷启动)。
别拿它当排期依据。

16.11 本章小结

  • 切换工具的设计核心是把"换引擎"降级成"写一个 env 文件 + 重启一条 lane",
    于是 supervisord、平台配置、服务层 Python 全都不用动。
  • 安全边界靠三件事:--yes 才动手、switch.lock 防并发、失败自动回滚 .prev。
  • 零停机做不到,原因很物理:两张卡已被两个实例占满,同卡跑不了新旧两份引擎;
    而网关按模型 ID + 会话粘性选路,不会自动 failover。能买到的是"一次只停一条 lane"。
  • 停机时长按 6–13 分钟规划(实测 4m55s / 6m02s / 6m36s;崩溃自愈 418s);
    手册的 1–3 分钟与脚本的 30–90s 都是乐观口径。
  • RUNNING 不等于可用:就绪判据是 /v1/status.loaded + 一次 1-token 冒烟。
  • 判断"这次构建真的生效了"用 engine_version(2026-10-21 起两条 lane 都是 ssebed-0.1.38-b5;更早的 A=b1 / B=legacy-b0 已过期)。
全部章节