摘要 :本文复盘六个真实形态的 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 节讲的"请求计数模型失效",但这次的失效形式更隐蔽:
- 我们做了用户级限流(每用户 5 并发),看起来"有隔离";
- 但没有做租户级的 Token 速率限制,所以单个租户可以无限放大自己的 Token 消耗;
- 批量任务和交互式任务共用一个池,批量任务把槽位占满后,交互式请求只能排队;
- 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 脱敏。这六项的实现成本都在几十行以内,覆盖了本文六个事故里五个的直接防护。
参考资料
- Google SRE Book --- Handling Overload (有界队列、负载丢弃与背压):https://sre.google/sre-book/handling-overload/
- Google SRE Book --- Addressing Cascading Failures (事故②正反馈雪崩的分析框架):https://sre.google/sre-book/addressing-cascading-failures/
- PagerDuty Incident Response --- Severity Levels (SEV-1 到 SEV-5 的公开定义与响应流程):https://response.pagerduty.com/before/severity_levels/
- HTTPX 官方文档 --- Resource Limits (连接池
max_connections/max_keepalive_connections/keepalive_expiry默认值):https://www.python-httpx.org/advanced/resource-limits/ - PostgreSQL 官方文档 ---
INSERT ... ON CONFLICT与唯一索引(幂等的最终防线):https://www.postgresql.org/docs/current/sql-insert.html - Python 官方文档 ---
logging模块与自定义Formatter:https://docs.python.org/3/library/logging.html - Google SRE Workbook --- Implementing SLOs (SLI / SLO / 错误预算,用于事故定级):https://sre.google/workbook/implementing-slos/
- Nygard, M. T. --- Release It! (舱壁隔离与断路器模式,本文事故②③的防护思想来源):https://pragprog.com/titles/mnee2/release-it-second-edition/
- Kleppmann, M. --- Designing Data-Intensive Applications (超时不等于失败、幂等的分布式语义):https://dataintensive.net/
- OWASP --- Top 10 for Large Language Model Applications (敏感信息泄露与不安全输出处理):https://owasp.org/www-project-top-10-for-large-language-model-applications/