季度演练抽到的题目是「AI 服务完全不可用」。
准备起来没什么可准备的,就是把 AI_CHAT_ENABLED 改成 False,重启一下,观察十五分钟。这个开关本来就是为这种时候留的:
if not settings.AI_CHAT_ENABLED or not settings.AI_CHAT_API_KEY:
return None
_maybe_ai_reply 里还有一层自己的判断,加起来是"总开关 + 客服是否离线 + 每日限额"三道。按设计,关掉之后所有提问安静地变成普通留言,用户无感,客服照常处理。
演练结果确实如此。留言数曲线平得像什么都没发生,没有 500,没有超时堆积,漏斗一个环节都没掉。
然后我发现监控大屏上也是平的,一条告警都没有。
静默降级是把双刃剑
这个"安静"是特意设计的。前面 85 篇那次上游挂了四十分钟,靠的就是 except Exception 把超时吞掉、返回 None,用户那条消息该存还是存。从可用性看,这是最理想的失败方式。
但从可观测性看,它等于把故障藏起来了。异常只写进 logger.exception,没有计数、没有指标、没有阈值。AI 从"每天答几十条"变成"一条不答",在监控上跟"今天没人问"长得一模一样。
真出事的时候,我们最早发现的途径仍然是有人在群里问一句"AI 怎么不回话了"——上次就是这样。
演练里还暴露了另一处:客服工作台没有任何提示说"AI 已停用"。客服上线照常看列表,看到的是一堆普通留言,不知道今天本该由机器人先顶一轮。有同事说"那我不是平白多干了一半活",语气半开玩笑,但确实是实话。
第一样是把降级计数做成指标,而不是只写日志:
DEGRADE_REASONS = ('disabled', 'api_error', 'limit', 'circuit_open', 'staff_online')
def note_degrade(reason):
key = f'ai:degrade:{reason}:{timezone.now():%Y%m%d%H}'
try:
cache.incr(key)
except ValueError:
cache.set(key, 1, 3600 * 26)
每个降级分支都带上原因,按小时聚合。告警规则是"连续两个小时 api_error 超过 N 次",staff_online 和 disabled 单独算,不混在一起——前者是正常业务,后者是人为操作,混进告警里只会让人学会忽略告警。
第二样是工作台横幅。开关关掉或者熔断打开时,客服列表顶部挂一条浅色提示:「自动回复当前不可用,所有提问将由人工处理」。不弹窗、不打断,就是让人知道。
这两样加起来不到一百行,都属于"早就该有"的那类。演练的价值大概就在这儿:平时没人会去问"降级的时候监控看得见吗",因为一切看起来都正常。
补完之后我心里还是不踏实,因为这次演练有个天然的前提:所有人都知道今天要关开关。
真故障不会提前通知。上游半夜挂了,值班的人未必盯着那个新加的指标,客服早上上线看见横幅,也未必知道该做什么——横幅只说了"不可用",没说"要不要改话术""要不要优先处理积压"。
我们讨论过写一份两页的处置卡片,贴在运维手册里,内容包括谁决定切备用、谁通知客服、什么时候算恢复。写到一半停了,因为发现里面大半内容是在重复 85 篇那次事后记录,而那份记录本身我写了三段就搁下了。
还有个更实际的:演练是手动改环境变量再重启,真出事时未必有人愿意在凌晨做这个动作。熔断(85 篇那个连续 5 次失败)能自动挡一部分,但它挡的是"模型一直超时",挡不住"配置被改错"或者"key 过期"这类。后者现在还是靠人。
VidDown 的下载通道也做过类似的演练,那次比这个狼狈。
演练报告我写了两页,最后一页只有一张图:关开关前后十五分钟的留言数曲线,两条线几乎叠在一起。配了一句"用户侧无感知"。现在回头看,那既是成绩,也是当时没告警的原因。