如何给你的博客加上 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 是一份索引/目录,内容是站点标题、一句话简介,以及按 H2 小节组织的「文章标题: 描述」清单;右边 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,否则容易乱码;
  • 务实地看,它现在更像「锦上添花」:低成本、零风险,但别指望立竿见影。