ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模型服务更新时:在途请求、摘流量与优雅停机

模型服务更新时:在途请求、摘流量与优雅停机

说明:本文讨论的是模型推理服务在部署与更新时,如何处理在途请求、摘流量与进程收尾,属于 AI 运维话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、模型服务的更新和普通服务不是一回事

请求时长量级差在哪

普通 Web 服务的发布能"起新的、停旧的"、秒级完成,前提是单个请求的存活时间在毫秒到几百毫秒之间。摘掉一个实例,最多一两个请求还在路上,客户端重试一次就补回来了。

模型推理服务不是这个量级。一次生成请求的存活时间由输出长度和解码速度共同决定:短问答几秒,长上下文长文生成、整文件代码补全、批量摘要这类任务跑到分钟级很常见。这个量级差改变了更新的性质——从"替换一个进程"变成"裁剪一个正在工作的实例"。

算力消耗还集中在请求中后段:前百分之十的时间可能只做完了预填充(prefill),大头在后面几百个解码步里。越晚终止,白烧的算力越多。

一次更新砍掉的请求,代价分三份

强行终止一个已进入解码阶段的请求,代价至少分三份:

  • 客户端拿到半截结果。流式链路下用户屏幕上已经有内容,客户端要么把半截标成未完成,要么清空重来。
  • 已消耗的算力白烧。从预填充到被终止那一刻的 GPU 时间没有产出,也不能转给别的请求。
  • 计费口径上这笔账要解释。按 token 计费时产生了消耗却没有完整结果,按调用计费时这次算成功还是失败,都要有明确规则。

所以更新策略首先要回答的不是"怎么起新版本",而是在途请求(in-flight request)怎么办。

触发更新的五种场景,急与不急

2026 年中,模型服务的更新动作主要来自五类触发。共同点是"旧进程必须消失",区别是能不能等:

触发场景典型成因能不能推迟主要约束
模型权重换版本上游发新权重、业务切到新版本能,可排到低峰期版本混跑窗口要可控
推理运行时升级运行时、算子库、驱动更新能,通常按季度批量做与权重的兼容矩阵
配置与采样参数变更并发上限、超时、默认参数调整能,多数可热加载热加载不生效的项才需重启
镜像与依赖更新基础镜像、安全补丁、语言依赖部分能,安全类不宜久拖依赖变更可能改变数值行为
缩容流量下降、实例规格调整能,但排空逻辑与扩容相反排空前不能直接下线

只有缩容没有"新版本"要起,它是纯下线,也最容易把在途请求整个忽略掉。配置类如果支持热加载,可以绕开进程重启。

二、在途请求:等它跑完,还是让它失败

两种策略的失败语义

等它跑完(drain-then-stop):停止接收新请求,继续服务已经在跑的请求,直到全部结束或超过等待上限。失败语义是"只有超出上限的请求被终止"。

让它失败(fail-fast):摘流量后立刻终止在途请求,客户端收到一个明确的错误。失败语义是"这一批请求全部失败",但更新窗口最短。

这不是安全偏好,而是一道关于请求时长分布的算术题。99 分位请求时长 8 分钟、等待上限只能给 60 秒时,"等它跑完"只覆盖九成左右请求,剩下的照样失败,还多花 60 秒窗口。等待上限给不到分布的高分位,“等它跑完"就退化成"慢一点的让它失败”。

最大等待时长从哪反推

等待上限从三个输入反推:

  1. 业务能接受的请求总时长。这个数通常来自调用方超时,不来自服务端意愿。调用方设了 300 秒超时,服务端等到 400 秒没有意义。
  2. 请求时长的分位分布。取 95 分位还是 99 分位,取决于能接受多少次终止,终止率约等于 1 减去覆盖率。
  3. 更新窗口的允许长度。单实例等待上限乘以批次串行度,就是整个更新窗口的最短耗时。

示例值:调用方超时 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 取结果,可重试
已超过调用方超时的请求不值得等,立即终止结果已经没人接收
健康检查与探测类不值得等无业务结果

判断入口是在途请求注册表里有没有这个请求的记录,以及它有没有携带可重投的任务标识。异步任务最容易漏——重投逻辑没写时,排空就变成了静默丢失,比直接报错更难查。

三、摘流量、排空、关进程

三步的正确顺序

正确顺序只有一条,换掉后面全错:

  1. 从可服务池移除。让负载均衡(load balancer)和上游发现机制不再把新请求发到这个实例,已建立的连接不受影响。
  2. 等在途请求自然收敛。读实例自己维护的在途计数,同时给长连接发结束信号,等待有上限。
  3. 在途清零(或到上限)后关闭进程。先停监听、再停工作线程、最后退出,给操作系统一次回收机会。

⚠️ 代码待验证

# 摘流量 → 排空 → 关进程,三步顺序不能换# 第 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)。影响面取决于"变的是什么":

变更类型混跑期输出是否会不同影响面
模型权重换版本会,同一问题两次调用可能落到两版同一会话内前后回答风格、格式可能不一致
推理运行时升级可能有细微数值差异采样本身随机,差异难归因
采样参数与默认值变更会,默认值不同则输出分布不同影响客户端没显式传参的请求
配置类热加载通常不会影响面最小
镜像与依赖更新取决于依赖是否影响数值路径需要单独核对

一个具体的坑是同一会话的多轮请求落到不同版本:历史由旧版生成、当前回答由新版生成,格式约定可能对不上。所以混跑期要把窗口压短,而不是指望调用方能分辨。

把窗口压短的三种做法

  1. 控制批次粒度。一次只更新一小部分实例,等这一批稳定后再做下一批。批次越小,同时在线的版本越少。
  2. 缩短单实例排空时间。排空越慢,单实例占用窗口的时间越长。但缩短排空时间会把压力转移成终止率上升,两个数一起动,没有只赚不赔的一侧。
  3. 把变更拆开做。混跑期已经引入了版本差异,不要再叠加一次流量调度变更。蓝绿(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 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进