跳转到主要内容

WRITING

每次跑都有输出,所以没人发现它漏了 87%

2026年7月28日13 分钟阅读曾田力
工程方法数据复盘Claude Code
每次跑都有输出,所以没人发现它漏了 87%

一个导出脚本跑了两个月,每次都有输出,每次看都正常。它漏掉了 87% 的数据。

发现它纯属意外。我在给通话记录和短信做一条合并时间线,需要从 macOS 的 chat.db 里取短信正文,顺手看了一眼原来那个导出脚本的 SQL:

WHERE m.text IS NOT NULL AND length(m.text) > 0

看着没问题。跑一下也没问题——打出最近五条短信,内容完整,中文正常。

但真相是:2023 年整年,text 列非空的短信是 0 条。

现代 macOS 把消息正文写进一个叫 attributedBody 的 NeXTSTEP 二进制列,不再写 text。只有较近的消息因为某些路径还留着 text。而那个脚本按时间倒序、只打最近 5 条——恰好全都命中了还有 text 的那一小撮。

10252 条有正文的消息,它看得见 1404 条。漏掉 86.3%。两个月,没有任何一次报错。

旧口径与新口径的年度对比
同一个库、同一段时间。斜纹是旧口径完全看不见的部分——2023 年那根柱子,旧口径的高度是零。

这篇讲的就是从这个 bug 出发的一次彻底清理:砍掉 12 个死掉的数据域、删掉 7.5 GB、把 6900 篇 markdown 从头盘了一遍。以及一个反复出现的教训——默认判据几乎总是错的,你得先把判据造出来,再动手。


一、起点:我自己也不知道这些东西在干嘛

事情的起因是一句质问:这些项目有什么用,我都不知道你在干嘛。

我在 ~/Apps/data/ 下建过 19 个「个人数据只读窗口」——日历、提醒、备忘录、通讯录、照片、短信、邮件、Safari 历史、音乐、播客、地图、便签、健康……每个域一个目录,一个 dump 脚本,一个 data/ 输出目录。

盘账结果很难看:

19 个域里,12 个的 data/ 只有一个文件,日期停在建脚手架那天。 之后两个月零产出,没有任何 skill、cron 或脚本调用过它们。

为什么会这样?因为它们打不过直接开 App

「我最近听了什么歌」——打开 Music,一秒就看到。写个脚本去读它的 SQLite 库,反而更慢、更容易错、还得维护。健康域根本没库;便签域两张便签;地图域八条记录;播客域三个订阅。

脚本唯一打得过 App 的场景只有一个:

跨源 + 跨年 + 结构化提问。

比如「这个人过去三年跟我一共来往过多少次,电话和短信合起来看」——通话在「电话」App、短信在「信息」App,两边永远拼不起来,而且都只能翻不能查。这种问题 App 做不到,脚本能。

所以方向不是优化那 19 个窗口,是砍到只留能跨源查的,并把它们做成索引


二、做了什么

砍完剩下两个索引,加一个共用地基。

跨仓库 markdown 索引md_index.py)。我的知识不在任何笔记软件里——本机零个 Obsidian vault,仅有的两个笔记库停更一到两年,已经删了。真正的知识散在 7 个 workspace、124 个 repo 的 6900 篇 md 里:方案、PRD、会话交接、项目说明、投标技术文件。SQLite FTS5 全文索引,40 秒全量重建,搜索直接给 文件:行号 加命中行原文。

跨源往来时间线touchpoints.py)。通话记录 + 短信/iMessage,按号码归一后合并成一条时间轴,再挂上通讯录的姓名与单位。12713 条触点,跨 2023-06 到现在。

iMessage 正文解码_imessage_body.py)。就是上面那个 bug 的修复。抽成单一实现,因为两个消费者都要解,两份拷贝迟早漂移。自带 1438 条真值 fixture——用同时有两列的消息做对照,实测 1438/1438。

顺手量出一个我原本以为不会是这样的数:通讯录 677 人,只有 97 人有过通话或短信记录。 其余往来全走了微信——而微信库不在这条链路里。所以这条时间线是「运营商通道的往来账」,不是全量社交账。知道边界在哪,比数字本身重要。


三、剪贴板整域删除:先量后砍

清理过程中发现 PastePal(剪贴板历史工具)已经停止运行 26 小时——进程不在、不在登录项、源库最后写入停在前一天凌晨。它已经不产出了。

要不要连同攒下的 9 个月历史一起删?这种问题不能凭感觉答。量一下:

剪贴板 20358 条的构成
去重后 20358 条。前两段加起来近一半是噪音,真正别处找不到的只有 5.3%。
构成条数占比
纯路径 / 文件条目(源文件还在,冗余)472223.2%
短碎片 < 20 字(检索价值≈0)533726.2%
微信 / 钉钉正文(别处确实没有10745.3%
业务台账关键词8154.0%
疑似真 API key(45 个 sk-、2 个 GitHub token)470.2%

一半是噪音,不可替代的只有 5.3%,同时还在持续囤密钥——而且源头早就停了。

删。PastePal 的 app、7.3 GB 容器、Application Scripts 全部移入回收站,全盘 mdfind 复核无残留;索引域 129 MB 一并归档。7.4 GB + 129 MB,全部可回滚。

那 47 个 key 是意外收获——它跟留不留索引无关,本来就躺在源库里,只是从来没人去看。


四、6900 篇 md 的盘点:查重的头号产出不是找到重复

这是这一轮最反直觉的部分。

诉求很朴素:内容重叠的合并成一份,做完了的挪进归档。

第一版实现只花了五行代码——正文归一化后取 SHA-256,哈希相同即重复。结果:

459 组重复,共 1134 篇,合并可省 675 篇。

这个数字是有毒的。因为里面绝大多数,删了就是破坏:

  • plugins/cache/claude-hud/{0.4.0 … 0.5.1}/TESTING.md —— 包管理器按版本存的,手删下次装回来
  • domain-skills/*/SKILL.md → 分发到 9 个项目的 .claude/skills/ —— 副本是派生物,要改改源头
  • 同一份《台环函〔2024〕55 号》同时出现在「08 一级保护区」「09 二级保护区」「11 含磷洗涤剂」三个目录 —— 这是水源地达标评估的交付结构要求,每个检查条目下都必须有对应佐证,删了过不了验收

所以我把工具重写成分层的。判据自上而下,命中即停:

6899 篇 md 的分层漏斗
裸查重给出 459 组;分层之后,真正需要人做决定的只剩个位数。
篇数处置
机器管理 / 构建产物314不可动
SSOT 分发副本151要改改源头
已归档区2666已处理过一轮
台账佐证656交付结构要求
人写的活文档2943只看这一层

活文档层里的跨仓库双写,从 156 组降到 4 组

工具的价值不在找出重复——那是五行代码——在于把不该动的挡在外面。


五、真正有意思的一档:不逐字相同的重复

逐字查重降到 0 组之后,用户说了一句:「主要就是合并吧,或者有些就 archived 掉。」

我意识到前面做的都是最表层。同一件事写了两三份、彼此改过几段但主体重合——哈希差一个字就全然不同,逐字查重对这一档完全瞎。

加近似重复检测。第一版用经典的 128 盐 MinHash,跑了 120 秒没跑完——3000 篇里有几十篇十万字的文档,每个 shingle 要做 128 次异或取最小值。换成 bottom-k sketch(哈希一遍,取最小的 K 个),同样能无偏估计 Jaccard,快了两个数量级,40 秒出结果。

找到 238 对高度重叠(Jaccard ≥ 0.55)。逐个查证之后:

238 对近似重复的判定结果
超过三分之一的「像」是对的——硬合并就是破坏。

已判定合法、绝不能动的

对数为什么相似是对的
31每日 EOD 报告时间序列——模板相同、数字每天不同,合并等于毁掉记录
18同一篇文章的博客版与微信版——平台变体,本来就该各存一份
9同一张调查表由不同企业填写——各是各的数据
6论文写作的版本留档——版本之间相似正是它存在的意义
6PDF 抽取的清洗版与带插图版——合并要么丢插图关联要么丢清洗
3按公司定制的简历——高相似正是「同一份经历换个侧重」
2投标件的草稿与正式版——草稿反而更新更长,判不出哪份投出去了,不猜

真正归档掉的是另外几族:一次性迁移的中间产物(44 篇)、一份已完成投标的四个加工阶段(38 篇,彼此 95~99% 重合)、两小时内跑了四次的检查报告(保留最新一份)、Finder 手滑留下的 copy 2 目录。

全部挪进各自项目内的 _archive/,带 README 写明复原命令——没离开项目,没删任何东西。

一个关键设计:白名单单列一节报出计数,而不是静默过滤。静默过滤会让下次跑的人以为漏检了。


六、我自己犯的三个错

这部分才是这篇真正想写的。上面那些结论听起来都很笃定,实际过程里我错了三次,每次都是同一类错误:拿一把没验过的尺子去量,然后相信了量出来的结果。

错误一:对账脚本本身是错的

验证 FTS5 索引对不对,我写了个脚本用 LIKE 做对照。结果 LIKE '%llm_client%' 数出 224,FTS 数出 221。我当场判定 FTS 有 bug。

其实错的是我的对账脚本——SQL 里 _ 是单字符通配符,llm-clientllm.client 全被算了进去。转义之后,六组词六组一致。

用来当「真值」的那把尺子,如果没验过,它给出的「不一致」只能说明两边有一边错,不能直接判被测方错。

错误二:我的索引器自己在造假重复

盘点时发现 156 组「跨仓库双写」,其中一族是 stations/docs/knowledge/session-retro-*.mdWork/projects/*/docs/retros/ 逐字相同。我写进报告说这是真债。

那些是符号链接。

os.walk(followlinks=False) 只挡住符号链接的目录,符号链接的文件照样被列出,open() 照样跟过去读到目标内容。本机 157 篇是这种,全都变成「与目标逐字重复」的幽灵条目。

改成按 realpath 去重,顺带挡掉断链,两个计数打进构建统计行——不静默吞掉。索引 6899 → 6742。

同一轮里另外两个「真债」也是误判:一个是站点 content/ 目录,prebuild 钩子本来就会从 SSOT 同步;一个是 wiki 的 memory 镜像,构建器源码里白纸黑字写着 canonical mirror

156 组里,真正是债的只有 4 组。我给出的第一版数字,错了 97%。

错误三:差点归档掉两份正式交付物

近似重复里有三份钱塘江岸线图册,93~95% 重合。我判断其中两份是转换残留,挪进了 _archive/

归档前按流程 grep 了一下引用——项目的 CLAUDE.md 里写着:

| 规划图册 | 钱塘江/钱塘江岸线规划图册.{md,pdf} | md 进 git · pdf local-only |
| 报告文稿 | 钱塘江/qjtbg.{md,docx,pdf}       | md 进 git · docx/pdf local-only |

它们是有 docx/pdf 配套件的正式文稿,不是残留。当场原样搬回来。

判据由此加固,并写进了代码注释:有同名 .docx / .pdf 兄弟件的 .md 不是残留,它是某份真交付物的 markdown 面。归档前必须先 grep 引用。


七、方法沉淀

四个可以带走的东西。

① 输出非空 ≠ 输出正确。

这是贯穿全场的那一条。messages 漏 87%、ZANSWERED 把 792 通打通的去电标成「未接通」(那个字段问的是「我接没接」,去电时我是主叫,恒为 0)、87 个含 NUL 字节的假 md 混进索引(85 个是 Neovim 的 undo 历史文件)——三个 bug 同一个形态:每次跑都有输出,所以没人怀疑。

验收判据必须是「拿一个本该看得见东西的窗口去测」:全库最早 400 条、双列真值 fixture、逐词计数对账。不是「跑完有没有输出」。

那个 87% 的 bug 是怎么确认的?取全库最早 400 条消息——旧口径显示 0 条,新口径 395 条。

② 守卫必须 fail-closed,且写完立刻反向验证。

给 wiki 镜像加同步步骤时,我写了两条守卫:源目录不存在 → 报错返回;源目录存在但零篇 md → 报错返回,绝不清空镜像

写完立刻反向验证:把源指向不存在的路径、指向空目录,各跑一次,确认真实退出码是 2、且镜像 138 篇纹丝不动。

没反向验证过的守卫不算数——哑掉的守卫和它要防的 bug 是同一类。

最后那句「跨仓库双写 0 组」也做了同样的事:在两个 workspace 各放一份同内容的文件,确认立刻报 1 组;撤掉,归零。

③ 分类器的价值在于「挡掉什么」,不在于「找到什么」。

查重五行代码就能写完。难的是判断哪些重复是对的。而且这个判断不能靠感觉——每一条白名单背后都得有实证:读 package.json 确认 prebuild 会同步、读构建器源码确认 memory 是 canonical mirror、读项目 CLAUDE.md 确认那两份 md 有 docx 配套件。

④ 该保留的判据要写进代码,不是写进脑子。

上面每一条踩坑,最后都变成了工具里的一行正则加一段注释,说明「为什么这个看起来像重复的东西不是重复」。下次跑的人(包括三个月后的我自己)不需要重新查证一遍。


八、对未来有什么用

直接的md_index.py 让 6900 篇 md 从「靠猜在哪个仓再 grep」变成一句话给到文件和行号。这是唯一一个每天都在省时间的产出。

方法上的md_audit.py 现在是一个可复跑的体检——分层、近似检测、合法家族白名单全部固化在代码里。半年后知识库又长胖了,一条命令就知道哪些是真的该合并。

更一般的:这一轮真正值钱的不是「建」,是「砍」和「验」。

  • 建:两个索引
  • 砍:12 个死域、一个整域、7.5 GB
  • 验:三个静默出错的数据管道,其中一个漏了 87%、两个月无人察觉

而这三件事里,的收益最大——因为一个看着全绿的错误管道,比一个明显坏掉的管道危险得多。后者你会去修;前者你会拿它的输出去做决定。

如果只能带走一句话:

每次跑都有输出,不代表它是对的。找一个本该看得见东西的窗口,去测它。