75 条文章索引被一条 add 清成 1 条,退出码还是 0:tri-article 的 index.py 我读了 205 行

那天晚上我在给 tri-article 补一篇文章,照例往 articles/index.json 里加一条记录。加完顺手 list 了一眼,返回 1 条。

我盯着屏幕愣了三秒。那个文件里躺着两三个月攒下来的 75 条文章记录------标题、slug、领域、标签、内容哈希、落盘路径全在里面。现在只剩我刚加的这一条。

退出码 0,stderr 一行都没有,终端上打印的是新记录完整的 JSON,跟过去几十次长得一模一样。要不是我那天多看了这一眼,它大概会在我下一次 add 之后彻底变成历史。

复现一次,比读十遍代码快

tri-article 的索引维护在 tri-article/hooks/index.py,205 行,纯标准库,dedup / add / search / list 四个子命令。我先把它拷到实验目录,造了 75 条假记录,然后把索引文件截断成 400 字节------这就是一个坏掉的 JSON。

bash 复制代码
# 索引已经坏了,但没人告诉我
$ python -c "
import sys; sys.path.insert(0,'.')
import index_orig as M
print('load_index ->', len(M.load_index('work_a')))"
load_index -> 0

# 照常 add
$ python index_orig.py --root work_a add --title "今天新写的一篇" \
      --domain demo --slug today --path p/today.md
rc = 0 | stderr = ''
add 之后条数 = 1 -> ['today']

75 条进去,1 条出来,中间没有任何提示。根因在第 38 到 43 行:

python 复制代码
def load_index(root):
    path = os.path.join(root, INDEX_FILE)
    if not os.path.isfile(path):
        return []
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, list) else []
    except (ValueError, OSError):
        return []          # ← 解析不出来,就当「从来没有过文章」

我个人特别讨厌这种写法。except 里返回空列表,语义上等于告诉调用方「你的库是空的」,而真实情况是「你的库我读不了」。这两件事在 add 眼里完全一样:都是「追加一条然后写回」。于是坏文件 + 一次 add = 静默清库。

文件不存在时返回 [] 我认,那是首次运行。解析失败也返回 [],就是把数据损坏伪装成初始化。

并发跑,六个进三个

既然读出来是整份、写回去也是整份,那并发就一定有问题。我起了 6 个子进程,各自往 10 条的库里加 1 条:

text 复制代码
6 个进程各自 add 1 条,起始 10 条 -> 最终 13 条
存活的并发 slug = ['conc0', 'conc4', 'conc5']
丢掉的         = ['conc1', 'conc2', 'conc3']

三条蒸发。没有锁,没有临时文件,没有 os.replace,就是 open(w) 直接盖。谁写得晚谁说了算。

这事儿跟我自己的工作流直接撞车------我那个「每天自动产一篇 tri-xxx 文章」的任务,同日并行过不止一次实例,两边都在往同一个 index.json 追加。以前我只当是「后写的覆盖先写的」这类小毛病,现在知道了:不是覆盖一条,是整份读改写,丢多少取决于调度时序。

add 的「覆盖更新」会把字段一起抹平

cmd_add 里有个我觉得挺好的设计:同 (slug, domain) 视为同一篇文章,原地替换而不是追加(127-135 行),避免索引随覆盖无限膨胀。这点值得学。

但它替换得太干净了。我造了一条带完整字段的旧记录,然后只传三个必填参数重新 add:

text 复制代码
覆盖前: {'slug': 's0', 'created_at': '2026-01-01', 'content_hash': 'deadbeef',
         'status': 'draft-ready', 'tags': ['旧标签']}
覆盖后: {'slug': 's0', 'created_at': '',       'content_hash': '',
         'status': 'done',         'tags': []}

--created_at 默认空串、--content_hash 默认空串、--status 默认 done--tags 默认空。四个字段全被默认值冲掉,同样没有警告。我原来那条 draft-ready 的记录,被一次无心的 add 改成了 done,内容哈希也丢了。

位置 原版行为 我期望的行为
load_index 解析失败 返回 [],后续 add 清库 抛错退出,绝不覆盖原文件
写回方式 open(w) 直接覆盖,无锁 临时文件 + os.replace 原子替换
add 覆盖同 slug 未传字段一律重置为默认值 未传字段沿用旧值
dedup 软重复 duplicate=True 但退出码 0 退出码非 0,编排能拦住
search --keyword 只匹配 title ------(文档 §1.5 本就说「title 包含」,属一致)

dedup 不传 --slug,软判定会反过来咬人

cmd_dedup 第 96 行长这样:

python 复制代码
if edit_distance(r["slug"], slugify(args.slug or "")) <= SOFT_DISTANCE:

--slug 是可选项。不传的时候 slugify("") 得到空串,于是 edit_distance(已有slug, "") 恒等于 len(已有slug)。判定条件退化成「库里有没有长度 ≤2 的 slug」。

实测造了两条记录,一条 slug 是 ab,一条是 ai-coding-pitfalls,然后查一个跟两者毫无关系的新标题:

text 复制代码
--- 未提供 --slug ---
rc = 0 | duplicate = True | level = soft | soft 命中: ['X']     ← 就是那条 slug='ab'
--- 提供 --slug "brand-new-title" ---
rc = 0 | duplicate = False | level = none

也就是说,你越认真地不填 --slug,越容易撞上假阳性。反过来说,长 slug 的库里软去重等于完全没生效------距离永远大于 2。

还有一个更影响编排的:cmd_dedup 结尾是 return 1 if hard else 0(第 106 行)。真·软重复(slug 距离 1)实测 duplicate=Truelevel=soft,退出码照样是 0。SKILL.md §1.5 白纸黑字写着软重复要「提示换角度」,可用退出码串流程的话,软重复会被整条吞掉。

真实索引里,有 6 条是搜不到的

我拿手上这份 75 条的真索引跑了 search

text 复制代码
--domain 开发者工具            -> 19 条
--domain developer-tools   -> 49 条
--domain "AI 工程"          ->   0 条
--domain "ai-工程"          ->   0 条

slugify("AI 工程") 得到 ai-工程(空格不是 \w 也不是 CJK,被换成连字符)。可这 6 条存进去的时候没走 slugify,domain 字段原样是 AI 工程。查询侧 slugify、存储侧不 slugify,两边永远对不上------这 6 条在 domain 维度上是索引里的暗物质,占全库 8%,而 search 的退出码还是 0。

顺带把 search 的另两条语义也摸清楚了:--keyword 只匹配标题,标签里的 HarmonyOS 搜不出来(实测 0 条);--tag 是小写后精确全等,Harmony 这种前缀搜不到。前者跟 §1.5 写的一致,后者属于我自己想当然。

检索方式 实测结果 说明
--keyword HarmonyOS(只在 tags) 0 条 只比 title
--keyword 鸿蒙(在 title) 1 条 命中
--tag HarmonyOS / --tag harmonyos 1 条 小写后全等
--tag Harmony(前缀) 0 条 不支持模糊
--domain "AI 工程" 0 条 存储值带空格,查询被 slugify

还有个 dead code,以及一次自证

save_index() 定义在第 46 行,全文零处调用cmd_add 在 136 到 139 行把同样的保存逻辑又内联写了一遍。短期看不影响功能,长期看这就是两处逻辑漂移的温床------哪天有人改了 save_index 的缩进或编码,cmd_add 不会跟着变。

我用脚本自己的 normalize_title + title_hash 去核对这 75 条记录的 title_hash 字段,65 条对得上,86.7%。剩下 10 条里,1 条能对上「只做 lowercase」的旧版归一化,另外 9 条连 title_norm 字段都停在旧值------是标题后来被改过、哈希没跟着更新。这批失配不是脚本的锅,是我自己改标题时没回写。重来一次我会在改标题的地方加一道钩子,标题一变就把 title_normtitle_hash 一起重算。

我给自己打的三个补丁

不打算改 skill 源码(那会让四件套版本号跟着动),我在外面包了一层校验壳,实测三处都生效:

python 复制代码
def load_index_strict(path):
    """原版:解析失败吞成 []。加固版:坏文件直接抛,绝不返回空列表当「没有历史」。"""
    if not os.path.isfile(path):
        return []                       # 首次运行,确实是空库
    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)            # 解析失败让它炸,交给调用方决定
    if not isinstance(data, list):
        raise ValueError(f"{path} 顶层不是数组,实际是 {type(data).__name__}")
    return data

def save_index_atomic(path, records):
    """原版:open(w) 直接覆盖。加固版:临时文件 + os.replace。"""
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(records, fh, ensure_ascii=False, indent=2)
    os.replace(tmp, path)

跑在刚才那个坏文件上,加固版抛 JSONDecodeError 并退出码 2,原文件一个字节没动;原版同一份文件返回 []dedup 那边我加了长度差预筛和「软重复返回 2」,实测不传 slug 不再误报、传 abc 能正确报 soft。

开销我也量了一下,顺手确认 edit_distance 不是瓶颈:同域 800 条、slug 长 34 字符,一次 dedup 比对耗时 114.7 ms(100 条 15.0 ms,400 条 58.2 ms),线性增长,这个量级完全够用。

上面这张图是我按代码画的三处静默点:坏文件被吞成空、并发写覆盖、覆盖更新抹字段。三件事单独看都不致命,叠在一起就成了「索引说不清自己有多少条」。

现在我的规矩变成两条:动 index.json 之前先 cp 一份 .bakadd 之后必跑一次 list 核条数,不相等就停手。土办法,但比退出码 0 靠谱。

顺便一提,产品雷达鸭那边每周自动攒竞品选题,撞的是同一道题:怎么知道这个角度上周是不是已经写过了。我给它的做法也很土------标题归一化后算哈希,跟历史库比一把。够用,但也就够用而已。

你们的索引是自己手写的还是脚本生成的,有没有真的数过一次条数?

个人介绍

我是老三,10 年软件开发老油条,软件设计师、注册人工智能工程师。平时搞 Web 前端,这两年也在啃鸿蒙 ArkTS 北向开发,顺手折腾 AI 自动化,不定期在 CSDN 写点实战踩坑。

本文遵循 MIT 协议,转载请注明出处。

相关推荐
蓝速科技2 小时前
医院导诊 AI 数字人一体机场景适配与落地指南丨蓝速科技
运维·数据库·人工智能·科技·自然语言处理·技术分享
QYR-分析2 小时前
重轨受电弓行业深度报告:市场格局、技术迭代与发展前景
大数据·数据库·人工智能
火山引擎开发者社区2 小时前
# 开发者集结!共探 AI Agent 创新应用新可能
人工智能
牛油果子哥q2 小时前
生产级AI项目上线全流程:Docker容器化、服务编排、监控告警、日志收集、容灾降级、线上运维闭环
人工智能·ai
Pocker_Spades_A2 小时前
视频不用再一张张截图:ClipSketch AI 把关键画面转成漫画,还能顺手生成文案
人工智能·音视频
星辰徐哥2 小时前
本地视频预览别只自己看:把Remotion动效项目发给客户远程验收
docker·ai·node.js·html·音视频·react·remotion
小小测试开发2 小时前
LLM 结构化输出测试:Schema 契约 + 故障注入,让工具调用的 JSON 不再靠重试赌运气
人工智能·json
阿里云大数据AI技术2 小时前
淘宝直播 AI 分身:基于阿里云 Milvus 的商品知识召回实践
人工智能
猎头南楼2 小时前
大模型后训练与 Agent 自迭代:两类工程能力的观察
人工智能·深度学习·机器学习