通过Alertmanager、Prometheus数据源让LLM自动组装值班摘要,避免交接时的信息丢失,人工一键审批后发布。
一个 on-call handoff agent 会自动组装交接摘要——那些即将下班的工程师累得来不及写的东西:哪些告警被触发了、为什么触发的、还在冒烟的问题有哪些、哪些静默规则会在下一班工程师值班时过期、过去 12 小时部署了什么、这次值班消耗了多少错误预算。所有数据都来自 Alertmanager、Prometheus 和部署日志,只读工具提供事实——LLM 不会编造Incident,只会对工具返回的内容做优先级排序和叙述。输出是一份结构化的 Markdown 交接文档,在换班时间点发布到 on-call 频道,由即将下班的工程师一键审批后,再交给即将接班的工程师阅读。
交接是 on-call 场景中最可预测的情境丢失时刻。每个轮班,每天两次,那位积累了 12 小时情境感知的人匆匆在 Slack 上打两行字——"这班很安静,node-7 的磁盘告警不用管,已经确认过了"——然后下线。接班的工程师只能硬着头皮重新推导其他一切,通常还正好赶上某个告警触发。团队用交接模板解决这个问题,但这背后有一个人性原因:熬了一宿之后早上 8 点还要填模板是种体力活,所以字段最终都会退化成"n/a"。Agent 不会累,而且一份好的交接文档里几乎所有内容本来就都带时间戳静静地躺在你的监控栈里。
在任何代码之前,先对齐 payload。一份有用的换班交接需要回答五个问题,每个问题都对应一个可查询的数据源:
expiring-silences 那一行是模板总是漏掉的,也是产生经典交接失败的罪魁祸首:即将下班的工程师在 21:00 将一个反复触发的告警静默了 12 小时,结果 09:00 它恢复并作为一个陌生页面打给了从未听说过它的人。一个静默是否在值班中途过期纯粹是日期计算——应该在代码里算好,永远不要留给模型去注意。
和本站所有 ops agent 一样的纪律:固定的、带参数的、只读的工具,没有自由格式查询。如果你已经在跑 Alertmanager MCP server,那前两个工具就是它的一个严格子集。
# handoff_tools.py — the agent's entire read surface
import os
from datetime import datetime, timedelta, timezone
import httpx
AM = os.environ["ALERTMANAGER_URL"]
SHIFT_HOURS = 12
NEXT_SHIFT_HOURS = 12
def _get(path: str, params: dict | None = None) -> list | dict:
r = httpx.get(f"{AM}/api/v2/{path}", params=params or {}, timeout=15)
r.raise_for_status()
return r.json()
@mcp.tool()
def shift_alert_state() -> dict:
"""Currently firing alerts plus alerts that fired during the last
shift window. Read-only snapshot from Alertmanager."""
now = datetime.now(timezone.utc)
shift_start = now - timedelta(hours=SHIFT_HOURS)
active = [{
"name": a["labels"].get("alertname", "?"),
"severity": a["labels"].get("severity", "?"),
"since": a["startsAt"],
"summary": a["annotations"].get("summary", ""),
} for a in _get("alerts", {"active": "true", "silenced": "false"})]
fired_in_shift = [a for a in active
if datetime.fromisoformat(a["since"]) >= shift_start]
return {"active": active, "started_this_shift": fired_in_shift,
"shift_window_hours": SHIFT_HOURS}
@mcp.tool()
def silences_expiring_next_shift() -> list[dict]:
"""Silences that expire within the incoming shift. Each of these
WILL resume paging on the next engineer's watch. Computed in code."""
now = datetime.now(timezone.utc)
horizon = now + timedelta(hours=NEXT_SHIFT_HOURS)
out = []
for s in _get("silences"):
if s["status"]["state"] != "active":
continue
ends = datetime.fromisoformat(s["endsAt"].replace("Z", "+00:00"))
if now < ends <= horizon:
out.append({"matchers": s["matchers"],
"ends_at": s["endsAt"],
"created_by": s["createdBy"],
"comment": s["comment"] or "(no comment)"})
return out
@mcp.tool()
def shift_deploys() -> list[dict]:
"""Deploys during the shift window from the CD system, newest first.
Returns [{app, version, deployed_at, author}] — read-only."""
...
第三个工具比看起来更重要。一份真实交接文档中最有价值的一句话通常是关联分析——"14:40 的 payments 延迟告警在 14:20 的 payments 部署后二十分钟触发;它自己恢复了但要继续观察"——而模型只有在部署和告警都带有时间戳摆在上下文里才能画出这条线。至于预算状态,直接复用 error budget agent 里的 get_burn_state 工具,handoff 只需要它的一行答案,不需要完整的 triage。
不要让模型自由书写 Markdown。强制使用 schema,然后自己渲染 Markdown——这种分离是让输出可测试的关键,也是防止模型某天犯懒时代码段静默消失的保障:
HANDOFF_TOOL = {
"name": "compose_handoff",
"description": "Compose the shift handoff from tool results ONLY. "
"Every item must cite which tool result it came from. "
"If a section is empty, say so explicitly.",
"input_schema": {
"type": "object",
"properties": {
"shift_character": {"enum": ["quiet", "noisy", "incident"]},
"headline": {"type": "string",
"description": "One sentence the incoming engineer must "
"read even if they read nothing else."},
"watch_items": {"type": "array", "items": {"type": "object",
"properties": {
"what": {"type": "string"},
"why": {"type": "string",
"description": "Evidence with timestamps, e.g. "
"'fired 3x between 02:00-04:30'"},
"action_if_it_pages": {"type": "string"}},
"required": ["what", "why", "action_if_it_pages"]}},
"expiring_silences_ack": {"type": "boolean",
"description": "True only if every expiring silence from "
"the tool result appears in watch_items."},
},
"required": ["shift_character", "headline", "watch_items",
"expiring_silences_ack"],
},
}
这里有两个刻意为之的设计决策。watch_items 强制要求 action_if_it_pages 字段——一份只说"密切关注 Kafka lag"而不说触发后具体怎么处理的交接,只是把焦虑挪了位置而不是传递了上下文。expiring_silences_ack 是一个自检,由渲染器在代码中验证:如果 silences 工具返回了两个即将过期的静默规则,但 watch_items 里出现的少于两个,那就大声失败,而不是发出一份不完整的交接。像这样的 schema 级绊线比在 prompt 里恳求要可靠得多。
渲染器只有三十行字符串格式化:固定的段落顺序、时间戳归一化到团队时区、在 footer 里打印原始工具计数("4 alerts fired, 2 silences expiring, 6 deploys"),这样读者一眼就能看出叙述是否漏掉了什么。所有位置和结构性的东西都是代码;只有判断——什么有资格成为 headline、什么值得 watch、什么和什么有关联——才是模型输出。
在换班前几分钟从 cron 运行,发布草稿到 on-call 频道,让即将下班的工程师在它被固定给接班人看之前审批或修改。这是代价最低的人机协同门:审核者是唯一亲历了这一班的人,审核时间不到一分钟因为他们只是在检查自己过去 12 小时的摘要,而他们的修改是一个免费的质量信号——任何人类总是添加的东西都是新工具的候选;任何人类总是删除的东西都是从 prompt 里砍掉的噪声。
与 on-call agent 栈的其他部分串联:handoff 复用了和 context engineering budget 一样的事实优先布局——实时状态在前,历史明确标记为历史。如果这一班产生的 incident 已经通过 postmortem agent 生成了经过审批的事后报告,它的记录会落入 incident memory——handoff 覆盖未来 12 小时;memory 覆盖未来 12 个月。同一数据源,不同半衰期;不要把它们合并成一个文档。
Handoff 生成异常容易评估,因为输入是完全可合成的。用现成的工具输出构建 fixture shift——一个安静的班、一个吵闹但无害的班、一个有活跃 incident 的班、一个有两个即将过期静默规则的班——然后每次运行断言三件事。Grounding:输出中的每个告警名称、版本字符串和时间戳必须出现在某个工具结果里;模糊子串匹配能捕获大多数伪造,而交接文档中一个编造的 incident 比没有交接更糟糕,因为它会被信任。Completeness:每个即将过期的静默规则和每个当前正在触发的告警都必须出现在输出的某处——这是上面那条绊线,晋升为测试。Calibration:安静班的 fixture 必须产生一份简短的交接;如果你的 agent 在一个什么都没发生的班上写上四段话,接班工程师不出两周就会停止阅读交接文档,整个系统是社会性失败而非技术性失败。
Agent 总结的是已被监控的东西,而换班包含着未被监控的东西:你正在等的供应商工单、DM 线程里的客户升级、node-7 磁盘告警实际上是一块正在故障的硬盘的直觉。这就是为什么审批步骤也是一个编辑步骤——人类添加那未被监控的 20%,而 agent 的工作是让这成为他们唯一需要写的东西。它也继承了监控的盲点;一个未被监控的故障模式在交接文档中是不可见的,这可能带来一种虚假的平静——footer 里的计数有帮助,但它们无法统计从未被测量过的东西。从这三个工具、强制 schema 和下班工程师审批门开始。第一次在 09:05 接班工程师被一个 09:00 静默过期的告警 page 到——而交接文档已经告诉他那是什么、该怎么处理——这一轮班就会替你为这个 agent 背书。
📌 阅读本指南的最新版本——以及 DevOps、SRE、Kubernetes、可观测性与云成本指南的完整库——请访问 devtocash.com。