跳到主要内容

llms.txt — 从清单到 XML 上下文

本章讲流水线的下半段:拿到结构化的链接清单后,怎么把每条链接的正文抓下来、拼成一份适合塞进模型的 XML。源码集中在 llms_txt/core.py 后半部。

1. 它要解决的小问题

解析器给的只是"清单"——一堆 title/url/desc,没有正文。要真正喂给模型,得:

  1. 逐个把 URL 的正文抓回来;
  2. 清洗掉对模型没用的东西(HTML 注释、内嵌大图);
  3. 组织成有结构、带元信息(标题、简介)的一整块文本。

作者选的组织格式是 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)

它做了三件事:

  1. 抓正文(get_doc_content,见 3.4);
  2. 逐行过滤掉整行 HTML 注释、和内嵌 base64 图片的 <img>——这些是纯噪声,占上下文还没信息量;
  3. 把清洗后的正文包成 Doc(txt, **kw),kw 里剩下的 title/desc 成为 <doc> 的属性。

一个真实的坑: 第一行 print(dict(kw))(core.py:87)是留下的调试打印——每处理一条链接就往 stdout 打一行链接属性。因为 CLI 默认把最终 XML 也 print 到 stdout,这些调试行会混进输出。用 > 文件 重定向时它们同样会进文件。属于未清理的开发残留,使用者要注意。

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_filemk_ctxto_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,模型吃标签化 XMLcreate_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:113create_ctx
搭 XML 骨架 / 跳过 Optionalllms_txt/core.py:101mk_ctx
一个分区 + 并行下载llms_txt/core.py:96_section
抓正文 + 清洗 + 包 Docllms_txt/core.py:85_doc
本地文件优先的抓取llms_txt/core.py:76get_doc_content
定位项目根 / 本地 docs 路径llms_txt/core.py:73-74_local_docs_pth / _get_config
各文档尺寸统计llms_txt/core.py:108get_sizes
命令行入口llms_txt/core.py:120llms_txt2ctx(@call_parse)
HTML 预览渲染llms_txt/txt2html.py:5main(llms_txt2html)
CLI 注册pyproject.toml[project.scripts]