跳到主要内容

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):

  1. 在根路径放 /llms.txt,用规定格式写“项目简介 + 文档链接清单”。
  2. 对每个有用的 HTML 页面,在同一 URL 后加 .md 提供干净的 Markdown 版本(无文件名的 URL 用 index.html.md)。

它跟 robots.txt / sitemap.xml 是什么关系。 路径约定的灵感来自它们,但用途不同:

文件给谁干什么
robots.txt爬虫声明哪些路径允许抓取
sitemap.xml搜索引擎列出所有可索引页面
llms.txtLLM / 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_filellms.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 图,包成 Docllms_txt/core.py:85-93
_section / mk_ctx把 sections 组装成带 <section><doc>Projectllms_txt/core.py:96-105
create_ctx端到端:解析 + 组装 + 序列化成 XML 字符串llms_txt/core.py:113-117
llms_txt2ctxCLI 入口(@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_filellms_txt/core.py:50-67 用它们做同样的 split+chunk。它最后调 fastcore 的 dict2obj 返回 AttrDict,可以 d.titled.sections 点号访问。

关键细节: 单条链接的正则是 -\s*\[(?P<title>[^\]]+)\]\((?P<url>[^\)]+)\)(?::\s*(?P<desc>.*))?(llms_txt/miniparse.py:11)——title 取到 ] 前、url 取到 ) 前、可选的 : desc 取到行尾。summaryopt_re 包成可选,所以没有 blockquote 的文件也能解析(测试 test_missing_optional_fieldstest_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:104123)。一个 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 是个独立小工具:用 mistletoellms.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:43miniparse.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-referenceweb-interop 关切——都在回答“怎么把网站内容以机器友好的方式暴露给自动化方”:

约定给谁形态取舍
llms.txtLLM / 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:113create_ctx
CLI 入口llms_txt/core.py:120llms_txt2ctx(@call_parse)
解析整份文件llms_txt/core.py:57parse_llms_file / _parse_llms
正则积木llms_txt/core.py:22opt_re, named_re, search
单条链接解析llms_txt/core.py:36parse_link, _parse_links
抓正文 + 去噪 + 包 Docllms_txt/core.py:85_doc
本地 nbdev 优先抓取llms_txt/core.py:76get_doc_content, _local_docs_pth
组装 Project/Section 树llms_txt/core.py:96_section, mk_ctx
Optional 取舍llms_txt/core.py:101mk_ctx(skip 变量)
各段体量诊断llms_txt/core.py:108get_sizes
零依赖参考解析器llms_txt/miniparse.py:8parse_llms_txt, chunked
HTML 预览工具llms_txt/txt2html.py:5main
解析测试(各种缺省)tests/test-parse.py:4TestParseLlmsFileShort
打包 / CLI 注册pyproject.toml:25[project.scripts]