大模型业务的稳定性,一直都是玄学。我接手那个内部代号为“23.8”的稳定性专项之前,团队已经连着被线上事故折腾了一个多月:模型偶尔返回空值、偶尔输出格式错乱、偶尔在关键金额字段上给你编一个数字出来,传统监控里什么异常都查不到,因为服务本身是200 OK。后来我们逐渐意识到,大模型业务的不确定性异常,不能用传统接口的故障模型去套,得专门为它设计一套“防错机制”。这中间最核心的成果,就是一套由路由重试、校验降级、复核机制组合而成的稳定性方案。这篇文章我就把这套方案从头到尾拆开讲一遍,不聊虚的,只聊我们实际怎么设计、怎么配参数、怎么踩坑又怎么填坑的,希望给正在被大模型输出折磨的团队一个可以直接落地的参考。
1. 大模型业务的不确定性,到底从哪里来
1.1 它和传统接口故障不是一回事
以前我们做的常规业务系统,稳定性问题基本可以归类为:服务不可用、接口超时、网络抖动、数据库连接池耗尽。这些问题有一个共同特点——只要把服务拉起来、把请求重试几次、把容量扩上去,故障基本就解决了。因为传统接口的输出是确定的,只要你给我的输入合法,我返回的结果就是稳定的,错误最多是“这次没返回结果”。
大模型业务完全不一样。它的输出是概率采样出来的,同一个Prompt,你调用十次,可能得到十种不同的描述。这时候你会遇到一个特别恶心的情况:服务一切正常、延迟正常、200 OK,但返回给你的JSON里,字段名是错的、类型是错的、或者干脆给你一个看似合理但业务上完全不合法的值。
我们内部把这种问题叫做“不确定性地雷”。它最大的麻烦在于,传统监控体系根本发现不了它,因为从网络层、应用层看过去,一切正常。只有到业务结果校验的时候,你才发现问题。所以做稳定性,第一步就是要接受一个现实:大模型服务是一个带有随机性的外部依赖,你必须在它外面再包一层“防护网”。
1.2 不确定性异常的真实分布
在专项推进过程中,我们花了两周时间把历史事故做了一次归类分析,梳理出四类典型异常。我把它们整理成了下面这个表,这也直接决定了我后面整个稳定性方案的设计方向。
| 异常层级 | 典型表现 | 高发原因 | 传统方案是否有效 |
|---|---|---|---|
| 请求层异常 | 连接超时、5xx、限流拒绝、流式响应中断 | 模型服务商负载波动、单点路由集中 | 有效,重试+负载均衡就能解决 |
| 结构层异常 | JSON解析失败、必填字段缺失、类型不匹配 | 指令遵循不稳定、输出被截断 | 部分有效,重试有用但代价高 |
| 语义层异常 | 格式正确但内容答非所问、关键实体识别错误 | 模型幻觉、上下文误导、Prompt歧义 | 无效,重试多少次都是错的 |
| 业务规则层异常 | 金额字段为负、用户ID不存在、违禁内容未拦截 | 模型不懂业务约束、指令被降权 | 无效,必须外部强校验 |
第一类异常,我们交给了路由重试机制来处理;第二类异常,交给校验降级机制;第三类和第四类,则是复核机制的主战场。这三条防线分别对应标题里提到的三个机制,也分别回答三个问题:请求能不能正确发出去、发出去之后的结果合不合规、结果合规但语义上靠不靠谱。
1.3 三条防线之间的协作逻辑
这里我要强调一个容易被忽略的点:这三条防线不是独立运行的,而是按“成本从低到高、拦截从粗到细”的顺序串起来的。
路由重试的成本最低,只做网络层的容错,不关心模型返回内容的对错;校验降级的成本中等,它拿到重试之后的输出结果,做结构性和确定性规则的校验,发现问题就触发降级策略;复核机制的成本最高,它处理的是那些“在校验层看来完全合法、但真实业务场景里不靠谱”的输出,通过二次甚至三次检查来兜底。
我把它们理解成一个漏斗:路由重试挡住“发不出去”的异常,校验降级挡住“长得不对”的异常,复核机制挡住“看着对但其实是错的”异常。这样分层之后的好处很明显——每一层只需要关心自己该做的事,逻辑不会纠缠在一起,出问题也好排查。
2. 路由重试:先让请求抵达正确的出口
2.1 把大模型调用当成“外部不可靠依赖”来设计
我见过太多团队,代码里直接用SDK调模型接口,什么保护机制都不加,结果线上稍微出点问题就手忙脚乱。他们的理由是“模型服务商承诺SLA很高,不用做容错”。这话我一开始也信了,直到某次晚上流量高峰期,单一出口超时率飙到15%,业务直接不可用,我才意识到一个道理:任何外部依赖,只要不在你的进程里,就不是100%可靠的。
处理外部不可靠依赖的标准手法,就是路由和重试。路由解决“往哪发”的问题——你有多个模型服务商或者同一服务商的多个入口时,怎么把流量分配得合理;重试解决“发失败了怎么办”的问题——第一次调用超时或报错,要不要换个出口再试一次。
2.2 多出口路由与动态健康检查
我们的路由层最开始做得很简单,就是配置了一个主出口和一个备出口,主出口挂了切备出口。后来发现这种静态主备方案有两个问题:一是主出口恢复后流量不会自动切回来,二是某个出口虽然没挂,但延迟已经劣化了,你还傻傻地把流量往里面发。
最后我们改成了加权轮询加动态健康检查。每个出口都有一个动态权重,权重由三个指标决定:最近5分钟的请求成功率、平均响应延迟、限流拒绝率。这三个指标会定期刷新,成功率低的出口权重自动下调,延迟高的出口减少分配流量。这样即使某个出口没有完全挂掉,只是“半死不活”,路由层也能感知到并把流量导走。
这里给大家一个具体计算权重的参考公式,我们实测下来效果不错:
weight = base_weight * success_rate * latency_factor * rate_limit_factor # success_rate:最近5分钟成功率,低于0.9时开始衰减 # latency_factor:如果P95延迟超过阈值,则按比例下调 # rate_limit_factor:出现限流时,直接设为0.1,进入“冷却”状态2.3 超时分级与重试参数的坑
重试机制是稳定性方案里最容易被搞砸的一环。我见过有人把所有超时统一设成10秒,重试三次,退避策略随便写了一下,结果一次上游波动直接触发了重试风暴。
我们的做法是,先把一次大模型调用拆成三个可独立测量的阶段:连接阶段、首Token阶段、完整响应阶段。每个阶段设置独立的超时时间。比如连接阶段一般3秒就够了,如果3秒还没建立连接,大概率是网络问题,直接切出口重试;首Token阶段可能设10秒,模型服务商在处理你的请求但还没开始输出,这时候如果超时,换个出口大概率能解决;完整响应阶段根据业务要求来,我们一般是30到60秒。
重试次数和退避策略也有讲究。你不能无脑重试三次,而是要看错误类型决定是否重试:
- 网络错误、连接断开、5xx错误:可以重试,这些是临时性问题。
- 4xx错误:说明请求本身有问题,重试多少次都是白费,直接放弃。
- 模型返回“内容审核不通过”之类的业务错误:更不能重试,应该走降级策略。
退避策略建议用指数退避加抖动,避免所有重试请求在同一时刻打爆服务商。
import random import time def retry_delay(attempt): # 基础退避:1s, 2s, 4s,加上随机抖动 base_delay = 2 ** attempt jitter = random.uniform(0, 0.5 * base_delay) return base_delay + jitter2.4 流式请求与幂等性:重试时最容易翻车的两个细节
如果你们的业务只是简单的“发请求、等完整结果”,前面说的基本够用。但如果你做的是流式响应(比如打字机效果),那重试就复杂了。
流式响应的最大问题是:请求已经发出去一半,客户端已经收到一部分Token了,这时候连接断了,你重试一次,客户端就会收到两段内容拼接在一起的诡异结果。
我们的方案是在流式请求里加入request_id和offset机制。具体来说,重试时不是从零开始重新请求,而是带上“已接收到的最后位置”,让模型服务商从指定位置继续输出。这个功能不是所有服务商都支持,不支持的情况下,就只能走“前端整体遮罩,重试成功后替换全部内容”的策略,宁可让用户看到一次卡顿,也不能让他看到内容被拼错。
另外还有一个极其重要、但经常被忽略的问题——重试的幂等性。大模型调用通常是按次计费的,你重试三次,如果每次都触发计费,账就算不清了。而且如果是生成图片、生成视频这类消耗更大的任务,重试带来的成本翻倍是不能接受的。
解决思路是给每个业务请求生成一个全局唯一的trace_id,带着这个ID去调用模型服务商,服务商侧做去重,同一个trace_id的重复请求只计费一次。即使服务商不支持,你也要在自己的日志系统里把这个ID记下来,方便对账和排查。
3. 校验降级:在错误结果进入业务之前拦住它
3.1 校验层的职责边界:只做机器能确定的事
路由重试解决了“请求发不出去”的问题,但发出去之后模型返回了内容,这些内容合不合格,就轮到校验层登场了。很多团队在这里犯的一个错误是,把校验层设计得过于理想化——希望它能像人类一样理解内容、判断语义,结果实现出来的东西要么过度复杂难以维护,要么误杀率特别高。
我们的经验是:校验层只做机器能百分之百确定的事。机器能百分之百确定的是什么呢?格式层面的东西。JSON能不能解析、必需字段在不在、字段类型对不对、枚举值是否合法、字符串长度有没有超限、数值是否在合理范围。这些规则是确定的、无歧义的,校验结果不会出现“公说公有理”的情况。
至于语义层面的判断,比如“这段文案是否真的回答了用户的问题”“这个摘要是否准确地概括了原文”,那不是校验层该干的活,那是复核机制的职责。把这两个边界划清楚,校验层的代码会非常简洁、可测试、可维护。
3.2 校验器设计和降级链路的完整流程
下面这个校验器设计,是我们方案里最核心的一段代码,我简化之后分享给大家看。它的核心思想是:把校验规则定义成数据,而不是写死在代码里。这样业务变了,改配置就行,不用改代码发版。
class ResponseValidator: def __init__(self, schema): self.schema = schema # 从JSON Schema配置加载 def validate(self, response_text: str): # 第一步:解析JSON try: data = json.loads(response_text) except json.JSONDecodeError as e: return ValidationResult(success=False, error="JSON_PARSE_FAILED") # 第二步:根据schema逐字段校验 for field, rules in self.schema.items(): if rules.get("required") and field not in data: return ValidationResult(success=False, error="MISSING_FIELD", field=field) value = data.get(field) if value is None: continue if "type" in rules and not isinstance(value, rules["type"]): return ValidationResult(success=False, error="TYPE_MISMATCH", field=field) if "enum" in rules and value not in rules["enum"]: return ValidationResult(success=False, error="INVALID_ENUM", field=field) if "min_value" in rules and value < rules["min_value"]: return ValidationResult(success=False, error="OUT_OF_RANGE", field=field) if "max_length" in rules and len(str(value)) > rules["max_length"]: return ValidationResult(success=False, error="TOO_LONG", field=field) # 第三步:自定义扩展规则(比如正则、黑名单词表) for rule_name, checker in self.custom_rules.items(): if not checker(data): return ValidationResult(success=False, error=rule_name) return ValidationResult(success=True, data=data)校验一旦不通过,就进入了降级链路。降级不是直接把错误抛给用户,而是按优先级走下面这条链路:
- 直接重试(限定次数):有些校验失败是模型随机性造成的,比如偶尔一次字段输出不完整,重试一次可能就好了。但注意重试次数要控制在1到2次,多了成本受不了。
- 提示词自动修复:重试仍然失败时,把上一次的错误信息塞回Prompt里,让模型“知道哪里错了,改一版出来”。实测这个方法对结构性问题非常有效,修复成功率能到70%左右。
- 静态兜底:如果修复还不行,就返回一个提前定义好的兜底结果。比如某类新闻摘要失败时,直接返回“服务升级中,请稍后查看”之类的静态文案,至少保证用户看到一个正常页面,而不是报错。
- 标记复核:把失败的请求标记为“降级处理”,写入复核队列,后续由复核机制决定是否需要人工修正或者重新生成。
3.3 硬失败与软失败:降级策略的分岔路口
这里我要重点讲一下“硬失败”和“软失败”的区分,这是校验降级方案里最关键的设计决策。硬失败,指的是校验规则明确判定请求结果不可用,必须走降级链路,绝不能把原始结果返回给用户。比如金额字段是负数、用户ID不存在、输出包含违禁词,这些必须拦下。软失败,则是结果虽然有一些瑕疵,但不影响用户正常使用,可以返回给用户但打上标记。比如某个字段缺失但业务上并不关键、文本长度截断但核心信息完整。
软失败的设计非常有用,它能显著提高业务的整体可用率。你想一下,如果一个请求只是少了非必需的点赞数字段,你就把整个结果都作废走降级,那可用率会很惨。正确的做法是:记录一条“SOFT_FAIL”日志,返回结果给用户,同时在后台生成一条待复核记录。
为了让大家对这个策略的取舍有更直观的理解,我列了一张决策矩阵:
| 场景 | 校验结果 | 处理策略 | 理由 |
|---|---|---|---|
| 核心金额为负数 | 硬失败 | 重新生成->静态兜底 | 绝对不能用错误金额 |
| 非必需字段缺失 | 软失败 | 正常返回+后台记录 | 用户感知不到,不影响核心体验 |
| JSON完全解析不了 | 硬失败 | 重新生成->静态兜底 | 前端没法渲染 |
| 文本长度超限且被截断 | 软失败 | 正常返回+后台记录 | 截断后的内容可能仍然可用 |
| 输出疑似包含无关内容(答非所问) | 软失败 | 返回但标记需复核 | 语义判断不交给校验层断言 |
4. 复核机制:给关键业务装一道“人性化保险丝”
4.1 实时拦截与异步复核的分工
校验降级能拦住格式错误和确定性规则问题,但它拦不住一类情况:模型返回的内容格式完全正确、字段也都合法,但内容本身是错的。最简单的例子:用户问“今天上海天气怎么样”,模型返回了一段结构完美、条理清晰的“北京天气介绍”。从校验层看,这段内容格式没问题、字段齐全,完全合法。但它就是错的。
这类问题,就是复核机制的地盘。我们先明确一个原则:复核不等于二次调用大模型来验证第一次调用。如果你复核的时候又调一次模型,那等于让同一个不稳定的裁判去复核自己不稳定的判决,没有意义。
我们把复核分成两层。第一层是实时拦截,它需要极低的延迟,所以只能跑一些确定性的跨字段一致性检查。比如“商品名称里提到了A品牌,但品牌字段写的是B品牌”“订单金额超过1万元但用户等级是普通用户,不符合优惠规则”,这类规则不依赖模型,是程序逻辑扫出来的,发现问题直接拦截。
第二层是异步复核,它不阻塞主请求,而是把结果投递到一个消息队列,由专门的后台任务去分析。这一层可以用模型,也可以用规则引擎,甚至可以用人工。
4.2 三级复核:规则复核、模型自评、人工兜底
我们最终把复核机制做成了三级结构,每一级的成本和能力都不同,适合不同风险的场景。
第一级:规则复核。这是速度最快、成本最低的一级。我们维护了一套跨字段一致性规则库,比如一个订单的收货城市和收货人所在省份必须匹配、生成摘要时输入文本里必须包含摘要中出现的实体、金额计算必须满足“单价乘数量等于总价”之类的恒等式。这些规则用代码实现,处理单个请求只需要几毫秒。
第二级:模型自评。这个自评不是让同一个模型再生成一遍,而是用一个独立的验证器模型或者同一模型的不同配置来做质量评估。具体做法是给验证器一个打分模板,让它从相关性、完整性、安全性几个维度给主模型的结果打分。只有打分低于阈值的才进入人工兜底。
这里我要推荐一个我们摸索出来的设计——问“反问题”来验证一致性。比如主模型生成了一段新闻摘要,验证器不直接问“这段摘要质量如何”,而是问“这段摘要中提到的事实,在原文中有依据吗”。让验证器逐条把摘要里的关键实体和原文做比对,这样更容易暴露幻觉。
第三级:人工兜底。对于金额、法律条款、医疗建议这类高风险内容,机器复核再靠谱,我们也保留人工复核的能力。当然人工复核无法覆盖所有流量,所以我们做了一个带优先级的抽样机制:风险等级高的业务100%进人工队列,风险等级低的业务按1%到5%的抽样率进入人工队列。人工复核的结果会反哺给规则库和模型,形成迭代。
4.3 复核队列的闭环:指标监控与策略迭代
复核机制不是搭完就完事了,它必须形成闭环。我们为复核系统设计了四个核心指标,每周复盘一次。
第一个指标是复核拦截率:进复核队列的内容里,最终被判定为异常并打回的比例。如果这个比例长期偏高,说明上游校验和降级策略可能有问题,或者主模型服务商的质量下降了。第二个指标是降级率:整体请求中走了降级链路的比例,正常情况下应该控制在5%以内。第三个指标是误伤率:被复核机制判定为异常、但人工确认其实是正确结果的比例。误伤率如果超过2%,就要考虑是不是规则或者验证器太严格了。第四个指标是漏检率:这个是抽样评估出来的,每批人工兜底结果里再抽一部分,由更资深的同事确认,看复核机制有没有漏掉真正的问题。
这四个指标定期复盘的意义在于,把大模型业务的稳定性从“碰运气”变成“可度量、可迭代”。每次模型服务商升级、每次业务上线新场景,我们都会先跑一轮回归样本集,用这四个指标校准策略,再全量放量。
5. 踩坑实录:这三个跟头我摔得最疼
5.1 重试风暴引发的连锁故障
这套方案上线之后的第一次大事故,不是模型服务商挂了,而是我们自己的重试策略引起的。当时我们把重试次数设得比较激进,某个出口出现延迟劣化时,第一批请求超时,触发重试,重试又因为路由权重没有及时更新,继续打到同一个劣化出口,再次超时,再次重试。瞬间重试流量放大到普通流量的三倍多,把一个还没完全挂掉的出口彻底压垮了。
这次事故之后,我们给重试机制加了一个核心约束——重试预算。以一分钟为窗口,所有业务的重试次数总和不能超过一个预设值。预算用完了,后面新进来的请求即使失败也不重试,直接走降级。这样从系统层面兜住了重试风暴的底,后续再没出现过类似问题。
5.2 校验过度导致可用率暴跌
还有一个坑是我印象最深的。当时为了追求“绝对可靠”,我们给校验层加了很多“理论上应该成立”的规则,结果上线当天可用率从99.2%直接跌到93%。排查了一整天才发现,有一条规则要求“生成的文案必须包含用户输入的关键词”,但有几个真实场景里,模型用同义词替换了关键词,语义完全正确,却被校验规则打回了。
从那以后我们立了一个规矩:新增校验规则必须先灰度,先在日志模式下观察误杀率,再决定是否开启强制拦截。每条规则上线前都要过一遍“误杀评估”,宁可不拦,不能误杀,因为误杀的代价是损失正常用户,比漏放问题内容还难承受。
5.3 模型升级后的判定漂移
最后一个坑属于“外部因素变化”造成的。我们用的模型服务商某次更新了底层版本,结果之前一直很稳定的校验和复核规则开始大量拦截正常内容。一开始我们还以为是自己的代码出问题了,查了很久才定位到是模型行为变了。
后来我们建立了一个回归样本集——把历史上出现过问题的请求、正常请求、边界请求都保存下来,每次模型服务商升级、或者我们改了Promp或校验规则,就先在回归样本集上全量跑一遍,对比新旧结果。这个习惯帮我们避免了很多次“因为模型升级导致线上策略失效”的尴尬。
写在最后的经验之谈
做完这个专项,我个人最大的体会是:大模型业务的稳定性,核心不是消灭不确定性,而是把不确定性控制在业务可接受的范围内。路由重试解决“发不出去”的问题,校验降级解决“格式不对”的问题,复核机制解决“内容不对”的问题,每一层都只管自己能管的那一段,不越界、不背锅。
如果让我给刚开始做这件事的团队一个建议,我会说:不要贪多求全,先做校验降级这一层。它实现成本最低、见效最快,能把最伤用户体验的格式错乱和字段异常挡住。等线上数据积累够了,再逐步补上路由优化和复核闭环。稳定性建设永远是个持续迭代的过程,你调好的每一个参数,都只在“当前环境”下成立,没有一劳永逸的方案,只有不断跟不确定性博弈的手感。