我们最近为一位客户的 LLM 项目构建了 Stack Exchange 网络的完整问答语料库:每个站点、每个问题、每个回答、每条评论,全部作为训练数据。语料覆盖 360+ 个站点、约 3300 万个问答串,以 JSONL 格式交付,每篇帖子正文都从 HTML 转换为 Markdown。
本文完整回顾这项工程:我们如何把整个网络建模为可抓取的单元并在开工前估算任务规模,只有真实数据才会暴露的数据质量陷阱,一个曾出现在早期样本中、最终靠实证推翻的内容许可证判定错误,以及 Crawling API 在这类任务中的真正价值。文中数字均来自采集工作的真实测量,客户细节已做移除。
交付物:完整的问答串,而不是页面
第一个设计决策是交付单元。页面是抓取的对象,却不是任何人想要消费的东西。问答数据的天然单元是组装完成的问答串:一条记录包含问题、它的全部回答、以及两者之上的全部评论,并附带让使用方能够筛选和加权的元数据(得分、标签、时间戳、采纳答案标记)。
每行 JSONL 一个问答串:
{ "site": "stackoverflow.com", "question_id": 11227809, "title": "Why is conditional processing of a sorted array faster...", "tags": ["java", "c++", "performance", "cpu-architecture"], "score": 27538, "view_count": 1990935, "answer_count": 26, "accepted_answer_id": 11227902, "is_answered": true, "creation_date": "2012-06-27T13:51:36Z", "last_edit_date": "2026-04-08T09:20:07Z", "content_license": "CC BY-SA 4.0", "closed_date": null, "owner": {"user_id": 87234, "display_name": "GManNickG", "reputation": 507167}, "body_markdown": "In this C++ code, sorting the data *(before the timed region)*...", "comments": [ ... ], "answers": [ { "answer_id": 11227902, "link": "https://stackoverflow.com/a/11227902", "is_accepted": true, "score": 35287, "content_license": "CC BY-SA 4.0", "owner": { ... }, "body_markdown": "**You are a victim of [branch prediction](...) failure.**...", "comments": [ ... ] } ] }
四条设计规则后来帮了大忙:
-
绝不静默截断。问答串必须完整交付;我们验证过的最大一条有 105 个回答。如果确实必须做子集(我们在早期格式样本中就做过),要带上显式的
*_total和*_included字段。数据客户一定会拿你的样本与线上站点比对,务必让他们发现的是文档中写明的行为,而不是意外。 - 评论要挂在两个层级上。问题的评论和各回答的评论是两份不同的列表。我们的第一版格式样本只交付了问题评论。在一个包含 8 个问答串的样本里,约 1,050 条评论中有超过 900 条挂在回答上。这一点很容易漏掉,被客户指出来则相当难堪。
-
已删除用户使用固定的占位符(
{"user_id": null, "display_name": "[deleted]", "reputation": null}),而不是让每个使用方各自处理的一团含糊空值。 -
每个回答都要有永久链接。
https://<site>/a/<answer_id>既稳定又几乎零成本,署名和抽查都离不开它。
估算任务规模:请求模型
所有内容都通过 Crawling API 采集:每个页面一次 GET 请求,代理轮换、重试以及(必要时的)JavaScript 渲染都由端点背后统一处理。
import requests CRAWLBASE = "https://api.crawlbase.com/" TOKEN = "YOUR_TOKEN" def fetch(url): r = requests.get(CRAWLBASE, params={"token": TOKEN, "url": url}, timeout=90) r.raise_for_status() return r.text
Stack Exchange 的问题页是服务端渲染的 HTML,因此使用标准 token(不带 JavaScript 渲染)就足够了,这让整个任务又快又省。一次全网络采集可以拆解为四类抓取单元:
| 抓取项 | 驱动因素 | 规模 |
|---|---|---|
| 问题列表页(发现) | 3300 万个问题,每页 50 个 | 约 70 万 |
| 问题页 | 每个问题一页:问题本身、最多 30 个回答、各帖前 5 条评论 | 约 3300 万 |
| 额外回答页 | 回答数超过 30 的问答串(少见) | 约 10 万到 30 万 |
| 评论展开 | 评论数超过 5 条的帖子,每篇多一次调用 | 约 400 万到 1600 万 |
总计:大约 3800 万到 5000 万次请求。唯一的弹性项是评论展开。在真正查看之前,你无法知道有多少帖子存在隐藏评论,所以我们把它估成一个区间,并在商务层面给估算加了上限,而不是假装拥有并不存在的精度。在开工之前完成规模估算并明示不确定性,正是一份站得住脚的报价与一次瞎猜之间的主要差别。
为什么需要代理层?Stack Exchange 的反爬并不算激进,但任何单 IP 爬虫远在爬到 3300 万个页面之前就会触发限流。在评估阶段,我们从单个 IP 发起快速请求,大约第 55 次就开始收到 429。在生产规模下,你需要轮换、退避和重试记账,而这恰恰是 Crawling API 所替代的那一层。
这套架构天然带来一个很有用的计费属性:只有成功(HTTP 200)的抓取才计数,失败和重试免费。在一个以数千万次请求计的任务上,这就是按成果计费与按尝试计费的区别。
HTML 转 Markdown 及其陷阱
LLM 团队要的是 Markdown,不是 HTML。这个转换听上去很简单,实际上并不。我们在真实数据上踩过三个陷阱:
陷阱一:粗糙的正则转换器会吞掉代码
Stack Exchange 的帖子正文里到处是 <pre><code> 代码块,其中包含类似 #include <iostream> 的行,而在去标签正则眼里,<iostream> 和 HTML 标签一模一样。我们第一版粗糙应急的转换器就悄悄删掉了全站最著名的 C++ 问题之一里的头文件包含语句。请使用真正基于 DOM 的转换器(我们用的是 markdownify),然后专门在代码密集的样本上验证保真度:
from markdownify import markdownify as md def to_markdown(html): return md(html, heading_style="ATX", bullets="-").strip()
陷阱二:嵌套链接残留
包裹着其他标记的锚点标签可能被序列化成 [text]([label](url)) 的形式。我们在后处理中扫描这一模式并将其折叠:
import re NESTED = re.compile(r'\[([^\[\]]*)\]\(\s*\[([^\[\]]*)\]\(([^()\s]+)\)\s*\)') while True: fixed = NESTED.sub(lambda m: f"[{m.group(1) or m.group(2)}]({m.group(3)})", text) if fixed == text: break text = fixed
然后在交付前对整个语料库断言零命中。"我们修好了转换器"只是一种说法;全语料扫描返回零才是事实。
陷阱三:不要"修复"LaTeX
数学类站点(math.stackexchange、stats、physics)的正文里内联着 LaTeX:$\frac{\textrm{d}y}{\textrm{d}x}$。请逐字节保留。它合法、信息密度高,正是模型训练的优质素材。常见错误是一次出于好意的清洗,把"奇怪的符号"统统剥掉。要把保留 LaTeX 写成语料库的一条明确、公开声明的属性,并为它编写测试。
许可证错误:键到了错误的日期
Stack Exchange 的每篇帖子都以 Creative Commons 授权,而许可证的版本取决于内容的贡献时间:
| 修订日期 | 许可证 |
|---|---|
| 2011-04-08 之前 | CC BY-SA 2.5 |
| 2011-04-08 至 2018-05-02 | CC BY-SA 3.0 |
| 2018-05-02 及之后 | CC BY-SA 4.0 |
我们为每个问题、回答和评论都附上 content_license,连同作者署名和永久链接:这些正是 CC BY-SA 署名所需的要素。少数非常古老的帖子在源数据中没有许可证字段,于是我们按上面的日期表推导。第一版推导以帖子的创建日期为键。一位眼尖的审阅者发现了一个 2008 年创建、2023 年编辑的问题被标成了 CC BY-SA 2.5,并追问这条规则究竟该用哪个日期。
我们没有拿文档来争论,而是直接用数据验证。在 1,384 篇确实带有源许可证的帖子上,我们同时检验了两种假设。验证切片中的全部 331 篇带源许可证的帖子都符合最后编辑规则,其中包括在一个许可证时代创建、又在另一个时代被编辑的全部 137 篇帖子,这 137 篇与创建日期键法的匹配数恰好为零。许可证属于当前修订版本,因此它以最后编辑日期为键,从未编辑过的帖子则回退到创建日期。
两条教训。第一条是技术层面的:许可证要按 last_edit_date or creation_date 推导,绝不能只用 creation_date。第二条更进一层:当数据使用方质疑某个字段的语义时,能终结讨论的答案是对语料库做实证检验,而不是引用文档。
必须写进文档的语义
一些看似不言自明、实则并非如此的字段:
-
is_answered并不等于"有采纳答案"。只要问题有采纳答案或任何得分为正的回答,它就为 true。如果不把这一点写进文档,下游一定会有人误读。我们还在问题层级加了可为空的accepted_answer_id,这样判断"有采纳答案"就无需扫描回答数组。 - 页面上的浏览次数是四舍五入的(显示为"Viewed 2.0m times");精确整数来自结构化数据源。如果你的流水线两者都用,就要写明某个字段装的是哪一种。
-
语料库是活的。帖子随时都在被编辑、删除、关闭和保护。要交付状态标志(
closed_date、closed_reason、protected_date、locked_date),并把所有时间戳统一为 ISO-8601 UTC,让使用方能够推断快照时刻的真实状态。
交付前的质量保障
上面的每一条主张,都会变成一条针对实际交付产物运行的断言:
import json threads = [json.loads(l) for l in open("threads.jsonl")] assert all(len(t["answers"]) == t["answer_count"] for t in threads) # complete assert all(a["link"] and a["content_license"] for t in threads for a in t["answers"]) # attribution assert all(c["creation_date"] for t in threads for a in t["answers"] for c in a["comments"]) # timestamps assert not any(NESTED.search(t["body_markdown"]) for t in threads) # conversion acc = lambda t: [a["answer_id"] for a in t["answers"] if a["is_accepted"]] assert all((t["accepted_answer_id"] is None and not acc(t)) or acc(t) == [t["accepted_answer_id"]] for t in threads) # consistency
整个项目中最有效的做法是:尽早交付一份小样本,让使用方拿它与线上站点比对,并把他们发现的每一处出入都当作一次模式改进。在正式采集完整语料库的第一个字节之前,我们的格式已经迭代了三轮样本,这远比在收完 3300 万个问答串之后才发现同样的问题便宜得多。
要点回顾
- 先把站点建模为抓取单元,并在开工前完成规模估算(同时明示不确定性)。
- 交付领域单元(问答串)而不是抓取单元(页面),并且绝不静默截断。
- HTML 转 Markdown 是保真度问题,不是排版小事:基于 DOM 的转换、残留模式扫描、LaTeX 原样保留。
- 给每篇帖子附上许可证、署名和永久链接,推导出的许可证要以最后编辑日期为键。语义受到质疑时,用实证方式验证。
- 为那些会骗人的字段(
is_answered)写好文档,并对实际交付的产物断言你承诺过的每一件事。
这一切的采集层(轮换、重试、渲染、只按成功计费)就是 Crawling API;其上的语料库工程才是真正属于你的部分。如果你的流水线需要活过原型阶段,我们关于扩展网络爬取项目的指南覆盖了生产环境的各种考量;如果最终目的地是 AI 工作流而不是一次 JSONL 交接,可以看看我们如何用 Web MCP Server 构建研究数据集。
任何规模下都是每页一次 GET。轮换住宅 IP、重试、退避和可选的 JavaScript 渲染都在同一个端点背后,而且只有成功的抓取才计入配额,因此一个 5000 万次请求的任务按交付页面计费,而不是按尝试计费。领取你的 token,从免费套餐开始。
常见问题
采集整个 Stack Exchange 需要多少次请求?
完整跑一遍大约需要 3800 万到 5000 万次:约 70 万个用于发现的列表页、3300 万个问题页、为回答数超过 30 的问答串准备的几十万个额外回答页,以及 400 万到 1600 万次评论展开调用。展开次数是开爬之前唯一真正的未知数,所以要把它估成带上限的区间,而不是给出虚假的点估计。
为什么交付组装完成的问答串而不是原始页面?
因为使用方训练的是对话,而不是 HTML 文档。每个问答串一条 JSONL 记录,包含问题、全部回答和全部评论,并附带得分、标签、时间戳和许可证,使用方无需自己重新组装状态就能筛选和加权。页面是抓取阶段的概念,到交付阶段就应该消失。
如何在语料库规模上保证 CC BY-SA 署名的正确性?
给每篇帖子附上三样东西:content_license(以最后编辑日期为键,从未编辑过的帖子回退到创建日期)、作者信息(已删除用户使用固定占位符),以及稳定的永久链接。然后用带有显式源许可证的帖子对许可证推导做实证验证,而不是盲信文档。
把 HTML 转成 Markdown 会丢失信息吗?
会,而且丢的恰恰是对训练重要的信息。去标签正则会删掉 C++ 的头文件包含语句,因为 <iostream> 看起来像标记;嵌套锚点会被序列化成损坏的链接语法;过于积极的清洗还会毁掉 LaTeX。请使用基于 DOM 的转换器,对全语料扫描已知的残留模式,并逐字节保留 LaTeX。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
