第 16 章 引擎切换与停机边界
本书反复说"改引擎要停服"。这一章把这句话变成可执行的东西:切换工具怎么设计、
一次切换实际停多久、失败会怎样、以及为什么这套系统做不到零停机。
素材: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。以代码为准;手册没跟着改。
两个设计细节值得抄走:
wait_health带follow_log(从引擎配置里解析出日志路径,:303),等待期间把引擎新增行回显到终端——
注释原话:让"漫长的专家 arena 加载显示它的逐层进度,而不是只有waiting lane心跳"。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 已过期)。