询问一个 AI 团队某份训练文档的来源,得到的答案往往是数据集名称,而不是来源本身。监管机构、客户和法院现在想要的是第二种答案:哪个页面、什么时候抓取的、在何种条款下、以及训练前对它做了什么处理。对于那些从网络数据构建模型的团队来说,溯源已经从一种良好实践变成了必须能够拿出证明的事情。
难点在于时机。溯源记录所需的大部分信息,只在采集那一刻才存在。本指南将介绍这个问题为何变得紧迫、每份文档值得保留哪些记录,以及如何在提取、过滤和去重过程中保持这条线索不断。
溯源为何成为一项要求
三项进展让溯源从一个研究课题变成了一项合规任务。
欧盟《人工智能法案》要求公开摘要。 第53(1)(d)条要求通用人工智能模型的提供者发布”关于用于训练的内容的足够详细的摘要”,并使用人工智能办公室提供的模板。这些义务自2025年8月2日起适用,而在该日期之前已上市的模型则有时间延至2027年8月2日。欧盟委员会于2025年7月提出了该模板,并说明使用该模板是强制性的。
该模板要求提供具体信息。 对于抓取的数据,它要求提供所使用的爬虫名称、采集时间段,以及最相关域名的列表:按抓取内容规模排名前10%的所有域名,或对中小型公司而言为前5%或1,000个域名,以较低者为准。它还要求提供者说明他们在”数据采集之前和期间”如何遵守文本与数据挖掘的退出声明。第53(1)(c)条另行要求提供一项版权政策,用以识别并遵守这些保留声明。
公共数据资源正在萎缩,而相关记录一直很差。 数据溯源倡议(Data Provenance Initiative)在2023年审查了超过1,800个文本数据集,发现许可证缺失率超过70%,在广泛使用的托管网站上错误率超过50%。一项针对14,000个网络域名的后续审查发现,在单单一年内,C4数据集中约5%的所有词元(token),以及其中维护最活跃来源的28%,已被robots.txt完全限制。某个在抓取时可以接受的数据集,在今天抓取可能就不再被接受,而你只有在当时记录了状态,才能证明这种差异。
每份文档应记录哪些内容
溯源记录由爬虫在抓取时一次性写入。下游的每一步都是在其基础上增加内容,而不是取代它。
| 字段 | 为何重要 |
|---|---|
| 来源 URL | 域名列表以及任何下架请求的依据 |
| 抓取时间(UTC) | 证明采集时间段以及该日期的退出状态 |
| 响应状态 | 区分真实内容与屏蔽页面、错误页面 |
| 原始正文的哈希值 | 将之后的每一份副本与收到的确切内容绑定 |
| 观测点 | 同一个 URL 在不同国家和网络下可能返回不同内容 |
| robots.txt 判定结果 | 表明爬虫在抓取时遵守了当时生效的规则 |
| 观察到的 TDM 保留声明 | 欧盟版权规则和该模板所关心的退出声明 |
| 声明的许可证或条款 | 页面上关于再利用的声明内容(如有) |
| 爬虫名称和版本 | 模板要求提供爬虫名称;版本信息能解释行为变化 |
| 父记录 | 将清理或去重后的副本链接回其来源 |
有两个字段经常被遗漏。第一个是观测点:从一个国家或网络观察到的页面,可能与在其他地方看到的同一 URL 不同,这正是我们那篇关于记录网络数据观测位置的文章所论证的。第二个是原始正文本身。哈希值能证明你收到的是哪些字节,但保留响应本身,例如通过将页面归档为 WARC 文件,才能让你展示它。
代码中的溯源记录
下面的示例为每份抓取的文档写入一条记录,只保留在抓取时被允许且未被保留声明限制的文档,并通过哈希值将每份提取的副本链接回其来源。它还生成了摘要模板所要求的域名排名。该示例可在 Python 3.9 及以上版本中运行,只使用标准库。
from __future__ import annotations
import hashlib
import json
from collections import Counter
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from urllib.parse import urlsplit
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
@dataclass
class Provenance:
"""One record per fetched document, written at collection time."""
url: str
fetched_at: str # ISO 8601, UTC
status: int
content_sha256: str # hash of the raw response body
vantage: str # where it was observed from, e.g. "residential:de"
robots_allowed: bool # robots.txt decision for our crawler, at fetch time
tdm_reserved: bool # machine-readable TDM opt-out seen, at fetch time
license: str | None # licence or terms as stated on the page, if any
collector: str # crawler name and version
parents: list[str] = field(default_factory=list) # hashes this record derives from
step: str = "fetch" # fetch, extract, filter, dedup ...
def fetched(url, body, status, vantage, robots_allowed, tdm_reserved, license=None,
collector="corpus-crawler/1.4"):
return Provenance(
url=url,
fetched_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
status=status,
content_sha256=sha256(body),
vantage=vantage,
robots_allowed=robots_allowed,
tdm_reserved=tdm_reserved,
license=license,
collector=collector,
)
def derived(parent: Provenance, new_body: bytes, step: str) -> Provenance:
"""A transformed copy keeps the source fields and points back at its parent."""
rec = Provenance(**{**asdict(parent), "parents": [parent.content_sha256], "step": step})
rec.content_sha256 = sha256(new_body)
return rec
def eligible(rec: Provenance) -> bool:
"""Only documents that were allowed and not reserved at fetch time go further."""
return rec.status == 200 and rec.robots_allowed and not rec.tdm_reserved
def domain_summary(records, top_share=0.1):
"""Domains by document count, and the top share of them, for a training-data summary."""
counts = Counter(urlsplit(r.url).hostname for r in records)
ranked = [d for d, _ in counts.most_common()]
keep = max(1, round(len(ranked) * top_share))
return counts, ranked[:keep]
if __name__ == "__main__":
raw = [
fetched("https://news.example/a", b"<html>a</html>", 200, "residential:de", True, False),
fetched("https://news.example/b", b"<html>b</html>", 200, "residential:de", True, False),
fetched("https://shop.example/p/1", b"<html>p1</html>", 200, "residential:fr", True, True),
fetched("https://blog.example/x", b"<html>x</html>", 403, "residential:us", True, False),
fetched("https://forum.example/t/9", b"<html>t9</html>", 200, "residential:us", False, False),
]
kept = [r for r in raw if eligible(r)]
texts = [derived(r, r.url.encode() + b" text", "extract") for r in kept]
with open("manifest.jsonl", "w") as f:
for rec in raw + texts:
f.write(json.dumps(asdict(rec)) + "\n")
counts, top = domain_summary(raw)
print(f"fetched {len(raw)}, eligible {len(kept)}, extracted {len(texts)}")
print("domains:", dict(counts))
print("top domains:", top)
child = texts[0]
print("lineage ok:", child.parents == [kept[0].content_sha256], child.step, child.url)
输出:
fetched 5, eligible 2, extracted 2
domains: {'news.example': 2, 'shop.example': 1, 'blog.example': 1, 'forum.example': 1}
top domains: ['news.example']
lineage ok: True extract https://news.example/a
被保留声明限制的页面、被屏蔽的页面以及被robots.txt禁止的页面,都仍然保留在清单中。这一点很重要:你选择不使用的内容的记录,也是证明的一部分。该模板按抓取内容的规模对域名进行排名,所以在实际生产中,应统计字节数或词元数,而不是文档数量。
在整个流程中携带溯源信息
在抓取时写入的记录,只有当之后的每一步都保留这条链接时,才有用。
- 提取。 你用来训练的文本并不是你抓取的 HTML。给提取出的文本赋予自己的哈希值,并让它指向原始记录,就像上面
derived()所做的那样。 - 过滤。 既要记录某份文档被丢弃这件事,也要记录丢弃的原因。“低于质量阈值”和”TDM保留声明”对监管机构来说是不同的答案。
- 去重。 当近似重复的内容被合并时,保留幸存副本所代表的每一个来源的列表。否则,针对某个 URL 的下架请求将无法找到它最终进入的那份文档。
- 混合与采样。 记录每次训练运行使用的是哪个版本的清单,这样摘要描述的才是模型实际看到的数据。
- 移除。 保留一个以 URL 和哈希值为键的移除清单。当某个网站之后声明保留其权利,或权利持有者要求移除时,相同的键可以在每一个衍生数据集中找到该文档。
退出状态尤其值得关注。robots.txt 文件或文本与数据挖掘保留声明在你抓取之后可能发生变化,变好变坏都有可能。记录下你在抓取时所做的判定,并在每次新的采集之前重新检查,而不是假设去年的答案仍然成立。
值得借鉴的标准
你不需要从零开始发明一套模式。
- 《数据集数据表》(Datasheets for Datasets)(Gebru 等人,发表于《ACM通讯》,2021年)提出,每个数据集都应记录其动机、组成、采集过程和推荐用途。这正是数据集层面摘要应有的形式。
- 数据与可信人工智能联盟(Data & Trusted AI Alliance)的数据溯源标准在三个组别(来源、溯源和用途)中定义了22个元数据字段,包括数据生成的日期范围、方法、使用许可以及同意文件的存放位置。1.0.0版本于2024年7月发布。
- **C2PA 内容凭证(Content Credentials)**将一份经签名的溯源清单附加到单个媒体资产上。它们对图像、音频和视频最为有用,因为资产本身可以携带其记录。
溯源通常在哪里出问题
- 事后才补充记录。 没有逐份文档记录的抓取,无论数据集说明文档写得多好,事后都无法被审计。
- 许可证是从数据集说明文档中照搬的。 上述审查发现,这正是错误集中出现的地方。记录来源页面实际说了什么,并把汇总的标签当作需要核实的说法。
- 屏蔽页面被当作内容计入。 200响应仍然可能是一个验证挑战页面。状态码和内容检查都应纳入记录。
- 观测点不明。 从本地的住宅连接采集每个市场的数据,只有在记录中注明每次抓取使用的国家和网络时,才对溯源有帮助。
结论
溯源信息在采集时记录成本很低,但事后几乎不可能补救。每份文档在抓取时写入一条记录,让哈希值贯穿每一次转换,既保留你排除的内容,也保留你采纳的内容,这样《人工智能法案》所要求的摘要就会变成一次查询,而不是一场调查。
来源与参考文献
- Regulation (EU) 2024/1689 (AI Act), Articles 53, 111 and 113: eur-lex.europa.eu
- European Commission, explanatory notice and template for the public summary of training content (July 2025): digital-strategy.ec.europa.eu
- Directive (EU) 2019/790, Article 4(3): eur-lex.europa.eu
- Longpre et al., The Data Provenance Initiative (2023): arxiv.org/abs/2310.16787
- Longpre et al., Consent in Crisis: The Rapid Decline of the AI Data Commons (2024): arxiv.org/abs/2407.14933
- Gebru et al., Datasheets for Datasets: doi.org/10.1145/3458723
- Data & Trusted AI Alliance, Data Provenance Standards v1.0.0: dtaalliance.org
- C2PA Specification: spec.c2pa.org