p4ni.

チュートリアル

Astro サイトに llms.txt を置く(手書きせず生成する)

· 読了まで約11分

目次

llms.txt は、言語モデルにサイトの地図を渡すための提案段階の慣習です。決まった URL に Markdown ファイルを1枚置き、何がどこにあるかを列挙しておく。そのドメインにたどり着いたモデルは、ブラウザ向けに組まれた HTML を這い回る代わりに、1回のフェッチで全体像をつかめます。

このサイトも /ja/llms.txt で配信していて、全記事の本文を収めた重量級の相棒 /ja/llms-full.txt もあります。どちらも、私が手で維持しているファイルではありません。両方とも Astro のエンドポイントで、HTML ページと同じ content collections から描画されるので、ビルドのたびに勝手に更新されます。実装する価値があるのはこの形です。手書きの llms.txt は2本目の記事を出した時点で古くなります。中核は60行ほど。この記事では、フォーマット、エンドポイントのコード、試行錯誤が要った細部(ロケール分割、予約公開、相対リンク)、そして「実際に読んでいるものはいるのか」への正直な見立てを扱います。

フォーマットを手短に

llms.txt の仕様は Jeremy Howard が2024年9月に提案したもので、意図的に最小限に抑えられています。決まった形の Markdown です。

  1. サイト名・プロジェクト名の H1(唯一の必須要素)
  2. サイトを1〜2文で要約する引用ブロック
  3. モデルに知っておいてほしい文脈を書く、任意の段落
  4. リンクリストを収めた H2 セクション。1行につき - [タイトル](url): 説明
  5. 文字どおり ## Optional と名付けた任意のセクション。コンテキストに余裕が無いときに飛ばしてよいリンクの目印

対になる慣習の llms-full.txt は、リンクの代わりに全コンテンツをその場に展開します。1フェッチでサイト全体がモデルのコンテキストウィンドウに収まり、クロールは一切不要になります。

なぜ HTML ではなく、決まった URL の Markdown なのか。読者が推論時の言語モデルだからです。いままさに質問に答えようとして、トークンをやりくりしているエージェント。HTML はナビゲーションやスクリプトやマークアップのオーバーヘッドだらけですが、Markdown にはノイズがほとんどありません。入力がきれいなサイトほど、モデルの回答の中で正確に扱われる、というのがこの仕様の賭けです(その賭けの回収が始まっているのかどうかは、記事の最後で)。

content collections から生成する

Astro で .txt の URL を作るのは、ただの静的エンドポイントです。src/pages/llms.txt.tsGET をエクスポートして Response を返す。必要な情報(タイトル、説明、日付、タグ)はブログを動かしている content collection に全部あるので、エンドポイントは getCollection を map するだけで書けます。

// src/pages/llms.txt.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { SITE_TITLE, SITE_URL, SITE_DESCRIPTION, publishedOnly } from '../consts';

// 折り返し YAML の description は改行を含む。リンクリストの形式は1行を要求する
const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim();

export const GET: APIRoute = async () => {
  const posts = (await getCollection('blog', publishedOnly))
    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());

  const articles = posts.map(({ id, data }) => {
    const meta = [
      data.category,
      `published ${data.pubDate.toISOString().slice(0, 10)}`,
      ...(data.tags.length ? [data.tags.join('/')] : []),
    ].join(' · ');
    return `- [${data.title}](${SITE_URL}/blog/${id}/): ${oneLine(data.description)} (${meta})`;
  });

  const body = `# ${SITE_TITLE}

> ${SITE_DESCRIPTION}

## Articles

${articles.join('\n')}

## Optional

- [Full article text](${SITE_URL}/llms-full.txt)
- [RSS feed](${SITE_URL}/rss.xml)
- [Sitemap](${SITE_URL}/sitemap-index.xml)
`;

  return new Response(body, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
};

他のページと同じくプリレンダリングされ、ただのファイルとしてデプロイされ、ランタイムコストはゼロです。間違えやすい細部が3つあります。

URL は絶対で書く。 ルート相対のリンクは、その場で読まれる HTML なら問題ありません。しかし llms.txt はよそで読まれます。オリジンから遠く離れたコンテキストウィンドウに貼り付けられる。だからリンクはすべて https:// 込みの完全な形にします。

未公開の記事をフィルタする。 エンドポイントにも、サイトの他の場所と同じ draft・予約公開のフィルタが要ります。コレクション呼び出しに入っている publishedOnly がそれです。このサイトは静的ビルドのまま予約公開をしています。このエンドポイントの初期版はフィルタを忘れていて、覗きに来たモデルに未来の記事を嬉々として予告するところでした。コンテンツを列挙する場所には、必ずフィルタも付いていきます。

説明の行にメタデータを足す。 仕様が求めるのはタイトルと説明だけですが、日付・カテゴリ・タグは数トークンの追加で済み、「この情報は最新か」「このサイトは何を扱うのか」という問いに、他のフェッチ無しで答える材料をモデルに渡せます。

llms-full.txt: サイト全体を1ファイルに

全文版も形は同じで、各記事へリンクする代わりに記事の Markdown ソースを埋め込みます。collections はそれを直接渡してくれます。post.body が、フロントマターを剥がした生の Markdown です。

const articles = posts.map((post) => {
  const { title, description, pubDate } = post.data;
  const header = [
    `URL: ${SITE_URL}/blog/${post.id}/`,
    `Published: ${pubDate.toISOString().slice(0, 10)}`,
  ].join('\n');
  return `# ${title}\n\n${header}\n\n> ${oneLine(description)}\n\n${absolutize(post.body ?? '')}`;
});

const body = `# ${SITE_TITLE} — full article text\n\n${articles.join('\n\n---\n\n')}\n`;

ここで効いてくる変換が1つあります。内部リンクです。記事同士はルート相対([デプロイガイド](/blog/...))でリンクし合っていて、抜き出した塊の中では、そのリンクは行き先を失います。1行の書き換えで全部直ります。

const absolutize = (markdown: string) =>
  markdown.replace(/\]\(\/(?!\/)/g, `](${SITE_URL}/`);

否定先読みが、プロトコル相対の //example.com 形式の URL を巻き込まないようにしています。

公開する前の注意が2つ。記事がコンポーネントを多用する MDX なら、post.body は JSX タグ込みのソースです。それをモデルに読ませたいかどうかは場合によります(このブログの記事はほぼ純粋な Markdown なので、ソースのままで十分きれいです)。もう1つ、このファイルはアーカイブに比例して育ちます(このサイトは十数本 × 記事あたり数千語で、ウェブの基準ではまだ極小ですが、500本のアーカイブならページ単位の .md を提供するほうがよいでしょう)。

多言語サイトでの分け方

このブログは英語と日本語の2言語で書いていて、仕様が触れていない問いに当たりました。2言語を1ファイルに混ぜるのか、言語ごとに分けるのか。私はロケールごとのファイル(/llms.txt/ja/llms.txt)にしました。日本語のページに着地したモデルには、2言語が交互に混ざった塊ではなく、日本語のタイトルと日本語の URL を渡したいと考えたからです。各ファイルは ## Optional にもう一方の言語版を載せているので、混ざりはしないけれど見つけられる。Astro なら特別なことをしなくてもこの形になります。エンドポイントを [...locale] のルーティングディレクトリに移せば、getStaticPaths が言語ごとに1ファイルずつ吐いてくれます。

で、実際に読んでいるものはいるのか

2026年半ばの時点で、llms.txt を読むと確約した主要クローラーは存在しません。Google の検索チームは公然と冷ややかで、John Mueller は llms.txtkeywords メタタグに例えました。測定できるほど引用が増えたと真顔で主張する人はいませんし、それを売り文句にする人がいたら疑ってかかるべきです。

一方で、確かなこともあります。配信側の採用は実在します(Anthropic のドキュメントが配信していますし、ドキュメントプラットフォームには標準で生成するものが増えました)。AI クローラーのトラフィック自体、量はすでに無視できません。このサイトに対して AI エージェントが何をするかは実測しました。そして、必要に応じてページを取りに来るエージェントは、バルクのクローラーが無視していても今日からこのファイルを使えます。推測可能な URL に置かれた、ただの Markdown だからです。体感でも、参照されているのを見かけるのはこの手のエージェント型フェッチャー経由が多く、インデックスを作るクローラーは素通りしていきます。

だから、この記事の主張は「AI からの視認性が上がる」ではありません。それは誰にも約束できない。言いたいのはこうです。生成版のコストは画面1枚分のコードを1回書くだけで、維持の手間は永遠にゼロ。慣習が定着したときの見返りは実在する。そして大半の AI-SEO 助言と違って、害になりようが無い。人間の目には決して触れない、足すだけのプレーンテキストです。元手がかからず、ビルドのたびに自動で書き換わる宝くじなら、持っておいて損はありません。

チェックリスト

  • src/pages/llms.txt.ts: H1、引用ブロック、リンクセクション。getCollection から生成する
  • src/pages/llms-full.txt.ts: 記事ごとに post.body の全文。内部リンクは絶対 URL 化する
  • HTML ページと同じ公開・draft フィルタを通す
  • URL はすべて絶対。Content-Type: text/plain; charset=utf-8
  • 多言語サイトはロケールごとに1ファイル、## Optional で相互リンクする

デプロイしたら curl https://your-site/llms.txt | head で確かめて、あとは忘れてください。次のビルドが最新に保ってくれます。生成にした意味は、まさにそこにあります。