事后诸葛:我的智能体说“这个问题五月就修过了”

Dev.to AI 2026-11-07T15:27:52.913813

周一早上 07:02,一个对账检查挂了:fact_supplier_commit 在 fiscal_week=FY27W06 下的行数是 0。可加载任务报的是 SUCCESS,还说写了 60 行。暂存区里也确实是 60 行。那它们去哪了?

同一个事故,我交给同一个模型跑了两次。提示词一样,数仓一样,SQL 工具也一样。第一次跑,它推断出这些行落到了下一周 FY27W07,建议“修一下周次查找逻辑”。第二次跑,它说加载器用的规则是 week_start > today——在新一周开始的那个周一,它会把数据选到下一周去。它还说这个 bug 在五月搞挂了 supplier_forecast_merge(INC-2026-0505),当时从 SFTP 重载暂存文件也没用,并且修完之后要把 FY27W07 那些行删掉。它用了四条查询就得出这些,而不是六条。

那会儿两次运行都看不到加载器的代码。第二次纯粹靠历史记录就认出了这个 bug。两次唯一的差别,就是记忆。

第二个智能体就是 PipelineMD。下面是我在 Hindsight 上构建它时学到的东西——Hindsight 是 Vectorize 开源的智能体记忆系统。

PipelineMD 是做什么的

PipelineMD 是一个数据管道的值班分诊智能体(on-call triage agent)。你把告警、日志片段或者失败的对账结果粘进去,它会:

技术栈是刻意做小的:用 Groq 跑 openai/gpt-oss-120b 作为智能体循环的模型,Hindsight Cloud 存记忆,SQLite 当演示数仓,最上层是 Streamlit 加一个 CLI。记忆库里存着 24 份事后复盘,来自一家虚构物流公司的五条管道。

这些失败场景我在真实仓库作业中都见过:映射表上的连接扇出、财周边界规则、NULL 与零值不匹配、约束变更后的主键冲突,以及 ORA-12839 并行 DML 错误。

数据库给不了你的那部分

整个项目依赖的核心思路是:值班工程师最需要的信息,其实不在数据里。

数据只能告诉基线,某些行被归到了错误的周。只有历史记录才能告诉它:周一滚动时哪条规则会失效、以前也出过同样的问题、上次哪些处理方式是浪费时间。因此我围绕这一点设计了记忆系统。

每次事故复盘都以散文形式保留,而不是 JSON,其中我最关心的是首次响应以及它是否奏效:

def to_memory_text(self) -> str:
    """渲染为简短的事后复盘。Hindsight 从散文里提取事实的效果比 JSON 更好。"""
    first = "worked" if self.first_response_worked else "did NOT work"
    return (
        f"Incident {self.id} on pipeline {self.pipeline} ({self.severity}, category {self.category}).\n"
        f"Symptom: {self.symptom}\n"
        f"Error: {self.error_excerpt}\n"
        f"First response: {self.first_response} This {first}.\n"
        f"Root cause: {self.root_cause}\n"
        f"Fix: {self.fix}\n"
        f"Resolved by {self.resolved_by} in {self.time_to_resolve_min} minutes."
    )

“重新跑了一遍加载,没起作用。”——这句话会变成智能体的“别再试这个”清单。没有它,有记忆的智能体只知道正确答案;有了它,智能体还知道哪些路走不通,而这才是在早上 7:02 真正省时间的东西。

每条复盘入库时都带上真实的事故日期,用事故 ID 作为 document_id,并打上管道和故障类别的标签:

items = [
    {
        "content": inc.to_memory_text(),
        "timestamp": inc.opened_at,
        "context": "data pipeline incident postmortem",
        "document_id": inc.id,
        "metadata": {
            "incident_id": inc.id,
            "pipeline": inc.pipeline,
            "severity": inc.severity
        },
        "tags": inc.tags(),
    }
    for inc in incidents
]

记忆库还会收到一条 retain_mission 指令,告诉 Hindsight 该提取什么,并要求逐字保留表名、错误码、工厂 ID 和财周键。

这比我想象的更重要。dim_plant_region 和 FY27W06 正是故障匹配时使用的字符串,而像“区域映射表”这样的改述则什么都匹配不到。Hindsight 关于 retain 和 memory bank 的文档覆盖了这些选项,mission 文本也值得好好调整。

召回不按 pipeline 过滤,这是刻意为之

我的第一反应是把召回过滤到出故障的 pipeline 标签上。还好我没这么做。最佳匹配往往是同一条 pipeline 上共享同一张表的同一个 bug。EU-W 的出货量多算匹配到了 INC-2026-0412,那是四月发生在 inventory pipeline 上的一个 dim_plant_region 扇出问题。同样的两个工厂,3104 和 5862,同样的每个工厂两行的问题,不同的 pipeline。按 pipeline 过滤就会把它藏起来。

召回必须返回故障记录,而不是碎片

我的第一个版本就是在这里栽的。Hindsight 的召回效果很好:在 Memory explorer 里,EU-W 多算的顶部结果正是关于 dim_plant_region 的正确事实。但它返回的是 Hindsight 抽取出来的事实,一份事后复盘会变成十几条事实(“根因:……”“修复:……”“第一次响应……失败”),大多数还都不带故障 ID。我的第一个 agent 拿到 84 条,平铺在一个列表里,混着周界和主键冲突的事实,然后它抓住了同一条 pipeline 上一个并行加载的事实。它的回答自信满满,却是错的。

修复用了 Hindsight 本来就会返回的两样东西。带 include_chunks=True 的召回会给出每条事实背后保留的原始文本,而每条事实都带着我保留它时所用的 document_id。于是我把事实重新按故障分组,把排名前四的作为完整的事后复盘展示给模型:

INCIDENT_ID = re.compile(r"\bINC-\d{4}-\d{4}\b")
for h in hits:
    if h.document_id and INCIDENT_ID.fullmatch(h.document_id):
        incidents.setdefault(h.document_id, []).append(h)
    elif h.text not in {x.text for x in loose}:
        loose.append(h)
# 分诊反馈和观察记录:不是故障,不管它们的 document id 是什么

写这个正则是因为我在实跑中撞到了一个 bug。反馈记忆没有故障 ID,所以 Hindsight 会给它们一个生成的 UUID 作为 document ID。

我最初的分组方式把每个文档 ID 都当成一起事件,模型于是乖乖报告「与过去事件匹配:d6e6d866-6208-…」,而我自己的引用校验居然放行了。现在只有符合事件格式的 ID 才能被展示或引用为事件,而且模型引用的 ID 只有确实来自 Hindsight 返回结果才算数。这也意味着记忆检索环节无法凭空捏造匹配。没有匹配时,它会直接说明:

matches = _verified_matches(answer.get("similar_incidents", []), hits) if has_memory else []
if has_memory and not matches and not root_cause.startswith(NO_MATCH):
    root_cause = f"{NO_MATCH} {root_cause}"

曾经有一次基础镜像重建后出现 TLS 故障,这类故障在记忆库里没有任何历史记录。记忆检索环节的回答开头是「团队记忆中没有匹配的事件」,但最终给出的修复方案依然正确:重建后的镜像缺少 CA 证书包。我觉得这句话和一条匹配记录同样有价值——它告诉工程师,他们正踏入一片新领域。

它会从团队中学习,而且整个过程可见

每次排查都以「正确」或「错误」结束,还可以附加备注。这些反馈会存回同一个记忆库:

client.retain(
    bank_id=self.bank_id,
    content=content,
    timestamp=datetime.now(timezone.utc).isoformat(),
    context="on-call triage feedback",
    tags=[
        f"pipeline:{pipeline}",
        "kind:triage_feedback",
        f"outcome:{'accepted' if accepted else 'rejected'}",
    ],
)

我确认了一条 EU-W 的诊断之后,下次召回该事件时,我自己的结论排在了最前面,位于四月份的事后复盘之上:「dim_plant_region 中 plant_id 3104 和 5862 存在重复行,导致仅按 plant_id 进行连接时 shipped_qty 被成倍放大。」现在 agent 会把它和匹配到的事件并列显示,标记为 [triage feedback]。这正是我想要的闭环:第二个遇到同一 bug 的工程师能直接受益于第一个人的结论,而不需要谁去写一篇 wiki。

扇出问题,结合数据加载器来看

事件 01——EU-W 的过度计数(发货量 193,280,源数据总量 115,023,比值 1.68)——又教会了我一件事。

记忆帮 agent 很快找到了正确的表,但方向一直搞错:它说数据源被放大了,或者直接把 193,280 当成真实总数,因为它看不到写入这些数字的加载逻辑(load)。于是我给了两次运行一个只读工具 get_pipeline_sql,返回每条 pipeline 实际执行的加载语句。把加载逻辑摆到眼前之后,两次运行都找到了那行有问题的代码——JOIN dim_plant_region m ON m.plant_id = s.plant_id——并且都建议改用去重后的映射表来关联。这是正确的结果。一个能展示代码的工具,无论 agent 有没有记忆,都应该有帮助。真正的差别在附带的东西上。有记忆的那次运行用三次查询就到位,而不是四次,而且它把这行代码和 INC-2026-0412 关联了起来:四月里同样是这两个工厂造成了同样的扇出(fan-out,一行数据被关联放大成多行),于是它给出了那次事故的修法——按 (plant_id, lob) 关联,并在映射表上强制唯一性。知道这是一次重复故障,才让“改掉这个 join”变成“改掉这个 join,再查清四月的唯一性检查为什么没拦住它”。

如果要我来给做这件事的人提建议

保留死路,而不只是保留答案。 “这条路走不通”是每份复盘里价值最高的一句话。正是它让有记忆的那次回答比基线更短,而不只是更准。

让记忆成为唯一的变量。 PipelineMD 的基线是一个 NoMemory 类,接口完全相同。同一个模型、同一段提示词、同一套工具、同一个数仓。少了这一点,所有“记忆有帮助”的说法都只是猜测;有了它,你可以直接指出是第 1 次查询和第 4 次查询的区别。

不要扔给模型一堆碎片。 召回效果好,照样可能把模型淹没。按你留存时的 ID 分组,用 include_chunks 给它完整文档,并限制条数。

检查模型引用了什么。 模型会引用任何看起来像 ID 的东西,包括 UUID。只接受确实从记忆里返回的引用;什么都没匹配到时,要求它明确说“无匹配”。

免费额度是一种设计约束。 Groq 的免费额度是每个模型每天 20 万 token,而 agent 循环每一步都会把整个提示词重发一遍。正是紧凑的 schema 和设了上限的工具输出,让我攒够了对比运行次数,录出了一个演示。

如果你对这个概念还不太熟,Vectorize 上有一篇入门文章讲得很清楚:什么是智能体记忆(agent memory),它和 RAG(检索增强生成)又有什么区别。在我看,简化版是这么回事:RAG 顶多能翻出四月份那次事故复盘。而记忆,则是把“从 SFTP 重新加载没成功”这件事,连同我上周自己下的结论,一起摆到智能体面前——正是靠这些,07:02 的告警才变成了一个只需四次查询就能解决的问题。PipelineMD 的代码都在 GitHub 上,包括预置的事故历史、演示用的数据仓库,以及测试:Thrisha999/pipeline-incident-agent。

事后回看,我的 Agent 说"这个问题五月就修好了"

周一早上 07:02,一次数据核对(recon check)报了异常:fiscal_week=FY27W06 对应的 fact_supplier_commit 表里一行数据都没有。可加载任务却报 SUCCESS,声称写入了 60 行。暂存区(staging)里这 60 行也确实都在。那它们究竟去哪了?

我把同一个故障、同一份提示词、同一个数仓、同一个 SQL 工具,交给同一个模型跑了两次。

第一次,它查出来这些行落到了下一周 FY27W07 名下,建议"修一下周次查找逻辑"。

第二次,它说……

查看原文