给 Agent 的知识库做一次安全清理:空标题残桩的三类判定与 dry-run 批量清理做法
给 Agent 的知识库做一次安全清理:空标题残桩的三类判定与 dry-run 批量清理做法
学习目标
读完这篇,你能给一个由 Agent 持续写入的 Markdown 知识库做一次"安全清理":先把空标题分成真损失、无害残桩、合法容器三类,再用一个 dry-run 缺省的脚本把无害残桩批量删干净——删 98 处、零误删、每一步都有回滚。做完之后,你手里多了一套可复用的方法:凡是"Agent 写的、越积越乱"的文件,都能按"分类→试运行→分批落盘→复跑归零"四步收拾干净,而不是凭感觉手动删。
先给大家科普一个概念「残桩」。翻译成人话:知识库里留下的空标题。标题行在,正文不在——就像一根砍断后留在地里的树桩,看着像个板块,点进去什么都没有。残桩本身不丢数据(正文往往还在另一个标题下面),但它有两个坏处:第一,体检每次都报 90 多个,看着闹心;第二,时间一长,没人分得清哪个空标题是"正文在别处"的残桩、哪个是"正文真丢了"的事故现场。清理的目的不是"删空行",是把这两类彻底分开。
daw 的知识库(OKF)就长过这么一批残桩。2026 年 8 月 31 日的第二轮复盘实锤了一个 P0 bug:write_okf_knowledge 工具会静默吞正文——模型传来的 content 首行常常回显一个和 heading 参数同名的标题,工具往文件里追加自己的标题后构成同名重复,去重逻辑按"同名保留首次"把第二次出现的标题连同它后面的正文一起吃掉,工具却照常报"已写入"。当天两处权威口径(苏州团队独立呈现、8 月团队维度重收口)就这样落成了空标题。事后清点,112 个历史空标题,正文都在模型自带的不同名标题下面,无损失——但"无损失"是复盘对账对出来的,不是肉眼看出来的。这篇文章讲的,就是这 112 个残桩后来怎么被一次清零的。
1. 前置知识:残桩是怎么长出来的
动手删之前,先把残桩的出生证明看明白。不搞清成因的清理,下次还会长出来。
残桩有两代。第一代是同名回显:模型 content 首行回显的标题和工具参数 heading 一字不差,upsert_block 追加自己的标题后文件里出现两个同名标题,deduplicate 按"同名保留首次"把第二个标题连同正文吃掉。工具报成功,磁盘上只剩一个空标题。第二代是近义回显:模型回显的标题和工具标题措辞不同——复盘记录里的真实例子,「返回时间显示为 UTC」和「返回结果的时间显示是 UTC」——剥壳逻辑按同名匹配不命中,空标题就留下来了。根修(剥离回显同名标题 + 空 content 拒写)堵住了第一代,但第二代近义回显留下的桩还在文件里,复盘脚本每次体检都报"90+ 个无害残桩,择机清理"。
有意思的是,残桩的"无害"两个字不是拍脑袋定的。复盘脚本的体检函数 okf_health(tools/review_transcript.py)给每个空标题做了分类,分类标准只有一个:空标题后面紧跟的是什么。这个分类,就是整篇清理方法的地基。
2. 第一步:先分类——空标题分三类,只有一类能删
清理的第一步不是写删除代码,是把空标题分成三类。分类规则写在 okf_health 里,清理工具 tools/okf_cleanup_stubs.py 的 scan_file 与它保持同一判定:
- 真损失候选,绝不动。 两种特征:空标题直达文件末尾(后面什么都没有,正文可能真丢了);空标题后紧跟一个同名标题(去重把第二次出现的标题连同正文吃掉了,这是第一代 bug 的现场)。这两类留给人工拿 jsonl 工具段的写入回执(
detail字段存了模型实际传来的 content)逐条对账恢复,脚本一个字都不碰。 - 无害残桩,可以删。 空标题后紧跟一个不同名的同级或更浅标题——正文就在后面那个标题下面,空标题纯属多余。112 个历史残桩全是这一类。
- 合法容器标题,不能删。 空标题后跟的是更深层级的标题(比如
#后面跟##),这不是残桩,是组织子节的容器结构。体检函数专门修过这个判定:空标题后跟更深层标题是合法结构,不再误报。
有人可能会问,为什么不直接按"空标题就删"一刀切?因为"空"这个表象下面是三种完全不同的事实:丢了的、挪了地方的、以及根本就不是空的(容器)。一刀切删掉的可能是用户纠正过的唯一一份口径。分类这一步,就是把"删"这个动作的 blast radius(爆炸半径)先锁死在"正文确定在别处"的那一类上。
3. 第二步:体检先行——让脚本先报数,再谈删不删
分类规则有了,第二步是跑体检、不动手。okf_health 扫全部 OKF 的 md 文件,按上面的三类计数。跑完你会拿到三组数字:真损失候选几处、无害残桩几处、疑似重复板块几处。
这一步的价值在于"先有数再有动作"。清理工具本身也是这个思路:缺省就是 dry-run,只列计划删除项,不动磁盘。用法就两行:
python tools/okf_cleanup_stubs.py # dry-run,只列计划删除项
python tools/okf_cleanup_stubs.py --apply # 删除 + 每个 okf 根目录 git 提交
dry-run 的输出长这样:每处计划删除都打印文件路径和标题,末尾汇总"残桩清理 dry-run 计划删除 N 处(M 个 okf 根);真损失候选 K 处未动"。你先看一遍清单,确认无误再加 --apply。这个顺序不能反:先看清单、再落盘,是整个方法里最重要的一条纪律。
4. 第三步:写清理工具——判定、删除、提交,三件事分开
清理工具 tools/okf_cleanup_stubs.py 只有 140 行左右,分工很干净,三件事分开做:
判定(scan_file)。 逐行扫文件,先跳过 frontmatter(--- 包起来的元数据区),再按三类规则走:空标题后跟更深层级标题→容器,跳过;直达 EOF→记入真损失;后跟同名标题→记入真损失(同名对撞);后跟不同名同级/更浅标题→记入可删。注意一个细节:判定用的是标题层级(# 的个数)和归一化后的标题文本(去空白、转小写再比较),所以「返回时间显示为 UTC」和「返回结果的时间显示是 UTC」这种近义回显不会被误判成同名对撞——它们会被正确归入"无害残桩",而不是被当成真损失锁死。
删除(main 的写回部分)。 --apply 时才执行:按行号删掉可删的空标题行,其他行原样写回。这里有个关键选择:删除按行号,不按文本匹配。按文本匹配删"空标题"会误删文件中其他同名同内容的行;按行号删,删的就是体检时判定的那一行,不多不少。
提交(每 okf 根一笔提交)。 每个 okf 根目录(okf_roots 列出的知识库根)改完后单独 git add -A + git commit,提交信息写明数量和成因:“清理空标题残桩 N 处——正文在模型自带标题下,近义回显留桩(复盘第十八轮批量清理)”。一个根一笔提交,回滚的时候可以按根回滚,不用在几百处改动里挑。
三件事分开的好处是:判定错了,dry-run 清单上能看出来;删除错了,git 提交能按根回滚;提交失败了,工具会打印失败原因而不是静默跳过。每一层都有自己的纠错机会。
5. 第四步:链式残桩要复跑到归零
97 处无害残桩一次清零后,复跑又抓出 1 处链式残桩:H1 空壳→H2 空壳→正文的三层结构。删掉中层的 H2 空壳后,外层的 H1 空壳才暴露出来——第一轮扫描时,H1 后面跟的是 H2(更深层级),按规则被判定为"合法容器";H2 删掉后,H1 后面直接是正文所在的标题,才露出残桩的真面目。
所以清理的收尾动作是:复跑,直到归零。跑一次 --apply,再跑一次 dry-run,看计划删除是不是 0;不是 0 就再跑一轮。daw 这次是跑到第二轮归零,总共 98 处,验收时 okf 残桩 0、真损失 0。链式残桩这个坑,靠"复跑到归零"这一条纪律就能兜住——你不需要在判定逻辑里预判嵌套层数,让循环替你处理。
6. 为什么这样设计
回头看,这套做法有四个设计选择,个个都是被真实事故逼出来的:
- dry-run 缺省。 删除是不可逆操作(虽然有 git,但回滚也是成本),缺省不落盘,等于给每一次清理都加了一道人工确认。工具的默认值就是安全默认值。
- 真损失候选绝不动。 脚本只处理"正文确定在别处"的残桩;凡是"可能丢了数据"的,一律留给人工对账。自动化的边界划在"确定无害"的地方,而不是"大概无害"的地方。
- 判定与体检共享同一逻辑。 清理工具的
scan_file和复盘脚本的okf_health用同一套分类规则——体检报多少,清理就删多少,两边数字对得上。daw 验收时 169 个测试全过,复盘基线 113 条判断与人工判断一致,靠的就是"同一规则、两处复用",而不是两套各自演化的逻辑。 - 每根一笔提交。 知识库是多根的(多个工作区各有 okf 根),一根一提交,改动按根隔离。哪根出问题回滚哪根,不牵连其他根。
这四个选择背后其实是同一条原则:“工具报成功"不等于"知识落了盘”,复盘必须以磁盘文件为准对账。 这句话是第二轮复盘实锤 P0 bug 后写进 AGENTS.md 的。清理工具的每一层设计,都是在兑现这句话:dry-run 是对账,分类是对账,复跑归零是对账,每根一提交还是对账。
7. 避坑清单
- 不要按文本匹配删除。 按行号删。文本匹配会误删文件中其他同名行,行号删只删判定过的那一行。
- 不要跳过 dry-run 直接 --apply。 先看清单。清单上有真损失候选被误判成可删的情况,dry-run 是最后一道人工关口。
- 不要把真损失候选也"顺手"删了。 直达 EOF 和同名对撞这两类,脚本特意留给人工——它们的正文可能真丢了,删标题等于销毁事故现场。对账要拿 jsonl 工具段的写入回执(
detail字段)比对磁盘,判定是"模型传空"还是"写入吞掉",再决定怎么恢复。 - 不要只跑一轮。 链式残桩(H1 空壳→H2 空壳→正文)第一轮扫不出来,复跑到 dry-run 报 0 为止。
- 不要忘了容器标题。 空标题后跟更深层级标题是合法结构,删了会破坏文档结构。判定里专门留了这一条,别为了"数字好看"把它也清掉。
- 提交信息要写清成因和数量。 “清理空标题残桩 N 处——正文在模型自带标题下,近义回显留桩”,半年后回看提交历史,能立刻知道这批改动为什么发生。
总结
给 Agent 写的知识库做清理,真正的难点从来不是"删空标题"这个动作,而是"分清哪类空标题能删"。daw 的做法是四步:先分类(三类空标题,只有一类能删)→体检先行(先报数再动手)→dry-run 缺省的清理工具(判定、删除、提交三件事分开,每根一笔提交)→复跑到归零(链式残桩靠循环兜底)。98 处残桩一次清零、零误删,靠的不是删除代码写得多巧,是分类划得够准、默认值够安全、每一层都有对账。
给开发者的建议:
- 你的 Agent 如果也在往 Markdown 知识库里写东西,先给体检函数加一个"空标题分类"——真损失、无害残桩、合法容器三类,计数先跑起来。分类不清的清理,迟早会删掉不该删的。
- 任何批量删除工具,缺省必须是 dry-run。
--apply应该是一个需要人主动敲出来的决定,而不是默认行为。 - 删除类工具的提交信息要写清"删了多少、为什么删",这是半年后的你唯一能依靠的上下文。
- 记住这句话:“工具报成功"不等于"知识落了盘”。凡是 Agent 写盘的地方,复盘都以磁盘文件为准对账。
项目开源在 GitHub:tsingliuwin/daw,觉得有用欢迎点个 star。
更多推荐




所有评论(0)