给 Agent 知识库的每次写入留一笔行数账:回执带统计、提交信息带板块与行数、复盘再做 git 审计

学习目标

读完这篇,你能给自己的 Agent 知识库(或者任何由 AI 写入的配置文件、文档库)加一套变更追溯:每次写入都算出增了几行、删了几行,把这个数字同时写进三处——工具回执、git 提交信息、复盘审计报告。做完之后,"工具报成功、内容却没落盘"的静默丢失,不再靠运气发现,而是被一行数字当场抓住。

先给大家科普一个概念「静默丢失」。翻译成人话:系统告诉你"写好了",实际上内容根本没写进去,或者写进去的是个空壳,而你直到下次要用的时候才发现。daw 就踩过这么一个真坑:它的知识库(OKF)本来就是 git 仓库,每次写入自动提交,看起来万无一失;但 2026 年 8 月 31 日的复盘实锤了一个 bug——模型传回的 content 首行常常回显一个和 heading 参数同名的标题行,写入函数追加自己的标题后构成同名重复,去重逻辑按"同名保留首次",把第二次标题连同它后面的全部正文一起吃掉了,工具却照常报告"已写入"。当天两处权威口径就这样落成了两个只有标题的空板块。

而铁证当时就躺在 git 历史里:那天 11:09 的一笔提交,diff 是 +4 −2,新增的只有一行光杆标题。没有人看——提交信息只有文件名,diff 没人翻,"写了什么"在系统里查不到。这篇文章讲的,就是 daw 按代码仓库的提交纪律给知识库补齐的三件事:写入时算行数、提交信息和回执都带上行数、复盘时做 git 审计。

1. 前置知识:知识库的写入链路长什么样

先把 daw 知识库的写入链路说清楚,追溯的三个落点都长在这条链上。

知识库存在 ~/.daw/okf,按 scope(全局/工作区)分仓库,每个仓库本身就是一个 git 仓库。写入入口是 write_okf_knowledge 工具,调 Okf::write 做四件事:upsert_block 合并进目标板块、去重、写文件、versioner.commit 自动提交。工具返回的 OkfWriteOutcome 原来只有 scope、文件路径和"是不是新建文件";回执 summary 原来只写"已写入某某板块",payload 只带位置信息。

这条链路的问题不在"没版本化",而在"版本化了却不可读"。自动提交的提交信息是 Update OKF: concepts/foo.md 这种格式——只说了改了哪个文件,没说改了哪个板块、增几行删几行。要判断一笔写入有没有丢内容,得把 diff 翻出来逐行看,而自动提交每天产生几十笔,没有人会逐笔翻。所以第一步不是加更多检查,而是先让"改了什么"变得一眼可见。

2. 第一步:写入时算出行数——body 级多重集近似 diff

第一步是在 store::write 里算出行变更:旧正文相对新正文,新增了几行、消失了几行。daw 的做法叫 line_delta,一个多重集近似 diff:

/// 行级变更统计(多重集近似 diff):new 相对 old「新增/消失」的内容行数。
/// 不求最小编辑距离——审计目的下「哪些行凭空消失了」用计数对比足够,且
/// O(n) 纯内存无 git 依赖。排除 frontmatter(updated_at 每写必变,纯噪音),
/// 由调用方只喂 body。
fn line_delta(old: &str, new: &str) -> (u32, u32) {
    use std::collections::HashMap;
    let mut counts: HashMap<String, i64> = HashMap::new();
    for l in old.lines() {
        *counts.entry(l.to_string()).or_insert(0) -= 1;
    }
    for l in new.lines() {
        *counts.entry(l.to_string()).or_insert(0) += 1;
    }
    let (mut added, mut removed) = (0u32, 0u32);
    for v in counts.values() {
        if *v > 0 {
            added += *v as u32;
        } else if *v < 0 {
            removed += (-*v) as u32;
        }
    }
    (added, removed)
}

翻译成人话:把旧正文的每一行记 -1、新正文的每一行记 +1,同一行出现多次就累计,最后正数加起来是新增行数,负数加起来是消失行数。这里有两个设计点值得照搬。算出的两个数进 OkfWriteOutcome 结构(新增 added_lines / removed_lines 字段),之后提交信息和回执都从它取数——"改了几行"从此是写入结果的一等公民,而不是事后从 git 里反推的二手信息。

第一,只喂 body,不喂整个文件。知识库文件头上有 frontmatter,里面的 updated_at 每次写入都会变——把它算进行变更,每次写入都会凭空多出"一行改动",数字就失去意义了。调用方在算之前先把 frontmatter 剥掉,只拿正文部分做对比。

第二,不求最小编辑距离。真正的 diff 算法要算"哪行移到哪行",又贵又没必要。审计要回答的只是"新增几行、消失几行",多重集计数足够,而且是 O(n) 纯内存,不依赖 git,写文件的那一刻就能算出来。

验收标准:写测试钉住三组数字——纯新增 4 行得 (4, 0),改写 2 行新内容、删 3 行旧内容得 (2, 3),等量替换得 (2, 2)(daw 的测试 write_reports_body_line_delta 就是这三组断言)。

3. 第二步:把行数同时写进提交信息和工具回执

第二步是把这两个数字送到人会看的地方:git 提交信息和工具回执。提交信息从"只写文件名"升级成"动词 + 类别/文件 + 板块名 + 行数":

let verb = if created { "Create" } else { "Update" };
let short_heading: String = heading.chars().take(40).collect();
versioner.commit(
    &repo_root(paths, ws, scope),
    &file,
    &format!("{verb} OKF: {cat_dir}/{name}「{short_heading}」(+{added_lines} −{removed_lines})"),
);

一笔写入现在留下的提交信息长这样:Update OKF: concepts/业绩口径「苏州团队独立呈现」(+6 −1)。翻 git log 不用点开 diff 就知道:改了哪个板块、新增 6 行、删 1 行。板块名截 40 字符,是为了让提交信息在一行里放得下。

工具回执也同步升级:summary 原来是"已写入[全局] concepts/业绩口径的「苏州团队独立呈现」板块",现在后面缀上 (+6 −1 行);payload 里同时带结构化的 addedLines / removedLines 字段。回执是模型当场看到的东西——刚写完就能看到自己写了几行,数字不对它自己就能警觉;payload 是结构化字段,复盘脚本和前端可以直接拿去统计展示,不用再从 summary 文本里抠数字。

有人可能会问,为什么提交信息和回执要放同一组数字?答案是:这是同一笔写入在两个时间点的可读性。回执是"当场可读",让模型刚写完就能核对;提交信息是"事后可读",让复盘的人不用考古。两个地方的数字必须来自同一个计算——都在 OkfWriteOutcome 里算好、分别格式化,绝不能两边各算一次,否则两个数字对不上,追溯本身就不可信了。

4. 第三步:净删多行,当场警告,别等复盘考古

光有数字还不够,还要给数字配上判断。daw 在回执里加了一道闸:如果这次写入不是新建文件、删了 5 行以上、且一行没新增,就当场警告模型:

// 静默丢知识防线的第二道闸(第一道是写入层剥离回显标题):
// 净删多行且一行未增,多半不是有意的改写——当场提醒模型核对,
// 而不是等下次复盘从 git 历史里考古。
let warning = if !o.created && o.removed_lines >= 5 && o.added_lines == 0 {
    format!(
        "\n⚠️ 本次写入删除了 {} 行、新增 0 行:若不是有意清空/大幅精简该板块,请立即 load_okf_knowledge 核对文件现状。",
        o.removed_lines
    )
} else {
    String::new()
};

这条规则的道理是:正经的知识改写几乎总是"有增有删"——改口径是删旧句子、写新句子;补内容是纯新增。只有"删一堆、增零行"这个形状最可疑:它要么是用户有意清空或大幅精简一个板块,要么就是写入层又把正文吃掉了。阈值 5 行是经验值:删一两行可能是修个措辞,删 5 行以上还一行不增,就值得打断一次。

注意这道闸和第一道闸的分工。第一道闸在写入层:upsert_block 剥离模型回显的同名标题、空 content 直接拒写——这是根修,堵住已知的丢内容路径。第二道闸在回执层:不管丢内容的原因是什么,只要结果形状可疑就警告——这是兜底,防还没见过的路径。两道闸一个堵已知、一个防未知。警告的动作也很具体:不是"请注意",而是"立即 load_okf_knowledge 核对文件现状"——告诉模型下一步查什么。

5. 第四步:复盘脚本加 git 审计,让最近的变更摆到面前

前三步解决的是单次写入的可追溯,第四步解决的是"一段时间里知识库被改成什么样"的可追溯。复盘脚本 tools/review_transcript.py 新增 okf_git_audit(),每次复盘时对每个 okf 仓库做两件事:

# 知识库 git 审计:okf 仓库本身就是版本库,每次写入由后端自动提交
# (提交信息含板块名与 +N −M 行变更)。复盘把它摆到面前——改了哪几行、
# 最近谁在动知识、有没有未提交的手工改动导致历史断档。
log = subprocess.run(
    ["git", "-C", repo, "--no-pager", "log", "-n", "6",
     "--date=format:%m-%d %H:%M", "--format=%h %ad %s"],
    capture_output=True, text=True, timeout=10, check=True,
).stdout.rstrip()
dirty = subprocess.run(
    ["git", "-C", repo, "status", "--porcelain"],
    capture_output=True, text=True, timeout=10, check=True,
).stdout.rstrip()
if dirty:
    print("    ⚠️ 有未提交的变更(手工改完要提交,否则历史断档):")

第一,打印最近 6 次知识变更的提交信息——提交信息里已带板块名和 +N −M,这 6 行本身就是一份"最近知识库被改成什么样"的简报,不用再翻 diff。第二,检查有没有未提交的变更:自动提交只覆盖工具写入的路径,手工改了文件不补提交,git 历史就断档。

这一步把"追溯"从单次写入的被动记录,变成了复盘时的主动检查。8 月 31 日那个吞正文的 bug,如果当时已有 git 审计,11:09 那笔 (+4 −2) 会直接打印在复盘报告里——新增只有一行光杆标题,一眼就能看出不对。追溯的价值不在于事后能查到,而在于下次复盘时它自己跳到你面前。

6. 为什么这样设计:追溯要对齐"谁在改知识"

回头看这套设计的道理,其实就一句话:追溯的粒度要对齐"改动"的粒度。知识库的改动单位是"一次写入改一个板块",追溯就按这个单位记账:一次写入一行账(回执)、一次提交一行账(提交信息)、一次复盘一份近况(git 审计)。不要按文件记(太粗),也不要按字符记(太碎)。行数刚刚好:新增一行就是一行,丢一段就是几行消失,数字本身就能讲故事。

三个收益是连带的:静默丢失从"事后考古"变成"当场发现",复盘从"翻 diff"变成"读简报",手工改动不再断档——"改完要提交"从口头纪律变成每次复盘都会被检查的动作。

避坑清单,照着抄:

  • 算行数只喂正文 body:frontmatter 里的 updated_at 每写必变,算进去就是噪音,行数立刻失真。
  • 行数不要两边各算一次:回执和提交信息必须用同一组数字,来源是同一个 OkfWriteOutcome,否则两个数字对不上,追溯本身就不可信。
  • 净删警告的阈值要留余地:删 5 行以上且零新增才警告,删一两行可能是正常修措辞,阈值太低等于狼来了。
  • 警告要给下一步动作:不是"请注意",而是"立即 load 核对文件现状"——告诉模型查什么,比告诉它出事了有用。
  • 提交信息里的板块名要截断:40 字符以内,保证 git log 一行能看完,太长反而看不清。

7. 总结:给开发者的建议

如果你也在给 Agent 做知识库、记忆系统,或者任何"AI 往里写东西"的存储,照这个顺序落地:写入时算出行变更(只算正文、多重集计数就够了);把行数写进提交信息和工具回执(同一组数字,两个地方);给"净删多行零新增"加一道当场警告;最后在复盘流程里加 git 审计。每一步都有独立的验收标准,任何一步没过关都不要往下一步走。

最后说一句判断标准:当你的 Agent 说"已写入"时,先别信,先看回执里的 (+N −M)。一行没增、删了一堆的写入,十有八九不是改写,是丢失。

项目开源在 GitHub:tsingliuwin/daw,觉得有用欢迎点个 star。

Logo

更多推荐