llms.txt — 架构与原理
30 秒导读:
llms.txt是一个极简约定——在网站根目录放一个/llms.txt的 Markdown 文件,用固定格式列出“这个网站/项目是什么 + 哪些 Markdown 文档值得 LLM 读”。本仓库同时给出参考实现:一个把llms.txt抓取展开成 XML 上下文文件的命令行工具llms_txt2ctx。规范本身只有几页文字,实现核心只有 ~130 行 Python,正则解析 + 并行下载。
1. 这是什么(零基础也能懂)
一句话定义: llms.txt 是给大模型看的“网站门牌 + 目录页”——一个放在 https://你的站点/llms.txt 的 Markdown 文件,用固定结构告诉 LLM“我是谁、读哪几个文档能搞懂我”。
它解决谁的什么问题。 设想你在用 AI 助手写代码,想让它了解某个库的用法。直接把官网首页喂给模型,会遇到两个老大难:
- 网页里塞满导航栏、广告、JavaScript,转成纯文本又脏又不准。
- 完整文档动辄几十万字,塞不进上下文窗口(context window,模型一次能读的文本上限)。
llms.txt 的思路是:别让模型啃整个网站,给它一张人工精选的“该读什么”清单,清单里链接的都是干净的 Markdown 文档。
这套约定其实是两条提议(出自 nbs/index.qmd:19-23):
- 在根路径放
/llms.txt,用规定格式写“项目简介 + 文档链接清单”。 - 对每个有用的 HTML 页面,在同一 URL 后加
.md提供干净的 Markdown 版本(无文件名的 URL 用index.html.md)。
它跟 robots.txt / sitemap.xml 是什么关系。 路径约定的灵感来自它们,但用途不同:
| 文件 | 给谁 | 干什么 |
|---|---|---|
robots.txt | 爬虫 | 声明哪些路径允许抓取 |
sitemap.xml | 搜索引擎 | 列出所有可索引页面 |
llms.txt | LLM / agent | 人工精选的、面向理解的概览 + 文档链接 |
关键差别:sitemap 求全(列全站、常超上下文窗口、不含外链),llms.txt 求精(人工挑选、可含外部链接、刻意控制体量)。设计目标是 inference 时按需取用(用户主动要某库文档时),而非训练。(nbs/index.qmd:68-78)
用起来什么样。 一个最小的 llms.txt 长这样(来自规范 nbs/index.qmd:50-64 的 mock 示例):
# Title
> Optional description goes here
Optional details go here
## Section name
- [Link title](https://link_url): Optional link details
## Optional
- [Link title](https://link_url)
然后用本仓库的命令行工具把它展开成一份带正文的上下文文件:
pip install llms-txt
llms_txt2ctx llms.txt > llms-ctx.txt # 抓取每个链接的正文,拼成 XML
一句话直觉/类比: 把 llms.txt 当成一本书的目录页 + 内容简介——模型先读目录知道有哪些章、各讲什么,需要时再按链接翻到具体章节;llms_txt2ctx 则是“按目录把相关章节复印装订成一册”的工具。
2. 顶层全景(它大概怎么转)
这个项目有两层,别混淆:
- 规范层(convention): 一份格式约定 + 写作指南,纯文档,任何语言都能实现解析。源头是
nbs/index.qmd(它也是网站llmstxt.org与仓库 README 的同一份内容)。 - 实现层(reference tool): Answer.AI 给的官方 Python 参考实现,核心是
llms_txt/core.py里的llms_txt2ctx:读llms.txt→ 解析 → 抓取每个链接正文 → 拼成 XML 上下文。
实现层主线(从一个 llms.txt 到一份 XML 上下文):
llms.txt 文本
│
▼
parse_llms_file ← 正则把文本切成 {title, summary, info, sections}
│ (结构化的 AttrDict)
▼
mk_ctx ← 为每个 H2 section 建一个 <section>
│ 里面每个链接 → _doc:抓 URL 正文,包成 <doc>
│ (并行下载所有 doc)
▼
to_xml(...) ← fastcore 的 FT 对象序列化成 XML 文本
│
▼
<project>…<doc>正文</doc>…</project> ← 喂给 Claude 等模型的上下文
怎么读这张图: 从上往下是数据流;每一格是 core.py 里的一个函数,左边是它的产物。parse 负责“拆结构”,mk_ctx 负责“抓正文 + 搭 XML 骨架”,to_xml 负责“序列化成字符串”。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
parse_llms_file | 把 llms.txt 文本解析成 {title, summary, info, sections} | llms_txt/core.py:57-67 |
parse_link / _parse_links | 解析单条 - [title](url): desc 链接 | llms_txt/core.py:36-47 |
_doc | 抓取一个链接 URL 的正文,清掉注释/base64 图,包成 Doc | llms_txt/core.py:85-93 |
_section / mk_ctx | 把 sections 组装成带 <section><doc> 的 Project | llms_txt/core.py:96-105 |
create_ctx | 端到端:解析 + 组装 + 序列化成 XML 字符串 | llms_txt/core.py:113-117 |
llms_txt2ctx | CLI 入口(@call_parse),读文件→打印/存盘 | llms_txt/core.py:120-131 |
parse_llms_txt(mini) | 零依赖版纯解析器,用于演示/移植 | llms_txt/miniparse.py:8-20 |
main(txt2html) | 把 llms.txt 渲染成 HTML 预览 | llms_txt/txt2html.py:5-21 |
主线走一遍(高层): 输入一个 llms.txt 文件 → create_ctx 先调 parse_llms_file 拆出结构,再调 mk_ctx 为每个 ## 段 建一个 section、为段里每个链接并行下载正文并包成 <doc> → 最后 to_xml 把整棵 FT 树序列化 → 输出形如 <project title="..."><docs><doc>...正文...</doc></docs></project> 的 XML,直接塞进模型上下文。
3. 核心原理(逐个机制,由浅入深)
3.1 规范的固定文件格式(为什么用 Markdown 而非 XML)
要解决的小问题: 一个文件,既要人能顺手写、模型能直接读,又要程序能可靠解析。
思路/直觉: 规范故意选了 Markdown 而不是 XML/JSON。理由很直接——这些文件主要是给语言模型和 agent 读的,Markdown 是当前模型最容易理解的格式;同时它的结构足够规整,经典的解析器/正则也能稳定处理。(nbs/index.qmd:37)
格式按固定顺序排列(nbs/index.qmd:41-46):
| 顺序 | 元素 | 是否必需 |
|---|---|---|
| 1 | 可选的 BOM(字节序标记) | 否 |
| 2 | # H1 项目/站点名 | 是(唯一必需项) |
| 3 | > blockquote 一句话摘要 | 否(但强烈建议) |
| 4 | 零或多段普通 Markdown(不能是标题)说明 | 否 |
| 5 | 零或多个 ## H2 段,每段是一个“文件清单” | 否 |
每个文件清单里的一行是:- [name](url),后面可选 : 备注。
## Optional 段的特殊语义(nbs/index.qmd:66): 名为 Optional 的 H2 段是约定俗成的“次要信息”——当需要更短的上下文时,这一段的链接可以整段跳过。这是规范里唯一带语义的段名,实现层会专门处理(见 3.4)。
3.2 用正则把 Markdown 拆成结构
要解决的小问题: 把上面那套“H1 + blockquote + 正文 + 多个 H2 清单”的文本,变成程序能用的字典。
思路/直觉: 因为格式固定,纯正则就够了,不需要完整 Markdown 解析器。分两步:先按 ## 把文件切成“开头部分”和“各段”,再分别用正则抠出字段。
原理演示(简化版,# 示意,非源码):
import re
# 这步:用 ## 把全文劈开。re.split 带捕获组时,分隔符本身(段标题)也会留在结果里
start, *rest = re.split(r'^##\s*(.*?$)', txt, flags=re.MULTILINE)
# rest 形如 [段名1, 段体1, 段名2, 段体2, ...],两两配对成 dict
sections = dict(zip(rest[::2], rest[1::2]))
# 开头部分再抠 H1 标题 / blockquote 摘要 / 剩余正文
m = re.search(r'^#\s*(?P<title>.+?$)\n+(?:^>\s*(?P<summary>.+?$))?\n+(?P<info>.*)',
start.strip(), re.MULTILINE | re.DOTALL)
重点看: re.split 用了捕获组 (...),所以分隔符(每个 H2 段名)不会被丢弃,而是夹在结果列表里,于是后面能把 [名,体,名,体,...] 两两配对。
真实实现: 仓库有两份解析器,逻辑一致:
- 零依赖、最易读的版本
parse_llms_txt,核心就是上面那几行(llms_txt/miniparse.py:8-20)。它用本地小工具chunked把[名,体,名,体]切成对(llms_txt/miniparse.py:4-6)。 - 正式版
parse_llms_file复用了一对可组合的正则积木opt_re(可选匹配)、named_re(命名捕获组)——它们定义在llms_txt/core.py:22-28,正式版的_parse_llms+parse_llms_file在llms_txt/core.py:50-67用它们做同样的 split+chunk。它最后调 fastcore 的dict2obj返回AttrDict,可以d.title、d.sections点号访问。
关键细节: 单条链接的正则是 -\s*\[(?P<title>[^\]]+)\]\((?P<url>[^\)]+)\)(?::\s*(?P<desc>.*))?(llms_txt/miniparse.py:11)——title 取到 ] 前、url 取到 ) 前、可选的 : desc 取到行尾。summary 用 opt_re 包成可选,所以没有 blockquote 的文件也能解析(测试 test_missing_optional_fields 与 test_no_links 覆盖了这两种缺省,tests/test-parse.py:52-79)。
3.3 抓取链接正文并组装成 XML(fastcore FT)
要解决的小问题: 解析只拿到了链接清单;真正喂模型还得把每个链接的正文抓回来,拼成一个结构化文档。
思路/直觉: 用 fastcore 的 FT(“FastTags”,Python 里用函数构造 XML/HTML 节点的方式)搭一棵树:顶层 Project,下面每个 H2 段是一个 Section,段里每个链接抓回正文后是一个 Doc。最后 to_xml 序列化。选 XML 包裹正文,是因为 Claude 这类模型对 XML 分节的上下文吃得很好(nbs/index.qmd:27)。
结构对应关系:
Project(title, summary)
├── info ← H1 下面那段说明文字
├── Section "Docs"
│ ├── Doc ← 抓 url1 正文
│ └── Doc ← 抓 url2 正文
└── Section "Examples"
└── Doc ← 抓 url3 正文
真实实现:
_doc(kw)取出链接的url,调get_doc_content抓正文,然后逐行过滤掉 HTML 注释(^<!--.*-->$)和 base64 内联图片(<img ... src="data:image/...">),再包成Doc(txt, **kw)(llms_txt/core.py:85-93)。过滤是为了别让注释/巨大的图片 data URI 污染上下文。mk_ctx为每个 section 调_section,组装出Project(title=..., summary=...)(info, *sections)(llms_txt/core.py:101-105)。create_ctx串起来并to_xml(ctx, do_escape=False)——注意do_escape=False,因为正文是 Markdown,不希望</&被转义(llms_txt/core.py:113-117)。
关键细节(并行下载): _section 用 fastcore 的 parallel(..., threadpool=True) 并发抓所有链接(llms_txt/core.py:96-98),n_workers 可调线程数,一路从 CLI 透传下来(llms_txt/core.py:104、123)。一个 llms.txt 可能链十几个文档,串行抓会很慢,所以并行是 必要的(并行下载是 0.0.2 版加入的特性,见 CHANGELOG.md)。
3.4 Optional 段的取舍 + 两份输出
要解决的小问题: 同一份 llms.txt,有时要“精简上下文”,有时要“完整上下文”。
思路/直觉: 复用规范里 ## Optional 的语义(见 3.1)。mk_ctx 接受 optional 开关:为 False 时把名叫 Optional 的那段直接跳过。
真实实现:
# llms_txt/core.py:101-104 # 真实源码片段
def mk_ctx(d, optional=True, n_workers=None):
skip = '' if optional else 'Optional'
sections = [_section(k, v, n_workers=n_workers)
for k,v in d.sections.items() if k!=skip]
skip 要么是空串('',匹配不到任何真实段名 → 全留),要么是 'Optional'(过滤掉那一段)。一行 if k!=skip 同时表达了两种模式。这就是 FastHTML 文档同时产出 llms-ctx.txt(不含 optional)和 llms-ctx-full.txt(含 optional)两份文件的来源(nbs/index.qmd:27)。
4. 深入实现(细节走读)
4.1 nbdev 本地优先抓取
get_doc_content(url) 在抓远程 URL 前,先看自己是不是在一个 nbdev 项目里(向上找 pyproject.toml)。如果是,就把 URL 的 path 映射到本地 _proc/ 目录下的文件,存在则直接读本地,否则才 httpx.get(url)(llms_txt/core.py:73-82)。
这条优化的意义:nbdev 项目在本地构建文档时,链接指向的 .md 文件可能还没发布到线上,本地优先既快又能在 CI/本地构建时拿到最新内容。这是 0.0.4 版加入的(CHANGELOG.md,thanks @hamelsmu)。
4.2 CLI 入口与 nbdev 集成
llms_txt2ctx 用 fastcore 的 @call_parse 装饰器(llms_txt/core.py:120),把函数签名里的类型注解 + 行内注释自动变成命令行参数和帮助文本——fname、--optional、--n_workers、--save_nbdev_fname 全是这么来的(llms_txt/core.py:120-131)。pyproject.toml:25-27 把它注册为 llms_txt2ctx 命令(还有一个 llms_txt2html)。
带 --save_nbdev_fname 时,输出会写进 nbdev 的 _proc/ 文档目录而非打印到 stdout(llms_txt/core.py:129-130),方便把生成的上下文作为站点的一个产物发布(如 llms-ctx.txt)。
4.3 get_sizes:量一量各段多大
get_sizes(ctx) 遍历组装好的树,返回 {section: {doc_title: 正文字符数}}(llms_txt/core.py:108-110)。用途是诊断:看哪个文档把上下文撑爆了,好决定要不要挪进 Optional。
4.4 HTML 预览工具
llms_txt/txt2html.py 是个独立小工具:用 mistletoe 把 llms.txt 渲染成 HTML,并把链接里的 .html.md" 改回 .html"(让预览页链到人读版而非 md 版),输出 llms.html(llms_txt/txt2html.py:5-21)。这是 0.0.6 版加入的(CHANGELOG.md,issue #63)。它和上下文生成无关,纯粹是“在浏览器里看看这份 llms.txt 长啥样”。
5. 巧妙之处(可借鉴的技术)
用捕获组 split 实现“切段且保留段名”。 re.split(r'^##\s*(.*?$)', ...) 一招同时完成“按分隔符切”和“保留分隔符”,省掉了手写状态机遍历(llms_txt/miniparse.py:15)。
正则即文法,用 opt_re/named_re 把模式拼起来。 正式版没堆一大坨正则,而是把“可选”“命名组”抽成函数再组合(llms_txt/core.py:22-28),可读性远好于裸正则——这是把“正则当 DSL 来搭”的范例。
一行 if k!=skip 表达双模式。 用一个会/不会匹配真实段名的 skip 变量,把“全留 / 跳过 optional”统一成同一条过滤(llms_txt/core.py:102-104)。
抓回正文先清噪声。 _doc 在入上下文前剥掉 HTML 注释和 base64 图片(llms_txt/core.py:90-92)—— 这正是 §1 说的“HTML 转文本又脏又不准”的针对性补救。
约定借路径惯例降低认知成本。 /llms.txt 蹭 /robots.txt、/sitemap.xml 的根路径心智模型,开发者一看就懂该放哪(nbs/index.qmd:72)。
6. 边界与局限
- 规范不规定怎么处理。 提案明确说:不推荐任何特定处理方式,具体取决于应用(
nbs/index.qmd:27)。llms_txt2ctx只是一种参考用法,不是协议的一部分。 - 没有发现机制 / 校验器 / 版本协商。 不像
robots.txt有成熟的爬虫生态,llms.txt目前只是路径约定;仓库里没有合规性校验器,解析器遇到不合格式的文件会因re.search(...).groupdict()拿到None而直接报错(llms_txt/core.py:43、miniparse.py:12),没有友好的错误处理。 - 解析假设格式严格。 H1 必须在最前、blockquote 紧随其后等顺序假设硬编码在正则里;偏离顺序的文件可能解析失败或丢字段。
- 抓取是“尽力而为”。
_doc直接httpx.get,没有重试/超时/缓存(本地 nbdev 命中除外),链接挂了就会让整批parallel出错。 _doc里留了一行print(dict(kw))(llms_txt/core.py:87),会把每个 doc 的属性打到 stderr/stdout——看起来是调试遗留,会污染输出。- 它不替代 sitemap,也不解决“模型是否愿意读”。 是否 被各家模型/agent 真正采用,属于生态采纳问题,不在代码能解决的范围。
7. 横向对比
同属 ai-protocol-reference 的 web-interop 关切——都在回答“怎么把网站内容以机器友好的方式暴露给自动化方”:
| 约定 | 给谁 | 形态 | 取舍 |
|---|---|---|---|
llms.txt | LLM / agent(inference 时) | 根路径 Markdown 清单 + .md 镜像页 | 人工精选、求精;无强制处理方式 |
robots.txt | 爬虫 | 根路径指令文件 | 控制访问,非内容供给 |
sitemap.xml | 搜索引擎 | 全量 URL 列表 | 求全,不裁剪、无外链 |
核心差异是**“精选 vs 全量”和“理解 vs 索引”:sitemap 为搜索引擎列全站,llms.txt 为 LLM 列“读这几个就懂”。llms_txt2ctx 这种“清单 → 抓正文 → 拼成单份大上下文”的模式,与 RAG(检索增强生成)按需检索片段形成对比:它是预先静态打包整份精选上下文**,而非运行时动态检索。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 规范全文(格式/动机/示例) | nbs/index.qmd | (无符号,纯文档) |
| 端到端入口(解析→组装→序列化) | llms_txt/core.py:113 | create_ctx |
| CLI 入口 | llms_txt/core.py:120 | llms_txt2ctx(@call_parse) |
| 解析整份文件 | llms_txt/core.py:57 | parse_llms_file / _parse_llms |
| 正则积木 | llms_txt/core.py:22 | opt_re, named_re, search |
| 单条链接解析 | llms_txt/core.py:36 | parse_link, _parse_links |
| 抓正文 + 去噪 + 包 Doc | llms_txt/core.py:85 | _doc |
| 本地 nbdev 优先抓取 | llms_txt/core.py:76 | get_doc_content, _local_docs_pth |
| 组装 Project/Section 树 | llms_txt/core.py:96 | _section, mk_ctx |
| Optional 取舍 | llms_txt/core.py:101 | mk_ctx(skip 变量) |
| 各段体量诊断 | llms_txt/core.py:108 | get_sizes |
| 零依赖参考解析器 | llms_txt/miniparse.py:8 | parse_llms_txt, chunked |
| HTML 预览工具 | llms_txt/txt2html.py:5 | main |
| 解析测试(各种缺省) | tests/test-parse.py:4 | TestParseLlmsFileShort |
| 打包 / CLI 注册 | pyproject.toml:25 | [project.scripts] |