习慈羊xiciyang
联系
订阅
正在读取订阅状态…

让回滚永远可达:把"调崩了怎么办"变成脚本能证明的事

你改了一个参数,重启服务。重启命令返回 0,容器状态显示 Up 20 seconds——看上去一切正常。大多数人到这一步就收工了。但压测一开始就报连接拒绝:服务在容器起来之后,还要花几十秒到几分钟加载权重。

现场调优最贵的失败是”调崩之后回不来”:调慢一点只损失时间,回不去损失的是这个服务本身。先说这个时间差里藏着什么,再给把回滚写进脚本的六件事。

一次变更的执行路径:写参数并存历史 → 重启 → 健康检查 → 真实前向自检 → READY;超时或自检失败则自动回滚

一、改错不危险,不知道它算不算起来了才危险

先说那段时间差本身。

一个推理服务在容器起来之后,还要花几十秒到几分钟加载权重。容器活着和服务可用之间,隔着一整段加载时间——而这段时间里,所有”启动成功”的判据都是假的。

更糟的是另一种情况:服务真的起来了,端口也在监听,但它加载的是错的模型、或者认证配置没生效、或者加速器根本没加载上权重。此时”健康检查通过”,而你的压测数字全部没有意义。

这就是我把”启动”这件事拆成两层的起点:

容器活着与服务可用之间隔着一整段加载时间:中间是误判窗口

  • 进程层:容器在跑、端口在听;
  • 服务层:它能真的完成一次推理,参数是我以为的那份。

第二层才是我们要的。

二、五步验证:把”服务能用”写成可执行的判据

上一节说的问题是”不知道它算不算起来了”,这一节给判据。每次重启后,我跑的是这五步,而不是一句 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

两个细节:

  1. 回滚后必须重新验证。 “我把文件换回去了”和”服务恢复了”是两件事。回滚脚本的返回值必须反映后者。
  2. 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 处),并在失败分支调用回滚——这样”五步验证”就长在流程里,不再是一次性的手工动作。

十一、这套东西的局限与边界

最后,必须说清楚它不是什么:

  1. 它不提高性能上限。它保证的是”你的实验不会把服务搞坏”,以及”搞坏了能回来”。性能来自选对参数,不来自回滚脚本。
  2. 脚本里的目录与端口是约定,不是普适真理。我用的是一套固定的目录布局(配置、历史、黄金基线、日志、脚本各一处)。换一套布局,脚本要跟着改;重要的是那几件事被做了,而不是它们被做在那个路径下。
  3. 五步验证是够用,不是完备。它能盖住”服务活着但推理坏了”这类故障,但盖不住语义质量问题(输出内容不对)。质量需要单独的质量闸。
  4. 熔断期是一个人为选择的时间。 24 小时是一次”足够让人冷静下来、又不至于长期停摆”的工程折中,不是算出来的。
  5. 预检脚本应当内联。我最初的一版预检脚本是从一个内部地址拉取的——这个设计有个明显的坏处:在客户内网、在没有外网的现场,它拉不到。正确做法是把脚本内联进手册(我也保留了一个远端版本用于自更新,但兜底必须是内联的)。同一类问题还有 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];而”回滚后必须重新验证""回滚目标应是最近一次留下的”这两条属于工程判断。真正可迁移的是这套纪律本身,不是脚本原文——读者可以按自己环境做一次最小复现:把一份配置改坏、再跑一次回滚,看它是否真的回到可用状态。

评论

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