dashboard 是给人眼用的

一句话

人需要选时间窗,是因为人要在图上做视觉比较。agent 不需要时间窗,因为比较逻辑可以直接写进表达式里。时间窗是给人眼用的中间产物。


全文的推导链

八步,每一步是上一步的推论。中间第 7 步是承重墙,单独用一节较真。

# 命题
1 消费者不同,认知机制就不同。人靠视觉模式识别,agent 靠精确数值比较与并行条件求值
2 认知机制不同,接口形态就该不同。给 agent 图,是把它逼到最弱的模态上
3 时间窗是视觉比较的容器。换掉消费者之后,它该被吸收进表达式,不该暴露成参数
4 已有的给机器看的判定就是告警规则。形态早就有了,不是新发明
5 但判定被投递决策捆住了,因为唯一下游只会投递
6 捆绑的代价是只有「值得叫人」的判定被写下来,诊断用的从未被表达
7 解绑之后要把判断消化在返回值里。一位信息够表达「要不要动」,不够表达「先动哪个、动多少、动哪里、还剩多久」,而这四件全都是确定性可算的,所以该在返回值里算完,不该留给模型
8 落地是加法:先补布尔表达不了的那四类,现有告警一行不动

两种不同的认知机制

人看 dashboard 靠视觉模式识别。人擅长在一条时序曲线上一眼看出台阶、周期性抖动、两条线同时动。dashboard 的整套设计范式都在服务这一点:panel 布局、时间轴、颜色编码、并排对比。这套东西演化了十几年,对人的眼睛是高度优化的。

agent 没有视觉模式识别。它的强项是精确数值比较、大量条件并行求值、组合逻辑。把一张 PNG 丢给它,是把它逼到最弱的模态上,而且拿回来的判断既不精确也不便宜。

于是同一个需求在两边长成不同的形状:

需求 人的形态 agent 的形态
突然涨了 选 1h 窗口看图,判断"涨得不少" (now - 1h_ago) / 1h_ago > 0.5 返回布尔
环比 切两个时间段并排看 ratio(now_5m, prev_5m) > 2
同比 加一条 offset 曲线肉眼比 deviation(now, offset 7d) > 0.3
是不是台阶 看形状 前后窗口均值比超阈值且方差小
是不是在饱和 看曲线贴顶 与已知 ceiling 的比值加导数趋零
flowchart LR TSDB[("TSDB\n原始时序")] subgraph H["人这一面"] W["选时间窗"] --> P["渲染 panel"] --> EYE["视觉模式识别"] --> J1["判断:涨得不少"] end subgraph A["agent 这一面"] E["表达式内建比较两端"] --> S["返回标量或布尔"] --> J2["判断:ratio 3.2,超阈值"] end TSDB --> W TSDB --> E

两条路径的分歧点在第一步。人这一面必须先选窗,因为比较发生在人眼里,窗是比较的容器。agent 这一面不选窗,因为比较发生在表达式里,两端都已经写好了。

左边那一列每一行都有一个隐含步骤:人要先选定一个时间窗,才能在窗内做比较。右边那一列没有这个步骤,因为比较的两端已经写在表达式里了。

时间窗不是需求的一部分。它是把"原始时序"转成"人眼可解析的形式"这个转换过程的产物。换掉消费者,这个产物就该消失。


我们其实早就有给机器看的监控

这一点值得说清楚,否则容易把已有的东西讲成新发明。

告警规则就是给机器看的监控。一条 alert rule 是一个命名的表达式加一个阈值,它不渲染任何东西,它输出判定。多窗口 burn-rate 更是把两个时间尺度的比较完全吸收进了表达式,人不需要选窗口。recording rule 也是同一件事的一半:把表达式预计算并命名。

所以形态不是问题,形态早就有了。问题在返回值。

告警规则被设计成只输出一位信息,firing 或者 not firing。 原因很实际:它的下游是 Alertmanager,而 Alertmanager 只能做投递。给一个只会投递的系统更多信息没有用。

换成 agent 之后,下游第一次具备了判断能力。但这里有个岔路口,走错的后果不小:上游该输出的不是更多原始信息,是更多已经算完的结论。 前者只是把负担从人转移给模型,后者才是真的减负。这是我认为监控体系里真正需要变化的地方,而不是给 agent 再做一套 dashboard。


但一位信息到底哪里不够

上面那句话说得太轻巧,值得停下来较真,因为它是整篇的承重墙。

反驳很容易成立:绝大多数告警场景,一位信息就够了。

1
2
# 这是一个布尔判定,而且它足够触发行为
histogram_quantile(0.99, ...) > 0.5 for: 5m

要不要 page,本来就是二值决策。加上 for: 5m 之后连"持续性"也被吸收进判定里了。所以「alert 只输出一位信息」这件事本身不构成缺陷,它精确匹配了它的消费者。

真正的分界线在这里:

一位信息足够表达「要不要动」,不足够表达「先动哪个、动多少、动哪里、还剩多久」。

告警的消费者只需要第一个。调查需要后面四个。逐个说。

有一点先说清楚,否则下面四节会被读反:这四件全都是确定性可算的。 讲它们不是为了让模型去判断,恰恰相反,是为了在返回值里就把它们算完,让模型不必判断这一段。模型是整条链路上唯一会幻觉的一层,每多一个能算出来的结论,就少一次幻觉机会。探针的成功标准是它替模型消掉了多少判断,不是它暴露了多少信息。

先动哪个:排序需要标量

真实事故不是一条告警,是一批同时到。三个服务的 P99 都超了 500ms,布尔判定全是 true,无法排序。而 triage 的第一个动作恰恰是排序。

排序需要的是超出的倍数(1.02 倍还是 8 倍)、受影响的租户数、预算的消耗速率。三个都是标量,布尔化之后信息全丢。

这一条是最实在的,因为它直接决定人先看哪块屏幕。

动多少:证据的强度不是二值的

"上游是不是变慢了"这个问题,布尔可以回答。但上游从 100ms 涨到 110ms 的 true,和涨到 3 秒的 true,对"上游变慢是根因"这个假设的支持强度完全不同。前者大概率是噪声,后者是决定性证据。

假设检验里,证据的强度决定了它能不能淘汰竞争假设。 布尔把强弱抹平之后,agent 拿到一堆 true,却推不动收敛。

动哪里:分解的输出是向量,不是布尔也不是标量

这一类布尔在结构上就表达不了。

1
request_time = queueing + connect + upstream_processing

这不是一个判定,是一次分配。要知道三段各占多少。可以硬把它布尔化成"排队是不是占大头",但那个阈值定在哪?60% 还是 80%?这个阈值本身是调查时才知道的,把它固化进规则等于提前做了一个不该做的决定。

同类的还有 top-N 归因:「429 是不是集中在单个租户」布尔能答,但止血动作要限流谁,依赖的是"哪几个租户、各占多少"这个排序列表。列表既不是布尔也不是标量。

还剩多久:外推的输出是时间

「预算是不是快烧完了」布尔能答。但"还有 20 分钟"和"还有 6 小时"对应完全不同的响应级别,前者要立刻止血,后者可以排期。

这是「多急」这个维度的答案,而告警体系里根本没有这个维度,它只有「急不急」。

顺带澄清一个我自己搅混的地方

unknown 三态和"返回标量"是两件正交的事,我前面把它们放在一起讲,容易让人以为是同一个问题。

  • 标量 解决的是"程度信息丢失"
  • 三态 解决的是"数据缺失被表达成条件不成立"

即使全部只返回布尔,三态也必须有。反过来,返回了标量也不代表就处理好了数据缺失。两者要分开做。


落地形态

上一节的结论直接决定了落地路径:不要把现有告警规则转成探针。

告警规则已经覆盖了布尔能覆盖的部分,重做一遍是纯重复劳动,还会立刻制造两套定义漂移的问题。要补的是布尔表达不了的那四类,而它们恰好是调查用的、不是告警用的,所以和现有体系零冲突:

优先补 属于哪一类 和现有告警的关系
分解类(延迟三段拆分、错误按 status 拆) 向量 完全没有对应规则
归因类(top-N 贡献者) 排序列表 完全没有对应规则
外推类(还剩多久) 时间 只有"急不急",没有"多急"
排序用的标量(超出倍数、影响面) 标量 有布尔版本,但丢了程度

这是加法不是重构。现有告警一行不动,先把调查缺的那一层补出来。等这一层跑通、被真实调查用起来之后,再考虑反方向:把告警规则从探针定义生成,消除两处定义。那一步的设计在第三篇

给 agent 的观测层是一组命名探针,每个返回标量或布尔,不返回时序数组:

1
2
3
4
5
is_step_jump(metric, ratio=2, window=5m)   → {verdict: bool, value: 实际比值}
yoy_deviation(metric, offset=7d) → {verdict: bool, value: 偏离比例}
is_saturating(metric, ceiling) → {verdict: bool, value: 水位, slope: 导数}
burst_cadence(metric) → {verdict: bool, value: 秒级周期}
disaggregate_by(metric, label) → {verdict: bool, top_n: [(label, share)]}

agent 调用探针的名字和参数,不写查询语言。每个探针背后是一条固定的表达式,参数只填进预留的位置。

注意每个探针都同时给 verdictvalue。只给布尔的话,前面刚论证过的「1.02 倍还是 8 倍」这个区别就在接口层丢掉了,后面再想排序也补不回来。判定和程度要一起返回,这是返回契约里最容易被简化掉的一条。

is_step_jump 的实现,比较两端都写在表达式里,没有时间窗参数暴露给调用方:

1
2
3
4
5
(
avg_over_time(sum(rate(http_requests_total{service="$svc"}[1m]))[5m:])
/
avg_over_time(sum(rate(http_requests_total{service="$svc"}[1m]))[5m:] offset 5m)
) > 2

yoy_deviation,同比。人要为此加一条 offset 曲线再肉眼比,表达式里它就是一个除法:

1
2
3
4
abs(
1 - sum(rate(http_requests_total{service="$svc"}[5m]))
/ sum(rate(http_requests_total{service="$svc"}[5m] offset 7d))
)

is_saturating,贴顶判定需要两个条件同时成立,水位高且还在往上,单看水位会把稳定的高负载误判成饱和:

1
2
3
4
5
6
(
sum(rate(container_cpu_usage_seconds_total{pod=~"$svc.*"}[5m]))
/ sum(kube_pod_container_resource_limits{pod=~"$svc.*", resource="cpu"})
) > 0.85
and
deriv(sum(rate(container_cpu_usage_seconds_total{pod=~"$svc.*"}[5m]))[10m:]) > 0

最有价值的一类探针不返回单个数,而是返回一个能区分假设的分解。延迟调查里最诊断的一步是把 ingress 的 request_time 拆成上游耗时与排队耗时,因为 request_time = waiting_latency + upstream_response_time 这个恒等式一算出来,就直接指出该查哪一层:

1
2
3
4
5
# 端到端
histogram_quantile(0.95, sum by (le) (rate(ingress_request_time_seconds_bucket{client="$c"}[5m])))
# 上游自己花的时间
histogram_quantile(0.95, sum by (le) (rate(ingress_upstream_response_time_seconds_bucket{client="$c"}[5m])))
# 相减得到排队耗时

三条假设摆在桌上,上游变慢、ingress 排队、监控假信号。问「上游是不是变慢了」,如果上游确实有点慢,三条假设都还活着,零淘汰。做这个分解,一次查询同时对三条表态:排队占主导支持第二条并否定第一条,两者都正常则支持第三条。一次查询淘汰两条假设。

对 agent 来说这不是"更方便",这是决定调查能不能收敛的东西。所以探针库真正要优化的目标不是覆盖多少指标,是每个探针的假设区分能力

假信号那一类同样可以固化。P99 高但错误率正常时,先查的不是上游,是各实例的分位数是否离散,因为 histogram 跨实例聚合的偏斜会凭空抬高 P99:

1
histogram_quantile(0.99, sum by (instance, le) (rate(request_duration_seconds_bucket{service="$svc"}[5m])))

按 instance 分组这一处改动,就是"假信号"这个判定的全部内容。人看聚合图看不出来,探针把它变成一次显式的比较。

这比"让 agent 自己写 PromQL"好在三处。

安全。 自由生成的查询会漏 label filter,一次全表扫就能拖慢整个 TSDB;step 给得太小会让 range query 返回量爆炸。探针的表达式是预先审查过的,可变的只有有限的参数。这正是那类应该用确定性规则兜住的地方:查询安全属于事后很难便宜检验的东西,一次错误查询造成的影响在它被发现之前就已经发生了。

成本。 dashboard 一次渲染拉回几百个数据点,探针返回一个标量。对人来说这些数据点是像素,几乎免费;对 agent 来说它们是 context,是最稀缺的那个资源。两者差两个数量级。一次 raw range query 三万 token,一次调查五到十次查询,主 agent 自己吃下去,走到需要下关键判断的阶段时判断力已经被榨干。

可复现。 探针的名字和参数进审计日志,事后能精确重放同一次观测。自由生成的查询每次都可能不同,事故复盘时无法确认当时看到的到底是什么。


写了第一个探针,结果比预期难看

论点讲到这里都还是设计。所以我挑了最小、最不依赖 agent 的那个探针先写出来:这个观测本身还有效吗。

告警规则在数据缺失时通常静默,no data 等于 not firing。所以一条规则因为指标改名、label 变化、exporter 下线而永久失效之后,它表现出来的样子和「一切正常」完全一样。这是告警熵增里最难发现的一条,因为它没有任何信号。

探针做的事很简单:把每条规则表达式里引用的指标名提出来,检查它在当前 TSDB 里还有没有数据。

成本上有个决定:不逐个指标跑 count(),几百个指标就是几百次查询。改成一次拉 /api/v1/label/__name__/values 拿到窗口内存在的全部指标名,之后是纯本地集合运算,查询成本 O(1)。

判定四态,unknown 那一档正是前面说的、alert 语义表达不了的东西:

判定 含义
ALIVE 引用的全部指标都有数据
PARTIAL 部分指标无数据。规则可能还会触发,但语义已经不完整
DEAD 全部引用指标都无数据。这条规则已经静默失效
UNPARSED 提取不出指标名,需人工看。不静默跳过

跑在生产规则集上:

1
2
3
4
lookback  TSDB 指标名   规则数   ALIVE  PARTIAL  DEAD  UNPARSED
24h 19,975 667 543 15 102 7
7d 22,020 667 545 15 100 7
30d 22,631 667 548 15 97 7

DEAD 的判据是「回看窗口内没有这个指标名」,这对「只在异常时才被导出的指标」会误判。所以跑两个窗口做差集:24h 判死而 30d 有数据的属低频指标,两个窗口都判死的才是真失效。

差集结果:低频误报 3 条,真失效 94 条,占 667 条规则的 14.1%

最难看的一处是分布:真失效里最大的一组落在实时 SLA 告警组,22 条。那一组恰好是最不该失效的那一组。

没有任何人删过这些规则。它们只是安静地不再工作了。这就是「加告警免费、删告警受罚、失效无声」这条机制的样子,而第三条最贵,因为前两条至少还看得见。

顺带说一句这个探针的开发过程,它印证了「探针要 review」不是客套话。第一版有两个解析 bug:nginx_up == 0 里的 nginx_up 被当成 label 名剥掉了,因为按 = 剥离时 == 也命中;OFFSET 大写没被认成关键字,于是被提取成一个不存在的指标名。两个 bug 的表现都是误报,也就是把活着的规则报成死的。如果这东西不带 review 直接接到治理流程上去,它会驱动人去删好规则。


监控栈需要第二个输出面

Prometheus 系监控栈的输出面实质上只有一个,就是给人看的那个。recording rule 算是半个例外,它把表达式预计算并命名,但它只产出数值,不产出判定;alert rule 产出判定,可是判定被绑死在投递上。

agent 需要第二个:已判定的语义事实流,不是原始时序。两个面共享同一份 TSDB,接口形态不同。人这一面的接口是 Grafana,一个 UI。agent 这一面的接口是两件东西:一个探针库,规定能问什么;一个结构化返回契约,规定答案长什么样。

探针库对应 Grafana 里那些预建的 panel,是「能问什么」的目录。返回契约对应人这边渲染出来的像素,是答案的形状。

叫契约而不叫格式,因为依赖它的不止 agent 一方:

依赖方 依赖什么 违约的后果
agent 的处理逻辑 每个探针返回同一个形状 形状不统一,就要给每个探针写一套解析与判断分支
审计与重放 probe + args + window 是「当时观测了什么」的完整记录 截图和临时查询做不到,复盘无法确认当时看到的是什么
探针的演进 schema 变更走版本号 静默改形状让历史审计记录失真

第一条的工程收益最大:统一契约意味着「遇到 unknown 怎么办」「结论怎么引用证据」「什么进审计日志」这些逻辑只写一次。每个探针各自定义返回形状的话,agent 侧就退化成 N 个特例。

这个划分还有一个副作用值得提。探针库是可 diff、可 review、可版本化的代码,而 dashboard 是 JSON 配置,改动很难 review。把"什么算异常"这件事从散落在几十个 panel 的阈值里,收敛到一组命名探针上,即使没有 agent,对人也是净收益。


边界

从一个探针到一个能被团队依赖的判定层,中间还隔着生命周期、防漂移、正确性验证和降级路径,那部分写在 一个探针不是一个系统

探针库只实装了上面那一个。剩下七个还是设计,其中区分度最高的延迟分解和填「多急」缺口的预算类探针都还没写。日常调查目前的做法是让 subagent 跑查询并回收不超过 500 token 的结构化摘要,它拿到了成本收益,但没有拿到安全和可复现这两项,因为查询仍然是生成的。

另外这套东西的适用范围是"已知该看什么"。探针库覆盖的是可以预先命名的判定,遇到全新的失效模式,仍然需要有人先在 dashboard 上看出形状,再把它固化成一个探针。dashboard 不会消失,它的角色从日常判定变成新模式发现。