llms.txt — 从清单到 XML 上下文
本章讲流水线的下半段:拿到结构化的链接清单后,怎么把每条链接的正文抓下来、拼成一份适合塞进模型的 XML。源码集中在
llms_txt/core.py后半部。
1. 它要解决的小问题
解析器给的只是"清单"——一堆 title/url/desc,没有正文。要真正喂给模型,得:
- 逐个把 URL 的正文抓回来;
- 清洗掉对模型没用的东西(HTML 注释、内嵌大图);
- 组织成有结构、带元信息(标题、简介)的一整块文本。
作者选的组织格式是 XML——注意这里和"输入用 markdown"是两个不同的选择:输入面向人类作者用 markdown,输出面向模型(尤其 Claude)用 XML 标签,因为标签能清晰界定"这是哪篇文档、标题是什么"。
2. 思路:把清单变成一棵标签树
目标 XML 长这样(取自 nbs/llms-ctx.txt):
<project title="llms.txt">
> A proposal that...(summary)
...(info)
<docs>
<doc title="llms.txt proposal" desc="The proposal for llms.txt">
# The /llms.txt file ...(抓来的正文)
</doc>
...
</docs>
</project>
对应关系一目了然:
| llms.txt 里的 | 变成 XML 的 |
|---|---|
| H1 标题 / 摘要 / 说明 | <project title=...> 的属性与开头文本 |
一个 H2 分区(如 Docs) | 一个同名标签 <docs>(渲染为小写) |
| 分区里的一条链接 | 一个 <doc title=... desc=...> |
| 链接指向的正文 | <doc> 的子内容 |
构建这棵树用的是 fastcore 的 FastTags(FT)——一种用 Python 函数调用直接写出标签树的机制。
3. 逐段拆解真实实现
3.1 组装骨架:mk_ctx
mk_ctx(llms_txt/core.py:101-105)搭出整棵树:
skip = '' if optional else 'Optional'
sections = [_section(k, v, ...) for k,v in d.sections.items() if k!=skip]
return Project(title=d.title, summary=d.summary)(d.info, *sections)
三点值得看:
Project(...)(...)是 FT 的调用风格:第一次调用给属性,第二次调用给子节点。d.info作为第一个子节点(即 project 开头那段说明文本),后面跟各个 section。skip那行落实了01-spec.md§3 说的"默认跳过 Optional 分区"。
3.2 每个分区:_section + 并行下载
_section(llms_txt/core.py:96-98):
def _section(nm, items, n_workers=None):
return ft(nm, *parallel(_doc, items, n_workers=n_workers, threadpool=True))
ft(nm, ...)用分区名当标签名造一个标签(Docs→<docs>)。parallel(_doc, items, threadpool=True)是关键性能点:一个分区里的多条链接并发下载,而不是一条条等。这是 fastcore 的线程池parallel。这条并行下载能力是 0.0.2 版专门加的(CHANGELOG.md,#6)。
3.3 每条链接:_doc 抓正文 + 清洗
_doc(llms_txt/core.py:85-93):
def _doc(kw):
print(dict(kw))
url = kw.pop('url')
txt = get_doc_content(url)
re_comment = re.compile('^<!--.*-->$', flags=re.MULTILINE)
re_base64_img = re.compile(r'<img[^>]*src="data:image/[^"]*"[^>]*>')
txt = '\n'.join([o for o in txt.splitlines()
if not re_comment.search(o) and not re_base64_img.search(o)])
return Doc(txt, **kw)
它做了三件事:
- 抓正文(
get_doc_content,见 3.4); - 逐行过滤掉整行 HTML 注释、和内嵌 base64 图片的
<img>——这些是纯噪声,占上下文还没信息量; - 把清洗后的正文包成
Doc(txt, **kw),kw里剩下的title/desc成为<doc>的属性。
一个真实的坑: 第一行
print(dict(kw))(core.py:87)是留下的调试打印——每处理一条链接就往 stdout 打一行链接属性。因为 CLI 默认把最终 XML 也> 文件重定向时它们同样会进文件。属于未清理的开发残留,使用者要注意。
3.4 抓正文:本地文件优先(nbdev 小技巧)
get_doc_content(llms_txt/core.py:76-82)有个聪明的短路:
def get_doc_content(url):
if (path:=_get_config()):
relative_path = urlparse(url).path.lstrip('/')
local_path = _local_docs_pth(path) / relative_path
if local_path.exists(): return local_path.read_text()
return httpx.get(url).text
逻辑:
_get_config()(core.py:74)向上找pyproject.toml,判断"当前是否在一个项目/nbdev 仓库里";- 若是,就把 URL 的路径部分拼到本地
_proc/目录下(_local_docs_pth,core.py:73),本地存在就读本地文件; - 否则(或本地没有)才走网络
httpx.get。
为什么这么设计: nbdev 在构建文档时生成 llms.txt 及其展开产物。那时候页面的 markdown 版本还没发布上线,网络上根本抓不到——但它们已经在本地 _proc/ 里生成好了。本地优先让"边构建边展开"成为可能,不必等部署(CHANGELOG.md 0.0.4 "Check if in nbdev project",#10)。
3.5 序列化 + 尺寸统计
create_ctx(llms_txt/core.py:113-117)串起全流程:parse_llms_file → mk_ctx → to_xml(ctx, do_escape=False)。do_escape=False 是要的——正文本身是 markdown,不能被 XML 转义(否则 <、& 会被改写)。
get_sizes(core.py:108-110)是个辅助:统计每个分区里每篇文档的字符数,便于判断上下文预算。它 返回 {分区: {文档标题: 长度}}。
4. CLI:一个装饰器搞定命令行
命令行入口 llms_txt2ctx(llms_txt/core.py:120-131)几乎没有样板代码,靠 fastcore 的 @call_parse 把函数签名直接变成 CLI:
| 参数 | 作用 |
|---|---|
fname | 要读的 llms.txt 路径 |
--optional | 是否包含 Optional 分区(默认否) |
--n_workers | 并行下载线程数 |
--save_nbdev_fname | 存到 nbdev 的 docs 目录而非打印到 stdout |
函数体就三行:读文件 → create_ctx → 打印(或写入 nbdev 目录)。pyproject.toml 把它注册成 llms_txt2ctx 命令([project.scripts])。
还有第二个 CLI llms_txt2html(llms_txt/txt2html.py:5-21):用 mistletoe 把 llms.txt 渲染成 HTML 预览页,并把链接里的 .html.md 改回 .html 指向人类可读版。这是给"想在浏览器里看 llms.txt"的场景用的,和上下文生成是两条独立的路。
5. 巧妙之处(可借鉴)
| 妙在哪 | 依据 |
|---|---|
| 输入 markdown / 输出 XML:按读者分别选格式——人写 markdown,模型吃标签化 XML | create_ctx + nbs/index.qmd "Format" 节 |
| 本地文件优先:构建期无需网络就能展开文档 | get_doc_content(core.py:76) |
| 一个分区一次并行:天然的下载并发边界 | _section(core.py:96) |
| FastTags 写树:用 Python 调用直接生成 XML,无模板 | mk_ctx(core.py:101) |
@call_parse 零样板 CLI:函数签名即命令行接口 | llms_txt2ctx(core.py:120) |
6. 边界与局限
- 残留调试打印。
_doc里的print(dict(kw))(core.py:87)会污染 stdout(§3.3)。 - 无重试 / 无错误兜底。
httpx.get(url).text直接取(core.py:82),某个 URL 失败会让整次生成抛错;规范也明说"不规定处理方式",容错留给使用者。 - 只清洗两类噪声。 只过滤整行 HTML 注释和 base64
<img>(core.py:90-91);其他 HTML 噪声不处理——因为它假定链接指向的本就是干净的.md镜像(见01-spec.md§4)。 - 依赖 fastcore/httpx/mistletoe。 core 版不是零依赖;想零依赖只解析、不下载,用
miniparse(见02-parser.md§4)。
7. 横向对比(在 ai-agent-reference · web-interop 里的位置)
llms.txt 解决的是"让 agent 高效获取网站/文档内容"这一类问题,和同 area 的其他"网页可交互性"方案是互补而非竞争:
- 它是内容供给侧的约定(网站主动提供精选、干净的 markdown 索引),而非抓取侧的技术(如把任意 HTML 转 markdown、或浏览器自动化)。
- 相比
sitemap.xml的"求全",它"求精 + 可跨站 + 面向推理期"(见01-spec.md§5)。 - 定位类似"给 AI 的 README":用极低的技术成本(一个 markdown 文件)换取"模型能一眼找到该读什么"。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 全流程入口(解析→组装→序列化) | llms_txt/core.py:113 | create_ctx |
| 搭 XML 骨架 / 跳过 Optional | llms_txt/core.py:101 | mk_ctx |
| 一个分区 + 并行下载 | llms_txt/core.py:96 | _section |
| 抓正文 + 清洗 + 包 Doc | llms_txt/core.py:85 | _doc |
| 本地文件优先的抓取 | llms_txt/core.py:76 | get_doc_content |
| 定位项目根 / 本地 docs 路径 | llms_txt/core.py:73-74 | _local_docs_pth / _get_config |
| 各文档尺寸统计 | llms_txt/core.py:108 | get_sizes |
| 命令行入口 | llms_txt/core.py:120 | llms_txt2ctx(@call_parse) |
| HTML 预览渲染 | llms_txt/txt2html.py:5 | main(llms_txt2html) |
| CLI 注册 | pyproject.toml | [project.scripts] |