一个探针不是一个系统

一句话

写出第一个探针只证明了这个形态可行。探针库和告警规则是同一类东西,一堆随时间累积的判定,而告警规则已经把这类东西的结局演示过一次了。 所以「怎么做成一个能用的系统」这个问题,等价于「怎么不重演那个结局」。

前一篇讲了为什么要有探针层,以及第一个探针跑在生产规则集上的结果。这一篇讲从那一个探针到一个能被团队依赖的判定层,中间隔着什么。


先看那个已知的结局

告警规则腐烂有三条机制,它们不是执行力问题,是激励结构问题:

机制 内容
加告警免费 新增一条是一行代码,没有准入成本
删告警受罚 删掉之后万一出事,责任在删的人身上;留着不会有人怪你
失效无声 规则因指标改名而永久静默,表现出来和一切正常完全一样

任何判定库只要满足这三条,最终形态都是一样的:数量单调增长,信噪比单调下降,没有人敢清理。

所以设计探针层的时候,逐条问「这一条我怎么破」,比列一张功能清单有用得多。


破第一条:准入门槛是「能淘汰哪些假设」

告警规则的准入问题在于门槛太低且判据模糊。「这个指标异常时我想知道」听起来永远成立。

探针层的准入判据可以硬得多,因为探针的用途比告警窄且明确:它存在的意义是帮助收敛一次调查。

所以提案模板只有一个必填的核心问题:

1
2
3
4
5
这个探针能区分哪些假设?

discriminates: [ingress_queueing, upstream_slowdown, false_signal]

如果答案是「它对所有候选假设都返回支持」,这个探针的信息量是零,不该存在。

这条判据来自情报分析的 ACH 方法:证据的价值不在于它支持某个假设,在于它能把假设集合切开。一条对所有假设都支持的证据,信息量是零。

配套的三个必填项:

字段 作用
owner 没有 owner 的探针在第一次退役审查时直接删除
cost_class 这个探针的查询开销档位,用于配额,见后文
replaces 它是否覆盖了某条既有告警规则的判定。填了就意味着那条规则可以被生成而不是手写

replaces 这一项承担了防重复的职责。判定重复是告警规则膨胀的一大来源,而重复往往不是有意的,是因为没人查得动几百条规则里有没有同义的那条。


破第二条:探针有调用记录,所以可以做基于使用的回收

这是探针相对告警规则的结构性优势,值得单独说。

告警规则没有「调用」这个概念,只有「触发」。一条从不触发的规则,你无法区分它是「守着一个从未发生的故障」还是「已经坏了」。所以删它永远有风险,于是没人删。

探针是被调用的。调用就有记录。于是第一次可以基于使用做垃圾回收,判据是行为不是猜测:

stateDiagram-v2 [*] --> proposed : 提案含 discriminates / owner / cost_class proposed --> draft : review 通过 draft --> stable : 有实测支撑,且至少在一次真实调查里被采用 stable --> deprecated : 连续 90 天零调用,或 owner 离开且无人接手 deprecated --> stable : 期内被重新调用或有人接手 deprecated --> [*] : 再 30 天无人认领,删除 draft --> [*] : 60 天未晋升,自动丢弃

三个设计点:

零调用不等于立刻删除,先降级。 deprecated 是一个可逆状态,给 30 天窗口。这样清理的心理成本降到接近零,而心理成本正是「删告警受罚」的真实内容。

draft 有过期时间。 提出探针很容易,写完就忘也很容易。60 天不晋升自动丢弃,避免 draft 区变成垃圾场。

晋升到 stable 的判据是「在一次真实调查里被采用」,不是「代码写完了」。 这条判据无法靠自觉满足,需要调用记录来证明,所以它也是可执行的。

调用记录同时提供了一个别的东西:哪些探针在被高频调用,说明它们对应的判定值得考虑固化成常驻告警。 使用数据是双向的,既指导删除,也指导晋升。


破第三条:失效无声,靠元探针加一个 SLI

第三条最贵,因为前两条至少还看得见。

元探针(检查这个观测本身还有效吗)解决的是存量检测,这个前一篇讲过。这里补另一半:探针层自己需要一个 SLI,而最有诊断力的那个是 unknown 率。

1
probe_unknown_ratio = unknown 返回数 / 总调用数

单个探针返回 unknown 是正常的,数据缺失本来就该显式说。但unknown 率的突然上升几乎总是意味着数据源那一侧出了问题:exporter 挂了、指标改名了、relabel 规则改动了、租户标签变了。

这个信号在传统告警体系里根本不存在,因为传统体系里数据缺失是静默的。把它显式化之后,它成了整个观测栈健康度的一个前置指标。

探针层的三个基础 SLI:

SLI 说的是什么 异常时的含义
调用成功率 探针本身有没有跑挂 探针代码或 TSDB 的问题
P95 调用延迟 会不会拖慢调查 表达式退化或 TSDB 压力
unknown 率 数据源健康度 上游观测栈出问题了

单一定义,多种绑定

这是和现有体系共存时最关键的一个架构决策。

天真的做法是探针库和告警规则并存,两边各自维护。结果必然是漂移:同一个判定在两处定义,改了一处忘了另一处,然后没有人知道哪个是对的。

正确的做法是方向明确的单向生成

flowchart LR P["探针定义(唯一真相源)<br/>表达式 + 参数约束 + 返回契约"] P -->|"绑定 page policy<br/>生成"| A["alert rule yaml"] P -->|"绑定 record policy<br/>生成"| R["recording rule yaml"] P -->|"on-demand"| G["agent 直接调用"] A --> AM["Alertmanager"] R --> TSDB[("TSDB")]

告警规则从探针定义生成,不是手写另一份。这样带来三件事:

  • 判定只有一个真相源,漂移在结构上不可能发生
  • 迁移可以是渐进的:先把新增的判定走探针定义,存量规则按需回收,不需要一次性重写
  • 「这条告警背后的判定是什么」这个问题第一次有确定答案,因为它有一个探针 ID

需要接受的代价是多了一层生成,调试告警时要看生成产物而不是手写文件。这个代价和 IaC 相对手工改配置的代价是同一种,我认为同样值得。


正确性怎么验证:回放历史事故就是 eval

探针会写错,而且写错的方向通常是误报。我自己写第一个探针时就有两个解析 bug,两个都是把活着的规则报成死的。一个会驱动人删好规则的工具,比没有这个工具更糟。

所以探针必须有测试,而探针的测试有一个天然的数据来源:历史事故

每一份事故记录里都有时间窗、受影响的范围、以及事后确认的结论。这三样正好构成一条测试用例:

1
2
3
4
5
6
7
- probe: decompose_latency
fixture: incident-2026-04-18-ingress-queueing
window: {from: "...", to: "..."}
expect:
verdict: true
dominant_component: queueing
# 这次事故的结论是排队占主导,探针在这个窗口上必须能得出同一结论

三层测试,成本递增:

测什么 成本
静态 表达式语法、参数约束是否自洽、返回 schema 是否合规 极低,可以进 pre-commit
固定 fixture 用一段录制的时序数据,断言已知输入得到已知输出 低,需要一次录制
事故回放 用真实事故的时间窗,断言探针能得出事后确认的结论 中,但每次事故复盘顺带产出一条

第三层是最有价值的一层,而且它有复利:每一次事故复盘顺带产出一条回归用例,探针库的测试集随着事故积累自动增长。 这也倒逼一件好事,事故复盘必须写清楚时间窗和结论,否则它无法被转成用例。

需要注意的约束是数据保留期。事故回放依赖那个时间窗的数据还在,所以关键事故的原始查询返回应该在事中就落盘,而不是指望事后还能查到。


运行时的三件事

前面讲的是生命周期,这一节是跑起来之后的。

配额。 探针被自由调用,就可能打爆 TSDB。三条约束:每个探针在提案时标 cost_class;高开销探针在单次调查内有调用次数上限;同一次调查内相同的 probe + args 结果复用,不重复查。第三条的收益比想象中大,因为调查过程里反复确认同一件事是常态。

权限。 这是一个容易漏的坑。探针如果以服务身份查 TSDB,就绕过了调用者的租户隔离,一个只该看 A 租户的调用者可以通过探针读到 B 租户的数据。探针必须继承调用者的权限,不能提权。 实现上是把调用者身份透传到查询层,而不是让探针持有一个全权凭证。

降级。 观测栈自己会出问题,这时候探针返回什么至关重要:

情况 必须返回 绝不能返回
TSDB 不可用 unknown + reason false
查询超时 unknown + 已用时长 部分结果当完整结果
数据有 gap 带低 data_completeness 的结果 不标注的结果
表达式报错 unknown + 原始错误 静默跳过

右边那一列全是同一类错误:把「不知道」表达成「没问题」。 这正是告警规则第三条腐烂机制的根源,探针层如果在降级路径上重犯,前面所有设计都白做。


最小可行的成熟形态

不需要一次做全。按依赖顺序,前四件是地基:

顺序 做什么 不做的后果
1 返回契约定死并版本化 后面一切都要返工
2 调用审计落盘 生命周期和配额都无从谈起,它们都依赖调用记录
3 降级路径全部返回 unknown 直接重演失效无声
4 静态测试进 CI 误报会驱动人做错误的清理
5 生命周期状态机与退役规则 库开始膨胀
6 从探针生成告警规则 判定开始漂移
7 事故回放测试集 正确性只能靠人工检查

第 2 件最容易被跳过,因为它在只有几个探针的时候看不出价值。但生命周期管理、配额、使用统计三件事全部建立在调用记录之上,没有它,后面就只能靠人工判断,而人工判断正是告警规则失败的地方。


探针和 agent 之间还有一层

单个探针一次调用只回答一个问题。真实调查从来不是一个问题接一个问题地问,而是一开始就有一堆信号同时冒出来。一次线上事故,同一个时间窗里可能有五六条告警同时触发,分布在不同租户、不同组件。这批信号是不是同一件事,谁跟谁沾边,这件事本身不需要世界知识,同 cluster、时间窗是否重叠、有没有共享 label,几条确定性判据就能判完。

如果把这批原始信号直接丢给 agent,它要先花一轮推理把这件事重新做一遍。这违背了前面反复用到的那条设计目标:能确定性算出来的判断,不该留给会幻觉的那一层去做。所以探针和 agent 之间需要一层编排,做的事是关联同一批信号、把该跑的那批探针跑一遍、按影响面排序,最后拼成一份东西交给 agent。这一层本身不调用模型,和探针一样是纯确定性代码。

agent 的输入因此不是原始探针返回值,是编排层产出的一份 incident bundle。

flowchart LR T["告警 / 人主动问 / 定时巡检"] --> O["编排层:关联 + 补探针 + 排序"] O --> B[("incident bundle")] B --> AG["agent:生成假设 → 选探针 → 验证"] AG --> R["假设树 + 建议动作"] R -.可复现的判断沉淀为新探针.-> O

bundle 里最要紧的四个字段

1
2
3
4
5
6
7
8
{
"bundle_id": "...",
"window": {"from": "...", "to": "..."},
"correlation_basis": ["same cluster", "window overlap 4m", "shared tenant label"],
"impact": {"affected_tenants": 12, "burn_rate": 6.4, "time_to_exhaustion": "22m"},
"unknowns": [{"probe": "metric_is_fresh", "reason": "series stale 14m"}],
"not_queried": ["变更历史(当前调查阶段还查不了)", "trace 数据(还没接入)"]
}

correlation_basis 存在的理由是防止 agent 把编排层的分组当成既定事实。这几条信号为什么被认为是同一件事,得显式写出来,不然 agent 拿到一批已经分好组的信号,会默认这个分组是对的,调查方向从第一步就被这个没说出口的假设锁死。写成 same clusterwindow overlap 4m 之后,这个分组本身变成一条可以被推翻的证据,不再是一个不能质疑的前提。

impact 对应的是前面「还剩多久」那类判断,受影响的租户数、预算消耗速率、预计耗尽时间,在编排层就该算完。这些数字不该留给 agent 去估,道理和探针要返回标量而不是布尔是一样的:确定性能算的东西,交给模型只会更贵、更不可信。

unknowns 把探针的三态往上聚合了一层。单个探针返回 unknown,说明的是这一次查询没查到。unknowns 说明的是这次调查里,哪几个观测整体上不可信。少了这个字段,agent 会把「没查到」直接当成「没问题」,这正是前面反复出现的那个坑,只是这次它出现在编排层,不是单个探针里。

not_queried 解决的是另一个更隐蔽的问题:覆盖率如果不带着「查了哪些维度、没查哪些」一起报,它会自我恭维。一次调查只看了三个维度,覆盖率写 100% 没有意义。这个字段把「没想到要查」变成「想到了但明确没查」,区别很大,前者是盲区,后者至少是一个可以被拿出来质疑的决定。


人拿到手的,应该是已经查过一轮的东西

不是两套系统。判定只有一个定义,告警规则从探针生成这件事前面讲过。这里要补一个此前没讲的整合点:agent 产出的东西最终要交到人手上,交接发生在哪一刻,决定了这套系统到底值多少。

理想的顺序是,告警触发之后,agent 在人打开 Slack 之前就把 bundle 和第一轮假设准备好。人看到的不再是一条原始 alert,而是一份已经跑过一轮的调查:哪些假设被验证掉了,剩下哪几条,影响面有多大。人的第一个动作从「开始查」变成「审阅并决定」。

这个定位给出了一个可测量的成功判据:从 alert firing 到人做出第一个决策的时间。这个指标比「MTTR 降低了多少」诚实,因为 MTTR 里混着这套系统完全管不到的部分,比如修复动作本身要跑多久,要不要等别的团队配合。人做出第一个决策这一段,恰好就是 agent 的准备工作实际能影响到的那一段,系统做得好不好,直接体现在这段时间上,不会被后面一大段无关的执行时间稀释掉。

接的时候有一步不能乱:bundle 没填满之前,不要接 agent。前面几类探针、编排层的关联与富化都得先跑通,bundle 里 impact 这些字段能自动填出来之后,再让 agent 消费它。提前接的后果很直接:bundle 是空的或者半空的,agent 发现能用的信息不够,就会退回去自己直接查 TSDB,绕开探针和编排层,变回一开始就想避免的那种自由查询。


边界

我做到第 1 到第 4 之间。返回契约有了,第一个元探针跑通并在生产规则集上出了结果,静态测试有但不完整。生命周期状态机、从探针生成告警规则、事故回放测试集都还是设计。编排层、bundle 契约、和 agent 对接的那几步,同样只停留在设计阶段,没有代码。

所以这篇文章的定位是设计而不是复盘。我把它写出来,是因为「写出第一个探针」和「有一个能用的判定层」之间的距离,比写第一个探针时看起来要大得多,而这个距离恰恰是判定库这类东西成败的地方。第一步是最容易的那一步。