如何校验你的 llms.txt 文件
使用零依赖 Node.js 脚本验证 llms.txt 中的链接、软件包名称与版本及 Markdown 结构,并在 CI 中自动运行检查。
校验 llms.txt 文件需要检查四项:文件以 H1 开头;每个链接都能访问,并指向链接文本所描述的页面;每个包名和版本号都确实存在于包注册表中;文件内容与当前文档没有冲突。
你的 llms.txt 很可能是某个文档工具在产品发布冲刺期间自动生成的,此后再也没人打开过。期间页面陆续改了名,包也被废弃了,而编程智能体(coding agent)仍在不断读取这个文件。
如果需要回顾这一格式,请先阅读我们的 llms.txt 介绍文章。本文不再赘述基础知识,而是逐项讲解各类检查,提供一个零依赖的 Node 脚本,并演示如何在 CI 中运行它,从而在智能体据此行动之前发现问题。
核心要点
- 最初的 llms.txt 提案只要求一个元素:一个标明项目或站点名称的 H1。引用块摘要、详细说明段落以及 H2 链接分区均为可选项。
- llms.txt 提案并未发布官方校验工具,部分在线检查工具会把缺少引用块或 H2 分区标记为问题,尽管提案将两者都视为可选。
- 如果 llms.txt 中的某个包名已不在注册表中,任何人都可以抢注它。拥有安装命令执行权限的智能体随后可能会拉取对方发布的任何内容。
- 在每次部署时运行检查,以发现你自己引入的错误;同时每周定时运行一次,以发现上游发生变化、而你这边没有任何改动时出现的失效链接和包。
为什么过时的 llms.txt 是一个供应链问题?
过时的 llms.txt 之所以构成供应链问题,是因为智能体将 llms.txt 视为可信的参考资料,并且往往在没有人工介入的情况下直接据此行动。这样一来,一个过时的链接或错误的包名就变成了一条指令。人类读者遇到失效链接会直接放弃;而拥有 shell 权限的智能体,则可能对一个已被他人占有的包名执行 npm install。
llms.txt 的供应链风险已经得到实证。TechRadar 报道了 Alon Hertz 的研究:他扫描了 6,214 个活跃域名,发现了 8,265 个 llms.txt 和 llms-full.txt 文件。据他统计,有 120 个不同站点的文件指向了无人注册的包或域名。随后,他抢注了其中部分空闲名称,并发布了会在被安装时向他回传信息的包。据这位研究人员称,一家财富 500 强企业的系统在一小时内就开始向他发起回连。报道中点名涉及的智能体包括 Claude、Codex 和 Hermes。
对此要客观看待。这是一次受控实验,并非真实环境中的恶意软件事件记录。但它依然是一个真实存在的供应链暴露面,而一个 CI 任务就能以很低的成本将其堵上。
访问控制是另一回事。使用 robots.txt 屏蔽 AI 爬虫决定了爬虫可以读取站点的哪些部分;而校验 llms.txt 则确保它们读到的内容仍然准确。
校验 llms.txt 时需要检查什么
一个有效的 llms.txt 应当包含 H1 和格式正确的链接,并且其中的每个链接和包引用都指向真实存在、且与描述相符的内容。llms.txt 提案没有发布官方校验工具。下面的检查涵盖了在线检查工具所测试的内容,并增加了针对文件中所列包的注册表检查。
链接可访问
llms.txt 中的每个 URL 在跟随重定向后都应返回 2xx 状态码。将 4xx、5xx 和 DNS 解析失败视为错误。一个无法再解析的域名,相当于域名版的”未注册包”:任何人都能买下它。重定向也应记录下来。跳转到站点根路径的重定向通常意味着该页面已被删除。先发送 HEAD 请求,失败后再回退到 GET,因为有些服务器会拒绝或错误处理 HEAD 请求。
链接内容与描述相符
200 响应只能证明 URL 有响应,并不能证明页面内容仍与链接文本所承诺的一致。检查 content-type 是否为预期的 HTML 或 Markdown;将页面的 <title> 或第一个标题与链接文本进行比对;并留意”软 404”,即以 200 状态码返回的错误页面。这项检查结果较为模糊,因此适合作为定期审查,而不是构建关卡。
包和版本存在于注册表中
对 llms.txt 中指示智能体安装的每个包,都要检查其是否存在于注册表中。手动检查时,npm view <name> 会显示注册表中该包的信息,如果包不存在则会报错。在脚本中,则可以从 registry.npmjs.org 获取包文档。注册表中不存在的名称,就是他人可以抢注的名称。对于锁定的版本,需确认该确切版本出现在包的已发布版本列表中。PyPI、crates.io 等其他生态系统也提供类似的查询方式。
Markdown 结构有效
最初的 llms.txt 提案只规定了一个必需部分:一个标明项目或站点名称的 H1。除可选的字节顺序标记(BOM)外,文件必须以 # Name 开头。当前的 llms.txt 规范规定,每个 H2 分区包含一个 Markdown 列表,其中每一项都必须以 [name](url) 链接开头,冒号后的说明为可选。对于缺少 H1 或链接语法错误的情况,应让构建失败;但不要因为缺少引用块或 H2 而让构建失败。部分在线检查工具仍会对两者发出警告,不要把这些警告当作违反规范。
文件内容与当前文档不冲突
将 llms.txt 中的 URL 集合与 sitemap.xml 或文档构建生成的路由清单进行比对。凡是出现在 llms.txt 中却不在站点地图里的 URL,都应视为可疑。对包也做同样处理:文件中的每个名称,都应与你所发布的某个 package.json 的 name 字段相匹配,或者是你有意推荐的依赖项。
用于校验 llms.txt 的零依赖 Node 脚本
该脚本接收一个 URL 或本地路径作为参数。它会检查 H1 和每个绝对链接,然后在 npm 注册表中查询安装命令里出现的每个包。脚本使用 Node 的全局 fetch,因此在 Node 24 等当前 LTS 版本上无需任何依赖即可运行。
// scripts/check-llms.mjs — usage: node scripts/check-llms.mjs <url-or-path>
import { readFile } from 'node:fs/promises';
const src = process.argv[2];
if (!src) { console.error('usage: node check-llms.mjs <url-or-path>'); process.exit(2); }
const failures = [];
async function load(s) {
if (!/^https?:\/\//.test(s)) return readFile(s, 'utf8');
const res = await fetch(s);
if (!res.ok) { console.error(`✗ ${s} returned ${res.status}`); process.exit(1); }
return res.text();
}
async function checkLink(url) {
let res;
try {
res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
} catch {
// some servers reset the connection on HEAD; fall through to GET
}
try {
if (!res?.ok) res = await fetch(url, { redirect: 'follow' }); // some servers refuse HEAD
if (res.status >= 400) failures.push(`link ${res.status}: ${url}`);
else if (res.redirected) console.warn(`↪ ${url} -> ${res.url}`);
} catch (err) {
failures.push(`link unreachable (${err.cause?.code ?? err.message}): ${url}`);
}
}
async function checkPackage(spec) {
const [, name, version] = spec.match(/^(@?[^@\s]+)(?:@(\S+))?$/) ?? [];
if (!name) return;
const res = await fetch(`https://registry.npmjs.org/${name.replace('/', '%2F')}`);
if (res.status === 404) return failures.push(`npm: "${name}" is not registered`);
if (!res.ok) return failures.push(`npm: registry returned ${res.status} for "${name}"`);
const doc = await res.json();
if (/^\d+\.\d+\.\d+/.test(version ?? '') && !doc.versions?.[version])
failures.push(`npm: ${name}@${version} does not exist`);
}
const text = await load(src);
if (!/^# \S/.test(text.trimStart())) failures.push('structure: file does not start with an H1');
const links = new Set([...text.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)].map((m) => m[1]));
const specs = new Set([...text.matchAll(/\b(npx|npm (?:install|i|add)|pnpm add|yarn add)\s+([^\n`]+)/g)]
.flatMap(([, cmd, args]) => {
const tokens = args.trim().split(/\s+/).filter((t) => !t.startsWith('-'));
return cmd === 'npx' ? tokens.slice(0, 1) : tokens;
}));
for (const url of links) await checkLink(url);
for (const spec of specs) await checkPackage(spec);
failures.forEach((f) => console.error(`✗ ${f}`));
console.log(`${links.size} links, ${specs.size} package refs, ${failures.length} failures`);
process.exit(failures.length ? 1 : 0);
工作原理:
load从磁盘或通过 HTTP 读取文件。如果 llms.txt 的 URL 本身请求失败,脚本会立即终止。checkLink先尝试HEAD请求,若HEAD请求出错或返回非成功状态码,则改用GET重试。重定向会以警告形式记录,而域名无法解析等网络层失败则记录为失败项。checkPackage在 npm 注册表中查询每个包名,标记出不存在的名称以及从未发布过的确切版本。- 请求刻意逐个串行执行,以降低对你的服务器和注册表造成的负载。
- 只要存在任何失败项,脚本就会以退出码 1 退出。
该脚本存在一些已知局限:会跳过相对链接;同一行中安装命令后面紧跟的说明文字可能导致误报;只检查确切版本,不检查版本范围。
一次失败的运行输出大致如下(示例输出,名称均为虚构):
↪ https://docs.example.com/guide -> https://docs.example.com/
✗ link unreachable (ENOTFOUND): https://docs.old.example.com/api
✗ npm: "example-sdk-legacy" is not registered
42 links, 3 package refs, 2 failures
在 CI 中于每次部署和每周定时运行检查
llms.txt 校验器应以两种方式运行:一是在每次部署时针对刚构建出的文件运行;二是按计划定时针对线上文件运行。部署时的运行可以发现你自己引入的错误;定时运行则可以在你的代码仓库没有任何改动的情况下,发现链接失效以及上游包被下架的问题。
name: Validate llms.txt
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Mondays 06:00: catches link rot and unpublished packages
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node scripts/check-llms.mjs https://docs.example.com/llms.txt
schedule 触发器接受标准的五字段 cron 表达式,GitHub 默认按 UTC 解析,除非你添加一个值为 IANA 时区名称的 timezone 键。workflow_dispatch 允许你手动触发运行。如果文档部署也在同一个工作流中,可以在发布步骤之前添加一个指向构建产物的步骤,例如 node scripts/check-llms.mjs dist/llms.txt,这样检查失败就会阻止发布。定时任务则保持指向线上 URL。
通过自动生成保持 llms.txt 同步
如果 llms.txt 与文档出自同一数据源生成,那么文件中就不可能列出文档中已不存在的页面或包。请在渲染文档的同一步骤中,基于相同的页面列表和 frontmatter 来构建它:
// scripts/build-llms.mjs
import { writeFile } from 'node:fs/promises';
import pages from '../dist/pages.json' with { type: 'json' };
const lines = ['# Example SDK', '', '## Docs', ''];
for (const p of pages.filter((p) => !p.draft)) {
lines.push(`- [${p.title}](https://docs.example.com${p.path}): ${p.description}`);
}
await writeFile('dist/llms.txt', lines.join('\n') + '\n');
切勿手动编辑生成的文件。要么只提交生成脚本,要么同时提交生成脚本及其产物,并在重新生成产生差异时让 CI 失败。一旦实现了自动生成,校验器就成了生成器的守护者,而不再是一项需要有人记着去做的清理工作。
结语
llms.txt 文件只有在其中列出的所有内容仍然存在、且与描述相符时才有价值,因为智能体会不加核实地据此行动。将 scripts/check-llms.mjs 添加到你的仓库,先针对线上文件运行一次,修复它报告的所有问题,然后提交工作流,这样下一个失效链接或孤立包名就会在被智能体发现之前让构建失败。
常见问题
llms-full.txt 是否应该以与 llms.txt 相同的方式进行校验?
链接和包方面应该如此,而且对包的检查要更加严格。llms-full.txt 内联了完整的文档内容,因此通常比 llms.txt 包含多得多的安装命令和代码示例,其中每一个都是智能体可能执行的包名。结构检查可以宽松一些,因为该文件主要是页面内容,而不是简短的链接列表。将同一个脚本分别指向这两个文件即可。
为什么链接检查会对浏览器中能正常打开的页面报告 403 错误?
很可能是机器人防护或 Web 应用防火墙拦截了自动化请求。部分 CDN 和安全层会拒绝缺少类浏览器请求头的请求,或来自 CI 运行器 IP 段的请求。可以在你自己的文档域名上将 CI 运行器或检查工具的 User-Agent 加入白名单,或者在 fetch 中发送一个具有描述性的 User-Agent 请求头。不要直接忽略 403,因为智能体可能也会以同样的方式被拦截。
如何根据 PyPI 检查 llms.txt 中的 Python 包名?
查询 PyPI JSON API:pypi.org/pypi/NAME/json。对于已存在的项目,它会返回元数据以及一个以版本字符串为键的 releases 对象,因此你可以同时确认包名和任何锁定的版本。如果请求返回 404,则将该名称视为未注册。按照脚本处理 npm install 的相同方式,从 pip install 和 uv add 命令中提取包名。
注册表检查通过是否意味着 llms.txt 中的包可以安全安装?
不是。该检查只能确认名称存在,而不能确认它归正确的人所有。在原始包被弃用后,可能已经有人抢注了该名称;而 llms.txt 中的一个拼写错误,也可能指向一个确实存在的仿冒包。对于你推荐的每一个包,都要检查其发布者和仓库链接是否与你的组织相符,并优先使用你可控的作用域(scoped)包名。