智慧农业

告警噪声治理:从规则去重到告警分级

行业背景与现场痛点:为什么这件事反复出问题

设备从几十个测点扩到上千个之后,告警条数几乎从不按比例增长,它沿着三条通道被放大。第一条是一对多:一台网关或一台采集主机掉线,挂在它下面的几十个测点会各自判定自己失联,同一个原因因此在十几分钟里变成几十条不同资源的告警。第二条是抖动:观测值在判定边界附近来回浮动,同一条规则一小时内反复触发又反复恢复,而每一次恢复通知也是一次外发,等于噪声翻了一倍。第三条是同源多规则:同一个物理故障被温度、电流、通信质量三条规则分别命中,同一时刻发出三条措辞不同、含义相同的告警。这三条通道都不需要新设备,只要系统还在往上加规则,它们就会一直存在。

噪声的代价不只是看着烦,它会改变人的行为。值班人最先学会的是清屏:按“全部已读”,或者把群消息设成免打扰,只在有人单独打电话时抬头看一眼。接下去是误报固化:某条规则一晚上响几十遍而每次都是假的,第二天就有人把它整条关掉,系统里不留记录,几个月后谁也想不起这条规则为什么不存在了。再往后是通道被占满:严重与提示共用同一个短信模板、同一个群,低优先级把高优先级挤出去,真正需要半夜起来处理的那条排在一百条未读消息的中间。最后是没有闭环:谁认领了、处理到哪一步、是不是同一个人在跟,系统里查不到,交接班只能靠口头问一句“那个报修是谁在跟”。

只加规则的路线必然走到这一步。规则越多、覆盖越全,噪声就越多;噪声越多,愿意看的人越少;看的人越少,剩下的唯一动作就是“全部静音”。静音之后系统在形式上还在告警,但已经没有人拿它做判断,它退化成一份事后追责用的记录,而不是一个能让故障被发现的机制。

所以这件事靠调高判定边界、或者多培训几轮值班人员都解决不了。把现场的问题拆开,其实只有三件事:哪些告警应该合并成一条,哪些应该有条件地不发,哪些必须叫醒人。第一件是去重与收敛,第二件是抑制与静默,第三件是分级与路由。三件事各有自己的字段、写权限和验证方法,压成一层去调,就退化成“多发几条还是少发几条”的参数问题——参数调完当周确实安静了,下一批设备上线,噪声又会一样地回来。

方案与取舍:可落地的结构怎么定

先给结构,再谈取舍。整套东西是三层加一份资源拓扑。第一层是产生:规则命中之后,这条要不要成为一条告警事件。第二层是收敛:同一个故障已经有一条在发,这条要不要再发出去。第三层是路由:这一条走哪个通道、多久没人接就升级。三层之上还有一份资源拓扑——网关下面挂着哪些测点、测点属于哪个地块或机房。拓扑不是可选件:没有「谁挂在谁下面」,就算不出影响面,既没法分级,也没法判断抑制关系是否成立。

层回答什么问题谁能写写错了怎么改
产生规则命中后是否成为一条告警事件规则由运维修订,事件只由引擎追加规则带版本号修订;事件只追加,判错用冲正记录抵消,不删不改
收敛同指纹是否合并、是否再外发引擎自动写,人只读收敛组只累加计数与首末时间,不覆盖已有记录
路由走哪个通道、多久升级分级矩阵由运维定,认领与升级由人写分级规则带原因修订;动作记录只追加,含人与时间

第一条取舍是指纹粒度。按完整告警文本做键太细,观测值一变就是一条新告警,收敛等于没做;只按「资源」做键又太粗,同一台网关下的不同故障会被并成一条,恢复时说不清恢复了什么。取「资源 + 规则 + 故障类型」三段拼接,观测值不进指纹。代价是同一资源上真正并发的两个故障只能靠故障类型区分,字段填错就会并错,这一点只能靠事后抽样核对兜住。

第二条是收敛、抑制、静默必须分开。收敛是自动的,按窗口计数;抑制是有条件的,父告警存在期间子告警折叠进父告警的计数,条件消失自动解除;静默是人工的,必须带到期时间和原因。把静默当收敛用,等于给自己造一个没有到期日的盲区。

第三条是分级维度。只留一个「严重度」字段,最后一定退化成按规则名分级——谁写的规则,谁觉得自己那条最严重。改成「影响面 × 时效」二维:影响面分单点、一组、整场,时效分可等、不可等,二维交叉出来才是通道与升级节奏。

第四条是规则引擎与统计或模型降噪之间怎么选。先规则。没有标注数据、没有稳定基线的时候,模型给出的是一个说不清理由的静音,值班人没法为它负责;规则至少能回答「这条为什么没发出去」。

第五条是拓扑谁维护。影响面完全依赖上下游关系,这份关系由谁维护、什么时候更新,必须在方案里写死——写进运维交接单,每次新增或迁移网关时更新一次。否则半年后拓扑过期,分级还在按过期关系算。

代价要写在明面上:拓扑是长期维护成本,每加一层设备就多一次更新;窗口收敛会让首次通知晚几十秒。所以必须同时有一份不进收敛的例外清单——人身安全、门禁与消防联动,这些逐条发,宁可重复也不合并。

技术实现:字段、口径与关键逻辑

落到系统里是四类记录,每类只允许一种写方式:规则可版本化修订,事件只追加、判错写冲正记录,收敛组只更新计数与首末时间,动作只追加且带人和时间。代价是表只涨不落,得按周期归档。

对象必需字段校验重点
规则规则标识、版本、资源范围、故障类型、判定表达式、生效区间同一资源同一故障类型不得有两条同版本规则同时生效
告警事件事件号、资源、规则标识与版本、故障类型、首次与末次采样时间、观测值、指纹事件号全局唯一且只追加;指纹写入时算好落库,不在前端补算
收敛组指纹、窗口起点、累计次数、首末时间、状态同一指纹同一窗口只能有一个未关闭的组
动作动作类型、操作人、时间、原因、关联事件或组静默必须带到期时间;升级必须记录判定依据

指纹由资源标识、规则标识、故障类型三段拼接后取哈希前缀。观测值不进指纹——它每采样一次就可能变,进了指纹等于每次采样都是新告警,收敛直接失效。

去抖用连续采样数而不是单点:连续 N 次超限触发,连续 M 次回到正常区间才判恢复。换来的是延迟,减少的是边界附近来回造成的翻转。N 与 M 要一起按采样频率与工况标定,只调触发不调恢复,恢复通知照样把噪声翻倍。

窗口收敛是同指纹在一个窗内只外发首条,后续只累加计数;恢复时发一条通知,带持续时长与累计次数。窗口长度与去抖参数必须一起看:短于采样周期等于没收敛,过长又让持续故障迟迟不升级。

抑制是父告警存在期间子告警不单独外发,折叠进父告警的计数,关系必须留记录,事后要能回答「这条为什么不发」。父告警恢复时被抑制的子告警要重新评估,不能跟着一起消失——否则网关恢复、测点还离线,系统里一条告警都没有。代价是恢复瞬间有一波集中评估,要排队串行处理。

分级按「影响面 × 时效」二维交叉,映射到通道与升级节奏:

影响面 \ 时效可等不可等
单点站内消息,30 分钟内首推,4 小时未认领转群通知群通知,5 分钟内首推,30 分钟未认领升级短信
一组群通知,10 分钟内首推,1 小时未认领升级短信短信,5 分钟内首推,20 分钟未认领升级电话
整场短信,10 分钟内首推,1 小时未认领升级电话电话,即时首推,10 分钟无人接再呼一次

影响面从拓扑算,时效由这个故障在业务里的后果定。两者都不写成规则里的一句说明——说明不参与计算,半年后也没人核对。

状态机只有四个:firing → acknowledged → resolved,另加 suppressed,带到期时间。认领、关闭、静默三类转换必须留痕(人、时间、原因);系统自动的收敛与抑制只改收敛组计数,不改事件状态。代价是报表要按「状态 × 是否被抑制」两个轴读。

下面这段是上述判定的最小骨架,只跑虚构数据,不连任何真实系统:


# 告警治理最小骨架:指纹化 -> 窗口收敛 -> 影响面×时效分级
# 只用标准库,输入是虚构事件,不连任何真实系统
# expect: 原始事件 12 条,指纹 6 个
# expect: 窗口收敛后外发 8 条(原始 12 条)
# expect: 分级结果 电话 1 / 短信 3 / 群通知 1 / 站内 3
import hashlib
from collections import OrderedDict

WINDOW_SEC = 300  # 收敛窗口,需按现场采样频率标定

# 拓扑:资源 -> 影响面(single / group / site);由运维在接入时维护
IMPACT = {"gw-a": "group", "gw-b": "site", "sensor-c": "single"}
# 故障类型 -> 时效(urgent 不可等 / waitable 可等)
URGENCY = {"link-down": "urgent", "overload": "waitable",
           "high": "waitable", "retry": "waitable"}

# (资源, 规则, 故障类型, 时间偏移秒, 观测值)
EVENTS = [
    ("gw-a", "offline", "link-down", 0, 0),
    ("gw-a", "offline", "link-down", 60, 0),
    ("gw-a", "offline", "link-down", 120, 0),
    ("gw-a", "current-high", "overload", 30, 12),
    ("gw-a", "offline", "link-down", 400, 0),
    ("gw-b", "offline", "link-down", 10, 0),
    ("gw-b", "offline", "link-down", 50, 0),
    ("gw-b", "current-high", "overload", 80, 18),
    ("sensor-c", "temp-high", "high", 15, 9),
    ("sensor-c", "temp-high", "high", 20, 10),
    ("sensor-c", "comm-retry", "retry", 25, 3),
    ("sensor-c", "temp-high", "high", 500, 11),
]


def fingerprint(res, rule, fault):
    """资源 + 规则 + 故障类型;观测值不进指纹,否则每次采样都是一条新告警。"""
    return hashlib.sha1(f"{res}|{rule}|{fault}".encode("utf-8")).hexdigest()[:8]


def converge(events, window):
    """同一指纹在同一窗口内只外发首条,其余只累加计数与首末时间。"""
    groups = OrderedDict()
    for res, rule, fault, ts, val in sorted(events, key=lambda e: e[3]):
        key = (fingerprint(res, rule, fault), ts // window)
        g = groups.get(key)
        if g is None:
            groups[key] = {"res": res, "fault": fault, "first": ts,
                           "last": ts, "count": 1, "peak": val}
        else:
            g["last"], g["count"] = ts, g["count"] + 1
            g["peak"] = max(g["peak"], val)
    return groups


# 影响面 × 时效 -> 通道 / 首次通知时限 / 升级节奏
MATRIX = {
    ("single", "waitable"): ("站内", "30 分钟内", "4 小时未认领转群通知"),
    ("single", "urgent"): ("群通知", "5 分钟内", "30 分钟未认领升级短信"),
    ("group", "waitable"): ("群通知", "10 分钟内", "1 小时未认领升级短信"),
    ("group", "urgent"): ("短信", "5 分钟内", "20 分钟未认领升级电话"),
    ("site", "waitable"): ("短信", "10 分钟内", "1 小时未认领升级电话"),
    ("site", "urgent"): ("电话", "即时", "10 分钟无人接再呼一次"),
}


def grade(g):
    impact = IMPACT.get(g["res"], "single")
    urgency = URGENCY.get(g["fault"], "waitable")
    return (impact, urgency) + MATRIX[(impact, urgency)]


groups = converge(EVENTS, WINDOW_SEC)
fps = {g["res"] + "/" + g["fault"] for g in groups.values()}
print(f"原始事件 {len(EVENTS)} 条,指纹 {len(fps)} 个")
print(f"窗口收敛后外发 {len(groups)} 条(原始 {len(EVENTS)} 条)")
tally = OrderedDict((c, 0) for c in ("电话", "短信", "群通知", "站内"))
for g in groups.values():
    impact, urgency, channel, deadline, escalate = grade(g)
    tally[channel] += 1
    print(f"{g['res']} {impact}/{urgency} -> {channel},{deadline}首推,"
          f"{escalate}(窗口内 {g['count']} 次,峰值 {g['peak']})")
print("分级结果 " + " / ".join(f"{c} {n}" for c, n in tally.items()))

输出三行对应三件事:原始事件的指纹数、收敛前后的条数、按矩阵分出的通道分布;同一网关的三条离线事件在同一窗口并成一条、跨窗口后重新开组,输出里都能直接看到。真实系统还要在收敛组关闭时补发恢复通知,示例只保留判定部分。

以上是记录结构与判定顺序;具体阈值、去抖次数、窗口长度必须由现场按设备与工况标定,本文不给默认值。

项目实践与价值边界:一次真实落地和它的局限

先把边界写死:本节没有可引用的客户验收记录,也不提供实测收益数字。下面这个场景是虚构的,只用来解释字段与判定顺序怎么对上——它不代表真实生产记录,也不等于已验证的降噪能力。

场景是一个园区平台:若干网关,每台下面挂若干测点,某台网关断电 12 分钟。不做治理时,每个测点各报一条离线,中间夹着通信重试失败与恢复通知,值班人看到的是几十条同义告警,前几条还看一眼,后面的直接未读清屏。按三层治理后,人看到两件事:一条父告警(网关离线),带被抑制子告警的计数;恢复时一条恢复通知,带持续时长与影响测点数。差别在结构——「折叠为一条并计数」是算法性质,写不出百分比,写成百分比就是编数据。

上线前要过的验证清单:

验证项预期待采集证据
指纹稳定性同一故障反复采样只出一个指纹采样序列与指纹列表对照
窗口收敛窗口内只外发首条,计数等于事件数收敛组与原始事件数核对
抑制有效性父告警在时子告警不外发,计数进父组抑制记录与父组计数
漏报回归父告警恢复后子告警重现;静默到期恢复外发恢复后首轮评估事件清单
抖动抑制边界附近往返时触发与恢复远少于采样翻转翻转次数与状态转换次数
分级命中每个收敛组落到预期通道与时限矩阵输出与实际分发记录

上表后两条最容易漏:漏报是治理唯一的真实风险,抖动只能靠翻转次数与状态转换次数比出来。度量口径要连分子分母一起写:收敛比=外发条数 / 原始事件数,按天;去抖后的状态翻转次数按天计;首次通知时延=送达时间减首采时间,按周取分位;静默到期复核率=按期复核数 / 到期数;漏报复盘条数按周统计,只数事后确认该报而未报的。分母不写清,比率随时能被口径拉高。

实施分四步,顺序不能反:先定指纹与拓扑,把编号唯一范围、资源层级写清;再用虚构或历史数据离线回放,逐项跑通;然后与现有告警抽样核对,差异逐条关闭;最后才评估试运行范围。本轮没有进入任何生产环境,也没有改动在跑的系统。

最后是价值边界。治理不消灭告警,也不替代根因分析,产出只有一件事——让该叫醒人的那几条真的能叫醒人。把降噪本身当成目标,第一轮就会把有用的告警一起关掉,而关掉的那几条不会有人来投诉。

Smart Agriculture

把智慧农业落到你的现场

围绕种植、设施农业与水产养殖场景,构建环境监测、智能控制与生产数据一体的数字农业体系,把经验种植变成可量化、可复制的数据化生产。