Agent 上线第一天就炸了:6 个生产事故的根因分析与修复记录

摘要 :本文复盘六个真实形态的 Agent 生产事故------上线 3 小时 P99 从 2s 涨到 40s (连接池与会话锁叠加)、一个用户把整个服务拖垮 (无租户配额隔离)、成本一夜超预算 5 倍 (重试放大 + 无预算上限)、输出偶尔多出一段乱码或英文 (模型版本静默切换)、工具调用写脏数据 (缺幂等)、日志里出现明文密钥 (中间件顺序错)。每个事故按 现象(含时间线与真实报错片段)→ 排查过程(用什么命令、怎么定位)→ 根因 → 修复代码 → 复盘要点 五段推进。核心主张是:这六个坑在前序篇目里都讲过,但它们在生产环境失效的形式和讲义里不一样------讲义讲的是机制,事故暴露的是"机制没接上的那条缝"。文末给出六类事故的共性归纳与一张「事故类型 → 检测手段 → 前置防护」总表。

📌 版本声明 :本文基于 Python 3.11+、asyncio、httpx 0.28、PostgreSQL 15、Prometheus + Grafana 编写,撰写时间 2026 年 10 月。文中引用的 httpx 默认连接池参数(max_connections=100、max_keepalive_connections=20、keepalive_expiry=5.0)以 HTTPX 官方文档为准。故障分级参照 PagerDuty 公开的 SEV 定义(SEV-1 为影响大量客户的严重事故,需启动事故指挥流程;SEV-2 为核心功能受损的 major incident;SEV-3 为需要服务团队立即处理的稳定性问题)。所有日志、IP、密钥、租户名均为脱敏后的示例值。

适用边界 :适用于已上线或即将上线、接真实流量与真实预算的 Agent 服务 。六个事故的形态在多租户、长耗时、依赖外部模型的系统里高度可复现;如果你是单用户内部工具,事故②③⑤大概率不会发生 。另外,本文的重点是排查链路而不是最终代码------同样的根因在不同技术栈上的修复方式不同,但排查顺序是可迁移的。

文章目录

    • 一、六个事故总览
    • [二、事故①:上线 3 小时,P99 从 2s 涨到 40s](#二、事故①:上线 3 小时,P99 从 2s 涨到 40s)
      • [2.1 现象](#2.1 现象)
      • [2.2 排查过程](#2.2 排查过程)
      • [2.3 根因](#2.3 根因)
      • [2.4 修复](#2.4 修复)
      • [2.5 复盘要点](#2.5 复盘要点)
    • 三、事故②:一个用户把整个服务拖垮
      • [3.1 现象](#3.1 现象)
      • [3.2 排查过程](#3.2 排查过程)
      • [3.3 根因](#3.3 根因)
      • [3.4 修复](#3.4 修复)
      • [3.5 复盘要点](#3.5 复盘要点)
    • [四、事故③:成本一夜超预算 5 倍](#四、事故③:成本一夜超预算 5 倍)
      • [4.1 现象](#4.1 现象)
      • [4.2 排查过程](#4.2 排查过程)
      • [4.3 根因](#4.3 根因)
      • [4.4 修复](#4.4 修复)
      • [4.5 复盘要点](#4.5 复盘要点)
    • 五、事故④:输出偶尔多出一段乱码或英文
      • [5.1 现象](#5.1 现象)
      • [5.2 排查过程](#5.2 排查过程)
      • [5.3 根因](#5.3 根因)
      • [5.4 修复](#5.4 修复)
      • [5.5 复盘要点](#5.5 复盘要点)
    • 六、事故⑤:工具调用写出重复脏数据
      • [6.1 现象](#6.1 现象)
      • [6.2 排查过程](#6.2 排查过程)
      • [6.3 根因](#6.3 根因)
      • [6.4 修复](#6.4 修复)
      • [6.5 复盘要点](#6.5 复盘要点)
    • 七、事故⑥:日志里出现明文密钥
      • [7.1 现象](#7.1 现象)
      • [7.2 排查过程](#7.2 排查过程)
      • [7.3 根因](#7.3 根因)
      • [7.4 修复](#7.4 修复)
      • [7.5 复盘要点](#7.5 复盘要点)
    • 八、六个事故的共性归纳
      • [8.1 四条共性](#8.1 四条共性)
      • [8.2 「事故类型 → 检测手段 → 前置防护」总表](#8.2 「事故类型 → 检测手段 → 前置防护」总表)
      • [8.3 六类事故的排查顺序(通用)](#8.3 六类事故的排查顺序(通用))
    • [九、适用边界与风险提示 ⚠️](#九、适用边界与风险提示 ⚠️)
    • 十、总结
    • 参考资料

一、六个事故总览

# 事故 分级 首发时间 定位耗时 直接影响 对应前序篇目 讲义里失效的形式
① P99 从 2s 涨到 40s SEV-2 上线后 3 小时 95 分钟 全站变慢,无报错 40 并发控制 池大小与并发数不匹配,且有会话锁叠加
② 单用户拖垮全站 SEV-1 上线后第 3 天 40 分钟 全站不可用 40 并发控制 限的是请求数,不是 Token 速率
③ 成本超预算 5 倍 SEV-2 上线后第 1 夜 6 小时(账单出来才发现) 直接资损 37 错误处理、39 超时与幂等 重试上限有了,但重试成本没进预算
④ 输出多出乱码/英文 SEV-3 上线后第 9 天 2 天 输出质量投诉 36 评估 版本锁了配置,没锁运行期返回值
⑤ 工具调用写脏数据 SEV-1 上线后第 12 天 3 小时 数据不一致 39 超时与幂等 幂等键做了,但键里少了作用域
⑥ 日志出现明文密钥 SEV-1(安全) 上线后第 15 天 20 分钟(定位)/ 3 天(处置) 密钥泄露需轮转 28 中间件 脱敏中间件写了,但注册顺序在日志之后

先看这张表的最后一列------它是本文的立论。 六个事故里,没有一个的根因是"不知道这个机制" :分层限流、重试退避、幂等键、中间件脱敏、模型版本管理,前序篇目都写过。真正的失效点在于机制之间的接缝:限流做了但限错了度量、重试有上限但没进预算、脱敏写了但顺序错了、幂等键生成了但少了一个字段。

⚠️ "机制做了"和"机制生效了"是两件事。 这是我在六个事故里学到的最贵的一课。检查"有没有这段代码"很容易,难的是验证"这段代码在真实负载下真的拦住了"------这需要有注入故障的演练,而不是代码评审。


二、事故①:上线 3 小时,P99 从 2s 涨到 40s

2.1 现象

text 复制代码
09:40  发布完成,灰度 10%
09:45  P99 1.8s,错误率 0.1%,一切正常
10:20  放量到 50%
10:35  客服群第一条反馈:"系统变慢了"
10:50  P99 8.4s(监控口径),错误率仍为 0.3%
11:30  放量到 100%
12:15  P99 40.2s,错误率 2.1%(开始出现上游超时)
12:20  声明 SEV-2

最诡异的一点是:错误率几乎是平的,只有延迟在涨。 而且 CPU 利用率 22%、内存 45%、活跃连接数 38/100------所有常规指标都在健康区间 。监控看板上唯一的异常是 P99 曲线平滑单调上升,不像抖动,像某种资源在被慢慢堵住。

用户侧的表现也很特别:不是所有请求都慢,而是"某些请求极慢,其余正常" ,且慢的请求往往集中在已经聊了很多轮的会话上。

2.2 排查过程

第一步,确认是不是上游变慢。 这是最容易验证也最容易排除的一条:

bash 复制代码
# 对比"我方记录的模型调用耗时"与"上游响应头里的服务端耗时"
curl -s "http://localhost:9090/metrics" | grep -E "llm_request_duration_seconds" 
# 结果:llm_request_duration_seconds{quantile="0.99"} 1.92  -> 上游侧基本没变

# 再看我方总耗时与模型调用耗时的差值(这个差值就是"排队时间")
curl -s "http://localhost:9090/metrics" \
  | grep -E "request_duration_seconds|concurrency_wait_seconds"
# request_duration_seconds{quantile="0.99"}            40.3
# concurrency_wait_seconds{quantile="0.99",layer="session"}  31.7   <- 异常
# concurrency_wait_seconds{quantile="0.99",layer="global"}    0.4

关键线索就在这里 :layer="session" 的等待 P99 是 31.7 秒 ,占了总耗时的近 80%。请求并没有卡在模型调用上,而是卡在了会话锁上。

第二步,看会话锁的排队深度分布。 我们加了按会话维度的排队深度直方图:

python 复制代码
# 排查脚本:把会话锁的等待时间与会话轮次关联起来
import asyncio
from collections import Counter

# 从指标端点拉取最近 10 分钟的样本(伪代码,实际从 Prometheus 拉)
samples = fetch("concurrency_wait_seconds{layer='session'}", last="10m")

# 按会话的对话轮次分桶
buckets = Counter()
for s in samples:
    turns = s.labels.get("turns", 0)
    buckets["1-5 轮" if turns <= 5 else "6-15 轮" if turns <= 15 else "16+ 轮"] += 1
print(buckets)
# Counter({'1-5 轮': 12, '6-15 轮': 47, '16+ 轮': 231})   <- 长会话占压倒多数

第三步,把"会话轮次"和"单请求模型调用次数"关联。 这一步揭开了第二个因素:

bash 复制代码
# 按会话轮次统计单请求的模型调用次数
curl -s "http://localhost:9090/metrics" \
  | grep "llm_calls_per_request"
# llm_calls_per_request{turn_bucket="1-5"}   1.2
# llm_calls_per_request{turn_bucket="6-15"}  2.8
# llm_calls_per_request{turn_bucket="16+"}   6.4   <- 长会话调用次数是短会话的 5 倍

2.3 根因

两个因素叠加,缺一个都不会出这个事故:

因素一:会话锁串行 + 长会话调用次数多。 第 40 篇讲过会话级必须串行(正确性要求),串行本身是对的 。但讲义里没展开的是:串行锁的持有时间 = 该请求全部模型调用耗时之和 。长会话单请求 6.4 次调用 × 1.9 秒 ≈ 12 秒的持锁时间 。同一个会话的第 2 条消息要等 12 秒,第 3 条要等 24 秒------队列是线性堆积的,P99 自然单调上升。

因素二:连接池上限导致的"假并发"被会话锁掩盖。 httpx 客户端默认 max_connections=100。我们的全局并发设的是 60,看起来没问题 。但会话锁的存在让实际在飞的模型调用数远低于 60 (大部分请求在等锁),所以连接池从来没被当成瓶颈------直到长会话把锁占满,等待的请求虽然不占连接,但它们占用的 goroutine/协程与内存让调度变慢。

真正的放大器是"排队深度没有上限" :会话锁的等待队列是无界的,我们没有对"这个会话已经排了 3 个请求"做任何拒绝。于是少数热门会话的队列越排越长,慢慢吃掉整个 worker 池。

💡 这个事故最阴的地方在于:错误率几乎不变。 如果你的告警只盯错误率,它会一直绿到 P99 涨到 40 秒。分位数延迟必须单独告警 ,而且等待时间要和实际执行时间分开记录------否则你只会看到"请求变慢了",不知道慢在哪。

2.4 修复

三处修改,按止血优先级排列:

python 复制代码
# fix/incident_01.py ------ 事故①修复:会话锁排队深度上限 + 连接池对齐 + 等待可观测
from __future__ import annotations

import asyncio
import time
from collections import defaultdict


class SessionLockV2:
    """会话锁(修复版)------第 40 篇 4.2 节的增强。

    相比原版新增三件事:
    1. max_queue:排队深度上限,超过就直接拒绝(原版无界,是事故放大器)
    2. wait_timeout:等待超时,避免为一个已放弃的请求继续排队
    3. 等待时长分层上报,用于定位"卡在哪一层"
    """

    def __init__(self, max_queue: int = 2, wait_timeout: float = 8.0):
        self.locks: dict[str, asyncio.Lock] = {}
        self.waiters: dict[str, int] = defaultdict(int)
        self.max_queue = max_queue
        self.wait_timeout = wait_timeout
        self._guard = asyncio.Lock()

    async def _get(self, key: str) -> asyncio.Lock:
        async with self._guard:
            if key not in self.locks:
                self.locks[key] = asyncio.Lock()
            return self.locks[key]

    async def run(self, key: str, fn, *args, **kwargs):
        # 修复点 1:排队前先看深度,满了立即拒绝而不是继续堆
        if self.waiters.get(key, 0) >= self.max_queue:
            raise SessionBusy(f"会话 {key} 排队深度已达 {self.max_queue},请稍后重试")

        lock = await self._get(key)
        self.waiters[key] += 1
        started = time.monotonic()
        try:
            # 修复点 2:等待锁也要有超时,且必须显著小于端到端超时
            await asyncio.wait_for(lock.acquire(), self.wait_timeout)
            waited = time.monotonic() - started
            metrics_observe("concurrency_wait_seconds", waited,
                            layer="session", key=key)
            try:
                return await fn(*args, **kwargs)
            finally:
                lock.release()
        except asyncio.TimeoutError:
            raise SessionBusy(f"等待会话锁超过 {self.wait_timeout}s")
        finally:
            self.waiters[key] -= 1
            if self.waiters[key] == 0:
                async with self._guard:
                    self.locks.pop(key, None)


class SessionBusy(RuntimeError):
    """会话繁忙------调用方应转降级或返回 503 + Retry-After。"""


def metrics_observe(name: str, value: float, **labels) -> None:
    """指标上报占位------实际项目替换为 Prometheus client 的 Histogram.observe。"""


def build_http_client() -> "httpx.AsyncClient":
    """修复点 3:连接池与实际并发对齐。

    原配置用的是 httpx 默认值(max_connections=100、
    max_keepalive_connections=20、keepalive_expiry=5.0)。
    问题是 keepalive_expiry=5s 远小于我们的请求间隔,
    导致连接频繁被回收重建,每次都要重新握手。
    """
    import httpx

    return httpx.AsyncClient(
        limits=httpx.Limits(
            max_connections=256,           # ≥ 实际并发调用数 × 1.2
            max_keepalive_connections=128,
            keepalive_expiry=60.0,         # 与上游的 keep-alive 时长匹配
        ),
        timeout=httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
    )

另外补了一条告警 :concurrency_wait_seconds{layer="session"} 的 P99 超过 5 秒即告警,且与错误率告警解耦。

2.5 复盘要点

  • 错误率不变不代表系统健康。 分位数延迟必须单独告警,且等待时间要与执行时间分开记录------事故里 31.7 秒的等待占了总耗时的 79%。
  • 任何队列都要有界,包括"看起来每次只有几个人在排"的会话锁队列。 无界队列的表现是"慢慢变慢",比突然崩溃更难发现。
  • 连接池参数要跟实际并发和上游 keep-alive 时长一起看。 keepalive_expiry 小于请求间隔时,连接池等于失效。
  • 这个坑第 40 篇讲过(2.4 节"假并发"、4.2 节会话锁),但这里失效的形式不同 :讲义讲的是"池太小会造成假并发",而这次是会话锁把并发压低到让池看起来够用 ,掩盖了真正的问题。教训是:分层指标要每层都看,不能只看全局。

三、事故②:一个用户把整个服务拖垮

3.1 现象

text 复制代码
14:02  监控:全局并发 60/60 打满,队列长度 812(阈值 200 告警)
14:03  告警:P99 延迟 28s
14:05  告警:上游 429 次数 5 分钟内 1400+
14:07  客服:多个租户反馈"一直转圈"
14:09  声明 SEV-1
14:11  临时措施:对该租户限流
14:42  服务恢复,P99 回到 2.4s

日志里的关键片段(脱敏):

text 复制代码
14:04:11 WARN  tenant=T-1042 user=U-8821 concurrency.wait layer=user waited=27.4s
14:04:11 WARN  tenant=T-1042 user=U-8821 upstream.status=429 retry=3/3
14:04:12 WARN  tenant=T-2077 user=U-3310 concurrency.wait layer=global waited=31.2s
14:04:12 ERROR tenant=T-2077 user=U-3310 upstream.timeout after 30.0s

注意第二行和第三行的对比 :T-1042 这个租户在疯狂重试(429 后重试),而 T-2077 是完全无辜的另一个租户,它卡在了全局层------等了 31 秒后超时。

3.2 排查过程

第一步,确认是谁在吃资源。 按租户维度聚合:

bash 复制代码
# 按租户统计活跃请求数与 Token 消耗(PromQL)
sum by (tenant) (rate(llm_requests_started[5m]))
sum by (tenant) (rate(llm_tokens_total[5m]))

# 结果(节选):
# T-1042: requests=18.4/s   tokens=412,000/min
# T-2077: requests=0.9/s    tokens=4,100/min
# T-1090: requests=1.1/s    tokens=5,300/min
# ...其余 6 个租户合计 tokens ≈ 38,000/min

一个租户吃掉了 91% 的 Token。 而请求数上它只占 18.4/(18.4+0.9+1.1+...) ≈ 62%------请求数看不出问题,Token 数才看得出。

第二步,看这个租户在跑什么。 查任务类型分布:

sql 复制代码
-- 按租户统计任务类型与预估时长
SELECT tenant_id,
       task_type,
       COUNT(*)                          AS cnt,
       ROUND(AVG(llm_calls), 1)          AS avg_calls,
       ROUND(AVG(total_tokens))          AS avg_tokens
FROM   agent_tasks
WHERE  created_at >= NOW() - INTERVAL '30 minutes'
GROUP  BY tenant_id, task_type
ORDER  BY avg_tokens DESC
LIMIT  10;
text 复制代码
 tenant_id | task_type   | cnt  | avg_calls | avg_tokens
-----------+-------------+------+-----------+-----------
 T-1042    | batch_scan  |  184 |    112.0  |     83400
 T-1042    | interactive |   12 |      1.4  |      2100
 T-2077    | interactive |   61 |      1.3  |      1900
 T-1090    | interactive |   55 |      1.5  |      2300

结论清楚了 :T-1042 在跑批量扫描任务,每个任务平均 112 次模型调用、8.3 万 Token 。而我们的限流是"每用户最多 5 个并发请求"------184 个批量任务分批跑,每批 5 个,完全合规,但每一批都是 560 次并发的模型调用。

3.3 根因

限流限的是请求数,而真实瓶颈是 Token 速率。 这正是第 40 篇 1.2 节讲的"请求计数模型失效",但这次的失效形式更隐蔽:

  1. 我们做了用户级限流(每用户 5 并发),看起来"有隔离";
  2. 但没有做租户级的 Token 速率限制,所以单个租户可以无限放大自己的 Token 消耗;
  3. 批量任务和交互式任务共用一个池,批量任务把槽位占满后,交互式请求只能排队;
  4. 429 之后的重试进一步放大 ------T-1042 的客户端在收到 429 后立刻重试,等于在上游已经过载时继续加压。

第(4)点是压垮骆驼的最后一根稻草 :上游开始 429 → 客户端重试 → 上游更过载 → 更多 429。这是一个典型的正反馈雪崩(Google SRE 书里称为 cascading failure)。
#mermaid-svg-Xq4CXx3u8NHRZ43V{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xq4CXx3u8NHRZ43V .error-icon{fill:#552222;}#mermaid-svg-Xq4CXx3u8NHRZ43V .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xq4CXx3u8NHRZ43V .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .marker.cross{stroke:#333333;}#mermaid-svg-Xq4CXx3u8NHRZ43V svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xq4CXx3u8NHRZ43V p{margin:0;}#mermaid-svg-Xq4CXx3u8NHRZ43V .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster-label text{fill:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster-label span{color:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster-label span p{background-color:transparent;}#mermaid-svg-Xq4CXx3u8NHRZ43V .label text,#mermaid-svg-Xq4CXx3u8NHRZ43V span{fill:#333;color:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .node rect,#mermaid-svg-Xq4CXx3u8NHRZ43V .node circle,#mermaid-svg-Xq4CXx3u8NHRZ43V .node ellipse,#mermaid-svg-Xq4CXx3u8NHRZ43V .node polygon,#mermaid-svg-Xq4CXx3u8NHRZ43V .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .rough-node .label text,#mermaid-svg-Xq4CXx3u8NHRZ43V .node .label text,#mermaid-svg-Xq4CXx3u8NHRZ43V .image-shape .label,#mermaid-svg-Xq4CXx3u8NHRZ43V .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xq4CXx3u8NHRZ43V .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .rough-node .label,#mermaid-svg-Xq4CXx3u8NHRZ43V .node .label,#mermaid-svg-Xq4CXx3u8NHRZ43V .image-shape .label,#mermaid-svg-Xq4CXx3u8NHRZ43V .icon-shape .label{text-align:center;}#mermaid-svg-Xq4CXx3u8NHRZ43V .node.clickable{cursor:pointer;}#mermaid-svg-Xq4CXx3u8NHRZ43V .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .arrowheadPath{fill:#333333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xq4CXx3u8NHRZ43V .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xq4CXx3u8NHRZ43V .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xq4CXx3u8NHRZ43V .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster text{fill:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V .cluster span{color:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Xq4CXx3u8NHRZ43V .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xq4CXx3u8NHRZ43V rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xq4CXx3u8NHRZ43V .icon-shape,#mermaid-svg-Xq4CXx3u8NHRZ43V .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xq4CXx3u8NHRZ43V .icon-shape p,#mermaid-svg-Xq4CXx3u8NHRZ43V .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xq4CXx3u8NHRZ43V .icon-shape .label rect,#mermaid-svg-Xq4CXx3u8NHRZ43V .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xq4CXx3u8NHRZ43V .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xq4CXx3u8NHRZ43V .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xq4CXx3u8NHRZ43V :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} T-1042 提交 184 个批量任务

每任务 112 次调用
用户级限流放行

每批 5 个并发请求 合规
实际每批 560 次并发模型调用
上游 TPM 配额打满
上游返回 429
客户端立即重试

无退避 无上限
共享槽位被批量任务占满
其余 7 个租户的交互式请求排队
排队超过超时 -> 全局超时
全站不可用 SEV-1

3.4 修复

python 复制代码
# fix/incident_02.py ------ 事故②修复:租户级令牌桶 + 长短任务分离 + 429 感知退避
from __future__ import annotations

import asyncio
import random
import time


class TenantTokenBucket:
    """租户级令牌桶------按 Token 速率而非请求数限流。

    这是第 40 篇 4.3 节的实现,事故②暴露的是"我们根本没接上它"。
    关键参数:burst_seconds 决定突发容忍度,设太大会让突发仍然打爆上游。
    """

    def __init__(self, tpm: int, burst_seconds: float = 5.0):
        self.rate = tpm / 60.0
        self.capacity = tpm * burst_seconds / 60.0
        self.tokens = self.capacity
        self.updated = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self, estimated: int) -> float:
        """返回需要等待的秒数,0 表示无需等待。"""
        async with self._lock:
            now = time.monotonic()
            self.tokens = min(self.capacity,
                              self.tokens + (now - self.updated) * self.rate)
            self.updated = now
            if self.tokens >= estimated:
                self.tokens -= estimated
                return 0.0
            deficit = estimated - self.tokens
            self.tokens = 0.0
            return deficit / self.rate

    async def settle(self, actual: int, estimated: int) -> None:
        """事后校正------只预估不校正会让误差累积。"""
        async with self._lock:
            self.tokens = max(-self.capacity,
                              min(self.capacity, self.tokens - (actual - estimated)))


class SplitPool:
    """长短任务分离------批量任务进独立的小池。

    为什么批量池要设得小:批量任务本来就慢,多给槽位不会让它快多少
    (瓶颈在上游配额),却会挤占交互式资源。让它慢慢跑是整体最优。
    """

    def __init__(self, interactive_slots: int = 50, batch_slots: int = 5,
                 threshold_seconds: float = 30.0):
        self.interactive = asyncio.Semaphore(interactive_slots)
        self.batch = asyncio.Semaphore(batch_slots)
        self.threshold = threshold_seconds

    def pick(self, estimated_seconds: float) -> asyncio.Semaphore:
        return (self.interactive if estimated_seconds < self.threshold
                else self.batch)


async def call_with_backoff(fn, *, max_retries: int = 3, base: float = 1.0):
    """429 感知的退避重试------修复事故里的正反馈雪崩。

    三个要点:
    1. 有上限(max_retries=3),不能无限重试
    2. 带 jitter,避免多个客户端同时重试造成重试风暴
    3. 尊重上游返回的 Retry-After,不要自己拍脑袋定等待时长
    """
    last_exc: Exception | None = None
    for attempt in range(max_retries + 1):
        try:
            return await fn()
        except RateLimited as exc:
            last_exc = exc
            if attempt == max_retries:
                break
            wait = exc.retry_after if exc.retry_after else base * (2 ** attempt)
            wait += random.uniform(0, wait * 0.3)      # jitter,打散重试时刻
            await asyncio.sleep(wait)
        except TransientError as exc:
            last_exc = exc
            if attempt == max_retries:
                break
            await asyncio.sleep(base * (2 ** attempt) + random.uniform(0, 0.5))
    raise last_exc  # type: ignore[misc]


class RateLimited(RuntimeError):
    def __init__(self, retry_after: float | None = None):
        super().__init__("上游限流")
        self.retry_after = retry_after


class TransientError(RuntimeError):
    pass

3.5 复盘要点

  • "做了限流"不等于"限对了对象"。 我们的每用户 5 并发是真在工作的,但它限的是与真实瓶颈无关的量 。判断方法:压测时逐步加压,看第一个开始报错的资源是什么。
  • 批量任务必须有独立入口和独立池。 最干净的做法是在 API 层就区分"交互式"与"异步任务"(后者返回任务 ID),从源头分离,而不是事后靠预估时长路由。
  • 429 后的重试必须退避 + 上限 + jitter。 无限重试把"上游过载"变成了"雪崩",这是链条上最危险的放大器。
  • 这个坑第 40 篇讲过(1.1 节事故一几乎是同一个场景),但这里失效的形式不同 :讲义讲的是"没有租户隔离",而我们是有用户级隔离但没有租户级 Token 隔离 ------隔离层次选错了 。教训是:隔离的单位必须等于瓶颈的单位。

图1:六个事故全景------现象、根因与后果三段式速查


四、事故③:成本一夜超预算 5 倍

4.1 现象

text 复制代码
Day 1  09:40 上线
Day 1  23:50 最后一条人工确认,当日累计消费 ¥1,860(预算 ¥2,000/天,看起来正常)
Day 2  09:15 财务同学发消息:"昨天的账单是 ¥10,430"
Day 2  09:20 核对:00:00 - 08:00 期间产生了 ¥8,570 的消费
Day 2  09:30 声明 SEV-2(资损类)

最难受的一点是:我们有一套成本告警,它确实没响。 因为告警的口径是"日累计超过预算 80% 告警",而日累计是在每天早上 8 点结算前一天的------凌晨烧掉的钱,要到第二天早上才能看见。

分解那 8 小时的账单:

时段 消费 用户请求数 模型调用数 放大倍数
00:00 - 02:00 ¥1,240 312 1,890 6.1×
02:00 - 04:00 ¥2,980 288 5,120 17.8×
04:00 - 06:00 ¥3,110 301 5,460 18.1×
06:00 - 08:00 ¥1,240 340 1,980 5.8×

"放大倍数 = 模型调用数 / 用户请求数"这一列是整个事故的关键。 正常情况下它应该是 1~2(一次用户提问对应 1~2 次调用),凌晨时段飙到 18 倍。

4.2 排查过程

第一步,找放大倍数高的请求长什么样。

sql 复制代码
-- 找出重试次数最多的请求,看它们的共同特征
SELECT request_id,
       user_id,
       retry_count,
       total_tokens,
       ROUND(cost_yuan, 2)                     AS cost,
       left(error_message, 40)                 AS last_error
FROM   llm_call_log
WHERE  created_at >= '2026-10-02 02:00:00'
  AND  created_at <  '2026-10-02 04:00:00'
ORDER  BY retry_count DESC, cost DESC
LIMIT  15;
text 复制代码
 request_id        | retry_count | total_tokens | cost  | last_error
-------------------+-------------+--------------+-------+------------------------
 req-8f21c...      |      7      |    412,800   | 82.56 | upstream 429 rate limit
 req-3a90d...      |      7      |    398,100   | 79.62 | upstream 429 rate limit
 req-b771e...      |      6      |    341,200   | 68.24 | upstream 429 rate limit

共性一眼可见:全部是 429,重试次数 6~7 次,单次请求成本 60~80 元。

第二步,看为什么重试这么贵。 检查重试时的请求体:

bash 复制代码
# 从归档日志里取一条重试请求,看它的上下文长度
python - <<'PY'
import json, pathlib
rec = json.loads(pathlib.Path("logs/req-8f21c.json").read_text(encoding="utf-8"))
for i, att in enumerate(rec["attempts"]):
    print(i, "messages:", len(att["messages"]),
          "chars:", sum(len(m["content"]) for m in att["messages"]))
PY
text 复制代码
0 messages: 42 chars: 61340
1 messages: 42 chars: 61340
2 messages: 42 chars: 61340
...
6 messages: 42 chars: 61340

每一次重试都带了完整的 42 条历史消息、6.1 万字上下文。 单次调用约 5.9 万 Token,7 次重试就是 41 万 Token------一条用户提问烧掉 82 元。

4.3 根因

三个缺陷叠加:

缺陷一:重试次数上限设在"调用层"而不是"请求层"。 我们的重试装饰器写在单次模型调用函数上,每次工具调用、每次上下文压缩都可能触发一轮新的重试。用户看到的"一次提问",在技术上是 7 轮独立重试。

缺陷二:重试带完整上下文,成本随重试次数线性放大。 429 是配额问题,重试时上下文一点没变 ,唯一变化的是时间------而我们没有退避,是立刻重试。立刻重试 429,成功率极低,但成本 100% 产生。

缺陷三:预算只有告警没有熔断,且告警粒度是"天"。 就算告警及时,值班的人凌晨三点也不会为了一条成本告警爬起来改配置 。告警在这里是无用的------必须自动熔断。
#mermaid-svg-hvOtwvFdEBYsXty8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hvOtwvFdEBYsXty8 .error-icon{fill:#552222;}#mermaid-svg-hvOtwvFdEBYsXty8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hvOtwvFdEBYsXty8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hvOtwvFdEBYsXty8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hvOtwvFdEBYsXty8 .marker.cross{stroke:#333333;}#mermaid-svg-hvOtwvFdEBYsXty8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hvOtwvFdEBYsXty8 p{margin:0;}#mermaid-svg-hvOtwvFdEBYsXty8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 单次用户提问的成本随重试次数放大(上下文 5.9 万 Token) 1次 2次 3次 4次 5次 6次 7次 90 80 70 60 50 40 30 20 10 0 单次提问成本(元)

第 39 篇讲过"重试必须幂等且必须有上限",第 37 篇讲过退避与熔断------但这里失效的形式不同 :讲义假设重试的是便宜的、可重放的调用 ,而 Agent 的重试带着整个对话上下文 ,每一次重试的成本和第一次一样高。重试成本 = 单次成本 × 次数,而不是"多花一点点"。

4.4 修复

python 复制代码
# fix/incident_03.py ------ 事故③修复:请求级重试预算 + 小时级熔断 + 重试成本感知
from __future__ import annotations

import asyncio
import random
import time
from dataclasses import dataclass, field


class BudgetExceeded(RuntimeError):
    pass


@dataclass
class RequestBudget:
    """请求级重试预算------修复缺陷一。

    原实现把重试上限写在"单词调用"上,导致一次用户提问可以触发 7 轮重试。
    这里把预算提升为"请求级":整个请求生命周期内共享一个重试额度。
    """

    max_retries: int = 3
    max_calls: int = 8              # 含重试的总调用次数硬上限
    used_retries: int = 0
    used_calls: int = 0

    def allow_retry(self) -> bool:
        return self.used_retries < self.max_retries

    def allow_call(self) -> bool:
        return self.used_calls < self.max_calls

    def note_call(self) -> None:
        self.used_calls += 1

    def note_retry(self) -> None:
        self.used_retries += 1


@dataclass
class BudgetGuard:
    """小时级预算熔断------修复缺陷三。

    与第 43 篇 F 组同源。核心差别是"熔断"而非"告警":
    触顶后直接拒绝新调用,由上层走降级路径。
    """

    hourly_budget: float
    per_request_ceiling: float = 3.0
    price_per_1k_tokens: float = 0.02
    spent_this_hour: float = 0.0
    hour_started: float = field(default_factory=time.monotonic)
    tripped: bool = False
    _lock: asyncio.Lock = field(default_factory=asyncio.Lock)

    def _roll_hour(self) -> None:
        if time.monotonic() - self.hour_started >= 3600.0:
            self.hour_started = time.monotonic()
            self.spent_this_hour = 0.0
            self.tripped = False

    async def charge(self, tokens: int, *, label: str = "") -> float:
        async with self._lock:
            self._roll_hour()
            cost = tokens / 1000.0 * self.price_per_1k_tokens
            if cost > self.per_request_ceiling:
                raise BudgetExceeded(f"单次调用 {cost:.2f} 超上限")
            if self.spent_this_hour + cost > self.hourly_budget:
                self.tripped = True
                raise BudgetExceeded(f"小时预算触顶,熔断中({label})")
            self.spent_this_hour += cost
            return cost


async def call_with_budget(fn, *, budget: RequestBudget,
                           guard: BudgetGuard, label: str = ""):
    """带重试预算与成本熔断的调用------修复缺陷二。

    关键改动:429 的退避时长取自上游 Retry-After,
    而不是"立刻重试"。立刻重试 429 的成功率极低但成本 100% 产生。
    """
    if not budget.allow_call():
        raise BudgetExceeded("请求调用次数已达上限")
    budget.note_call()

    attempt = 0
    while True:
        try:
            result, tokens = await fn()
            await guard.charge(tokens, label=label)
            return result
        except RateLimited as exc:
            if not budget.allow_retry():
                raise
            budget.note_retry()
            attempt += 1
            wait = exc.retry_after or min(60.0, 2.0 ** attempt)
            await asyncio.sleep(wait + random.uniform(0, wait * 0.3))
        except TransientError:
            if not budget.allow_retry():
                raise
            budget.note_retry()
            attempt += 1
            await asyncio.sleep(min(30.0, 2.0 ** attempt) + random.uniform(0, 0.5))


class RateLimited(RuntimeError):
    def __init__(self, retry_after: float | None = None):
        super().__init__("上游限流")
        self.retry_after = retry_after


class TransientError(RuntimeError):
    pass

配套的两条运维改动:

bash 复制代码
# 1) 成本指标改为小时级,并配置触顶即熔断(不是告警)
#    Prometheus 告警规则示例
#    - alert: HourlyBudgetTripped
#      expr: budget_tripped == 1
#      for: 0m
#      labels: { severity: sev2 }

# 2) 每日对账:把"放大倍数"做成日常巡检指标
psql -h db.internal -U svc -d agent -c "
SELECT date_trunc('hour', created_at) AS hour,
       COUNT(DISTINCT request_id)     AS reqs,
       COUNT(*)                       AS calls,
       ROUND(COUNT(*)::numeric / NULLIF(COUNT(DISTINCT request_id),0), 2) AS amp
FROM   llm_call_log
WHERE  created_at >= NOW() - INTERVAL '24 hours'
GROUP  BY 1
HAVING COUNT(*)::numeric / NULLIF(COUNT(DISTINCT request_id),0) > 3
ORDER  BY 1;"

4.5 复盘要点

  • "放大倍数 = 模型调用数 / 用户请求数"应该是一个日常巡检指标,而不是事故后才算的。 正常值 1~2,超过 3 就要查。这个指标比"总成本"有用得多,因为它能区分"流量涨了"和"重试炸了"。
  • Agent 的重试成本 = 单次成本 × 次数,不是"多花一点点"。 因为重试带完整上下文。任何重试设计都必须先看单次成本。
  • 成本防护必须是熔断不是告警。 凌晨三点的告警等于没有告警。
  • 这个坑第 37 篇(退避与熔断)和第 39 篇(重试)都讲过,但这里失效的形式不同 :讲义里的重试是廉价的幂等调用,Agent 的重试带上下文,成本和首次相同 。教训是:重试策略必须和单次成本一起设计,不能独立设定次数上限。

五、事故④:输出偶尔多出一段乱码或英文

5.1 现象

text 复制代码
Day 9   陆续收到 5 条反馈:"回答最后多了一段看不懂的内容"
Day 9   复现失败(我们自己测 200 次都没复现)
Day 10  反馈增加到 17 条,集中在某个特定功能入口
Day 10  声明 SEV-3
Day 11  定位到模型版本漂移

用户发来的截图描述(脱敏后整理):

text 复制代码
用户 A:回答正常,但末尾多了一段:
        "--- Answer generated by assistant v2. Please verify before use."
用户 B:回答末尾多了一段乱码:
        "锟斤拷锟斤拷锟斤拷"
用户 C:整个回答变成了英文(用户用中文提问)

三种现象看起来完全不同,但它们是同一批反馈里出现的,而且都发生在"末尾追加"这个位置------这个共同点最后成了突破口。

5.2 排查过程

第一步,从"末尾追加"入手查代码。 全文搜索所有会往输出后追加内容的路径:

bash 复制代码
# 找所有对输出做追加/拼接的地方
grep -rnE "(output|answer|result|text)\s*(\+=|\+\s|\.join\(|f\")" \
  --include="*.py" src/ | grep -vE "test_|#" | head -30

找到一处可疑的兜底分支:

python 复制代码
# 事故代码片段(修复前)
if not raw_output.strip():
    raw_output = DEFAULT_FALLBACK_TEMPLATE      # 常量,内容是英文

这解释了用户 A。但用户 B(乱码)和用户 C(全英文)还没解释。

第二步,查编码。

bash 复制代码
# 检查读写链路里所有涉及编码的地方,看有没有用系统默认编码
grep -rnE "open\(|decode\(|encode\(" --include="*.py" src/ | grep -v "utf-8" | head
# 命中:src/storage/archive.py:88  f = open(path, "r")   <- 未指定 encoding

在 Windows 环境的那台归档机器上,open() 默认用 GBK 解码 UTF-8 字节,产生了"锟斤拷" ------这是 UTF-8 字节被 GBK 解码的经典产物。这解释了用户 B。

第三步,查用户 C(全英文)。 这一条最难,因为复现不了。最后是靠给每次调用记录上游实际返回的模型标识才定位的:

sql 复制代码
-- 按天统计上游实际返回的模型标识分布
SELECT DATE(created_at)      AS day,
       returned_model        AS model,
       COUNT(*)              AS cnt
FROM   llm_call_log
WHERE  created_at >= NOW() - INTERVAL '14 days'
GROUP  BY 1, 2
ORDER  BY 1, 3 DESC;
text 复制代码
    day     |        model         |  cnt
------------+----------------------+-------
 2026-09-28 | gpt-x-2026-06-11     | 18422
 2026-09-29 | gpt-x-2026-06-11     | 19033
 2026-09-30 | gpt-x-2026-06-11     | 18210
 2026-10-01 | gpt-x-2026-06-11     | 17455
 2026-10-02 | gpt-x-2026-06-11     | 15120
 2026-10-02 | gpt-x-2026-09-30     |  2140   <- 新版本开始出现
 2026-10-03 | gpt-x-2026-06-11     |  9120
 2026-10-03 | gpt-x-2026-09-30     |  9310   <- 灰度比例上升
 2026-10-04 | gpt-x-2026-09-30     | 18870   <- 已全量切换

我们在配置里填的是别名 gpt-x-latest,上游在 10 月 2 日开始把它灰度指向新版本 2026-09-30,10 月 4 日全量。 而新版本对中文指令的遵循度略有差异,在某些 Prompt 下会整段用英文回答。

5.3 根因

三个独立缺陷,被同一批反馈暴露:

现象 根因 缺陷类型
末尾多英文 兜底模板是硬编码英文常量,且空输出时直接返回 兜底路径未本地化
末尾乱码 归档代码 open() 未指定 encoding,用系统默认编码解码 UTF-8 编码未显式声明
整段英文 配置用了 latest 别名,上游静默灰度切换版本 版本未锁定

第三个是最根本的 ,因为它意味着系统的行为在没有我们做任何变更的情况下改变了。这是"不可控变更"的典型形态。

⚠️ "我们没有发布任何变更"是这类事故最误导人的一句话。 它没有错------但上游变了 。对依赖外部模型的服务来说,"我们没有改"从来不等于"系统没变"。所有外部依赖的版本都必须在运行期校验,而不是只校验你请求的是什么。

5.4 修复

python 复制代码
# fix/incident_04.py ------ 事故④修复:版本锁定 + 运行期校验 + 编码归一 + 本地化兜底
from __future__ import annotations

import json
import logging
import re
from pathlib import Path

logger = logging.getLogger("model_guard")

# 允许出现在生产配置里的模型标识:必须带日期或显式版本号
STRICT_MODEL_RE = re.compile(r"^[a-z0-9.\-]+-\d{4}-\d{2}-\d{2}$")
FORBIDDEN_TAILS = {"latest", "stable", "preview", "default"}

DEFAULT_FALLBACK_TEMPLATE = "抱歉,我暂时没能生成有效回答,请换个说法再试一次。"


def assert_models_pinned(cfg_path: str) -> None:
    """启动期校验:配置里不允许出现可变别名。

    放在启动期而不是发布前,是因为配置可以热更新------
    只做发布前检查挡不住"上线后有人改了配置"。
    """
    cfg = json.loads(Path(cfg_path).read_text(encoding="utf-8"))
    for key, value in cfg.get("models", {}).items():
        name = str(value)
        tail = name.rsplit("-", 1)[-1].lower()
        if tail in FORBIDDEN_TAILS:
            raise ValueError(f"模型 {key} 使用了可变别名: {name}")
        if not STRICT_MODEL_RE.match(name):
            raise ValueError(f"模型 {key} 不是精确版本: {name}")


async def call_and_verify(client, *, expected_model: str, payload: dict):
    """运行期校验:比对上游实际返回的模型标识。

    这是事故④唯一的早期检测手段------配置锁了不代表上游返回的还是它。
    不一致时不要直接抛错中断业务,而是打点 + 告警,
    因为模型切换本身不一定有问题,有问题的是"我们不知道它切了"。
    """
    resp = await client.post("/v1/chat", json=payload)
    body = resp.json()
    actual = body.get("model", "")
    if actual != expected_model:
        logger.warning("模型版本漂移: 期望 %s 实际 %s", expected_model, actual)
        metrics_inc("model_version_drift_total",
                    expected=expected_model, actual=actual)
    return body


def read_text_safely(path: str) -> str:
    """编码归一------修复"锟斤拷"。

    所有文件读写都必须显式指定 encoding,
    依赖系统默认编码在跨平台部署时必然出问题。
    """
    return Path(path).read_text(encoding="utf-8")


def with_localized_fallback(raw: str) -> str:
    """兜底路径本地化------修复末尾多出的英文。

    顺带修掉一个隐患:原来只在 raw 为空字符串时兜底,
    但模型可能返回纯空白字符,strip() 后才为空。
    """
    if not raw or not raw.strip():
        return DEFAULT_FALLBACK_TEMPLATE
    return raw


def metrics_inc(name: str, **labels) -> None:
    """指标上报占位。"""

配套的评测改动 :把"输出语言一致性"做成评测集里的一个断言项(第 36 篇),每次模型版本变更都必须重跑评测集。

5.5 复盘要点

  • 三种看起来毫不相干的现象,可能来自同一条代码路径上的不同分支。 突破口是"都发生在末尾"这个共性------找共性比逐个复现快得多。
  • open() / decode() / encode() 必须显式指定编码。 跨平台部署时依赖系统默认编码是定时炸弹。
  • 模型版本必须在运行期校验,不只是配置里锁定。 我们配置里填的是别名,这才是根本错误。
  • 这个坑第 36 篇讲过(评测集兜住质量回归),但这里失效的形式不同 :评测集是我们主动跑 的,而这次的变更不是我们发起的 。教训是:对外部依赖要有"变更检测",不能只依赖"变更时我会跑测试"这个前提。

六、事故⑤:工具调用写出重复脏数据

6.1 现象

text 复制代码
Day 12  10:15  运营反馈:"有客户的工单被创建了两次"
Day 12  10:40  核对:近 7 天共 34 条重复工单
Day 12  11:00  声明 SEV-1(数据一致性)
Day 12  13:20  定位完成,暂停该工具的自动执行

日志片段:

text 复制代码
10:02:31 INFO  tool=create_ticket idem=7f3a91c2... status=timeout after 10.0s
10:02:31 INFO  retry scheduled tool=create_ticket attempt=2
10:02:42 INFO  tool=create_ticket idem=7f3a91c2... status=ok ticket=T-90211
10:02:44 INFO  tool=create_ticket idem=7f3a91c2... status=ok ticket=T-90212

注意最后两行 :同一个幂等键 7f3a91c2...,两次都返回了成功 ,而且创建了两个不同的工单号。幂等键完全没起作用。

6.2 排查过程

第一步,确认幂等键的生成逻辑有没有被调用。

bash 复制代码
grep -rn "make_idem_key\|idempotency" --include="*.py" src/ | head -20
# 命中:src/guards/tool_guard.py:61  key = self.make_idem_key(tool, args, scope)
# 看起来是调用了

第二步,为什么同一个键会执行两次? 查幂等结果缓存的实现:

python 复制代码
# 事故代码片段(修复前)
hit = self._idem.get(key)
if hit:
    return hit                      # 命中缓存直接返回
result = await executor(tool, args)
self._idem[key] = result           # 注意:写入发生在执行之后

找到了 :缓存写入发生在执行完成之后 。第一次调用超时了(10 秒超时),executor 抛异常,缓存从来没被写入 ;重试时缓存里没有键,于是真的又执行了一次。

第三步,为什么第一次超时却实际成功了? 这是分布式系统的经典:
数据库 工具服务 Agent 数据库 工具服务 Agent #mermaid-svg-i8e6OkGBDSFgVfpE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i8e6OkGBDSFgVfpE .error-icon{fill:#552222;}#mermaid-svg-i8e6OkGBDSFgVfpE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i8e6OkGBDSFgVfpE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i8e6OkGBDSFgVfpE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i8e6OkGBDSFgVfpE .marker.cross{stroke:#333333;}#mermaid-svg-i8e6OkGBDSFgVfpE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i8e6OkGBDSFgVfpE p{margin:0;}#mermaid-svg-i8e6OkGBDSFgVfpE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8e6OkGBDSFgVfpE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8e6OkGBDSFgVfpE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-i8e6OkGBDSFgVfpE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-i8e6OkGBDSFgVfpE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-i8e6OkGBDSFgVfpE .sequenceNumber{fill:white;}#mermaid-svg-i8e6OkGBDSFgVfpE #sequencenumber{fill:#333;}#mermaid-svg-i8e6OkGBDSFgVfpE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-i8e6OkGBDSFgVfpE .messageText{fill:#333;stroke:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8e6OkGBDSFgVfpE .labelText,#mermaid-svg-i8e6OkGBDSFgVfpE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .loopText,#mermaid-svg-i8e6OkGBDSFgVfpE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8e6OkGBDSFgVfpE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-i8e6OkGBDSFgVfpE .noteText,#mermaid-svg-i8e6OkGBDSFgVfpE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-i8e6OkGBDSFgVfpE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8e6OkGBDSFgVfpE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8e6OkGBDSFgVfpE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8e6OkGBDSFgVfpE .actorPopupMenu{position:absolute;}#mermaid-svg-i8e6OkGBDSFgVfpE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-i8e6OkGBDSFgVfpE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8e6OkGBDSFgVfpE .actor-man circle,#mermaid-svg-i8e6OkGBDSFgVfpE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-i8e6OkGBDSFgVfpE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 响应在返回途中丢失/超时 幂等缓存未写入 因为写入在执行之后 数据库里出现两条工单 幂等键形同虚设 create_ticket(幂等键 K) INSERT 工单(成功,T-90211) 10s 超时,判定失败 重试 create_ticket(幂等键 K) INSERT 工单(又成功,T-90212) 返回 T-90212

关键认知 :超时 ≠ 失败。超时只是"我不知道结果是什么",而工具可能已经成功执行了。

第四步,核对影响范围。

sql 复制代码
-- 找出所有重复工单:同一幂等键对应多条记录
SELECT idempotency_key,
       COUNT(*)                AS cnt,
       ARRAY_AGG(ticket_id)    AS tickets,
       MIN(created_at)         AS first_at
FROM   tickets
WHERE  created_at >= NOW() - INTERVAL '30 days'
GROUP  BY idempotency_key
HAVING COUNT(*) > 1
ORDER  BY cnt DESC;
text 复制代码
 idempotency_key | cnt | tickets           | first_at
-----------------+-----+-------------------+---------------------
 7f3a91c2...     |  2  | {T-90211,T-90212} | 2026-10-12 10:02:42
 9d10e44a...     |  2  | {T-90233,T-90234} | 2026-10-12 09:41:07
 ...
 共 34 组重复

6.3 根因

幂等键生成了,但它的"写入时机"错了------写在执行之后而不是之前。 这个错误的本质是:把幂等做成了"结果缓存",而真正的幂等需要"执行前登记"。

正确的幂等有两种做法,我们一种都没做对:

做法 机制 我们的实现 问题
结果缓存 执行前查缓存,执行后写缓存 执行后写缓存 超时时缓存未写,重试仍会执行
唯一索引 数据库层 (tool, idem_key) 唯一约束 没做 数据库不会阻止第二次插入

第二个根因是幂等键里缺少作用域。 当时的键是 sha256(tool + args),没有带 tenant_id 。这意味着:不同租户传相同参数会命中同一个幂等键------虽然这次没造成事故,但它是个随时会爆的雷(第 40 篇 2.3 节强调过"任何缓存的键都必须带作用域")。

6.4 修复

python 复制代码
# fix/incident_05.py ------ 事故⑤修复:执行前登记 + 数据库唯一索引 + 键带作用域
from __future__ import annotations

import asyncio
import hashlib
import json
import time
from dataclasses import dataclass, field


@dataclass
class PendingRecord:
    state: str                      # pending | done
    result: object | None = None
    at: float = field(default_factory=time.monotonic)


class IdempotentExecutor:
    """幂等执行器(修复版)。

    与事故版本的根本差别:登记发生在执行之前。
    这样即使执行超时,第二次进来也能看到"已在处理中",
    从而选择等待而不是重复执行。
    """

    def __init__(self, ttl_s: float = 600.0):
        self._rec: dict[str, PendingRecord] = {}
        self.ttl_s = ttl_s
        self._lock = asyncio.Lock()

    @staticmethod
    def make_key(tool: str, args: dict, *, tenant_id: str, user_id: str) -> str:
        """幂等键必须带作用域------修复"键里少了 tenant_id"的隐患。

        只用 (tool, args) 会让不同租户的相同参数互相命中,
        这在第 40 篇 2.3 节讲租户隔离时强调过,同构问题。
        """
        canon = json.dumps(args, sort_keys=True, ensure_ascii=False)
        raw = f"{tenant_id}|{user_id}|{tool}|{canon}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    async def run(self, key: str, executor, *, wait_timeout: float = 15.0):
        async with self._lock:
            now = time.monotonic()
            rec = self._rec.get(key)
            if rec is not None and now - rec.at < self.ttl_s:
                if rec.state == "done":
                    return rec.result
                # 修复点:遇到 pending 时等待结果,而不是重复执行
                return await self._await_pending(key, wait_timeout)
            # 修复点:执行之前就登记为 pending
            self._rec[key] = PendingRecord(state="pending")

        try:
            result = await executor()
        except Exception:
            async with self._lock:
                # 失败要清除登记,否则后续重试永远看到 pending
                self._rec.pop(key, None)
            raise

        async with self._lock:
            self._rec[key] = PendingRecord(state="done", result=result)
        return result

    async def _await_pending(self, key: str, timeout: float):
        """等待一个正在进行中的同类操作完成。

        这是"超时不等于失败"的正确处理:
        既然已经有人在跑,最合理的做法是等它的结果。
        """
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            await asyncio.sleep(0.05)
            async with self._lock:
                rec = self._rec.get(key)
                if rec is None:
                    break                      # 前一个执行失败并被清除
                if rec.state == "done":
                    return rec.result
        raise TimeoutError(f"等待同键操作超时: {key}")

数据库层补唯一索引(这一层才是最终防线):

sql 复制代码
-- 幂等的最终防线:数据库唯一约束。
-- 应用层的缓存会丢(进程重启、多实例),唯一索引不会。
ALTER TABLE tickets
  ADD COLUMN IF NOT EXISTS idempotency_key TEXT;

-- 先清理历史重复数据,再建唯一索引(否则索引建不上)
WITH dup AS (
  SELECT id FROM (
    SELECT id,
           ROW_NUMBER() OVER (PARTITION BY idempotency_key ORDER BY created_at) AS rn
    FROM   tickets
    WHERE  idempotency_key IS NOT NULL
  ) t WHERE rn > 1
)
DELETE FROM tickets WHERE id IN (SELECT id FROM dup);

CREATE UNIQUE INDEX IF NOT EXISTS ux_tickets_idem
  ON tickets (tenant_id, tool_name, idempotency_key)
  WHERE idempotency_key IS NOT NULL;

写入侧改成 upsert,让重复插入变成无操作:

sql 复制代码
INSERT INTO tickets (tenant_id, tool_name, idempotency_key, payload)
VALUES (:tenant_id, :tool_name, :idem_key, :payload)
ON CONFLICT (tenant_id, tool_name, idempotency_key) DO NOTHING
RETURNING ticket_id;
-- 无返回行 => 说明这条已经存在,直接按幂等键回查即可

6.5 复盘要点

  • 超时 ≠ 失败。 超时只意味着"我不知道结果",工具可能已经成功。任何"超时即重试"的逻辑都必须配幂等。
  • 幂等的登记必须在执行之前,写入结果在执行之后。 顺序反了,超时场景下幂等完全失效。
  • 应用层缓存不是幂等的最终防线,数据库唯一索引才是。 缓存会随进程重启丢失、在多实例下不共享;唯一索引不会。
  • 幂等键必须带作用域(tenant_id / user_id)。 这次没出事只是运气。
  • 这个坑第 39 篇讲过(超时与幂等是同一件事),但这里失效的形式不同 :讲义讲的是"要有幂等键",而我们是有幂等键但登记时机错了 ------代码看起来是对的,评审也会通过,只有超时这个特定时序下才暴露 。教训是:幂等必须靠注入超时来测试,不能靠读代码确认。

七、事故⑥:日志里出现明文密钥

7.1 现象

text 复制代码
Day 15  14:20  安全同学例行扫描发来一条消息:"logs/app.log 第 88342 行有疑似 API Key"
Day 15  14:22  确认:明文密钥,且该日志系统对全公司可读
Day 15  14:25  声明 SEV-1(安全类)
Day 15  14:40  定位到中间件顺序问题
Day 15  15:10  完成密钥轮转
Day 18  完成全部下游排查与合规留档

日志片段(已脱敏,仅示意结构):

text 复制代码
2026-10-15 13:58:11 DEBUG middleware.request tenant=T-1042
  headers={"authorization": "Bearer sk-REDACTED-EXAMPLE-ONLY",
           "x-api-key": "sk-REDACTED-EXAMPLE-ONLY",
           "content-type": "application/json"}
  body={"messages": [{"role": "user", "content": "帮我查一下上个月的订单"}]}

两个问题同时存在:① 请求头里的密钥是明文;② 用户消息内容也完整入库了(虽然这次不是主要问题,但它同样是合规隐患)。

7.2 排查过程

第一步,确认范围。 扫全量日志:

bash 复制代码
# 扫最近 30 天的日志,统计命中行数与涉及的天数
grep -cE "sk-[A-Za-z0-9]{16,}" logs/app.log*
# logs/app.log:  2,140
# logs/app.log.1: 8,905
# ...

# 确认命中时间是哪一段(决定轮转范围)
grep -nE "sk-[A-Za-z0-9]{16,}" logs/app.log | head -1
grep -nE "sk-[A-Za-z0-9]{16,}" logs/app.log | tail -1

第二步,定位是哪条代码路径打印的。 这一步最快:

bash 复制代码
grep -rnE "(logger|log)\.(debug|info).*headers" --include="*.py" src/ | head
# src/middleware/request_logger.py:34:  logger.debug("middleware.request headers=%s", dict(req.headers))

第三步,为什么脱敏没生效? 我们确实写了脱敏中间件,也确实注册了。查注册顺序:

bash 复制代码
# 看中间件注册顺序
grep -rn "add_middleware\|use(\|register(" --include="*.py" src/app.py | head -20
text 复制代码
src/app.py:41:  app.add_middleware(RequestLoggerMiddleware)    # 先注册:记录请求
src/app.py:42:  app.add_middleware(RedactionMiddleware)        # 后注册:脱敏
src/app.py:43:  app.add_middleware(AuthMiddleware)             # 最后:认证

顺序反了。 中间件是洋葱模型 :先注册的在外层,后注册的在内层 。请求从外往里走------RequestLogger(外层)先看到请求,此时脱敏还没发生,于是它记录下了带明文密钥的原始请求头。

text 复制代码
请求流向:
  RequestLogger(外层,先执行)→ 看到原始 headers,含明文密钥 ❌
      ↓
  Redaction(中层)→ 脱敏
      ↓
  Auth(内层)→ 认证
      ↓
  业务处理

7.3 根因

中间件顺序错误:日志记录在脱敏之前。 这不是"忘了写脱敏",而是写在了错误的执行位置------比"忘了写"更难发现,因为代码评审时两段代码看起来都对。

第二个根因是日志级别 :logger.debug 平时不开,但运维为了排查另一个问题把级别调成了 DEBUG ,两小时内产生了 2000+ 条含密钥的日志。"只在 DEBUG 下打印敏感信息"这个假设是错的------DEBUG 迟早会被打开。

根因 表现 为什么难发现
中间件顺序错 脱敏发生在记录之后 两段代码各自看都对,评审看不出
依赖日志级别做保护 DEBUG 一开就泄露 生产环境迟早会开 DEBUG

7.4 修复

三处修改,按"纵深防御"原则叠加三层:

python 复制代码
# fix/incident_06.py ------ 事故⑥修复:顺序断言 + Formatter 兜底 + 敏感字段白名单
from __future__ import annotations

import logging
import re

SENSITIVE_RE = re.compile(
    r"(sk-[A-Za-z0-9]{16,}"
    r"|Bearer\s+[A-Za-z0-9._\-]{16,}"
    r"|1[3-9]\d{9}"
    r"|\d{17}[\dXx])"
)

# 请求头白名单:只记录这些,其余一律不进日志
SAFE_HEADERS = {"content-type", "content-length", "user-agent",
                "x-request-id", "x-tenant-id"}


class RedactingFormatter(logging.Formatter):
    """第二层防御:Formatter 兜底脱敏。

    为什么还要这一层:中间件顺序可能被后人改错,
    而 Formatter 是每条日志的必经之路,绕不过去。
    """

    def format(self, record: logging.LogRecord) -> str:
        return SENSITIVE_RE.sub("<redacted>", super().format(record))


def install_logging(level: int = logging.INFO) -> None:
    handler = logging.StreamHandler()
    handler.setFormatter(
        RedactingFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")
    )
    root = logging.getLogger()
    root.handlers = [handler]
    root.setLevel(level)


def safe_headers(headers: dict) -> dict:
    """第三层防御:请求头白名单。

    从"过滤掉已知的敏感字段"改为"只保留已知的安全字段"------
    黑名单永远追不上新增的敏感字段,白名单才是稳的。
    """
    return {k.lower(): v for k, v in headers.items() if k.lower() in SAFE_HEADERS}


def assert_middleware_order(chain: list[str]) -> None:
    """第一层防御:启动期断言中间件顺序。

    把"顺序"从隐性约定变成显性断言------
    这是事故⑥最重要的修复:让"顺序错了"在启动时就崩,
    而不是等到日志泄露了才发现。
    """
    rules = [
        ("redaction", "request_logger"),     # 脱敏必须在日志记录之前
        ("auth", "request_logger"),          # 认证也应在日志记录之前
    ]
    for before, after in rules:
        if before in chain and after in chain:
            if chain.index(before) > chain.index(after):
                raise RuntimeError(
                    f"中间件顺序错误:{before} 必须在 {after} 之前,"
                    f"当前顺序为 {chain}"
                )


if __name__ == "__main__":
    install_logging()
    # 启动时校验,顺序错了直接拒绝启动
    assert_middleware_order(["redaction", "auth", "request_logger"])
    logging.getLogger("demo").info("headers=%s", safe_headers({
        "authorization": "Bearer sk-EXAMPLEEXAMPLEEXAMPLE",
        "content-type": "application/json",
    }))
    # 输出:headers={'content-type': 'application/json'}

事故处置的三个动作(定位只要 20 分钟,处置花了 3 天):

bash 复制代码
# 1) 立即轮转密钥(这一步不能拖)
#    ------ 在密钥服务平台生成新密钥,旧密钥设 24 小时宽限期后吊销

# 2) 清理已泄露的日志
#    注意:不要直接 rm,先确认日志系统的保留策略与合规要求
python - <<'PY'
import re, pathlib
pat = re.compile(r"sk-[A-Za-z0-9]{16,}")
for p in pathlib.Path("logs").glob("app.log*"):
    out = []
    changed = 0
    for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
        new = pat.sub("<redacted>", line)
        if new != line:
            changed += 1
        out.append(new)
    p.write_text("\n".join(out) + "\n", encoding="utf-8")
    print(f"{p.name}: 清理 {changed} 行")
PY

# 3) 加入 CI 门禁,防止复发
grep -rnE "(logger|log)\.(debug|info|warning|error).*(api_key|secret|token|password|authorization)" \
  --include="*.py" src/ && echo "❌ 存在敏感字段进日志" && exit 1 || echo "✅ 通过"

7.5 复盘要点

  • 中间件顺序是隐性知识,必须变成显性断言。 事故⑥最重要的修复不是脱敏代码,而是 assert_middleware_order ------让"顺序错了"在启动期就崩掉。
  • 不要用日志级别做安全边界。 "只在 DEBUG 下打印"迟早会失效,敏感信息在任何级别都不该出现。
  • 过滤敏感字段要用白名单,不用黑名单。 黑名单永远追不上新增的敏感字段。
  • 纵深防御要有三层 :中间件顺序断言(事前)+ Formatter 兜底(事中)+ 日志离线扫描(事后)。单层防护一定会被绕过。
  • 这个坑第 28 篇讲过(横切能力走中间件),但这里失效的形式不同 :讲义讲的是"中间件应该承担横切关注点",而事故暴露的是多个中间件之间的顺序依赖没有约束机制 。教训是:当正确性依赖于多个组件的相对顺序时,必须有一个断言来验证这个顺序。

图2:事故分级与响应节奏------先止血再查根因是唯一正确的顺序


八、六个事故的共性归纳

8.1 四条共性

共性一:所有事故的根因都不是"不知道",而是"没接上"。 分层限流、退避重试、幂等键、中间件脱敏、模型版本管理------前序篇目都写过,代码里大多也有。失效的是机制之间的接缝:限错了度量、少了一个字段、顺序错了一位、登记晚了一步。

共性二:监控指标"看起来健康"是最强的误导。 事故①里 CPU 22%、错误率 0.3%,一切正常;事故③里日累计预算 93%,一切正常。共同点是:我们监控的量与真实瓶颈不是同一个量。

共性三:放大器才是事故的真正主体。 六个事故里真正致命的都不是初始故障,而是放大环节:

事故 初始故障 放大器 放大效果
① 长会话持锁时间长 无界排队 延迟从 2s 线性涨到 40s
② 单租户批量任务 429 后立即重试 从"一个租户慢"到"全站不可用"
③ 上游 429 带完整上下文的重试 ×7 从"偶尔失败"到"超预算 5 倍"
④ 上游灰度切版本 无运行期校验 从"输出风格略变"到"用户投诉"
⑤ 一次网络超时 缺幂等 从"一次重试"到"34 条脏数据"
⑥ DEBUG 级别被打开 中间件顺序错 从"调试日志"到"密钥泄露"

共性四:修复必须分三层,只做一层一定复发。 止血(立即降低影响)→ 根因修复(消除这个失效模式)→ 防复发(让同类问题不可能再发生或能被提前发现)。
#mermaid-svg-s5WyGBN6GHy7FqsS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s5WyGBN6GHy7FqsS .error-icon{fill:#552222;}#mermaid-svg-s5WyGBN6GHy7FqsS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s5WyGBN6GHy7FqsS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s5WyGBN6GHy7FqsS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS .marker.cross{stroke:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s5WyGBN6GHy7FqsS p{margin:0;}#mermaid-svg-s5WyGBN6GHy7FqsS defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-s5WyGBN6GHy7FqsS g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-s5WyGBN6GHy7FqsS g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-s5WyGBN6GHy7FqsS g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-s5WyGBN6GHy7FqsS g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-s5WyGBN6GHy7FqsS .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-s5WyGBN6GHy7FqsS .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-s5WyGBN6GHy7FqsS .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-s5WyGBN6GHy7FqsS .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-s5WyGBN6GHy7FqsS .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-s5WyGBN6GHy7FqsS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s5WyGBN6GHy7FqsS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s5WyGBN6GHy7FqsS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s5WyGBN6GHy7FqsS .edgeLabel .label text{fill:#333;}#mermaid-svg-s5WyGBN6GHy7FqsS .label div .edgeLabel{color:#333;}#mermaid-svg-s5WyGBN6GHy7FqsS .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-s5WyGBN6GHy7FqsS .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-s5WyGBN6GHy7FqsS .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-s5WyGBN6GHy7FqsS .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS #statediagram-barbEnd{fill:#333333;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s5WyGBN6GHy7FqsS .cluster-label,#mermaid-svg-s5WyGBN6GHy7FqsS .nodeLabel{color:#131300;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-s5WyGBN6GHy7FqsS .note-edge{stroke-dasharray:5;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-note text{fill:black;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram-note .nodeLabel{color:black;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagram .edgeLabel{color:red;}#mermaid-svg-s5WyGBN6GHy7FqsS #dependencyStart,#mermaid-svg-s5WyGBN6GHy7FqsS #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-s5WyGBN6GHy7FqsS .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s5WyGBN6GHy7FqsS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 监控告警或用户反馈
按影响面判 SEV
降级 限流 熔断 回滚
影响得到控制
找到失效点
注入故障确认修复生效
补断言 补门禁 补指标
注入故障后仍未拦住
检测
定级
止血
定位
根因修复
验证
防复发
重新修复
止血优先于定位

不要在未止损时深挖根因
必须注入故障验证

读代码确认不算验证

8.2 「事故类型 → 检测手段 → 前置防护」总表

事故类型 早期信号 检测手段 前置防护 失效时的止血动作
延迟劣化型(①) P99 单调上升、错误率不变 分层等待时长指标(concurrency_wait_seconds{layer}) 队列有界 + 等待超时 + 连接池对齐并发 收紧会话排队深度上限、扩容连接池
噪声邻居型(②) 单租户 Token 占比异常 按租户聚合 rate(llm_tokens_total[5m]) 租户级令牌桶 + 长短任务分离 对该租户单独限流、暂停批量入口
成本失控型(③) 放大倍数 = 调用数/请求数 > 3 SQL 对账 + 小时级预算指标 小时预算熔断 + 请求级重试预算 触发熔断、关闭重试、强制降级
静默变更型(④) 输出语言/格式分布漂移 记录并比对上游返回的模型标识 版本锁定 + 运行期校验 + 评测回归 回滚到上一个确定版本
数据不一致型(⑤) 同幂等键出现多条记录 GROUP BY idempotency_key HAVING COUNT(*)>1 执行前登记 + DB 唯一索引 + 键带作用域 暂停该工具自动执行、转人工核对
信息泄露型(⑥) 日志命中密钥正则 离线扫描 + CI 代码扫描 顺序断言 + Formatter 兜底 + 头白名单 立即轮转密钥、清理日志、缩权限

8.3 六类事故的排查顺序(通用)

不管哪一类,我用的排查顺序是固定的------它比记住六个具体案例更有价值:

text 复制代码
1. 确认是不是上游的问题
   → 对比"我方记录的调用耗时"与"上游响应头的服务端耗时"
   → 一致:问题在我方;不一致:问题在上游

2. 拆分耗时:等待 vs 执行
   → 等待占比高 => 排队/限流/连接池,看分层等待指标
   → 执行占比高 => 看单次调用的内部构成(重试次数、上下文长度)

3. 按维度聚合找异常个体
   → 按租户、按用户、按会话轮次、按任务类型分别聚合
   → 事故②③都是靠这一步定位的

4. 对账(数据类问题)
   → 用 SQL 找重复/缺失/不一致,而不是看单条日志

5. 注入故障验证修复
   → 修改完必须主动制造同样的故障,确认被拦住
   → 读代码确认不算验证

每一步落到一个具体的量上,这样排查才不是靠直觉。下表是本文六个事故里实际用到的对应关系:

排查步骤 落到什么量 事故① 事故② 事故③ 事故④ 事故⑤ 事故⑥
确认是否上游 上游服务端耗时 vs 我方耗时 我方慢(排队 31.7s) 上游 429 是结果不是原因 上游 429 触发点 上游返回模型标识 网络层超时 与上游无关
拆分等待与执行 concurrency_wait_seconds{layer} 关键线索 全局层等待 重试次数 不适用 不适用 不适用
按维度聚合 租户 / 用户 / 轮次 / 任务类型 会话轮次 租户维度 重试次数 按天模型分布 按幂等键分组 按日志行
对账 SQL 分组统计 不适用 任务类型 SQL 放大倍数 SQL 模型分布 SQL 重复键 SQL 密钥正则扫描
注入验证 复现并确认被拦住 并发 20 次测串行 单租户打满 超预算调用 切版本比对 注入超时 故意打敏感头

💡 "每一步落到一个具体的量"是这张表真正的用法。 排查时最怕的是"我看了日志但看不出问题"------那通常是因为看的量不对。把上面的对应关系抄进你们的 runbook,事故的定位时间能显著缩短。

图3:排查手段四象限------指标聚合、链路追踪、日志对账、故障注入


九、适用边界与风险提示 ⚠️

① 六个事故的形态依赖具体技术栈,但排查顺序可迁移。 同样的"连接池瓶颈"在 Go、Node.js 上的参数完全不同,但"拆分等待与执行时长"这一步是通用的。

② 文中的数字(P99 40s、超预算 5 倍、34 条脏数据)是这次的量级,不是普遍规律。 不要拿它们当阈值------阈值必须由你自己的基线测出来。

③ SEV 分级是参照 PagerDuty 的公开定义,团队必须替换成自己的口径。 PagerDuty 的定义里明确建议把分级绑定到具体指标 (比如"影响超过 50% 用户"),而不是抽象描述。另外他们给出的一条原则很实用:不确定级别时按更高一级处理,事中不要争论分级。

④ 事故复盘的产出应该是"门禁项",不是"注意事项"。 "大家以后记得检查中间件顺序"这句话三个月就会失效;assert_middleware_order 这行代码不会。凡是能用断言表达的,都不要写成注意事项。

⑤ 不要指望复盘能防住所有事故。 六个事故里有两个(④的静默变更、⑥的 DEBUG 被打开)在发生前没有任何团队会想到要防。复盘的价值是让同类事故不复发,以及缩短下一次未知事故的定位时间------后者靠的是可观测性(事故①②③都靠分层指标才快速定位)。


十、总结

核心只有一句:这六个坑前序篇目都讲过,但它们在生产环境失效的形式和讲义不一样------讲义讲的是机制,事故暴露的是机制没接上的那条缝。

五个最该记住的结论:

第一,"机制做了"和"机制生效了"是两件事。 六个事故里没有一个的根因是"不知道这个机制"。验证生效的唯一方式是注入故障,读代码和代码评审都看不出"顺序错了一位""登记晚了一步"这类问题。

第二,监控的量必须与真实瓶颈是同一个量。 事故①监控 CPU 和错误率(都正常),真正的问题是会话锁等待;事故②监控请求数(合规),真正的瓶颈是 Token 速率。先做压测找到第一个报错的资源,再决定监控什么。

第三,放大器才是事故主体。 无界队列、立即重试、带完整上下文的重试、缺幂等、DEBUG 日志------初始故障通常只是个小问题,放大器把它变成 SEV-1。排查时先看放大环节,修复时先切断放大器。

第四,超时 ≠ 失败。 事故⑤的根因就这一句话。任何"超时即重试"的逻辑都必须配幂等,且幂等必须在执行前登记、并最终落到数据库唯一索引上。

第五,隐性依赖必须变成显性断言。 中间件顺序、幂等键字段、模型版本------这些"看起来对"的东西,只有变成启动期断言或 CI 门禁,才真正可靠。

最后一点 :止血优先于定位。 事故②从声明 SEV-1 到恢复用了 31 分钟,其中 20 分钟花在"先限流止血"上------如果当时先去查根因,全站不可用的时间会翻倍。先止血、再定位、最后防复发,这个顺序在任何级别的事故里都成立。

图4:三层修复模型------止血、根因修复、防复发缺一不可

落地自检清单(Checklist)

  • ✅ 有分层等待时长指标(会话 / 用户 / 租户 / 全局),能定位"卡在哪一层"
  • ✅ 分位数延迟单独告警,与错误率告警解耦
  • ✅ 所有队列有界,包括会话锁的排队深度
  • ✅ 连接池大小 ≥ 实际并发 × 1.2 ,且 keepalive_expiry 与上游匹配
  • ✅ 有租户级 Token 速率限制(不是请求数限制)
  • ✅ 批量任务与交互式资源分离,最好在 API 层就分开入口
  • ✅ 429 重试有上限 + 退避 + jitter ,且尊重 Retry-After
  • ✅ 重试预算是请求级的,不是单次调用级
  • ✅ 有小时级预算熔断(不是天级告警)
  • ✅ 放大倍数 = 调用数 / 请求数作为日常巡检指标
  • ✅ 模型版本显式锁定 且运行期比对上游返回值
  • ✅ 所有文件读写显式指定 encoding="utf-8"
  • ✅ 幂等执行前登记 ,且有数据库唯一索引兜底
  • ✅ 幂等键带 tenant_id / user_id 作用域
  • ✅ 有重复数据对账 SQL 定期跑
  • ✅ 中间件顺序有启动期断言
  • ✅ 日志脱敏在 Formatter 层 兜底,敏感头用白名单
  • ✅ 有日志离线扫描(定期跑,不只上线前跑一次)
  • ✅ 有密钥轮转流程且演练过
  • ✅ 修复后注入故障验证,且门禁本身每季度验证一次

常见问题(FAQ)

Q1:事故复盘该写多详细?

详细到"下一个人照着能复现排查过程"这个程度。 重点是排查过程 (用了什么命令、看到了什么、为什么排除了某个方向),而不是根因结论------结论一句话就够,过程才是可复用的资产。

Q2:怎么判断是上游问题还是自己的问题?

对比"你记录的调用耗时"和"上游响应头里的服务端耗时"。 两者一致 → 慢在你自己的排队或处理;上游侧耗时长 → 是上游的问题。再加一个判据:扩容后如果 429 增加但吞吐不变,瓶颈一定在上游配额。

Q3:连接池到底设多大?

≥ 实际并发调用数 × 1.2 ,注意是"调用数"不是"请求数"(一个请求可能几十次调用)。另外必须监控池的等待队列长度------长期大于 0 说明池是瓶颈,这时加大并发限制不会有任何效果。

Q4:为什么告警没响?

最常见的三个原因 :① 监控的量不是瓶颈(事故①②③都是);② 告警粒度太粗(天级成本告警看不到凌晨的消耗);③ 只看平均值不看分位数。定期做"告警有效性演练":手动触发一次故障,看多久能收到告警。

Q5:幂等键放在 Redis 还是数据库?

两个都要,职责不同。 Redis 做执行前登记与结果缓存 (快,覆盖秒级到分钟级的重试窗口);数据库唯一索引做最终防线 (慢但不丢、跨实例一致)。只做 Redis 会在进程重启或多实例时失效。

Q6:模型版本锁定了还会出问题吗?

会。 锁定只保证"我请求的是这个版本",上游返回的可能不是 (走网关、代理时尤其常见)。必须在运行期比对响应里返回的模型标识并打点告警。 另外上游也可能在不改版本号的情况下调整行为,所以评测集要定期重跑,不能只在版本变更时跑。

Q7:SEV 分级怎么定才不吵架?

绑定到具体指标而不是抽象描述。 比如"SEV-1 = 超过 50% 用户的核心功能不可用,或发生数据泄露/资损",而不是"很严重"。PagerDuty 给的一条原则很实用:不确定时按更高一级处理,事中不要争论分级,事后复盘时再回顾。

Q8:小团队没有专职 SRE,这些能落地吗?

能,但只做最关键的六项 :分层等待指标、队列有界、小时预算熔断、运行期模型校验、幂等唯一索引、日志 Formatter 脱敏。这六项的实现成本都在几十行以内,覆盖了本文六个事故里五个的直接防护。


参考资料

  1. Google SRE Book --- Handling Overload (有界队列、负载丢弃与背压):https://sre.google/sre-book/handling-overload/
  2. Google SRE Book --- Addressing Cascading Failures (事故②正反馈雪崩的分析框架):https://sre.google/sre-book/addressing-cascading-failures/
  3. PagerDuty Incident Response --- Severity Levels (SEV-1 到 SEV-5 的公开定义与响应流程):https://response.pagerduty.com/before/severity_levels/
  4. HTTPX 官方文档 --- Resource Limits (连接池 max_connections / max_keepalive_connections / keepalive_expiry 默认值):https://www.python-httpx.org/advanced/resource-limits/
  5. PostgreSQL 官方文档 --- INSERT ... ON CONFLICT 与唯一索引(幂等的最终防线):https://www.postgresql.org/docs/current/sql-insert.html
  6. Python 官方文档 --- logging 模块与自定义 Formatter:https://docs.python.org/3/library/logging.html
  7. Google SRE Workbook --- Implementing SLOs (SLI / SLO / 错误预算,用于事故定级):https://sre.google/workbook/implementing-slos/
  8. Nygard, M. T. --- Release It! (舱壁隔离与断路器模式,本文事故②③的防护思想来源):https://pragprog.com/titles/mnee2/release-it-second-edition/
  9. Kleppmann, M. --- Designing Data-Intensive Applications (超时不等于失败、幂等的分布式语义):https://dataintensive.net/
  10. OWASP --- Top 10 for Large Language Model Applications (敏感信息泄露与不安全输出处理):https://owasp.org/www-project-top-10-for-large-language-model-applications/
相关推荐
秋饼17 分钟前
GPT-Live 全双工语音 API Java 服务端落地实战:事件协议、会话恢复与生产踩坑
java·ai·技术分享·后端开发
bst@微胖子19 分钟前
SpringAI快速入门(7) RAG 实战(三):检索增强配置与重排序实战
ai
#六脉神剑22 分钟前
myBuilder 11 月新版本:AI辅助设计
ide·人工智能·低代码·ai·mybuilder
用户95179422510292 小时前
最小循环:让 Agent 跑起来(I1)
python·agent
字节跳动数据平台2 小时前
不重拍,也能换道具、换商品、换画风?短剧转漫、电商换款新解法来了
大数据
武子康2 小时前
攻击动作做对了,游戏规则就对了吗?H3-World 与三条控制路线的分工
大数据·人工智能·agent
ZhengEnCi3 小时前
Agent 任务规划怎么选:ReAct 与 Plan-and-Execute 的成本账与适用边界
agent
vivo互联网技术3 小时前
AI Agent 技术全景解析 —— 从提示词工程到高级智能体构建
人工智能·agent