让回滚永远可达:把"调崩了怎么办"变成脚本能证明的事
0次浏览0条评论0个赞
你改了一个参数,重启服务。重启命令返回 0,容器状态显示 Up 20 seconds——看上去一切正常。大多数人到这一步就收工了。但压测一开始就报连接拒绝:服务在容器起来之后,还要花几十秒到几分钟加载权重。
现场调优最贵的失败是”调崩之后回不来”:调慢一点只损失时间,回不去损失的是这个服务本身。先说这个时间差里藏着什么,再给把回滚写进脚本的六件事。
一、改错不危险,不知道它算不算起来了才危险
先说那段时间差本身。
一个推理服务在容器起来之后,还要花几十秒到几分钟加载权重。容器活着和服务可用之间,隔着一整段加载时间——而这段时间里,所有”启动成功”的判据都是假的。
更糟的是另一种情况:服务真的起来了,端口也在监听,但它加载的是错的模型、或者认证配置没生效、或者加速器根本没加载上权重。此时”健康检查通过”,而你的压测数字全部没有意义。
这就是我把”启动”这件事拆成两层的起点:
- 进程层:容器在跑、端口在听;
- 服务层:它能真的完成一次推理,参数是我以为的那份。
第二层才是我们要的。
二、五步验证:把”服务能用”写成可执行的判据
上一节说的问题是”不知道它算不算起来了”,这一节给判据。每次重启后,我跑的是这五步,而不是一句 curl /health(用到的几个端点都是推理服务的 OpenAI 兼容接口 [1],下表逐个列出):
| 步骤 | 检查 | 为什么必须是它 |
|---|---|---|
| 1 | 健康端点返回成功 | 服务进程在响应(最低门槛) |
| 2 | 从 /v1/models 里读出模型名,且非空 | 它加载的是哪个模型——不是我以为的那个 |
| 3 | 发一次真实推理,要求至少拿到 1 个输出 token | 端到端真的能算出东西 |
| 4 | 指标端点里出现预期的指标前缀(如 vllm:) | 监控链路通了,否则后面无法观测 |
| 5 | 加速器显存已加载权重(如 >1000 MiB) | 权重真在卡上,不是一个空壳服务 |
第 3 步是关键,也是最容易被省掉的一步:只检查 /v1/models 会漏掉”服务活着但推理坏了”这类故障。而”发出一个 prompt、拿到至少一个 token”这件事,把 tokenizer、调度、算子、显存整条链都盖住了。
完整可复制的脚本骨架放在文末附录——脚本是查阅材料,不是叙事,放在这里会把开头的阅读成本堆得太高。
注意第 5 步必须用加速器自带的显存查询工具(如 npu-smi;NVIDIA 侧对应 nvidia-smi)——它检查的是硬件层面的权重驻留,而不是服务自己报的数字。自报的数字只能证明服务以为自己在干什么。
三、把”回滚”从救火动作变成默认分支
前面说的是”怎么判断服务起来了”,接下来是”判断不过怎么办”。这是整篇文章我最想让人带走的一条设计。
大多数人的回滚长这样:出事 → 想起有个历史版本 → 手忙脚乱找 → 复制回来 → 重启 → 祈祷。它是一个人工分支,而且发生在你最慌的时候。
我的做法是把它写进重启脚本本身:
# 1) 先把当前配置存进历史(时间戳目录),再写新参数
BACKUP=$APP/conf/service.history/$(date +%s)
mkdir -p "$BACKUP" && cp $APP/conf/service.yaml "$BACKUP/"
[ -n "$1" ] && echo "$1" > $APP/conf/service.yaml
# 2) 变更写审计日志(谁、什么时候、改成了什么)
echo "$(date -Iseconds) | $1" >> $APP/logs/audit/changes.log
# 3) 重启
cd $APP && docker compose restart vllm
# 4) 健康检查:最多等 5 分钟(30 × 10s)
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/v1/models >/dev/null 2>&1; then
# 健康之后还要做一次真实自检
curl -sf -X POST http://localhost:8080/v1/completions \
-H "Content-Type: application/json" \
-d "{\"model\":\"${SERVED_NAME:-$MODEL_NAME}\",\"prompt\":\"hi\",\"max_tokens\":4}" >/dev/null
echo "READY"; exit 0
fi
sleep 10
done
# 5) 超时 → 不是报错退出,而是自动回滚
echo "STARTUP TIMEOUT"
bash $APP/scripts/rollback.sh
exit 1
关键在第 5 步:超时之后自动把配置换回上一份并重启,不等人来看报错。
这样一来,回滚成了重启流程的默认分支,不再需要有人在场。人在不在,它都会发生。
为什么”健康检查”必须包住”真实自检”
上面第 4 步里有个细节值得单说:健康端点通过之后,脚本还发了一次真实推理。
因为存在这样一类故障:服务能响应 /v1/models,但实际推理会失败(算子不匹配、KV 分配异常、量化配置错)。如果健康检查只看到”端点有响应”就宣布 READY,那么回滚逻辑永远不会触发——你会带着一个坏掉的服务去做后面所有实验,直到压测结果全线崩掉才发现。
把真实自检放进健康检查里,等于把”能不能用”当作”起来了没有”的判据。代价是多花几百毫秒,换来的是回滚时机的正确性。
四、回滚目标必须是”最近一次留下的”,不是”上一次改的”
上一节把回滚变成了默认分支,但默认分支要退回到哪里,我一开始就选错了。
这是我踩过的一个概念坑,值得写下来。
直觉上”回滚”就是”退回到上一个版本”。但如果上一个版本是已经被判为无效、已经被回滚掉的那一次尝试,那么退回到它等于退回到一个已知的坏状态。
所以历史目录里存的,不能是”每一次改动”,而必须是已经确认可用的配置:
LATEST=$(ls -t $APP/conf/service.history/ 2>/dev/null | head -1)
if [ -z "$LATEST" ]; then
# 连历史都没有 → 退到黄金基线
cp $APP/conf/service.golden.yaml $APP/conf/service.yaml
else
cp $APP/conf/service.history/$LATEST $APP/conf/service.yaml
fi
cd $APP && docker compose restart vllm
# 回滚本身也要验证它真的起来了,否则"回滚成功"是个幻觉
for i in $(seq 1 30); do
curl -sf http://localhost:8080/v1/models >/dev/null 2>&1 && exit 0
sleep 10
done
echo "❌ 回滚后仍异常"; exit 1
两个细节:
- 回滚后必须重新验证。 “我把文件换回去了”和”服务恢复了”是两件事。回滚脚本的返回值必须反映后者。
golden(黄金基线)是最后一道底。一份在环境刚部署好、一切正常时固化下来的配置,它存在的意义就是”当历史也都不可信时,还有一个已知可用的起点”。
贯穿线:先让”变坏”可控,才有资格做实验
前面四节做的是同一件事:把”出事之后怎么办”从人的反应变成代码的默认路径。
- 五步验证把”起来了没有”从主观判断变成可执行判据(二);
- 回滚写进重启脚本,超时即自动执行,使它不再依赖有人在现场(三);
- 回滚目标锁定”最近一次留下的”,而不是”上一次改的”,避免退回到一个已知的坏状态(四)。
它们合起来指向一个判断:
回滚可达,才有资格做实验。
这句话不是修辞。一次不可回滚的失败,代价不只是这次实验白做——它会让你在下一次实验前犹豫,而犹豫会让整个优化过程变慢、变保守、变成依赖”这次应该没问题吧”这种判断。回滚能力真正买到的是”敢试”,不只是”安全”。
剩下的三节是在给这个判断补上配套:变更要有审计链(五)、最后手段必须存在(六)、以及必须提前定好止损线(八)——因为一个只有”继续尝试”而没有”何时停手”的框架,是不完整的。
五、变更要有审计链,环境要有指纹
接下来是一层常被忽略的东西:变更本身留下的痕迹。换句话说,审计链的价值不在事后追责,而在于让”现在这个状态是怎么来的”变成一个能回答的问题。
调优实验最难回答的两个问题是:
- “这个配置是怎么变成现在这样的?”
- “为什么同样的配置,昨天和今天表现不一样?”
第一个问题靠变更审计日志回答:每次改动记一行——时间戳 + 改成什么。这不需要复杂的设计,一个 append-only 的文本文件就够:
2026-08-29T14:03:11+08:00 | max_num_seqs=96, enable_chunked_prefill=true
关键在于它由脚本自动写,而不是靠人记。凡是”记得手动做”的步骤,都会在赶时间的时候被跳过。
第二个问题靠环境指纹:启动时把与运行时相关的环境变量摘出来,取哈希,落到启动日志里:
ENV_FP=$(env | grep -E 'CANN|ASCEND|DRIVER|HCCL' | md5sum | awk '{print $1}')
echo "env_fingerprint=${ENV_FP}" >> $APP/logs/audit/start.$(date +%s).log
为什么这个哈希重要?因为它把”环境一致”变成了可比较的事实,而不是感觉。
我在前面那篇文章里讲过统计决策协议要求 σ_baseline 与压测条件一起记录——环境指纹就是这个要求更底层的一层。当两次测量的结果差异很大时,你至少能立刻回答:“这两次的环境变量集合是不是同一套?”
六、最后手段必须存在,而且要是”一键”的
前面已经有了自动回滚。但自动回滚自己也会失效——任何自愈机制都会有失效的时候。所以除了回滚,还需要一个明确的最坏情况方案:
# 停止服务 → 配置回退到黄金基线(含环境变量)→ 重启 → 五步验证
docker compose down
cp $APP/conf/service.golden.yaml $APP/conf/service.yaml
cp $APP/conf/env.golden $APP/.env
docker compose up -d vllm
sleep 30
bash $APP/scripts/verify_after_start.sh \
&& echo "✅ 黄金基线恢复,24 小时熔断" \
|| { echo "❌ 黄金基线也起不来"; exit 1; }
它有两条纪律:
- 它恢复的不只是配置,还有环境变量。因为”服务起不来”的原因经常在环境(驱动版本、通信配置、设备映射),不在参数。只回退一者,可能回退不干净。
- 恢复成功后进入熔断期。意思是”这台机器现在回到已知可用状态,接下来一段时间不要再做实验”。熔断的价值在于防止刚恢复就又被下一次实验打崩——在压力下连续试错,是把一个小事故放大成一次故障的主要方式。
七、现场分级:把”哪些绝对不能碰”写在第一页
前面都是脚本。但现场最常见的失败,是有人为了快动了那个本来不该动的开关;脚本本身通常写得没问题。
所以我把操作手册里所有路径与参数都打上分级标记,并把图例放在最前面:
| 标记 | 含义 |
|---|---|
| 🔴 | 禁止改(会导致不可回退的后果,或超出本方法论范围) |
| 🟡 | 必须改(不改起来也是错的) |
| 🟢 | 必须跑(脚本与验证步骤) |
| ⚪ | 视情况(按场景分册选) |
| 📦 | 文件位置(写在哪、从哪读) |
举个”🔴 禁止”的例子:某些与量化格式、推测解码、专家并行相关的开关,一旦与环境或权重不匹配,会导致服务根本起不来或者输出静默错误。这类开关的正确位置是在”场景分册”里被显式选择,而不是在现场被临时试探。
把禁止项前置的价值,是让”不能碰”和”要改”在视觉上就不可能混。写在附录第 40 页的警告,等于没有警告。
八、什么时候放弃:把”止损线”也提前定好
前面定的都是”该怎么做”,这里补上”什么时候不做”。一套只有”继续尝试”的执行框架是不完整的。我把止损线明确成三条(“什么时候该停手”这条纪律在 SRE 体系里讨论得很充分,可参考 [2]):
- 连续 3 次回滚都失败 → 跑最后手段脚本(黄金基线恢复);
- 黄金基线也起不来 → 停手,找基础设施方(这不是调优能解决的问题了);
- 5 分钟内启动后验证过不了 → 直接跑最后手段脚本,不再试第二次。
止损线的作用是在正确的时刻把决定权交出去,它不会让你更保守。
第二条特别重要:“黄金基线起不来”是一个强信号——它说明问题不在你改的参数上,而在更底层(硬件、驱动、环境)。此时继续做参数实验,只会在一个坏掉的基础上叠加噪声,而且会把现场记录弄乱。
九、附:故障关键字速查
接下来是附录性质的速查。现场排障最耗时的部分是”这句话是什么意思”。把常见报错与处置固定成表,是投入产出比很高的一件事——本质上,它是在把排查经验从个人记忆搬到团队资产里。以下是启动类与推理类这两组(其余按场景再细分):
启动类
| 报错关键字 | 含义 | 处置 |
|---|---|---|
EngineDeadError | 引擎进程死了 | 降并发(max_num_seqs) |
out of memory | 显存耗尽 | 降并发或降 max_model_len |
ACL error | 算子层错误 | 核对加速器工具链与驱动配套 |
HCCL connection failed | 集合通信没建立 | 提高通信建链超时 |
NPU unhealth | 卡硬件异常 | 换卡 |
Address already in use | 端口被占 | 找到占用进程 |
Permission denied | 设备权限 | 检查容器设备映射 |
No space left on device | 磁盘满 | 清理容器存储 |
import vllm_ascend failed | 镜像/配套不对 | 重装匹配版本镜像 |
SOC_VERSION not match | 芯片型号不匹配 | 修正芯片版本声明 |
推理类
| 报错关键字 | 含义 | 处置 |
|---|---|---|
context length exceeded | 输入超长 | 提高 max_model_len 或截断 |
tokenizer not found | 模型路径错 | 检查模型参数 |
inference timeout | 单请求超时 | 提高超时或降并发 |
OOM during generation | 生成中显存不足 | 降并发 |
NaN/Inf in output | 数值异常 | 启用更高精度回退路径 |
性能类的粗判也值得固化成表,因为它直接指向动作:
| 现象 | 首选动作 |
|---|---|
| 首字延迟 P99 超阈值 | 降并发,或开分块预填充 |
| 单 token 时间 P99 超阈值 | 查 KV 内存与算子 |
| 加速器利用率 < 30% | 提高并发 |
| HBM 带宽 > 90% | 降并发或量化 |
| 队列深度暴涨 | 提高单批 token 上限 |
十、再往下一层:算子级杠杆
前面都是”参数层”的调优。再往下还有一层:算子与图模式。这一层需要 profiling,产出也更大,属于进阶。
以我在昇腾侧看到的几个杠杆为例(NVIDIA 侧有对应物):
- 算子融合:把 RMSNorm 与量化融合、Attention 输出与量化融合。后者在某些配置下默认关闭,开启后可带来个位数百分比的加速——属于”不改参数也能拿到的收益”,值得单独一轮实验。
- 计算通信融合:把矩阵乘与 AllReduce 这类串行的通信/计算合并成融合算子,用切分与流水线掩盖通信时间。多卡场景下它是消除通信墙的直接手段。
- 图模式:运行时捕获整图并重放,消除逐算子的调度开销 [3]。注意它通常依赖推理引擎的某一代执行器,切换执行器与使用图模式这两件事必须一起考虑——这也是”🔴 禁止”里容易踩的一类:把图模式相关开关与执行器版本改成了互相矛盾的状态。
- 集合通信调优:通信展开模式、通信缓冲区大小、建链超时这几个环境变量,在多机场景里经常是”卡住”与”跑起来”的分界。
这一层的纪律和前一层不同:它必须先有 profiling 数据再动手。参数层可以靠分层粗扫,算子层不行——算子层的假设错了,代价是一轮完整的 profiling 周期。也就是说,越往下一层,前提条件的成本越高:方法论的顺序在算子层只会更省不了。
附:启动后五步验证脚本
下面这份骨架对应第二节那张表(以 vLLM 的 OpenAI 兼容接口为例):
echo "=== 启动后 5 步验证 ==="
curl -sf http://localhost:8080/v1/models >/dev/null \
&& echo "✅ 1/5 健康" || exit 1
MODEL_NAME=$(curl -sf http://localhost:8080/v1/models \
| python3 -c "import sys,json;print(next(m['id'] for m in json.load(sys.stdin)['data']))")
[ -n "$MODEL_NAME" ] && echo "✅ 2/5 模型: $MODEL_NAME" || exit 2
TOKENS=$(curl -sf -X POST http://localhost:8080/v1/completions \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL_NAME\",\"prompt\":\"hi\",\"max_tokens\":4}" \
| python3 -c "import sys,json;print(len(next(c['text'] for c in json.load(sys.stdin)['choices'])))")
[ "$TOKENS" -ge 1 ] && echo "✅ 3/5 推理: $TOKENS tokens" || exit 3
curl -sf http://localhost:8080/metrics | grep -q "vllm:" \
&& echo "✅ 4/5 metrics" || exit 4
NPU_MEM=$(npu-smi info -m 2>/dev/null | grep "Memory Usage" | head -1 \
| awk '{print $4}' | tr -d 'MiB')
[ "${NPU_MEM:-0}" -gt 1000 ] && echo "✅ 5/5 NPU 已加载 ${NPU_MEM}MiB" || exit 5
把它接到重启脚本的成功分支上(第三节那个 restart.sh 的 READY 处),并在失败分支调用回滚——这样”五步验证”就长在流程里,不再是一次性的手工动作。
十一、这套东西的局限与边界
最后,必须说清楚它不是什么:
- 它不提高性能上限。它保证的是”你的实验不会把服务搞坏”,以及”搞坏了能回来”。性能来自选对参数,不来自回滚脚本。
- 脚本里的目录与端口是约定,不是普适真理。我用的是一套固定的目录布局(配置、历史、黄金基线、日志、脚本各一处)。换一套布局,脚本要跟着改;重要的是那几件事被做了,而不是它们被做在那个路径下。
- 五步验证是够用,不是完备。它能盖住”服务活着但推理坏了”这类故障,但盖不住语义质量问题(输出内容不对)。质量需要单独的质量闸。
- 熔断期是一个人为选择的时间。 24 小时是一次”足够让人冷静下来、又不至于长期停摆”的工程折中,不是算出来的。
- 预检脚本应当内联。我最初的一版预检脚本是从一个内部地址拉取的——这个设计有个明显的坏处:在客户内网、在没有外网的现场,它拉不到。正确做法是把脚本内联进手册(我也保留了一个远端版本用于自更新,但兜底必须是内联的)。同一类问题还有
curl | sh式的安装:看起来方便,实际把”能不能装”交给了网络。
回到开头那句话。
性能调优的教程讲的是”怎么让数字变好”。但在现场待过一轮之后,我的判断是:先让”变坏”这件事可控。
因为一次不可回滚的失败,代价不只是这次实验白做——它会让你在下一次实验前犹豫,而犹豫会让整个优化过程变慢、变得保守、变得依赖”这次应该没问题吧”这种判断。
回滚可达,才有资格做实验。
参考文献
[1] vLLM 文档 · OpenAI 兼容服务(/v1/models、/v1/completions、/metrics):https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html(2026-10-11 实测可达)
[2] Site Reliability Engineering(Google SRE Book)· 全书目录:https://sre.google/sre-book/table-of-contents/(2026-10-11 实测可达)——“什么时候该停手""错误预算”这类纪律的公开出处。
[3] vLLM 文档 · 优化配置(分块预填充、图模式等):https://docs.vllm.ai/en/latest/configuration/optimization.html(2026-10-11 实测可达)
关于脚本的核验说明:脚本来自我自己部署的一套服务,它不在公开仓库里。可替代的核验路径是:/v1/models、/v1/completions、/metrics 三个端点与重启语义都能在公开文档里对上 [1];而”回滚后必须重新验证""回滚目标应是最近一次留下的”这两条属于工程判断。真正可迁移的是这套纪律本身,不是脚本原文——读者可以按自己环境做一次最小复现:把一份配置改坏、再跑一次回滚,看它是否真的回到可用状态。





评论
填个昵称就能发,不需要注册或登录