Chendi WuMediaProjectsBlog
Back to blog

给博客加一份 llms.txt,顺便把 GEO 的坑补上

llms.txt 早就有了,但 42 篇文章的 canonical 全指向首页。补了 canonical、BlogPosting JSON-LD 和 updated 字段,顺手整理 llms.txt 的边界和验证方式。

Published2026-10-11
NotesAISEO

最近看了不少讲 GEO 的东西,llms.txt 是绕不开的一个词。我第一反应是"这个我博客不是有吗",第二反应是去翻构建产物,然后发现了一个更丢人的问题。先把 llms.txt 说清楚,再说那个问题。

一、llms.txt 是什么

一句话:面向 AI 的站点目录。Markdown 格式,开头一段简介,下面按分类列链接,每条链接后面跟一句话说明这页解决什么问题。放在站点根目录 /llms.txt。

我自己的记法是:先给目录,需要什么,再读什么。

它要解决的问题很实际。一份产品的说明往往散在首页、帮助中心、更新日志三个地方。人可以慢慢翻,AI 工具得先检索再提取再筛选,中间任何一步拿错东西,结果就偏了。典型场景是让 AI 接入某个工具,它找到一篇旧教程,代码写出来了但接口是老的。如果有个目录明确告诉它快速入门在哪、当前接口在哪、常见错误在哪、版本变更在哪,至少有路可循。

和它配套的还有 llms-full.txt,区别是 llms.txt 只负责定位,llms-full.txt 把正文全部合并进去。文档量小可以让 AI 直接读全文,文档多的话还是按问题挑页面,不然无关内容把上下文占满了。

我这个站的 llms.txt 长这样,由 scripts/gen-llms-txt.mjs 在每次 build 前生成:

# Chendi Wu

> Personal blog of Chendi Wu (wuchendi) — a full stack engineer based in Shenzhen, China. ...

Each post is also available as plain Markdown at `https://wcd.pages.dev/api/post/<slug>.json` (the `content` field).

## Posts

- [next-on-pages 突然构建失败:Vercel CLI 默认启用了 Next.js Build Adapter](https://wcd.pages.dev/posts/next-on-pages-vercel-adapter/) (2026-09-23): 代码没动,Cloudflare Pages 上的 next-on-pages 构建却突然报 Edge Runtime 错误。...
- ...

## Projects
## Pages
## Optional
- [RSS feed](https://wcd.pages.dev/feed.xml)
- [Posts API](https://wcd.pages.dev/api/posts): JSON array of post summaries

那句"每篇文章都能在 /api/post/<slug>.json 拿到纯 Markdown 正文"是规范之外多走的一步。当初做这个 API 是给另一个项目用的,没想到正好是 AI 最想要的格式。

二、边界先说清楚

这部分我觉得比"怎么写"更重要,因为很多讲 GEO 的内容都在卖焦虑。

文件放上去,不代表所有 AI 都会读。效果的前提是对方用的工具能访问到这些资料,而且确实读了。Google 的说法是不需要额外创建什么 AI 文本文件就能进 AI Overviews 和 AI Mode,而且就算满足了要求也不保证收录或展示。其他平台各自什么态度,得单独验证。

所以它不是流量开关。对做产品的人来说,更值得验证的是它能不能帮 AI 找对文档、少用错信息,而不是能不能带来访问量。

验证方式也不复杂。先选一个真实任务,比如让 AI 根据文档完成一次接口调用,把鉴权、参数、返回、错误说明整理出来。同一个问题分两种方式跑:原样问一次,明确给它导航再问一次。看它有没有找到正确版本、引用的链接能不能打开、关键参数对不对。一次结果说明不了问题,多换几个常见任务重复,代码和步骤还是要实际跑一遍。文档本身有缺漏的话,换个文件格式救不回来。

还有两个细节值得记:

  • 2026 年 8 月提案更新后,明确了 /docs/llms.txt 这类子路径的覆盖范围,也补充了通过页面链接或 HTTP Link 响应头帮助发现文件的方式。
  • 如果 AI 工具不能联网,只上传一份链接目录没用,它拿不到链接背后的内容,得把正文一起给它。

最后一条是维护。文档变了目录和正文要跟着更新,指向旧资料的入口会让错误被反复使用。这个和缓存失效是一个道理,加的时候爽,忘了更新就是负资产。

三、回头看我的博客

llms.txt 过关了,那就看看别的。一行命令:

curl -s https://wcd.pages.dev/posts/webpack-notes-review/ \
  | grep -o '<link rel="canonical"[^>]*>\|<meta property="og:url"[^>]*>\|"@type":"[A-Za-z]*"' \
  | sort | uniq -c

线上跑出来是这样:

1 <link rel="canonical" href="https://wcd.pages.dev/"/>
1 <meta property="og:url" content="https://wcd.pages.dev/"/>
1 "@type":"ImageObject"
1 "@type":"Person"
1 "@type":"ProfilePage"
1 "@type":"WebSite"

这是一篇文章页。canonical 指向首页,og:url 指向首页,JSON-LD 里只有站点级的 Person / WebSite / ProfilePage,没有任何描述这篇文章本身的东西。换一篇 slug 再跑,结果一模一样。42 篇文章,每一篇都在对外声明"我的正式地址是首页"。

3.1 为什么会这样

根布局的 metadata 里写死了 alternates.canonical 和 openGraph.url,文章页的 generateMetadata 只覆盖了 title 和 description:

// src/app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  openGraph: { type: 'website', url: SITE_URL, /* ... */ },
  alternates: { canonical: SITE_URL },
}

// src/app/(site)/posts/[slug]/page.tsx(改之前)
export async function generateMetadata(props) {
  const post = await getPost(slug)
  return { title: post.frontMatter.title, description: post.frontMatter.description }
}

Next.js App Router 合并 metadata 的规则是:子路由没写的字段原样继承父级,alternates 和 openGraph 这种对象字段是整体替换,不是深合并。文章页没写 alternates,就继承了根布局那个指向首页的 canonical。

这个 bug 从迁到 App Router 之后就一直在。搜索引擎看到这个会怎么处理不好说,但 AI 引擎的 RAG 流程通常拿 canonical 当去重键,42 个页面同一个 canonical,最坏情况是只保留一份。llms.txt 写得再好也架不住这个。

教训

alternates、openGraph.url 这几个字段不要放在根布局里一把梭。根布局只设 metadataBase 和站点级默认值,每个有独立 URL 的页面都在自己的 generateMetadata 里显式写 canonical。

3.2 另外两个缺口

文章页没有 Article 类型的 JSON-LD。 上面那段输出已经说明了,根布局有 Person、WebSite、ProfilePage,文章本身没有 BlogPosting。datePublished、dateModified、author 这些字段都没对外声明。AI 引擎判断一段内容是谁写的、什么时候写的、可不可信,主要看这个。

没有更新时间的概念。 frontmatter 只有 date。我前几天把 2021 年那篇 webpack 笔记按 5.x 重写了一遍,但 sitemap 里它还是:

<loc>https://wcd.pages.dev/posts/webpack-notes-review/</loc>
<lastmod>2021-11-24T00:00:00.000Z</lastmod>

GEO 对时效性很敏感,同样的内容它会偏向引用更新时间近的来源。一篇写着 2021 的 webpack 文章,AI 大概率直接跳过,哪怕内容已经是 2026 年的。

四、修了什么

三件事,加起来几十行。

4.1 canonical 和 OG 按文章输出

// src/app/(site)/posts/[slug]/page.tsx
export async function generateMetadata(props: PageProps): Promise<Metadata> {
  const post = await getPost(slug)
  if (!post) return {}
  const { title, description, date, updated, tags, author } = post.frontMatter
  const url = `${SITE_URL}/posts/${slug}/`
  return {
    title,
    description,
    alternates: { canonical: url },
    openGraph: {
      type: 'article',
      url,
      title,
      description,
      publishedTime: isoDate(date),
      modifiedTime: isoDate(updated ?? date),
      authors: author ? [author] : undefined,
      tags,
    },
    twitter: { card: 'summary_large_image', title, description },
  }
}

4.2 BlogPosting JSON-LD

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  '@id': `${url}#article`,
  mainEntityOfPage: url,
  url,
  headline: title,
  description,
  datePublished: isoDate(date),
  dateModified: isoDate(updated ?? date),
  keywords: tags,
  author: { '@type': 'Person', '@id': `${SITE_URL}/#person`, name: author },
  publisher: { '@id': `${SITE_URL}/#person` },
  isPartOf: { '@id': `${SITE_URL}/#website` },
}

author 和 publisher 通过 @id 挂到根布局里已有的 Person 节点上,不重复写一遍姓名、头像、社交链接。

4.3 updated 字段

frontmatter 加了可选的 updated,和 date 一样的格式。接到三个地方:sitemap 的 lastModified、JSON-LD 和 OG 的 modifiedTime、页面日期旁边多一行 Updated。webpack 那篇先标上了 updated: 2026/10/10。

改完再跑一遍第三节那条 curl,本地构建产物里是:

1 <link rel="canonical" href="https://wcd.pages.dev/posts/webpack-notes-review/"/>
1 <meta property="og:url" content="https://wcd.pages.dev/posts/webpack-notes-review/"/>
1 <meta property="article:published_time" content="2021-11-24"/>
1 <meta property="article:modified_time" content="2026-10-10"/>
1 "@type":"BlogPosting"
1 "@type":"Person"
...

五、没做的,以及为什么先不急

事项状态判断
<html lang="en"> 但正文大多是中文没做影响的是中文 AI 引擎的语言判断,但这些引擎本身会检测正文语言,lang 属性是辅助信号。要做就得给每篇加 lang 字段再透传到 <html>,改动面比前三项大,收益不确定,排后面。
RSS 只有摘要没正文没做读 feed 的 AI 聚合器确实偏好全文,但我的正文已经有 /api/post/<slug>.json 这个入口,llms.txt 里也指过去了。重复一份进 feed 不是不行,先看前三项的效果。
llms-full.txt没做42 篇文章拼起来大概几十万字,超过多数工具一次能读的上下文,和"按问题挑页面"的原则冲突。索引加单篇 Markdown API 的组合对我这个体量更合适。

排序逻辑很简单:先修"对外说错话"的(canonical),再补"该说没说"的(Article、更新时间),最后才是"说得更好"的那些。

六、怎么验证

规范层面的验证已经做了:构建产物里 grep 过,线上部署后再跑一次第三节那条命令就行。

效果层面只能靠问。我准备了几个我写过、而且网上同类内容不多的具体问题:

  1. next-on-pages 在 Vercel CLI 启用 Build Adapter 之后构建报 Edge Runtime 错误怎么办
  2. Claude Code 的登录凭证怎么从 WSL 迁到 macOS,Keychain 里有多条记录怎么处理
  3. Telegram Mini Apps 服务端怎么校验 initData

部署后两周,在 Perplexity、ChatGPT 搜索、豆包里各问一遍,记三件事:有没有引用到我,引用的链接能不能打开,显示的日期是 2021 还是 2026。没被引用也正常,一个个人博客的权重就那样。但至少现在它对外说的话是对的了。

结果待补

测完会把结果更新到这里,包括没被引用的情况。

七、不用 Next.js 的话

上面的问题和框架无关,Next.js 只是让它更容易被忽略。不管用什么,文章页至少检查这三处:

  1. canonical 是不是指向这篇文章自己。多语言、分页、带参数的 URL 尤其容易错。
  2. Article 类型的结构化数据 有没有,headline、datePublished、dateModified、author 四个字段齐不齐。
  3. 内容时效字段 有没有真的在更新。sitemap 的 lastmod、JSON-LD 的 dateModified、页面上显示的日期,三个地方要一致。

llms.txt 值得作为文档建设里的一个小改进,先把一个具体任务跑通,再决定要不要扩大投入。目标不是让 AI 读到你,是让 AI 用过之后,用户更容易把事情做成。

Copyright (c) 2023-PRESENT All Rights Reserved. Powered by wudi