如何给你的博客加上 llms.txt
前阵子给博客折腾 动态 OG 图片 的时候,顺手把 llms.txt 也一起做了。这东西成本很低,半小时能搞定,思路跟生成 RSS、sitemap 没区别——本质都是构建时多吐一个文件出来。记一下过程,顺便讲一个中文站一定会踩的坑。
llms.txt 是什么
简单说,它是放在网站根目录的一个 Markdown 文件(/llms.txt),专门写给大语言模型看的一份「站点导览」。提案来自 Answer.AI 的 llmstxt.org,格式很克制:
- 一个 H1,写站点名字;
- 一段 blockquote 做简介;
- 若干 H2 小节,每节是一组链接,格式
[标题](链接): 一句话描述。
它想解决的问题是:模型的上下文有限,与其让爬虫自己去啃满是导航栏、广告、脚本的 HTML,不如你主动把「这站有什么、哪篇值得读」整理成一份干净清单递过去。可以理解成给 AI 用的 sitemap——sitemap 给搜索引擎排序用,llms.txt 给模型理解用。
规范里还有个可选的搭档文件 llms-full.txt:把所有正文拼在一起的全文版,方便模型一次性把整站内容读进去。
生成 llms.txt
这个博客是 Astro,内容都在 content collection 里,所以生成 llms.txt 和生成 RSS 是同一个套路:建一个 src/pages/llms.txt.js,导出 GET,把内容拼成字符串返回。
import { getCollection } from 'astro:content';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
export async function GET(context) {
const origin = String(context.site).replace(/\/$/, '');
const abs = (p) => `${origin}${p.startsWith('/') ? p : `/${p}`}`;
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const lines = [];
lines.push(`# ${SITE_TITLE}`);
lines.push('');
lines.push(`> ${SITE_DESCRIPTION}`);
lines.push('');
lines.push('## Blog Posts');
lines.push('');
for (const post of posts) {
lines.push(`- [${post.data.title}](${abs(`/blog/${post.id}/`)}): ${post.data.description}`);
}
return new Response(lines.join('\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
我实际还加了 Pages、Products、Tools 几个小节,逻辑都一样,无非是多几个循环。这里没写 export const prerender = false,所以 Astro 在构建时就把它预渲染成静态文件,跟着部署一起上线,运行时零开销。这一点记住,后面会回来咬你一口。
顺手做一个全文版
llms-full.txt 更省事,遍历所有文章,把 post.body 接起来就行:
for (const post of posts) {
out.push('---');
out.push(`# ${post.data.title}`);
out.push(`URL: ${abs(`/blog/${post.id}/`)}`);
out.push('');
out.push(post.body.trim());
out.push('');
}
文章不多的时候无所谓,几十篇拼一起也就几百 KB。要是你的站内容很多,这个文件会很大,那就要么分卷,要么干脆别做——留 llms.txt 一个索引也够用。
一个中文站一定会踩的坑
这是我真正花了时间的地方。本地 pnpm build 一切正常,部署到 Cloudflare 之后打开 /llms.txt,中文全是 çš„ 这种乱码,可英文和 Markdown 结构却好好的。
排查下来文件本身是合法 UTF-8,问题出在响应头:
content-type: text/plain
x-content-type-options: nosniff
text/plain 后面没带 charset,又加了 nosniff 禁止浏览器猜编码,浏览器只能按默认的 Latin-1 解码,中文自然就崩了。纯 ASCII 的英文不受影响,所以乱得很有迷惑性。
根子上的原因前面其实埋了:我在代码里明明写了 charset=utf-8,但因为这个路由是「预渲染成静态文件」的,Response 的 header 在构建时被丢掉了——上线后由 Cloudflare 的静态资源服务按 .txt 后缀自己补了个 text/plain,不带 charset。
知道原因就好办,加一个 public/_headers,让 Cloudflare 给这两个文件补上 charset:
/llms.txt
Content-Type: text/plain; charset=utf-8
/llms-full.txt
Content-Type: text/plain; charset=utf-8
public/ 下的文件会原样复制到部署根目录,Cloudflare 认 _headers。重新部署,乱码消失。换别的平台(Vercel、Netlify)写法不一样,但思路一致:保证 .txt 以 UTF-8 下发。
顺带一句,RSS、sitemap 这些是 XML,规范默认就是 UTF-8、不依赖 HTTP 头,所以它们没事;纯文本的
.txt没这待遇,得自己声明。
它到底有没有用
实话说,我不确定。
llmstxt.org 自己把它定位成「提案」,目前并没有哪家主流厂商公开承诺会去抓 llms.txt。也就是说你做了,不代表 ChatGPT 或者 Claude 明天就会照这份清单读你的站。官方怎么说是一回事,实际有多少模型在用是另一回事,自己打个折扣。
但我还是做了,理由也简单:
- 成本极低,复用现成的 content collection,几十行代码,构建时顺带生成,没有任何运行时负担;
- 就算现在没人读也没有副作用,放那等生态跟上就行;
- 顺便逼自己把站点结构和每篇文章的一句话描述理了一遍,这个过程本身有点价值。
属于「做了不亏,不做也不会怎样」的事。你要是也自己折腾博客、不介意花半小时,可以顺手加上;指望它立刻带来什么效果,就算了。
小结
- llms.txt 是给模型看的站点索引,一份干净的 Markdown 清单,规范见 llmstxt.org;
- Astro 里建个
GET路由、遍历 content collection 拼字符串就行,和 RSS 一个套路,构建时预渲染成静态文件; - 中文站盯紧
.txt的 charset,用_headers(或对应平台的配置)强制 UTF-8,否则容易乱码; - 务实地看,它现在更像「锦上添花」:低成本、零风险,但别指望立竿见影。