目次14

AI 検索や LLM にサイトを読ませる入口として、llms.txt という提案がある。robots.txt や sitemap.xml と似た位置の、サイトルートに置くプレーンテキストの目次だ。仕様はまだ固まりきっておらず、主要な LLM ベンダーが公式に消費を保証しているわけでもない。それでも生成コストが小さいので、Aulvem では出すだけ出しておく方針にした。

この記事は、その llms.txt / llms-full.txt を Astro で動的生成し、英日 2 言語に分けて出すまでの実装記録です。効果があったかどうかの話はしない。現時点で計測できる流入は無く、断定できる材料が無いからだ。あくまで「何を、どう生成しているか」だけを書く。Zod schema で運用ルールを強制するhreflang をリシプロカルに出す と同じ、「frontmatter と Content Collections を単一情報源にする」系列の続きにあたる。

llms.txt とは何か

llms.txt は、サイトルートに置く LLM 向けの Markdown 形式の目次だ。sitemap.xml が機械可読な URL 一覧なのに対し、llms.txt は「このサイトは何で、どこを読めばいいか」を自然言語の要約付きで並べる点が違う。

提案元(llmstxt.org)のスペックでは、ファイル先頭に H1 のサイト名と引用ブロックの概要を置き、その下に H2 でセクション分けしたリンク一覧を並べる、というゆるい構造が決まっている。各リンクには - [タイトル](URL): 一文の説明 の形で注釈を添える。

スペックはもう 1 つ /llms-full.txt という慣習を示している。こちらは目次ではなく、各ページの本文を展開した全文寄りのインデックスだ。LLM がリンクを辿らずに 1 ファイルで内容を把握できるようにするためのもの。Aulvem では両方を出している。

llms.txtllms-full.txt
役割軽い目次(リンク + 要約)全文寄りインデックス(抜粋 + 見出し + タグ)
載せる記事代表記事を 7 本に絞る全記事
言語言語ごとに分ける(EN / JA)1 本に英日を混ぜ、各エントリに言語タグ
サイズ小さい大きい

Astro で生成する基本形

Astro の file-based API ルートを使えば、src/pages/ 配下に .txt.ts を置くだけで動的にテキストを返せる。GET ハンドラから Response を返し、Content-Typetext/plain にするのが基本形だ。

// src/pages/llms.txt.ts
import type { APIContext } from "astro";
import { renderLlmsTxt } from "../lib/llmsTxt";

export async function GET(_context: APIContext) {
  const body = await renderLlmsTxt({ docLang: "en" });
  return new Response(body, {
    status: 200,
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "public, max-age=3600",
    },
  });
}

.txt.ts という拡張子の付け方がポイントで、ビルド時に /llms.txt という URL に静的出力される。中身の生成ロジックは別ファイル(src/lib/llmsTxt.ts)に切り出して、ルート側は薄い入口だけにしている。こうすると言語別エンドポイントを足すときにロジックを再利用できる。

スタックはこれだけで足りる。

パッケージ用途
astro (API routes + astro:content)ルート定義と Content Collections の取得
typescriptレンダラの型付け

Content Collections を単一情報源にする

llms.txt の中身は、記事一覧を手で書かない。Content Collections(astro:contentgetCollection)から記事とプロダクトを取得して、その場で組み立てる。手書きのリストにすると記事を足すたびに更新を忘れて、本文と llms.txt がずれる。

// src/lib/llmsTxt.ts(抜粋)
export async function renderLlmsTxt(opts: LlmsTxtOptions): Promise<string> {
  const services = await getCollection("services");
  const blog = await getCollection("blog", ({ data }) => !data.draft);

  // 新しい順に並べる。プロダクトは status(active→preparing→archived)優先
  blog.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
  // ...セクションを組み立てて join("\n") で返す
}

getCollection("blog", ({ data }) => !data.draft) のフィルタで draft: true を落としているのが要点だ。これを忘れると、書きかけの下書きが llms.txt に載って、まだ公開していない URL を LLM に案内してしまう。sitemap や RSS と同じ除外条件をここでも揃えておく。

記事の説明文には、frontmatter の summary(無ければ description)をそのまま使う。summary は「AI 引用される短い答え」として全記事に必須で書いているので、llms.txt の注釈と本文側の TLDR が同じ情報源から出る。ここでも frontmatter が単一情報源になっている。

多言語をどう分けるか

ここが多言語サイトの肝で、Aulvem は filterLangdocLang の 2 軸でレンダラを動かしている。

  • filterLang: どの言語の記事・プロダクトを載せるか(ja なら日本語エントリだけ)
  • docLang: 見出し・概要・利用条件など、ドキュメント自体の文言をどの言語で書くか

この 2 軸を分けたことで、同じレンダラから 3 本のエンドポイントを出せる。

// src/pages/llms.txt.ts        → 英語見出し・全言語の記事
renderLlmsTxt({ docLang: "en" });

// src/pages/ja/llms.txt.ts     → 日本語見出し・日本語の記事だけ
renderLlmsTxt({ filterLang: "ja", docLang: "ja" });

英語ルートの /llms.txtfilterLang を指定していない。これは英語版を「サイト全体の入口」と位置づけて、両言語の記事を拾える状態にしているためだ。日本語版 /ja/llms.txtfilterLang: "ja" で日本語面に閉じる。

flowchart LR
  CC[Content Collections<br/>blog + services]
  R[renderLlmsTxt<br/>filterLang / docLang]
  EN["/llms.txt<br/>docLang=en"]
  JA["/ja/llms.txt<br/>filterLang=ja, docLang=ja"]

  CC -->|getCollection| R
  R --> EN
  R --> JA

代表記事は docLang の言語で絞る

1 つ設計判断がある。英語版 /llms.txt は記事自体は両言語を拾えるが、「代表記事(Featured posts)」のセクションだけは docLang の言語に絞っている。

// 代表記事は docLang の言語のものだけを出す
const featuredSource = filteredBlog.filter(
  (p) => entryLangLocal(p.id) === opts.docLang,
);

翻訳ペアの両方を代表枠に並べると、同じ内容が 2 言語で 2 枠を占めて、限られた枠の中のユニークなシグナルが半分になる。だから代表枠はドキュメント言語のものに揃えて、英日をまたいだ全集合は llms-full.txt 側に任せる。枠が有限なリスト(代表記事 7 本)では言語を絞り、容量の制約がゆるい全文ダンプでは両言語を載せる、という住み分けにしている。

llms-full.txt — 全文インデックスと言語の相互参照

llms-full.txt は別ルート(src/pages/llms-full.txt.ts)として、全記事の本文抜粋・見出し・タグを 1 ファイルに展開する。MDX 本文は stripMdx で記法を剥がし、clip で 500 字に切ってから載せる。

各エントリには言語タグ((ja) / (en))を付け、さらに対になる言語版があれば Lang-Alt でその URL を相互参照させている。

// 対になる言語版の URL を Lang-Alt として添える
const alt = altByBase.get(slug);
if (alt) {
  const otherLang = lang === "ja" ? "en" : "ja";
  const otherPath = alt[otherLang];
  if (otherPath) {
    lines.push(`Lang-Alt (${otherLang}): ${SITE}${otherPath}`);
  }
}

これは hreflang と同じ発想だ。HTML 側で両言語にある記事だけ alternate を相互に出す実装を hreflang の記事 で書いたが、llms-full.txt でも「この記事には対になる言語版がここにある」を LLM 向けに明示しておく。同じ記事の英日が別物として扱われて重複カウントされるのを避けたい意図がある。

robots.txt との役割分担

llms.txt は robots.txt の代わりではない。クロールの可否を伝えるのは robots.txt で、llms.txt が伝えるのはサイトの内容と読みどころだ。学習や引用を許す/許さないの最終的な許諾は robots.txt 側が握っている。

Aulvem は llms.txt の中に利用条件(Usage & citation)セクションを置いて、引用は歓迎・引用時は元 URL へリンクしてほしい、学習も現時点では許可、という方針を自然言語で書いている。ただしその末尾に「最終的なボット方針は robots.txt を参照」と添えて、機械可読な権威は robots.txt にあると明示する。llms.txt はあくまで案内文で、規範を上書きするものではない、という立て付けにしてほしいところだ。

ここを曖昧にすると、llms.txt に「学習禁止」と書いたのに robots.txt は全許可、のような食い違いが起きる。2 つのファイルの方針は手で揃える必要がある。

落とし穴と運用ルール

実装で踏みかけた点を整理しておく。

  • draft 除外を 3 本すべてで揃える: getCollection のフィルタを共通レンダラに置いて、llms.txt・ja/llms.txt・llms-full.txt が同じ除外条件を通るようにする。1 本だけ素通しにすると下書きが漏れる
  • filterLangdocLang を混同しない: 「載せる記事の言語」と「文言の言語」は別軸。英語版で日本語記事も拾うために filterLang を未指定にしている設計を、後から読んでバグと勘違いして filter を足さないこと
  • 代表記事の言語絞り込み: Featured だけは docLang で絞る。ここを外すと翻訳ペアで枠が埋まる
  • Cache-Control を付ける: 3 本とも max-age=3600 にしている。生成は軽いが、毎リクエスト再生成する必要は無い
  • 利用条件と robots.txt の整合: 方針を変えるときは両方を直す。llms.txt 単体で完結させない

まとめ

llms.txt は、Content Collections を単一情報源にして Astro の API ルートから動的生成すれば、記事を足すたびの手動メンテが要らなくなる。多言語は filterLang(載せる記事)と docLang(文言)の 2 軸に分けると、1 つのレンダラから英語版・日本語版・全文版の 3 本を出せる。

効果があるかどうかは別の話で、ここでは触れない。llms.txt が AI 検索の流入にどう効くかは、まだ慣習も計測も固まっていない段階だと思う。Aulvem としては「コストが小さいので出しておく、権威は robots.txt に置く」という構えで運用している。

frontmatter を単一情報源にする設計は Aulvem ブログの作り から続く系列なので、合わせて読むと位置づけが分かりやすいかもしれない。

よくある質問

llms.txt と llms-full.txt は何が違う?

llms.txt は「このサイトは何で、どこを読めばいいか」を要約付きリンクで並べた軽い目次。llms-full.txt は各記事の抜粋・見出し・タグまで含めた全文寄りのインデックスです。Aulvem では前者を代表記事 7 本に絞ったキュレーション、後者を全記事の本文抜粋ダンプとして役割を分けています。

llms.txt を出せば AI に読まれて流入が増える?

現時点でそれは断定できません。llms.txt は提案段階の慣習で、主要な LLM ベンダーが公式に消費を保証しているわけではない。robots.txt と sitemap.xml が依然として権威的なボット向けシグナルです。Aulvem は生成コストが小さいので「出しておく」程度のスタンスで置いていて、流入効果を主張する材料は持っていません。

多言語サイトで llms.txt は言語ごとに分けるべき?

Aulvem は英語ルート(/llms.txt)とは別に日本語版(/ja/llms.txt)を出しています。見出しや利用条件の文言を読み手の言語に合わせたいのと、代表記事をその言語のものに絞りたいため。全文を 1 本に混ぜる llms-full.txt とは別系統として両方を持たせています。

robots.txt があるのに llms.txt も要る?

役割が違います。robots.txt はクロールの可否を伝えるルールで、llms.txt はサイトの内容と読みどころを伝える目次。学習・引用の最終的な許諾は robots.txt が authoritative で、llms.txt はあくまで補助的な案内です。Aulvem は llms.txt の利用条件セクションにも「最終方針は robots.txt を参照」と明記しています。