说明:本文讨论的是模型推理服务在部署与更新时,如何处理在途请求、摘流量与进程收尾,属于 AI 运维话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。
一、模型服务的更新和普通服务不是一回事
请求时长量级差在哪
普通 Web 服务的发布能"起新的、停旧的"、秒级完成,前提是单个请求的存活时间在毫秒到几百毫秒之间。摘掉一个实例,最多一两个请求还在路上,客户端重试一次就补回来了。
模型推理服务不是这个量级。一次生成请求的存活时间由输出长度和解码速度共同决定:短问答几秒,长上下文长文生成、整文件代码补全、批量摘要这类任务跑到分钟级很常见。这个量级差改变了更新的性质——从"替换一个进程"变成"裁剪一个正在工作的实例"。
算力消耗还集中在请求中后段:前百分之十的时间可能只做完了预填充(prefill),大头在后面几百个解码步里。越晚终止,白烧的算力越多。
一次更新砍掉的请求,代价分三份
强行终止一个已进入解码阶段的请求,代价至少分三份:
- 客户端拿到半截结果。流式链路下用户屏幕上已经有内容,客户端要么把半截标成未完成,要么清空重来。
- 已消耗的算力白烧。从预填充到被终止那一刻的 GPU 时间没有产出,也不能转给别的请求。
- 计费口径上这笔账要解释。按 token 计费时产生了消耗却没有完整结果,按调用计费时这次算成功还是失败,都要有明确规则。
所以更新策略首先要回答的不是"怎么起新版本",而是在途请求(in-flight request)怎么办。
触发更新的五种场景,急与不急
2026 年中,模型服务的更新动作主要来自五类触发。共同点是"旧进程必须消失",区别是能不能等:
| 触发场景 | 典型成因 | 能不能推迟 | 主要约束 |
|---|---|---|---|
| 模型权重换版本 | 上游发新权重、业务切到新版本 | 能,可排到低峰期 | 版本混跑窗口要可控 |
| 推理运行时升级 | 运行时、算子库、驱动更新 | 能,通常按季度批量做 | 与权重的兼容矩阵 |
| 配置与采样参数变更 | 并发上限、超时、默认参数调整 | 能,多数可热加载 | 热加载不生效的项才需重启 |
| 镜像与依赖更新 | 基础镜像、安全补丁、语言依赖 | 部分能,安全类不宜久拖 | 依赖变更可能改变数值行为 |
| 缩容 | 流量下降、实例规格调整 | 能,但排空逻辑与扩容相反 | 排空前不能直接下线 |
只有缩容没有"新版本"要起,它是纯下线,也最容易把在途请求整个忽略掉。配置类如果支持热加载,可以绕开进程重启。
二、在途请求:等它跑完,还是让它失败
两种策略的失败语义
等它跑完(drain-then-stop):停止接收新请求,继续服务已经在跑的请求,直到全部结束或超过等待上限。失败语义是"只有超出上限的请求被终止"。
让它失败(fail-fast):摘流量后立刻终止在途请求,客户端收到一个明确的错误。失败语义是"这一批请求全部失败",但更新窗口最短。
这不是安全偏好,而是一道关于请求时长分布的算术题。99 分位请求时长 8 分钟、等待上限只能给 60 秒时,"等它跑完"只覆盖九成左右请求,剩下的照样失败,还多花 60 秒窗口。等待上限给不到分布的高分位,“等它跑完"就退化成"慢一点的让它失败”。
最大等待时长从哪反推
等待上限从三个输入反推:
- 业务能接受的请求总时长。这个数通常来自调用方超时,不来自服务端意愿。调用方设了 300 秒超时,服务端等到 400 秒没有意义。
- 请求时长的分位分布。取 95 分位还是 99 分位,取决于能接受多少次终止,终止率约等于 1 减去覆盖率。
- 更新窗口的允许长度。单实例等待上限乘以批次串行度,就是整个更新窗口的最短耗时。
示例值:调用方超时 300 秒,95 分位请求时长 42 秒,99 分位 110 秒。取等待上限 120 秒,理论上 99% 的在途请求能自然跑完。这些数字按自己的请求时长分布反推,不能照抄。
⚠️ 代码待验证
# 更新动作的时间参数,几个数互相约束,改一个要重算其余update:caller_timeout_s:300# 调用方超时,等待上限不能超过它wait_budget_s:120# 单实例最大等待时长,按请求时长高分位反推idle_threshold_s:30# 多久没有新数据流出算空闲poll_interval_s:5# 检查在途计数的间隔unregister_settle_s:10# 摘除后等发现机制收敛max_overlap_window_s:600# 允许的版本混跑窗口上限terminate_on_budget:true# 超预算时终止,还是降级继续等wait_budget_s超过caller_timeout_s是这组参数里最容易犯的错:服务端以为自己在优雅等待,客户端早就超时返回了,最后等到的只是一个没人要的结果。
哪些请求可以提前拒绝,哪些必须跑完
按"结果有没有人接"分档:
| 请求类型 | 是否值得等 | 判断依据 |
|---|---|---|
| 流式长生成、用户正在看 | 值得等,或至少发结束事件 | 结果已经在客户端展示 |
| 同步短请求、调用方阻塞等待 | 值得等 | 结果要立刻用于下游 |
| 异步批处理任务 | 不值得等,可入队重投 | 调用方按任务 ID 取结果,可重试 |
| 已超过调用方超时的请求 | 不值得等,立即终止 | 结果已经没人接收 |
| 健康检查与探测类 | 不值得等 | 无业务结果 |
判断入口是在途请求注册表里有没有这个请求的记录,以及它有没有携带可重投的任务标识。异步任务最容易漏——重投逻辑没写时,排空就变成了静默丢失,比直接报错更难查。
三、摘流量、排空、关进程
三步的正确顺序
正确顺序只有一条,换掉后面全错:
- 从可服务池移除。让负载均衡(load balancer)和上游发现机制不再把新请求发到这个实例,已建立的连接不受影响。
- 等在途请求自然收敛。读实例自己维护的在途计数,同时给长连接发结束信号,等待有上限。
- 在途清零(或到上限)后关闭进程。先停监听、再停工作线程、最后退出,给操作系统一次回收机会。
⚠️ 代码待验证
# 摘流量 → 排空 → 关进程,三步顺序不能换# 第 1 步:从负载均衡的可服务池摘除(按你的 LB 调整命令)lbctl backend disable--poolinference--instance"$INSTANCE_ID"# 第 2 步前置:摘除是异步的,上游可能还缓存着旧列表,先给一个短等待sleep"${UNREGISTER_SETTLE_S:-10}"# 第 2 步:等在途请求收敛,到预算上限不硬等python drain_wait.py--instance"$INSTANCE_ID"\--wait-budget"${WAIT_BUDGET_S:-120}"\--idle-threshold"${IDLE_THRESHOLD_S:-30}"\--poll-interval"${POLL_INTERVAL_S:-5}"# 第 3 步:在途清零后才关进程,先停监听再退出kill-TERM"$PID"一次完整的更新动作在这三步上的时间线大致长这样:
⚠️ 代码待验证
T+00s 摘除命令发出,可服务池少一个实例 T+00s 在途请求数不变(摘除只影响新增,不削减存量) T+10s 上游发现机制收敛完成,该实例不再收到新请求 T+11s 存量在途请求继续跑,计数从峰值缓慢下降 T+40s 流式连接到达 idle 阈值,收到 server_draining 结束事件 T+52s 在途计数归零,排空完成(远小于 120s 预算) T+53s 发出 TERM,进程走自己的关闭流程 T+58s 进程退出,实例从拓扑里消失时间线里最值得记住的是第二行:摘除的那一刻,在途请求数一点都不会降。
最常见的错误做法,以及错在哪
错误做法:摘完流量立刻关进程。
它看起来完全合理——“流量都摘走了,还留着进程干什么”。错在两个地方:
- 摘流量和端口关闭是两件事。摘流量只负责"不再新增",已经在跑的请求还在跑,计数不会归零,只是不再增长。
- 摘除动作有传播延迟。服务发现与负载均衡配置的更新不是瞬时的,上游可能还缓存着几秒到几十秒前的实例列表。你摘完就关,这段时间里的请求打在一个正在退出的端口上,得到连接错误——这是更新期间错误率尖峰最常见的来源。
正确的判断依据是在途计数归零,而不是"我已经发了摘除命令"。
只摘一部分流量时的路由一致性
分批更新时只有一部分实例在被排空,其余仍在正常服务。这时容易忽略的是上游的重试会打到哪里。
负载均衡如果用"最少连接数"这类动态算法,被摘掉的实例连接数掉到最低之后,即使摘除没完全生效也可能重新被选中。所以摘除要作用在"可服务池"这个维度上,而不是仅靠把权重调低。带粘性会话(session affinity)的链路还要提前想清楚:会话被固定到正在排空的实例上时,后续请求是切到别的实例还是直接失败——能切的前提是会话状态不在本地。
四、流式与长连接怎么收尾
断开不等于客户端走了
普通 HTTP 请求的生命周期由请求本身界定:连接断开,请求就结束了。流式请求不是——连接会一直挂着,从连接状态上无法分辨"客户端在慢慢读"和"客户端早就走了"。半开连接、客户端切后台但没关 socket、中间设备静默丢包,都会让服务端以为对面还在。
直接后果是:排空时"当前连接数"这个指标不可靠,用它判断能不能关进程会一直等下去,或者等错时机。
空闲判定与最大在途时长双条件
两个条件取先到者:
- 空闲判定(idle detection):连续一段时间没有新数据从服务端流出,就认为这条连接不必继续了。示例值 30 秒,要大于两次数据块之间的正常最大间隔,否则思考时间长但正常的请求会被误判。
- 最大在途时长(max in-flight duration):单条连接从建立起算的总时长上限,示例值 300 秒,兜住"一直慢慢出数据、永远不空闲"的连接。
分工是:空闲判定处理"卡住的",最大在途时长处理"拖着的"。只留前者,一条每 29 秒吐一个 token 的连接能拖到天荒地老;只留后者,几秒结束的正常请求也要被硬等。示例值按自己的出块节奏反推。
⚠️ 代码待验证
defshould_close_connection(conn,now,*,idle_threshold_s,max_inflight_s):"""排空期判断一条流式连接能否收尾:两个条件取先到者。"""idle_for=now-conn.last_chunk_at alive_for=now-conn.opened_at# 条件一:长时间没有新数据流出,认为对面已经不需要了ifidle_for>=idle_threshold_s:returnTrue,"idle",idle_for# 条件二:总时长到上限,兜住"一直慢慢出数据"的连接ifalive_for>=max_inflight_s:returnTrue,"max_inflight",alive_forreturnFalse,None,Nonedefdrain_wait(inflight,stream_conns,*,wait_budget_s,idle_threshold_s,max_inflight_s,poll_interval_s):deadline=time.monotonic()+wait_budget_swhiletime.monotonic()<deadline:now=time.monotonic()# 先给到期的流式连接发结束事件,再判定是否退出forconninlist(stream_conns):close,reason,_=should_close_connection(conn,now,idle_threshold_s=idle_threshold_s,max_inflight_s=max_inflight_s,)ifclose:conn.send_terminal(reason=reason)# 体面收尾,见下节stream_conns.remove(conn)# 短请求走的是在途计数,清零且无流式连接即可退出ifinflight.count()==0andnotstream_conns:return"drained"time.sleep(poll_interval_s)# 到预算上限还没清空:交给上层按配置决定return"budget_exhausted"budget_exhausted分支必须有明确的后续动作:按失败语义终止剩余请求并记录数量,或者把实例重新放回可服务池、跳过本次更新。后者常被忽略,但它往往是对的——预算耗尽说明时长分布估计错了,先恢复服务再修参数,比硬做下去安全。
结束事件怎么发才算体面
“直接关掉 socket"和"发一个结束事件再关"的差别,就是用户看到"内容卡住了"还是"内容生成完了”:
- 正常收尾:这一轮生成确实跑完了,发正常结束事件,客户端按完成处理。
- 更新导致的提前收尾:没跑完但必须断开,发一个带原因的结束事件(比如
server_draining),客户端据此决定是提示重试还是续接。 - 预算耗尽导致的终止:发错误类事件,并带上可重试标记。
关键点是把"正常结束"和"服务端要走了"分开表达。两种都发同一种结束事件时,客户端会把截断的内容当成完整结果展示,这是流式链路里最容易被用户发现的一类问题。
五、版本混跑窗口
混跑期的输出差异与影响面
滚动更新期间新旧两版会同时在线一段时间,这段窗口叫版本混跑(version overlap)。影响面取决于"变的是什么":
| 变更类型 | 混跑期输出是否会不同 | 影响面 |
|---|---|---|
| 模型权重换版本 | 会,同一问题两次调用可能落到两版 | 同一会话内前后回答风格、格式可能不一致 |
| 推理运行时升级 | 可能有细微数值差异 | 采样本身随机,差异难归因 |
| 采样参数与默认值变更 | 会,默认值不同则输出分布不同 | 影响客户端没显式传参的请求 |
| 配置类热加载 | 通常不会 | 影响面最小 |
| 镜像与依赖更新 | 取决于依赖是否影响数值路径 | 需要单独核对 |
一个具体的坑是同一会话的多轮请求落到不同版本:历史由旧版生成、当前回答由新版生成,格式约定可能对不上。所以混跑期要把窗口压短,而不是指望调用方能分辨。
把窗口压短的三种做法
- 控制批次粒度。一次只更新一小部分实例,等这一批稳定后再做下一批。批次越小,同时在线的版本越少。
- 缩短单实例排空时间。排空越慢,单实例占用窗口的时间越长。但缩短排空时间会把压力转移成终止率上升,两个数一起动,没有只赚不赔的一侧。
- 把变更拆开做。混跑期已经引入了版本差异,不要再叠加一次流量调度变更。蓝绿(blue-green)或分批次推进比一把梭安全,原因很简单:出问题时你要能说清是哪一步引入的。
窗口里必须单独看的指标
混跑窗口内全局指标被混在一起,看不出问题。下面几项要按版本打标签单独看:
- 按版本分的错误率与超时率;
- 按版本分的首字时间与端到端时间分位;
- 被终止请求数(只在排空中的实例上非零);
- 输出格式异常率(结构解析失败、结束事件缺失)。
判据不是绝对数值,而是两个版本之间的差值。差值为零说明这次更新行为无损;差值非零且集中在某一类请求上,说明窗口要再压短,或者该准备回退。
完整版资料清单:本文用到的更新参数对照表与演练记录都整理在里面了,扫码即可获取:
六、回退不是重跑一遍更新
回退要提前准备的三样东西
回退(rollback)不是"把上一次的镜像再部署一次"。更新前没准备,回退时会发现旧版本已经拿不回来。三样东西必须在更新动作开始之前就位:
| 准备项 | 具体内容 | 没准备的后果 |
|---|---|---|
| 上一版的可挂载状态 | 旧镜像、旧权重的可拉取地址与校验值 | 回退时旧版本已不可拉取 |
| 配置的回退点 | 变更前的配置快照与生效方式 | 配置改了没留底,回退后行为不一致 |
| 依赖版本的锁定 | 运行时与依赖的版本清单 | 回退后的环境与旧版预期不符 |
最容易漏的是权重的可挂载状态。权重文件体积大,更新时容易被"新版本已经拉到位了,旧的可以从本地缓存里清掉"这个动作顺手删掉。清理要等混跑窗口完全关闭之后再做。
回退后的流量重分布
回退会把流量从新版切回旧版,这本身就是一次流量重分布,带来两个副作用:
- 旧版实例可能已经排空了。更新时如果策略是"新实例起来就下线旧实例",回退时你要重新把旧实例拉起来并接入,等于做一次方向相反的完整更新。
- 重分布会让并发重新聚集。流量集中到少数旧实例上,排队时间变长。旧版并发上限比新版低时,回退后的延迟可能比更新前更差。
所以回退应该复用同一套排空/接入逻辑,而不是单独写一个脚本:两套逻辑一定会不一致,而回退恰恰是最不希望出意外的时候。
什么时候宁可往前修也不要回退
- 变更本身是对的,问题在参数。等待上限设得太小导致终止率偏高时,改参数再走一次更新比回退再重做便宜。
- 旧版本已经不可用。上游依赖、运行时或安全补丁不允许回到旧版本,回退后的实例起不来。
- 问题只出现在少量请求上。影响面可控时,先收窄入口,同时准备修复版本。
判据是回退后的状态是不是确定可用。旧版本也需要修复才能跑时,回退只是把问题推迟一次。
七、演练、留档与观测
低峰期做一次真实的演练
更新动作的演练只能练这一个动作本身,和故障演练不是一回事。做法是在低峰期挑一个实例走一次完整流程:
⚠️ 代码待验证
# 低峰期单实例更新演练:只换一个实例,目标是测出三个数INSTANCE_ID="inference-0"BATCH_SIZE=1./update_one_instance.sh# 演练后必须核对三件事# 1) 这次有没有请求被终止(期望 0,非 0 说明等待上限偏小)# 2) 排空实际耗时是多少(期望明显小于 wait_budget_s)# 3) 摘除命令发出到上游不再发新请求,中间隔了多久grep-E'drained|terminated|unregister'"/var/log/update/${INSTANCE_ID}.log"演练要产出的不是"脚本能跑通",而是三个数:排空实际耗时、被终止请求数、摘除传播延迟。前两个用来校wait_budget_s,第三个用来校摘除后的短等待——它经常被跳过,却往往比前两个更能解释更新期间的错误率尖峰。
留档四件套
一次完整的更新动作,事后要留四样东西,缺一样下次还是拍脑袋:
| 留档项 | 内容 | 用途 |
|---|---|---|
| 版本信息 | 更新前后的版本标识与制品校验值 | 出问题时定位是哪一次变更 |
| 请求影响 | 被终止、被超时、被重投的请求数与占比 | 校等待上限、评估影响面 |
| 指标对照 | 更新前后的错误率、延迟分位、吞吐 | 判断这次更新是否行为无损 |
| 回退记录 | 是否触发回退、触发原因、回退耗时 | 决定下次是否调整策略 |
留档结构建议固定下来并落库,而不是写在工单正文里:
⚠️ 代码待验证
{"update_id":"2026-10-14T02:10Z-inference","from_version":"runtime-a/weights-7","to_version":"runtime-b/weights-8","batch":{"updated":3,"total":12},"drain":{"wait_budget_s":120,"actual_drain_s":52,"unregister_settle_s":10,"stream_conns_closed":4,"close_reason":{"idle":3,"max_inflight":1}},"impact":{"terminated_requests":0,"timed_out_requests":2,"requeued_tasks":11},"overlap_window_s":260,"rollback":{"triggered":false,"reason":null,"cost_s":null}}工单里的记录无法参与统计。下一次更新时你拿不到"历史上等待上限设成多少、对应的终止率是多少"这条曲线,参数就只能继续靠着感觉调。
要看的数
过程类:排空耗时、摘除传播延迟、按版本分的被终止请求数。结果类:更新窗口内按版本分的错误率与延迟分位、超时率、请求重投率。
分开看的原因和别处一样:过程类说明动作做得好不好,结果类说明变更本身有没有问题。混成一个"更新成功率"时,一次变更引入的行为差异会被误读成动作没做好,团队就去调等待参数——但问题其实在新版本上。
观察基准是同版本、非更新时段的同一指标。更新期间指标一定比平时难看,绝对值没有意义,只有相对基线的偏移量能说明问题。
完整版资料清单:本文用到的演练检查表与留档模板都整理在里面了,扫码即可获取:
附表 A:关键取舍一览
本文涉及的所有工程判断集中在这里,方便按需回看。
| 工程取舍 | 原因 | 依据章节 |
|---|---|---|
| 更新策略先回答在途请求怎么办 | 强行终止的代价分三份,不是单纯的失败 | 第一章 |
| 权重类更新排在低峰期 | 只有它能等,混跑窗口影响最直接 | 第一章 |
| 配置类优先走热加载 | 初始化时读取才需重启,避免动进程 | 第一章 |
| 等待上限由调用方超时反推且不超过它 | 超过调用方超时的等待没有接收方 | 第二章 |
| 等待上限取请求时长高分位 | 低分位覆盖不了长请求,等于慢一点的失败 | 第二章 |
| 摘流量、排空、关进程三步不换序 | 摘除只停止新增,不削减存量 | 第三章 |
| 摘除后留一个短等待 | 服务发现传播有延迟,否则出现错误尖峰 | 第三章 |
| 摘除作用在可服务池而非权重 | 动态算法会把权重调低的实例重新选中 | 第三章 |
| 收尾判断用在途计数而非连接数 | 流式连接无法从状态判断客户端是否还在 | 第四章 |
| 空闲判定与最大在途时长双条并取先到 | 单条只能拦一类异常连接 | 第四章 |
| 区分正常结束与更新收尾 | 否则客户端把截断内容当完整结果 | 第四章 |
| 超预算时允许放弃本次更新 | 预算耗尽说明分布估计错了 | 第四章 |
| 混跑窗口按批次压短 | 同一会话可能跨版本,格式约定对不上 | 第五章 |
| 混跑期不叠加流量调度变更 | 两类变更叠加后无法归因 | 第五章 |
| 回退前先备好旧版可挂载状态 | 制品缓存清理会把旧版本删掉 | 第六章 |
| 回退复用同一套排空逻辑 | 两套逻辑必然不一致,回退最怕意外 | 第六章 |
| 留档落库而非写工单 | 工单记录无法参与统计与参数回归 | 第七章 |
| 过程类与结果类指标分开看 | 混算会把版本问题误判成动作问题 | 第七章 |
附表 B:术语速查表
| 术语 | 含义 |
|---|---|
| 在途请求 | in-flight request,已接收但尚未完成的请求 |
| 摘流量 | 把实例从可服务池移除,只停止新请求,不削减存量 |
| 排空 | drain,让在途请求自然结束的过程 |
| 优雅停机 | graceful shutdown,先收尾再退出的关停方式 |
| 等待上限 | wait budget,单实例排空允许的最长时长 |
| 摘除传播延迟 | 摘除命令发出到上游不再发新请求之间的间隔 |
| 空闲判定 | idle detection,连续无新数据流出即判定连接可收尾 |
| 最大在途时长 | 单条连接从建立起算的总时长上限 |
| 终止事件 | 流式链路里显式表示本轮生成结束的事件 |
| server_draining | 服务端因更新主动收尾时给出的结束原因 |
| 版本混跑 | version overlap,新旧两版同时承接流量的窗口 |
| 蓝绿 | blue-green,两套环境整体切换的发布方式 |
| 回退 | rollback,把流量切回上一版本的动作 |
| 过程类 / 结果类指标 | 分别描述更新动作执行质量、更新后服务表现 |
写在最后:这篇用到的资料
写这篇文章时,我把几个模型服务的部署文档、参数表和实测记录都对了一遍,顺手整理成几份配套的东西:
- 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
- 大模型全套教程:按主题分好的视频与文档清单
- 大模型实战好书:24 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。