作者利用分卷ZIP结构和HTTP Range请求,只下载约48MB便检查了数GB训练数据集。文章展示了如何从样本数量规律、归档元数据和局部内容快速识别批量合成或低质量语料。
两个月前,我在自己关注的互联网角落里发现了一个代码仓库:3,358 个 star、744 个 fork、MIT 许可证,还有一个号称包含 518,400 条训练样本的 release——总计 5.5 GB,分成三个 zip 分卷。我的领域是中国命理软件,几乎没有任何机器可读的数据,所以如此规模的语料库,要么是这些年来最有价值的发布成果,要么什么也不是。我想弄清楚它究竟属于哪一种。
然后,我多看了样本数量一秒钟。518,400 = 60 × 12 × 30 × 12 × 2。六十年、十二个月、三十天、十二个双小时、两种性别。这不是某个人观察所得事物的统计数量,而是一个嵌套循环的规模。
这是一个绝佳的好奇理由,却是一个糟糕透顶的下结论理由。所以我去读取了这个归档文件——通过 HTTP range request 读取其中的 48 MB,完全没有碰剩下的 5.83 GB。事实证明,过程比结果更有意思,而这套方法的适用范围,也远远超出了我这个古怪的小众领域。
所以,下面就来讲讲:如何用下载几张照片的流量成本,读完一个数 GB 的 release,以及它告诉我的四件事。
有三个事实让这件事成为可能,而且都很值得了解:
使用 split 制作的 split zip,本质上就是普通的文件拼接。各分卷并不是彼此独立的归档文件。第 1 卷以第一个 local file header 开头,而 central directory 中记录的字节偏移量,是跨越整个分卷集合的绝对偏移量。
central directory 位于末尾,其中列出了每个条目的名称、解压后大小、压缩后大小以及 local-header offset。只要获取最后几 MB,你手上就有了整个归档文件的目录。
GitHub release asset 支持 Range request。一旦知道某个条目的偏移量和大小,就可以只请求那个条目,不获取任何其他内容。
这个归档文件还有一项额外便利。它使用 method 0 存储 .jsonl.gz 分片——即只存储、不进行 deflate——因为这些文件本身已经经过 gzip 压缩。每个条目都可以独立寻址,而末尾的小型文本文件未经压缩就放在那里,因此直接使用 curl -r 就能拿到可读的源代码。
# 1. the table of contents: last 3 MB of the last part
curl -sL -r 1890639808-1893639807 \
".../ziwei-samples-v3-part3.zip.003" -o p3.tail
# then find PK\x05\x06 (EOCD), walk the PK\x01\x02 entries,
# and honour the ZIP64 extra field — cdoff came back as 0xFFFFFFFF
# 2. one 8 MB data shard, at the offset the directory gave me
curl -sL -r 48646942-56753200 \
".../ziwei-samples-v3-part1.zip.001" -o feb.bin
最终得到了 917 个条目。其中 781 个是所有人下载它时想要的数据。另外 136 个,才是故事真正转折的地方。
总获取量:47,897,905 字节——相当于整个 release 的 0.81%。完整脚本见附录。
公开仓库中的 lib/ziwei/db-analysis.ts 是一个 2,124 字节的 stub,其头部注释非常直白地解释了原因(以下是我的翻译):
解析内容库不属于开源范围。完整在线版本包含 14 颗主星 × 13 种宫位语境的详细解读——这是核心内容,不与排盘引擎一同开源。
而文件底部是:export const STAR_DB: Record<string, unknown> = {};
release 中相同路径的文件却有 377,157 字节,STAR_DB 从第 161 行开始填充了实际内容:524 个字符串字面量,以及 145,580 个字符的人工撰写中文文本。那个未提交到 git 的内容库,终究还是跟着 release 一起发布了——只不过被放在一个三分卷 split zip 内部 5.87 GB 深的位置,没人会去注意。
这是第一个、也是最具普适性的教训,而且它和命理毫无关系:脱敏内容会通过大型二进制 artifact 泄漏。你可能在 git 中小心翼翼地把一个文件替换成 stub,它却会兴高采烈地搭上 release tarball、Docker layer、model checkpoint 或训练数据 dump 的便车。artifact 越大,就越不容易有人注意到——而从实际风险的角度来看,这种认知恰好完全颠倒了。
找到源代码,也顺便确认了这个数据集究竟是什么。整个 lib/ziwei/ 目录共有 216,172 个字符的 TypeScript,其中 183,182 个字符位于字符串字面量中。生成器通过五层嵌套循环遍历那张网格,经度硬编码为 120;除审计脚本自身的 sampler 外,任何地方都没有调用 Math.random。输出完全是五个整数组成的 key 的纯函数。
这使得整个 release 实际上就是一张 memo table。解压后的总大小约为 32.9 GB——我测得一个分片为 45,649,978 字节,而这样的分片共有 720 个——其中保存了大约 1.09 × 10¹⁰ 个字符的生成文本,所有内容都由 18.3 万个字符的源代码组装而来。源代码就在同一个 zip 中,旁边还放着能够重新生成全部数据的 npm run full 命令。
根据我获取的 3,600 条样本——占语料库的 0.69%——测得的复用情况如下:总共输出了 46,800 个 topic block,其中只有 16,359 个互不相同,而且每一个都只是相同字符串字面量的不同排列组合。在这种数据上进行 fine-tuning,并不能教会模型一个领域。它只是让你付一笔 GPU 账单,以一种糟糕的方式把 template engine 压缩进 weights,而你原本完全可以直接调用这个 engine。
days: range(1, 30)——每个月一律三十天,而且永远只有三十天。
这会带来两个后果。所有月份的 31 日都缺失了:七个月 × 60 年 × 12 个时辰 × 2 种性别 = 10,080 个真实生日完全没有对应记录。与此同时,无论年份如何,二月都会包含 29 日和 30 日,因此有 2,520 条记录使用了历史上从未存在过的日期作为 key。
我实在忍不住获取了 1962 年 2 月的数据——那一年不是闰年——想看看生成器会如何处理一个不可能的输入。它没有失败,而是径直越过月底,继续往后计数:
所以,标记为 1962-02-30 的样本是一份完整、自信、63 KB 大小的命盘——但它实际对应的是一个出生于 3 月 2 日的人。整个 pipeline 中没有任何环节发现问题。
同一张网格中还存在另外两个结构问题。年份维度使用的是 range(1924, 1983),源代码注释解释说,“60 年覆盖一个完整的干支周期”——选择这个时间窗口,是为了得到一个整齐的数字,而不是为了覆盖任何实际用户。这里没有 1983 年以后出生的人;到了 2026 年,这意味着整个语料库只涉及 43 岁及以上的人群。
此外,每一条记录的经度都是 120,这一点尤其令人遗憾:在一个出生时辰属于最高 entropy 输入的系统里,最值得改变的变量反而被固定成了常量。
这个 release 附带了自己的 validation 和 audit log,看上去非常漂亮。零失败、零警告,其中还有这样的记录:
health contains 「liver/kidney/spleen-stomach」: 518400/518400 (100.00%)
health contains 「子午流注」 and 「經絡」: 518400/518400 (100.00%)
female health contains 「gynaecology/menses/pregnancy」: 259200/259200 (100.00%)
male/female wealth topic pairs differ: 259200/259200 (100.00%)
failures: 0 warnings: 0
其中每一项都只是 String.includes。它们能够证明某个 template 被触发,却无法区分“内容存在”和“内容正确”。而在这里,这一区别至关重要,因为就在同一份 log 往上五行的位置,赫然写着:
daXians[0] contains siHua: 0/518400 (0.00%)
any daXian contains siHua: 0/518400 (0.00%)
daXians[0] contains stemIndex: 0/518400 (0.00%)
samples containing palace.selfSihua: 0/518400 (0.00%)
交给生成 Agent 的 spec——同样位于归档文件中的 README-CODEX.md——把其中一个字段称为“最容易出错的单项内容”,将其列为硬性要求 #6,并在生成后的 checklist 中明确要求对它进行验证。我直接检查了发布的记录:每个 daXians 条目都恰好只有四个 key——startAge、endAge、palaceBranch 和 palaceName。整个 spec 围绕构建的那个字段,根本不存在。
与此同时,打包 manifest 在英文标题“Verified results”下列出了以下内容:
Verified results:
- Total samples: 518,400
- Validation failures: 0
- daXians[].siHua: absent
- daXians[].stemIndex: absent
- palace.selfSihua: absent
归档中还保留着一份更早的 audit report,其中显示相同字段在抽样的 20 条记录中都填充了真实值。因此,在那次运行与最终发布版本之间,三个最重要的计算字段不见了,而 validator 却把字段缺失评定为通过。随后,scorecard 文件又给“硬约束实现与审计”这个维度打出了 10/10,给出的依据是每一项约束都存在相应的验证命令。
再看这份 log 中的一个例子,因为这个细节一直萦绕在我脑中。audit 统计了十二条预制警告语各自出现的次数,其中三条恰好都出现了 518,400 次——包括这句话:“若福德宫受克而夫妻宫生离不显,则必为死别。”这句话本应只在一种大多数命盘并不具备的配置下有条件地出现。可它出现在了整个语料库的 100% 样本中。在这些数据上训练出来的模型,会告诉每个人他们都将丧偶。
release 中藏着一个 corpus/annotations.json——它审查了解析内容库中每一处归于某位具名老师的引文。这位老师是一名医师讲师,于 2012 年去世。以下是该文件使用自身分类得出的总结:
19% 的引用归属可以追溯来源。注释以第一人称撰写,毫不留情——“这句话是我自己编的,应该改成‘北派认为……’”;“‘精确月份推导法’这个名字是我发明的,并不是他的术语”;“⚠️ 严重错误:他的疾病判断以宫支为依据,而不是星曜五行——整个映射都偏离了该体系。”
其中还有一个 top_priority_fixes 数组。一个 P1 条目正是我在上文引用的福德宫语句,它被标记为可能并非那位老师的原话,需要重写。可这句话却出现在 518,400 条样本中的全部 518,400 条里。修复清单就放在本应被它阻止发布的 artifact 内部。(生成文本在引用这句话以及另外几十句话时,还把那位老师的名字拼错了。)
接下来这一点,才是这件事值得写成文章,而不是单纯落井下石的原因。这个 annotations 文件在来源追溯方面所做的工作,超过了这一领域几乎所有 repo;而且它是 maintainer 在发布之前,亲自针对自己的文本编写的。旁边还有一份 rights policy,将收集的材料划分为四个等级,禁止在没有许可证的情况下保存全文,并禁止 rights_status=unknown 的材料进入可训练语料库。source registry 中写着:211 个来源,1 个获准使用全文,167 个需要许可证,15 个被明确禁止。确实有人认真思考过所有这些问题。
所以,问题并不是缺少尽职调查,而是这些调查从未真正接入 gate。一个列出六条捏造引用的文件,并不能阻止 release。一项声明“权利状态未知的材料不得进入训练语料库”的政策,也没有阻止由此类材料衍生出的语料库以 MIT 许可证发布。一个只 grep 某段字符串的 validator,无法因为内容错误而让 build 失败。那个归档中所有用来建立信任的 artifact,都是由它们本应检查的同一个 pipeline 生成的——Agent 生成数据、运行 audit、报告成功,而这三个步骤各自的评分标准,都只是它有没有完成运行。
这就是 2026 年的失败模式,而且我认为它并不少见。
不是垃圾,而是带着全绿测试套件的垃圾。
分解样本数量。如果 N 可以分解成一些较小的整齐因子,那么你看到的就是一张网格——它覆盖的是 key space,而不是什么实际观察结果。要问清楚 observation unit 是什么。如果答案是“一个可能的输入”,那么其中就没有可供学习的 signal,也没有 outcome、adjudication 或参与其中的人类来核对某项声明。
先找生成器,再看数据。如果生成器也随之发布——而且它经常就藏在归档文件的末尾——那么生成器本身就是数据集,而且体积小五个数量级。接着检查它是否包含随机性。没有随机性,就意味着整个 release 只是某个纯函数的 memo table;你应该直接调用这个函数。
阅读 validator,而不是 validation 结果。grep 它真正 assert 的内容。substring 存在性、字段存在性和文件数量都只是 shape check;面对正确输出和完全颠倒的输出时,它们都会同样热情地给出通过。在你看过 predicate 之前,一份绿色 log 什么也说明不了。仅供参考,我自己的测试会 assert 各个计算值之间的关系,但我依然无法做到像自己期望的那样信任它们。
这里补充一个简短说明,解释为什么有必要做这些工作。那个 release 的下载次数分别是:第 1 卷 10,814 次,第 2 卷 7,506 次,第 3 卷 7,989 次,checksum 文件 1,250 次。开始下载的人中约有 30% 没有完成,而大约只有十分之一的人验证了自己拿到的内容。一个 5.5 GB 的三分卷下载,本身就是一种 moat——人们不会阅读自己打不开的东西,而 3,358 个 star 衡量的是这项宣称有多么吸引人,并不是其中的内容。
这次 audit 的成本是 48 MB。
至于我最初为什么会调查它:我开发的是邻近体系的软件——八字,而不是紫微斗数(auspiceoracle.com)——所以这份语料库来到了我的桌面上,成为我可能会使用的东西。我刻意没有在正文中写出 repo、tag 和文件路径,因为本文的重点是方法,而不是 maintainer。
我也没有得出任何许可方面的结论:我不是律师,而且我引用的每一项计数都来自该 repo 自身。除此之外,下面的脚本可以在大约两分钟内复现所有结果;比起被相信,我更愿意被纠正。
import struct, json, urllib.request
REL = "https://github.com/{owner}/{repo}/releases/download/{tag}/"
PART = ["...part1.zip.001", "...part2.zip.002", "...part3.zip.003"]
SIZE = [1992294400, 1992294400, 1893639808] # from the releases API
def grab(part, start, end, out): # HTTP range fetch
req = urllib.request.Request(REL + PART[part],
headers={"Range": f"bytes={start}-{end}"})
open(out, "wb").write(urllib.request.urlopen(req).read())
# 1. central directory: tail of the last part.
# find PK\x05\x06 for the EOCD, then walk PK\x01\x02 entries.
# cdoff == 0xFFFFFFFF means ZIP64 -> read the real values from
# extra field header id 0x0001 (usize, csize, local-header offset).
grab(2, SIZE[2] - 3_000_000, SIZE[2] - 1, "p3.tail")
# 2. any entry, given its absolute offset from the directory:
# absolute -> (part, local offset) via the cumulative sizes,
# then re-read the local header to skip name+extra, and the
# remaining csize bytes are the stored member, byte for byte.
# Entries with method == 0 (stored) need no decompression —
# the .jsonl.gz shards drop straight into gzip.open().
有三点值得记住,没有一点与命理有关:脱敏内容会通过大型 artifact 泄漏;网格不是数据;绿色 audit log 和其他任何声明一样,都值得追问一句——它究竟测量了什么。
所有数字均于 2026-08-05 针对当时公开发布的 release 实测得出。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。