RCA(根因分析)
告警触发时,Ongrid 派出一个 LLM worker,在 incident-investigator persona 上驱动 graph-kernel ReAct agent,调用工具收集证据,把结构化报告写回 investigation_reports。
报告渲染在 SPA 的 /alerts/incidents/:id 页上,紧挨着触发的 series —— 人工 SRE 永远不用"从一张空白 prompt 开始"。
HLD-013
当前管线落地了 HLD-013 的 Phase 1+2 因果模型。"总结触发了什么"的朴素做法 (PR-2)被替换掉了,因为很明确运维想看的是0 号病人 —— 那个引发级联的 具体进程 / 容器 / 行 —— 而不是告警文本的复述。
生命周期
incident.fire (isNew=true)
└─ alert.Usecase.RecordFiring
└─ Investigator.InvestigateAsync(incident)
└─ Enqueue → Gate 1-3 (severity, inflight, semaphore)
└─ repo.Create(pending row) ← UI shows "investigating…"
└─ go run(reportID, incident, dedupKey, locale)
├─ spawner.SpawnWorker(incident-investigator persona)
├─ worker drives the ReAct loop with tools
├─ Pass-2 structured extraction (cheap model)
└─ repo.MarkReady(report fields) ← UI shows readyInvestigateAsync 是 alert.Usecase 调用的公开缝 —— 见 usecase.go:301。
Gate
三道门在 worker 派出去之前过滤。每次拒绝都会持久化为一行 status=skipped, 这样 SPA 给运维一个理由,而不是"永远没启动"。
| 门 | 默认 | 未命中行为 |
|---|---|---|
严重级地板(Config.MinSeverity) | warning | 静默跳过,不写行 |
进程内 inflight(按 incident_id) | 永远开 | 静默合并 |
并发上限(Config.MaxConcurrent) | 5 | 写一行 skipped: concurrency limit reached (N workers in flight) |
并发上限保护 LLM provider 限流,并在 100 条 incident 同时触发时约束 RAM。 超上限的调用方会拿到一行明确的 skipped,不是排队。
worker
运行时用 chatruntime.SpawnRequest 派 worker:
// internal/manager/biz/alert/investigator/usecase.go:571
worker, err := uc.spawner.SpawnWorker(ctx, chatruntime.SpawnRequest{
AgentName: uc.cfg.AgentName, // "incident-investigator"
Prompt: prompt,
Background: false,
SessionKind: "investigation",
})prompt 由 renderAlertPrompt 渲染,包括:
- Incident 元数据(rule、severity、device_id、value、threshold、summary)。
- 一条明确的起始指令:
Start with correlate_incident to pull metrics + logs + traces + topology around the fire window. - 一个硬预算:最多 10 次 tool 调用,第 7 次前必须开始写报告。这个预算 住在 user 消息里,因为非前沿模型(GLM、DeepSeek)跟 user 消息里的约束 比 system 消息里的约束更可靠 —— 不带它的话 eino MaxStep 上限每跑一次就 撞一次。
- 一条locale 指令,用运维的 UI locale 覆盖 persona 的隐式语言(locale 怎么传播见 模型 / 路由)。
tool 预算 + 抢救
eino ReAct graph 限制总步数。worker 在没写最终答案前用完上限时,investigator 会抢救部分 trail:
MessageReader.ListMessages(sessionID, limit=100)把每一轮拉出来。- assistant + tool 消息拼成一份合成的"我们找到了什么"markdown。
- 把抢救件喂给同一个 Pass-2 抽取器。
- 报告被标
ready,开头加一条低置信度注:工作器超出最大步数预算(exceeds max steps);以下为根据已收集工具结果的局部分析,置信度偏低。
不抢救的话运维看到的是 status=failed,没有任何有用数据 —— worker 通常已经 调了 10+ 次工具拿到答案,就是没写出最后的综合 turn。
结构化抽取(Pass 2)
worker 的最终 assistant 消息是 markdown —— SPA 需要结构化字段。第二次便宜的 LLM 调用抽取:
root_cause—— 一段 TL;DR。affected_window—— 症状区间什么时候开始 / 结束。pinpointed_target—— 发生变化的具体进程 / 容器 / 文件。related_alerts—— 共触发的 incident(通过RelatedAlertQuerier)。evidence—— 带项目符号的源引用(PromQL 输出、日志行等)。suggested_actions—— 运维可执行的下一步。confidence+confidence_factors。tool_call_count—— 从chat_messages读回来,UI 显示 worker 实际调了 多少次工具(不是硬写的 0)。
模型 + provider 可通过 Config.SummarizerProvider / Config.SummarizerModel 配置。默认 30s 超时(短 prompt、短 reply、无 tool 循环)。
抽取器没接线或出错时,回退用 firstParagraphOneLine 在 worker markdown 上 跑,填 root_cause,并把整份 markdown 原样作为 findings_md 发出去。
加粗 header 陷阱
firstParagraphOneLine(usecase.go:846) 跳过纯 markdown 脚手架(heading、divider、整行加粗的章节标题),让 root_cause 读起来像一句话,而不是 **现象**。之前有个 bug 只剥掉了开头 的 **,留了尾巴上一对 —— 在同一个函数里修了。
手动重触发
POST /v1/alerts/incidents/{id}/investigation
Accept-Language: enForceEnqueue 跑手动路径:
- 停掉这条 incident 当前在跑的 worker(best-effort —— worker_id 在重启 后过期就 warn 并继续)。
- 硬删之前的
investigation_reports行(覆盖之前 force-enqueue 留下的软 删行,让唯一incident_id索引不拒下次 Create)。 - 释放 inflight 守卫。
- 用从
Accept-Language解出的 locale 调EnqueueWith,让重生成的报告用 运维 UI 语言回来。
严重级地板照样适用 —— 对 info 级 incident 手动触发会返回 severity below floor 错误。
启动期回填
新装会撞蛋鸡问题:结构化 RCA 链路只在至少配置了一个 LLM provider 时才接 上,但 incident 可以在运维加 provider 之前先触发。BackfillUnstartedIncidents 在启动时跑,走 ListIncidentsWithoutReport(since, limit),重新入队窗口内 触发过的。常规 gate 仍然适用,所以已解决的 incident 和超地板的会被跳过。
接线见 cmd/ongrid/main.go —— 启动时用 24 小时窗口跑。