我们最近为一位客户的 LLM 项目构建了 Stack Exchange 网络的完整问答语料库:每个站点、每个问题、每个回答、每条评论,全部作为训练数据。语料覆盖 360+ 个站点、约 3300 万个问答串,以 JSONL 格式交付,每篇帖子正文都从 HTML 转换为 Markdown。

本文完整回顾这项工程:我们如何把整个网络建模为可抓取的单元并在开工前估算任务规模,只有真实数据才会暴露的数据质量陷阱,一个曾出现在早期样本中、最终靠实证推翻的内容许可证判定错误,以及 Crawling API 在这类任务中的真正价值。文中数字均来自采集工作的真实测量,客户细节已做移除。

交付物:完整的问答串,而不是页面

第一个设计决策是交付单元。页面是抓取的对象,却不是任何人想要消费的东西。问答数据的天然单元是组装完成的问答串:一条记录包含问题、它的全部回答、以及两者之上的全部评论,并附带让使用方能够筛选和加权的元数据(得分、标签、时间戳、采纳答案标记)。

页面进,问答串出。列表页驱动发现,问题页承载内容,展开调用负责补齐超长的回答和评论尾部。一切都经由 Crawling API 汇入组装器,最终每行 JSONL 输出一个完整的问答串。

每行 JSONL 一个问答串:

json
{
  "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 渲染都由端点背后统一处理。

python
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),然后专门在代码密集的样本上验证保真度:

python
from markdownify import markdownify as md

def to_markdown(html):
    return md(html, heading_style="ATX", bullets="-").strip()

陷阱二:嵌套链接残留

包裹着其他标记的锚点标签可能被序列化成 [text]([label](url)) 的形式。我们在后处理中扫描这一模式并将其折叠:

python
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,并追问这条规则究竟该用哪个日期。

许可证属于修订版本,而不是帖子本身。一个 2008 年创建、2023 年编辑的问题应标为 CC BY-SA 4.0,而不是 2.5。在验证切片中,331 篇带源许可证的帖子里有 331 篇与最后编辑日期键法一致;而在一个许可证时代创建、又在另一个时代被编辑的 137 篇帖子中,与创建日期键法一致的恰好为零。

我们没有拿文档来争论,而是直接用数据验证。在 1,384 篇确实带有源许可证的帖子上,我们同时检验了两种假设。验证切片中的全部 331 篇带源许可证的帖子都符合最后编辑规则,其中包括在一个许可证时代创建、又在另一个时代被编辑的全部 137 篇帖子,这 137 篇与创建日期键法的匹配数恰好为零。许可证属于当前修订版本,因此它以最后编辑日期为键,从未编辑过的帖子则回退到创建日期。

两条教训。第一条是技术层面的:许可证要按 last_edit_date or creation_date 推导,绝不能只用 creation_date。第二条更进一层:当数据使用方质疑某个字段的语义时,能终结讨论的答案是对语料库做实证检验,而不是引用文档。

必须写进文档的语义

一些看似不言自明、实则并非如此的字段:

  • is_answered 并不等于"有采纳答案"。只要问题有采纳答案任何得分为正的回答,它就为 true。如果不把这一点写进文档,下游一定会有人误读。我们还在问题层级加了可为空的 accepted_answer_id,这样判断"有采纳答案"就无需扫描回答数组。
  • 页面上的浏览次数是四舍五入的(显示为"Viewed 2.0m times");精确整数来自结构化数据源。如果你的流水线两者都用,就要写明某个字段装的是哪一种。
  • 语料库是活的。帖子随时都在被编辑、删除、关闭和保护。要交付状态标志(closed_dateclosed_reasonprotected_datelocked_date),并把所有时间戳统一为 ISO-8601 UTC,让使用方能够推断快照时刻的真实状态。

交付前的质量保障

上面的每一条主张,都会变成一条针对实际交付产物运行的断言:

python
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 构建研究数据集

Crawlbase Crawling API

任何规模下都是每页一次 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 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量