跳转至

用对象存储部署 AI 友好的个人知识库

本文记录本wiki知识库的定位设计和部署过程,围绕以下两点:

  • 对 AI 友好 —— 构建产物不只有给人看的 HTML,还有一份随线上一起发布的 markdown 源。给 AI 一个 URL,就可以用markdown格式读取知识库里记录的想法和知识,对于复杂的互相引用结构,ai也能顺着相对链接一层层读进去,实现渐进式披露,从而把整个 wiki 当远程 skill 用。
  • 托管在对象存储上 —— 我终于意识到别人的静态网站都不部署在自己的服务器,用服务器部署带宽慢,服务器到期还要迁移实在是不方便,心血来潮部署好后慢慢忘掉,已经搞丢了好多原来做的小网页。

本文重点AI 友好仍然是建立在人读的基础之上的锦上添花。所以本文先把"AI 友好"立成一个可检验的标准,再讲为什么、然后是设计与选型;至于具体怎么部署落地,因为强依赖腾讯云、篇幅也长,另拆成一篇实操手册。静态站点生成器选的是 Zensical1,一个rust重写的mkdocs + material主题,对象存储用的是腾讯云 COS。

什么算"对 AI 友好":五个可检验的问题

全文的一切选择都围绕"对 AI 友好",而这个词太容易说成口号,所以开篇先定义为一组可检验的问题 —— 后文每个设计、每次选型,都对着这张表交代。注意第一条"人可读":它严格说不属于"AI 友好",却是整件事不沦为平凡解的前提 —— 如果只伺候 AI,裸发一堆 .md 就完了,谈不上 wiki。所以对 AI 友好 = 在人可读的前提下,再解决后面四个问题:

问题 达标线
人可读 有给人的渲染视图:排版、导航、mermaid 架构图、表格都正常呈现,人愿意直接阅读
可获取 网络能通(特别是要方便国内网络),一个裸 GET 就能 200
AI 可读 GET 到的是 markdown 纯文本,不是 HTML,节约token消耗
可寻址 页内引用本身就是可拼接的相对 URL,顺引用自动走到对方;不限 md —— 被引用的 .py / .sh 等文件同样拼得到真身,零潜规则。还有个隐含前提:每篇都得真的被引用到,没人链的页面对 AI 等于不存在,这条靠部署闸门强制
单源 人的视图和 AI 的视图由同一份源机械生成,绝不双写

这套标准的判别力,在于几种现成方案各卡在不同的条上:

方案 卡在哪
只发 HTML 的普通站点 未能实现:AI 可读
把仓库推上 GitHub、AI 直读 raw 链接 未能实现:人可读(没有渲染视图)+ 可获取(rate limit、bot 403、国内不通或者不稳定)
llms.txt 生态3(pydantic / uv 实测) 未能实现:可寻址 —— 页内链接被改写指回渲染页,图断了(下文实测
用自己的服务器部署 未能实现:网速慢,人读和ai获取都难受

背景:个人 wiki 正在变成刚需

为什么 2026 年还要自己搭 wiki?因为"维护 wiki"这件事的成本被 AI 改写了。

Karpathy 在 2026 年 4 月发过一份只有 75 行的 gist —— llm-wiki2,讲他怎么用 LLM agent 维护个人知识库。整个体系只有三个部件:

llm-wiki 的三个部件

输入层,不可变 —— 原始材料进来就原样保存、不再修改,是 LLM 加工的原料。

产出层 —— LLM 读完 raw/,把要点整合进这里的 markdown 网络,交叉引用、索引、旧结论的更新全由它维护。

规矩层 —— 一份约定文档,告诉 LLM 材料怎么进、wiki 怎么组织、更新时要守什么规矩。

其实这里也藏了一点从低级原始数据到中级中间过程再到最终高级结果接口的的演化过程和想法完整项目,可以参考里面。

回到wiki主题上,他的想法是人只管选材料、提问题,wiki 全部由 LLM 写和维护 —— 一个研究主题他攒到了上百篇、几十万字,自己没直接写过一个字。文中解释"为什么这事现在成立了":

The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. [...] Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.

人弃掉自己的 wiki 从来不是因为懒,而是维护负担的增速快过价值的增速 —— 每多一篇笔记,要更新的交叉引用、要核对的旧结论、要重排的索引都在变多,迟早入不敷出。LLM 把这项边际成本压到近零之后,个人 wiki 第一次从"迟早荒废的工程"变成能复利的资产。这就是"现在人人需要自己的知识库"的底层原因——不是知识变多了,是维护第一次变得免费了。

他还给了一个很准的类比:

Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.

注意这个闭环停在哪:Obsidian、本地 git 仓库 —— 这是"维护端"的故事。wiki 被 LLM 写出来之后怎么被,他的答案还是同一台电脑前的那个人和那个 agent。本文的想法是接在他的文章之后:wiki整理好后,只需要一份额外的原文同步,就可以把这个wiki发布成便于AI阅读消费的带 URL 的 markdown 源,让任何的AI客户端根据链接直接读、也可以顺链接阅读,实现 skill 一样的渐进式披露。相当于有了维护端,还可以接上消费端。

原文存档:llm-wiki.md(clone 自 gist,2026-07-03)

真身在本文 assets/llm-wiki.md,随 wiki 一起发布、线上有独立 URL 可直接 GET。以下为原文:

# LLM Wiki

A pattern for building personal knowledge bases using LLMs.

This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.

## The core idea

Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.

The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.

This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.

You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.

This can apply to a lot of different contexts. A few examples:

- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.

## Architecture

There are three layers:

**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.

**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.

**The schema** — a document (e.g. CLAUDE.md for Claude Code or AGENTS.md for Codex) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.

## Operations

**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.

**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.

**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.

## Indexing and logging

Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:

**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.

**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.

## Optional: CLI tools

At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.

## Tips and tricks

- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.

## Why this works

The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.

The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.

The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.


## Note

This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.

设计:一颗语法糖,两个约定

这是这个知识库跟普通博客最不一样的地方,也是标题里那半句的由来。所谓"AI 友好"不是加了什么 AI 功能,而是线上保留 markdown 源、让 AI 顺着 URL 一层层读进去。实现它也不靠插件(llms.txt 系插件偏重4,我的场景用不上完整实现,差别见下文实测),只靠 deploy.sh 里一行 rsync 的语法糖:docs/ 源树原样镜像进构建产物,跟 HTML 一起发布

这行 rsync 为什么够用、又为什么不跟 HTML 打架,得看 zensical build 这步:它把 docs/ 里每个 .md 渲染成 HTML(use_directory_urlsfoo.md 落到 foo/index.html,非 md 文件原样搬、软链还解引用成真身),但只产 HTML、不留一个 .md。所以"线上保留 md 源"这半件事 build 帮不上,正好留给那行 rsync 补;而 foo.md 这个 key 又被 build 空着(页面占的是 foo/index.html),补进来的源和 HTML 同域不撞 —— 人访问 /foo/ 拿 HTML,AI 访问 /foo.md 拿同一份源。

对照开头立的标准,五条就此各就各位:

标准 靠什么 怎么落地
人可读 Zensical 渲染出 HTML 站点,排版、导航、mermaid、表格齐全
可获取 托管(见选型 对象存储直出,裸 GET 即 200
AI 可读 语法糖 wiki.liuhetian.work/index.md.../skills/fastapi/reference/rag.md 直接 GET 到纯 markdown,不用 clone、不用爬 HTML
单源 语法糖 人看渲染页、AI 读同名 .md,两个视图同出一份源,不存在双写
可寻址 语法糖 + MkDocs 写作约定 源文件本来就用相对路径互引(reference/rag.md),镜像保持源树结构,链接不经改写就是合法 URL

其中"可寻址"正是渐进式披露的骨架:AI 从入口 /index.md 进,按 URL 相对规则一跳跳往下拼,自己就能走到任意深处 —— Claude skill 的渐进式披露(入口只给索引 + 链接,用到哪层才展开哪层),原样搬到远程 HTTP 上。实测过:一个没有任何先验知识的 AI,只给它 /index.md,三跳(首页 → FastAPI 入口 → RAG 子页)就找到了"fastapi 里怎么用 rag"。

语法糖解决"读得到",读到的东西长什么样,由两个写作约定决定 —— 也是渐进式披露的两级:

  • 结构即 skill:整个 docs/skills/Anthropic 官方 skill 标准目录组织(index.md + reference/ + assets/),跟 ~/.claude/skills/ 一一对应。AI 顺 URL 读到的目录形态,就是它熟悉的 skill 形态 —— 整个 wiki 可以直接当远程 skill 喂给 Claude Code;哪怕一篇还没资格独立成 skill 的普通文章(比如你正在读的这篇),只要是 docs/ 下的 .md,同样有 URL、能被顺链跟随 —— 写文章 = 顺手就喂了 AI
  • 代码即引用,真身可远程取:文章里的代码用 --8<-- snippet 引用仓库里的真实文件,构建时注入 HTML(文档跟实际跑的代码永不脱节);真身软链进文章 assets/、随源一起发布 —— 这是披露的最深一级:AI 顺 URL 一路走到底,取到的是最新的 deploy.sh、证书脚本本身,"可寻址"就此覆盖到非 md 文件。

nav 随便重排,链接图纹丝不动 —— 两套信息架构是解耦的

一个容易担心的点:mkdocs.yml 里的 nav 哪天重新分组、改名、排序,会不会把 AI 的导航弄断?不会,因为 nav 根本不在 AI 的链路上。 MkDocs 的规则是:页面路径 —— HTML 和 md 源都一样 —— 只由 docs/ 里的文件位置决定;nav 只控制给人看的导航树(分组、排序、显示名),是纯"策展层"。本站就是活例子:导航里 "Skills" 这个分组在磁盘上并不存在,docs/ 里只有 skills/fastapi/... —— 人看到的是策展后的目录,AI 走的是 skills/fastapi/index.md → reference/rag.md 这张由文件位置 + 相对引用织出来的图。所以给人重排门面随便折腾,md 互引一根都不断;真正动图的操作只有挪文件 —— 链接跟着文件树走,这也正是下文"源树镜像能让链接图天然闭合"的同一个原因。

上面那段说了 nav 不在 AI 链路上。反过来看就是一条硬约束:一篇文章在 nav 里注册了、构建也出页面了,只要没人在索引页里挂一行链接,AI 就走不到它 —— 对 AI 等于不存在。 前面"三跳找到 RAG 子页"的实测能成立,靠的是那三跳每一跳都有人挂了链接。

这件事纯靠自觉会漏,而且漏了没有任何报错。实际扫一遍就现形了:首页压根没链 posts/index.mdnotes/index.mdskills/index.md 三个板块索引,只链了具体分类,AI 想进笔记得绕道 COS 部署这篇文章。写的时候谁都不觉得自己会漏。

所以做成部署闸门 —— deploy.shgit pull 之后、读凭证和构建之前跑 scripts/check-links.py,非零退出码被 set -e 拦下,部署中止:

检查 规则
父级链接 每篇 .md 必须被某个祖先目录index.md / MIRROR.md / SKILL.md 用 markdown 链接直接链到
死链 指向不存在的 .md
nav 注册 路径出现在 mkdocs.yml 里(MIRROR.md 按规范豁免)

两个设计要点都是被真实数据逼出来的,不是设计时想到的:

  • 是"祖先"而不是"最近父级"。吸收型 skill 的 index.md 是上游原文照录、不许改,它的归档文件只能由 MIRROR.md(目录里唯一自己写的文件)或分类索引挂链,隔着一层。活例:xi-wen 的 9 份归档全靠 MIRROR.md 的"本地归档"一栏挂住。按"最近父级"写的原型在这批文件上报了 14 个误报。
  • 吸收来的原文里的死链降级为警告。原文里的路径常是举例(活例:domain-modeling/CONTEXT-FORMAT.md 举例 ./src/ordering/CONTEXT.md 该放哪),本地不可能存在;而往原文里加豁免注释就是改原文,违反照录规矩。判据是同级或祖先目录有 MIRROR.md。警告仍然打印 —— 归档真漏了一份依赖也会在这里现形。

豁免写在文件顶部 <!-- link-check-ok: 理由 -->,理由跟着文件走、不躺在脚本白名单里:挪文件不会让豁免失效,读到那个文件的人也立刻知道为什么。"已迁移存根"不是正当理由 —— 本 wiki 不给挪走的文件留存根页,要保住旧 URL 就别挪文件。存根页是天生的孤儿(无人链接、不进 nav),留着只会逼校验开豁免口子,而每个口子都是下一次漏挂链接的藏身处。

覆盖边界说清楚:它只查 .md 文件之间的引用。页内锚点([写作规范](#写作规范))不在它的职责里 —— zensical 构建时会报 anchor does not exist,但那只是 Warning、退出码仍是 0,所以坏锚点目前不阻断部署。知道这个缺口在哪,比假装没有好。

scripts/check-links.py —— 真身,deploy.sh 构建前强制跑
#!/usr/bin/env python3
"""校验 docs/ 的 AI 链路完整性 —— 每篇文章都必须能被 AI 顺链接从上一级走到。

本 wiki 的核心设计是「AI 顺相对链接一层层读进去」,而 mkdocs.yml 的 nav 只是
给人的策展层、不在 AI 链路上。所以一篇文章漏挂链接 = 对 AI 不存在,nav 里有它
也没用。这个校验就是那道闸门,在 deploy.sh 构建前跑,不过就不许部署。

三项检查:

1. 父级链接 —— 每篇 .md 必须被**某个祖先目录**的索引页用 markdown 链接直接指向。
   索引页指 index.md / MIRROR.md / SKILL.md 三种:后两者是吸收型 skill 里唯一
   自己写的文件和上游主文件,它们照样承担链接责任(活例:xi-wen 的 9 份归档由
   MIRROR.md 挂链,因为 index.md 是上游原文照录、不许改)。
   「祖先」而非「最近父级」是必要的:MIRROR.md 的链接责任归分类索引,隔了一层。

2. 死链 —— 指向不存在 .md 的链接。

3. nav 注册 —— 写作规范要求新增页面必须进 mkdocs.yml 的 nav。

豁免:文件顶部(前 5 行内)写 `<!-- link-check-ok: 理由 -->` 可跳过检查 1 和 3。
理由写在文件里而不是脚本白名单里 —— 挪文件不会让豁免失效,读到那个文件的人也
立刻知道为什么。注意:本 wiki 不给旧路径留存根页(要兼容就别挪文件),所以
「已迁移存根」不是正当豁免理由。

assets/ 下的 .md 整体排除:按规范那里只放资产真身(含 clone 进来的外部存档),
不是文章,本来就不进 nav、不上链路。
"""
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DOCS = ROOT / "docs"
MKDOCS = ROOT / "mkdocs.yml"

# markdown 链接 [text](target),容忍 <> 包裹和 "title" 后缀
LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)\s>]+)>?[^)]*\)")
EXEMPT = re.compile(r"<!--\s*link-check-ok:")
INDEX_NAMES = ("index.md", "MIRROR.md", "SKILL.md")


def rel(p: Path) -> Path:
    """docs/ 下的相对路径。"""
    return p.relative_to(DOCS)


def md_links(md: Path) -> set:
    """该文件指向的、docs 内存在的 .md(相对 docs),顺带返回死链。"""
    text = md.read_text(encoding="utf-8")
    hits, dead = set(), []
    for m in LINK.finditer(text):
        target = m.group(1)
        if target.startswith(("http://", "https://", "mailto:", "#")):
            continue
        target = target.split("#")[0].strip()
        if not target.endswith(".md"):
            continue
        try:
            resolved = (md.parent / target).resolve().relative_to(DOCS.resolve())
        except ValueError:
            continue  # 指到 docs 之外(如项目根的 mkdocs.yml),不算页面链接
        if (DOCS / resolved).exists():
            hits.add(resolved)
        else:
            dead.append(target)
    return hits, dead


def is_absorbed(md: Path) -> bool:
    """是否为吸收来的上游原文 —— 同级或祖先目录有 MIRROR.md 即是。

    这类文件一律原文照录、不许改,所以不能往里加豁免注释;而原文里的路径常是
    举例(活例:domain-modeling/CONTEXT-FORMAT.md 举例 `./src/ordering/CONTEXT.md`
    该放哪),本地不可能存在。它们的死链降级为警告,不阻断部署 —— 但仍然打印,
    因为归档真漏了一份依赖也会在这里现形。MIRROR.md 是自己写的,不算原文。
    """
    if md.name == "MIRROR.md":
        return False
    d = md.parent
    while True:
        if (d / "MIRROR.md").exists():
            return True
        if d == DOCS:
            return False
        d = d.parent


def responsible_pages(md: Path) -> list:
    """该文件的责任索引页:自身目录及各级祖先目录下的 index/MIRROR/SKILL。"""
    out, d = [], md.parent
    while True:
        for name in INDEX_NAMES:
            cand = d / name
            if cand.exists() and cand != md:
                out.append(cand)
        if d == DOCS:
            break
        d = d.parent
    return out


def main() -> int:
    all_md = sorted(DOCS.rglob("*.md"))
    nav_text = MKDOCS.read_text(encoding="utf-8")
    root_index = DOCS / "index.md"

    # 预先算好每个文件的出链,供反查
    outgoing, dead_links, dead_warn = {}, {}, {}
    for md in all_md:
        if "assets" in rel(md).parts:
            continue
        hits, dead = md_links(md)
        outgoing[md] = hits
        if dead:
            (dead_warn if is_absorbed(md) else dead_links)[md] = dead

    orphans, unregistered = [], []
    for md in all_md:
        r = rel(md)
        if "assets" in r.parts or md == root_index:
            continue
        head = "".join(md.read_text(encoding="utf-8").splitlines(keepends=True)[:5])
        if EXEMPT.search(head):
            continue
        # 1. 父级链接
        if not any(r in outgoing.get(p, set()) for p in responsible_pages(md)):
            orphans.append(r)
        # 3. nav 注册(MIRROR.md 按规范不进 nav)
        if md.name != "MIRROR.md" and str(r) not in nav_text:
            unregistered.append(r)

    def report(title, items, hint):
        if not items:
            return
        print(f"\n{title}{len(items)})", file=sys.stderr)
        for it in items:
            print(f"    {it}", file=sys.stderr)
        print(f"  → {hint}", file=sys.stderr)

    report(
        "没有被任何祖先索引页链到 —— AI 顺链接走不到它",
        orphans,
        "在它所在目录(或上级)的 index.md / MIRROR.md 里加一行链接;"
        "确实不该上链路就在文件顶部写 <!-- link-check-ok: 理由 -->",
    )
    report(
        "未在 mkdocs.yml 的 nav 注册",
        unregistered,
        "补进 nav;reference/ 一层按规范展平到父级",
    )
    if dead_links:
        print(f"\n✗ 死链,指向不存在的 .md({len(dead_links)} 个文件)", file=sys.stderr)
        for md, targets in dead_links.items():
            print(f"    {rel(md)}{', '.join(targets)}", file=sys.stderr)
    if dead_warn:
        print(f"\n⚠ 吸收来的上游原文里有指不到本地的 .md 路径({len(dead_warn)} 个文件,"
              f"不阻断部署;多半是原文举例,若是归档漏了依赖则需补)")
        for md, targets in dead_warn.items():
            print(f"    {rel(md)}{', '.join(targets)}")

    failed = len(orphans) + len(unregistered) + len(dead_links)
    checked = sum(1 for m in all_md if "assets" not in rel(m).parts)
    if failed:
        print(f"\n链路校验未通过:{failed} 处问题(检查了 {checked} 篇)", file=sys.stderr)
        return 1
    print(f"✅ 链路校验通过 —— {checked} 篇文章都能从上一级索引走到")
    return 0


if __name__ == "__main__":
    sys.exit(main())

和 llms.txt 的对比

llms.txt 提案解决的是普通网站从 html 到 md 的问题,本文场景起点已经是 md、也天然有 md 之间的相对位置,一行 rsync 已经把内容层的活干完了。但 llms.txt 还有一层功能 rsync 没覆盖:它是不知情 AI 爬虫的约定入口(Perplexity、AI 搜索器等按约定去 /llms.txt 找站点结构,类比 robots.txt / sitemap.xml),这个"发现层"的价值不能白让。

所以本站也做了兼容,做法尽可能省:docs/index.md 本身按 llms.txt 规范写(H1 用站点名 / 首段 blockquote 做 summary / H2 分区带链接列表 / 链接一律指 .md),build 时一句 cp site/index.md site/llms.txt 复制成 llms.txt 端点。写作时只维护 index.md 一份 —— 人访问 / 拿渲染 HTML,AI 访问 /llms.txt/index.md 拿同一份 markdown 源。

清单粒度这一步走规范作者 Jeremy Howard 的原意 —— 他在提案原文里用词是 "curated overview / most pertinent links",并明确把 sitemap.xml"太大装不下 LLM context 窗口、含大量无关信息"当反例。本站首页只列 8 条精选顶级入口,AI 顺相对链接自己往下钻,正是他心目中的形态。这跟主流 mkdocs 站(uv 53 条、pydantic 95 条把整份 nav 摊平到 llms.txt)不同 —— 后者其实是规范作者反对的 sitemap 路线,只是插件生态默认从 nav 全量派生,路径依赖形成了主流。对本 wiki 而言,"精选入口 + AI 顺链接下钻" 也正好是上文语法糖那一节已经确立的"渐进式披露"哲学,选精选路线跟自身设计天然合拍。

原文存档:llms-txt.md(clone 自 llmstxt.org/index.md,2026-07-06)

真身在本文 assets/llms-txt.md,随 wiki 一起发布、线上有独立 URL 可直接 GET。以下为原文:

# The /llms.txt file
Jeremy Howard
2024-09-03

## Background

Large language models increasingly rely on website information, but face
a critical limitation: context windows are too small to handle most
websites in their entirety. Converting complex HTML pages with
navigation, ads, and JavaScript into LLM-friendly plain text is both
difficult and imprecise.

While websites serve both human readers and LLMs, the latter benefit
from more concise, expert-level information gathered in a single,
accessible location. This is particularly important for use cases like
development environments, where LLMs need quick access to programming
documentation and APIs.

## Proposal

<figure>
<img src="logo.png" class="lightbox floatr" width="150"
alt="llms.txt logo" />
<figcaption aria-hidden="true">llms.txt logo</figcaption>
</figure>

We propose adding a `/llms.txt` markdown file to websites to provide
LLM-friendly content. This file offers brief background information,
guidance, and links to detailed markdown files.

llms.txt markdown is human and LLM readable, but is also in a precise
format allowing fixed processing methods (i.e. classical programming
techniques such as parsers and regex).

We furthermore propose that pages on websites that have information that
might be useful for LLMs to read provide a clean markdown version of
those pages at the same URL as the original page, but with `.md`
appended. (URLs without file names should append `index.html.md`
instead.)

The [FastHTML project](https://fastht.ml) follows these two proposals
for its documentation. For instance, here is the [FastHTML docs
llms.txt](https://www.fastht.ml/docs/llms.txt). And here is an example
of a [regular HTML docs
page](https://www.fastht.ml/docs/tutorials/by_example.html), along with
exact same URL but with [a .md
extension](https://www.fastht.ml/docs/tutorials/by_example.html.md).

This proposal does not include any particular recommendation for how to
process the llms.txt file, since it will depend on the application. For
example, the FastHTML project opted to automatically expand the llms.txt
to two markdown files with the contents of the linked URLs, using an
XML-based structure suitable for use in LLMs such as Claude. The two
files are: [llms-ctx.txt](https://fastht.ml/docs/llms-ctx.txt), which
does not include the optional URLs, and
[llms-ctx-full.txt](https://fastht.ml/docs/llms-ctx-full.txt), which
does include them. They are created using the
[`llms_txt2ctx`](https://llmstxt.org/intro.html#cli) command line
application, and the FastHTML documentation includes information for
users about how to use them.

The versatility of llms.txt files means they can serve many purposes -
from helping developers find their way around software documentation, to
giving businesses a way to outline their structure, or even breaking
down complex legislation for stakeholders. They’re just as useful for
personal websites where they can help answer questions about someone’s
CV, for e-commerce sites to explain products and policies, or for
schools and universities to provide quick access to their course
information and resources.

Note that all [nbdev](https://nbdev.fast.ai/) projects now create .md
versions of all pages by default. All Answer.AI and fast.ai software
projects using nbdev have had their docs regenerated with this feature.
For an example, see the [markdown
version](https://fastcore.fast.ai/docments.html.md) of [fastcore’s
docments module](https://fastcore.fast.ai/docments.html).

## Format

At the moment the most widely and easily understood format for language
models is Markdown. Simply showing where key Markdown files can be found
is a great first step. Providing some basic structure helps a language
model to find where the information it needs can come from.

The `llms.txt` file is unusual in that it uses Markdown to structure the
information rather than a classic structured format such as XML. The
reason for this is that we expect many of these files to be read by
language models and agents. Having said that, the information in
llms.txt follows a specific format and can be read using standard
programmatic-based tools.

The llms.txt file spec is for files located in the root path `/llms.txt`
of a website (or, optionally, in a subpath). A file following the spec
contains the following sections as markdown, in the specific order:

- An optional byte-order mark (BOM)
- An H1 with the name of the project or site. This is the only required
  section
- A blockquote with a short summary of the project, containing key
  information necessary for understanding the rest of the file
- Zero or more markdown sections (e.g. paragraphs, lists, etc) of any
  type except headings, containing more detailed information about the
  project and how to interpret the provided files
- Zero or more markdown sections delimited by H2 headers, containing
  “file lists” of URLs where further detail is available
  - Each “file list” is a markdown list, containing a required markdown
    hyperlink `[name](url)`, then optionally a `:` and notes about the
    file.

Here is a mock example:

``` markdown
# 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)
```

Note that the “Optional” section has a special meaning—if it’s included,
the URLs provided there can be skipped if a shorter context is needed.
Use it for secondary information which can often be skipped.

## Existing standards

llms.txt is designed to coexist with current web standards. While
sitemaps list all pages for search engines, `llms.txt` offers a curated
overview for LLMs. It can complement robots.txt by providing context for
allowed content. The file can also reference structured data markup used
on the site, helping LLMs understand how to interpret this information
in context.

The approach of standardising on a path for the file follows the
approach of `/robots.txt` and `/sitemap.xml`. robots.txt and `llms.txt`
have different purposes—robots.txt is generally used to let automated
tools know what access to a site is considered acceptable, such as for
search indexing bots. On the other hand, `llms.txt` information will
often be used on demand when a user explicitly requests information
about a topic, such as when including a coding library’s documentation
in a project, or when asking a chat bot with search functionality for
information. Our expectation is that `llms.txt` will mainly be useful
for *inference*, i.e. at the time a user is seeking assistance, as
opposed to for *training*. However, perhaps if `llms.txt` usage becomes
widespread, future training runs could take advantage of the information
in `llms.txt` files too.

sitemap.xml is a list of all the indexable human-readable information
available on a site. This isn’t a substitute for `llms.txt` since it:

- Often won’t have the LLM-readable versions of pages listed
- Doesn’t include URLs to external sites, even though they might be
  helpful to understand the information
- Will generally cover documents that in aggregate will be too large to
  fit in an LLM context window, and will include a lot of information
  that isn’t necessary to understand the site.

## Example

Here’s an example of `llms.txt`, in this case a cut down version of the
file used for the FastHTML project (see also the [full
version](https://www.fastht.ml/docs/llms.txt)):

``` markdown
# FastHTML

> FastHTML is a python library which brings together Starlette, Uvicorn, HTMX, and fastcore's `FT` "FastTags" into a library for creating server-rendered hypermedia applications.

Important notes:

- Although parts of its API are inspired by FastAPI, it is *not* compatible with FastAPI syntax and is not targeted at creating API services
- FastHTML is compatible with JS-native web components and any vanilla JS library, but not with React, Vue, or Svelte.

## Docs

- [FastHTML quick start](https://fastht.ml/docs/tutorials/quickstart_for_web_devs.html.md): A brief overview of many FastHTML features
- [HTMX reference](https://github.com/bigskysoftware/htmx/blob/master/www/content/reference.md): Brief description of all HTMX attributes, CSS classes, headers, events, extensions, js lib methods, and config options

## Examples

- [Todo list application](https://github.com/AnswerDotAI/fasthtml/blob/main/examples/adv_app.py): Detailed walk-thru of a complete CRUD app in FastHTML showing idiomatic use of FastHTML and HTMX patterns.

## Optional

- [Starlette full documentation](https://gist.githubusercontent.com/jph00/809e4a4808d4510be0e3dc9565e9cbd3/raw/9b717589ca44cedc8aaf00b2b8cacef922964c0f/starlette-sml.md): A subset of the Starlette documentation useful for FastHTML development. 
```

To create effective `llms.txt` files, consider these guidelines:

- Use concise, clear language.
- When linking to resources, include brief, informative descriptions.
- Avoid ambiguous terms or unexplained jargon.
- Run a tool that expands your `llms.txt` file into an LLM context file
  and test a number of language models to see if they can answer
  questions about your content.

## Directories

Here are a few directories that list the `llms.txt` files available on
the web:

- [llmstxt.site](https://llmstxt.site/)
- [directory.llmstxt.cloud](https://directory.llmstxt.cloud/)

## Integrations

Various tools and plugins are available to help integrate the llms.txt
specification into your workflow:

- [`llms_txt2ctx`](https://llmstxt.org/intro.html#cli) - CLI and Python
  module for parsing llms.txt files and generating LLM context
- [JavaScript Implementation](./llmstxt-js.html) - Sample JavaScript
  implementation
- [`vitepress-plugin-llms`](https://github.com/okineadev/vitepress-plugin-llms) -
  VitePress plugin that automatically generates LLM-friendly
  documentation for the website following the llms.txt specification
- [`docusaurus-plugin-llms`](https://github.com/rachfop/docusaurus-plugin-llms) -
  Docusaurus plugin for generating LLM-friendly documentation following
  the llmtxt.org standard
- [Drupal LLM Support](https://www.drupal.org/project/llm_support) - A
  Drupal Recipe providing full support for the llms.txt proposal on any
  Drupal 10.3+ site
- [`llms-txt-php`](https://github.com/raphaelstolt/llms-txt-php) - A
  library for writing and reading llms.txt Markdown files
- [`VS Code PagePilot Extension`](https://dmux.github.io/pagepilot) -
  PagePilot is a VS Code Chat participant that automatically loads
  external context (documentation, APIs, README files) to provide
  enhanced responses.

## Next steps

The `llms.txt` specification is open for community input. A [GitHub
repository](https://github.com/AnswerDotAI/llms-txt) hosts [this
informal
overview](https://github.com/AnswerDotAI/llms-txt/blob/main/nbs/index.qmd),
allowing for version control and public discussion. A [community discord
channel](https://discord.gg/aJPygMvPEN) is available for sharing
implementation experiences and discussing best practices.

所以本文建立标准,然后证明一个小语法糖能实现所要的需求,顺便再用一句 cp 兼容了 llms.txt 协议的"发现层"入口,最后再加上写作规范优化和工程层面部署的经验,构成了本篇文章的全部创新点。

部署在远程,比 clone 到本地强在哪

  • 最新版本:每次读到的是最新版本,对skill会不断优化很重要
  • 图片渲染效果人可以看:同一套内容,AI 读 .md 源,人看渲染页 —— mermaid 架构图、表格、截图全渲染出来。对设计类 skill(系统架构、页面布局、视觉规范)帮助很大,而且不止图:连可交互的 HTML 单页都能嵌进文章直接玩 —— 纯客户端的自包含 SPA(比如一个 React demo)丢进 assets/ 随 wiki 一起发布,iframe 同域嵌入,人玩交互效果、AI 读同一 URL 下未压缩的源码,规矩与活例见写作规范·嵌入交互单页
flowchart LR
    G[git 仓库<br>markdown 源 + skill 目录] -->|zensical build:只出 HTML| H[site/<br>HTML + .md 源]
    G -->|deploy.sh rsync:并入 .md 源| H
    H -->|deploy.sh 同步| O[对象存储<br>线上知识库]
    O -.->|HTTPS 访问| U[人读渲染页]
    O -.->|GET .md、顺链接导航| AI[AI 远程读源<br>渐进式披露、当 skill 用]

一句话:人访问渲染页,AI 顺着 URL 读同一套 markdown 源、一层层自己走进去 —— 一个部署在对象存储上、远程可导航的 skill 知识库;成体系的 skill 目录和普通的单篇博客,它承载得一样好。 更进一步,源本身在 git 里版本化,AI 读完还能改一改、一行 ./deploy.sh 重新发布。

选型:为什么是对象存储,而不是服务器

因为对象存储没有带宽问题,也不担心服务器过期(维护成本低)。

整体架构是两条完全分开的链路 —— 这是理解整个方案的关键:

flowchart TB
    subgraph 流量["用户访问链路(线上流量)"]
        U[浏览器 / AI]
        D[wiki.liuhetian.work]
        C[对象存储 bucket<br>带绑定证书]
        U -- HTTPS 443 --> D
        D -. DNS CNAME .-> C
    end

    subgraph 续期["证书续期链路(约每 30 天自动)"]
        A[服务器 acme.sh<br>cron 触发]
        L[Let's Encrypt]
        P[DNSPod API]
        T[对象存储 API]
        A -- 1.申请 --> L
        L -- 2.挑战 --> A
        A -- 3.加 TXT --> P
        L -- 4.查 TXT --> P
        L -- 5.签证书 --> A
        A -- 6.推证书 --> T
        T -- 7.更新绑定 --> C
    end

服务器只在续期链路里出现,用户访问完全不经过它。

落地:跑起来

选型和设计都定了,剩下是把它真正跑起来——建 bucket、绑自定义域名、HTTPS 自动续期,以及完整代码和一次性安装命令。这部分实操较长、且强依赖腾讯云 COS(强制下载、CNAME、证书 hook 这些坑都在里面),单独拆成了一篇手册:

腾讯云 COS + acme.sh:部署实操手册

如果你只想看"为什么这么设计",读到这里就够了;要照着搭一套,顺链接进手册。搭好之后日常怎么写 —— 尤其多台机器协同(主机写作即部署机,副机 push 后搭车或一句 ssh 触发)—— 在手册的日常写作流一节。这个站从搭起来到一路折腾的流水账,在建站手记里按时间记着。

下一步:风格与管线

发布机制到这里算闭环了,但还有两件事没做,先记在这:

  • 个人 IP。站点、配图、交互 demo 的风格现在各写各的,观感不统一。想把个人审美落成一份 AI 可执行的 style spec——色板、字体、构图规则、负面清单、示例对,进 git 当 skill——以后产出任何图和 HTML 都强制引用它防漂移。原型是项目管理那篇里 changelog 卡片的纪律:第一张定死,之后只换皮。配图工具也倾向真身是文本的方案(如 Excalidraw,.excalidraw JSON 进 git、导出 SVG 发布),跟本文"真身进仓库"一脉。
  • 写作管线。本文开头借了 Karpathy 的 raw → wiki 分层,但本 wiki 的 raw/ 层还空着。想走"讲述式写作":对着 AI 把事情讲一遍,讲述的 transcript 就是 raw/;中间攒成原子笔记;攒够之后的长文总结由 AI 跑合成。
  • 正文标注视觉。想过给重点段落加"荧光笔 / 圈选 / 手绘箭头"这类批注效果,让长文里的关键处更抓眼。方案见过两条路:纯 CSS 的 neat-annotations(观感最像手写批注,但字体只覆盖拉丁字符,中文标签要另配手写字),或 pymdownx.critic + Material 自带的正文 annotations(语义清晰、深浅色都稳,观感规矩)。没定要不要上 —— 顾虑是它容易从"标重点"滑到"分散注意力",尤其中文长文更怕花哨;先记一笔,看以后写作里真的需要再选。

做了再回来补。


  1. Zensical —— Material for MkDocs 团队的下一代静态站点生成器,Rust 内核 + 兼容 mkdocs.yml。 

  2. karpathy/llm-wiki.md —— "A pattern for building personal knowledge bases using LLMs.",2026-04。本文 assets/ 存档了 2026-07-03 clone 的版本。 

  3. The /llms.txt file(Jeremy Howard,2024-09-03)· Answer.AI 发布博文。本文 assets/ 存档了 2026-07-06 clone 的版本(取自 llmstxt.org/index.md)。 

  4. 插件如 mkdocs-llms-source(把原始 .md 源复制进构建产物并生成 llms.txt)、pawamoy/mkdocs-llmstxt。FastAPI 的状态见 typer discussion #1114(2025-01 发起 "make docs LLM friendly",至本文更新时无维护者回应)。