公開: 2026.01.21更新: 2026.03.19

AIチャット実装の記録

Astro + Cloudflare Workers + OpenAI Agents SDKでサイト特化型LLMを実装する

自分のWebサイトに掲載している情報をもとに回答する、サイト特化型のLLMを実装しました。

この記事では、Astroで構築したWebサイトにCloudflare WorkersとOpenAI Agents SDKを組み合わせ、サイト内検索まで行えるAIチャットを構築した方法について説明します。

目次

完成した構成

最終的な構成は次のようになっています。

ユーザー

   │ 質問

Astro製チャットUI

   │ POST /api/ai

Cloudflare Worker


OpenAI Agents SDK

   ├── search_site
   │      │
   │      ▼
   │   site-search-index.json

   ├── fetch_site_page
   │      │
   │      ▼
   │   tatsukiw.me の実ページ

   └── 回答生成


      OpenAI API


      JSONレスポンス

サイトの情報源としては、主に以下の3つを使用しています。

ベクトルデータベースや外部のWeb検索エンジンは使用していません。

なぜサイト特化型LLMを作るのか

一般的なチャットAIに自分のWebサイトについて質問しても、サイトに掲載されている情報を必ずしも正確に参照できるとは限りません。

そこで、LLMにWebサイトそのものを知識として学習させるのではなく、

「このサイトについて質問されたら、このサイトの情報だけを使って回答する」

という仕組みを作ることにしました。

例えば、

オーストラリアではどんな仕事をしていましたか?

という質問に対して、LLMがサイト内を検索し、該当するページを取得して回答します。

この方法なら、サイトの内容を変更した場合にも、LLM自体を再学習させる必要がありません。

使用した技術

今回使用した主な技術は以下です。

技術用途
AstroWebサイト・チャットUI
Cloudflare WorkersAPIバックエンド
Cloudflare Workers Static AssetsAstroの静的ファイル配信
OpenAI APILLM
OpenAI Agents SDKAgentとツール実行
TypeScriptWorker・フロントエンド
Zodデータのバリデーション
markedMarkdownのHTML変換

Cloudflare Workersでは、Astroで生成した静的ファイルをStatic Assetsとして配信しながら、/api/*だけWorkerで処理する構成にしています。

https://tatsukiw.me/

Cloudflare Workers

        ├── /api/*
        │      ↓
        │   Worker

        └── その他

          Static Assets

これにより、WebサイトとAPIを別々のサーバーとして運用する必要がありません。

Cloudflare Workersをバックエンドにする

Workerでは、APIリクエストだけを専用のハンドラーに渡しています。

import { handleAi } from "./functions/api/ai";

export default {
  async fetch(request, env, ctx): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/api/ai") {
      return handleAi(request, env, ctx);
    }

    return env.ASSETS.fetch(request);
  },
} satisfies ExportedHandler<Env>;

/api/aiの場合はLLM APIとして処理し、それ以外はAstroの静的ファイルを返します。

Cloudflare WorkersのStatic Assetsを利用することで、WebサイトとAPIを1つのWorkerにまとめることができます。

サイト情報をLLMに渡す

サイト全体の概要として、public/llms.txtを用意しています。

llms.txtには、サイトの構成や主要なコンテンツについてまとめています。

重要なのは、llms.txtをすべてのページ本文の代わりにするのではなく、サイト全体の地図として使うことです。

LLMには、

llms.txtをサイト全体の地図として扱ってください。

と指示しています。

サイト内検索を実装する

個別のページを探すために、site-search-index.jsonを作りました。

例えば次のような形式です。

[
  {
    "title": "人生哲学について",
    "description": "23才で出会った哲学",
    "path": "/philosophy/",
    "summary": "23才で出会った哲学について..."
  }
]

このインデックスは手動で作るのではなく、AstroのContent Collectionから自動生成しています。

const pages = await getCollection("pages");

const index = pages
  .map((page) => ({
    title: page.data.title,
    description: page.data.description ?? "",
    path: page.id === "index" ? "/" : `/${page.id}/`,
    summary: page.data.summary ?? "",
  }))
  .sort((a, b) => a.path.localeCompare(b.path));

これによって、ページを追加・変更しても検索インデックスを手動で更新する必要がありません。

なぜサイトマップを使わなかったのか

当初はXMLのサイトマップを利用することも考えました。

しかし今回は、AIがページを探すための情報としては、サイトマップよりも、

を持った専用のインデックスのほうが扱いやすいと判断しました。

そのため、sitemap.xmlではなく、site-search-index.jsonをAI用の検索インデックスとして使用しています。

検索用LLMとメインAgentを分ける

ここは今回の実装で重要なポイントです。

サイト内検索については、メインAgent自身がインデックス全体を直接検索するのではありません。

ユーザー

メインAgent

「この質問についてサイト内検索して」

search_site

検索用LLM

site-search-index.json

関連ページを最大3件返す

メインAgent

という構造にしています。

検索用LLMはAgentではなく、1回のLLM推論だけを行う単純な処理にしています。

メインAgentには、

関連するページを探す必要がある場合はsearch_siteを使用してください。

と指示しています。

さらに、検索結果だけからページ本文を推測しないようにしています。

search_siteの検索結果だけからページ本文の内容を推測しないでください。

取得したサイト情報に基づいて回答してください。

サイトに記載されていない情報を推測して補完しないでください。

Agentにツールを使わせる

OpenAI Agents SDKを使ってAgentを作成しています。

Agentにはサイト内検索とページ取得という2つのツールを与えています。

search_site
fetch_site_page

例えばユーザーが、

オーストラリアでどんな仕事をしていましたか?

と質問した場合、

ユーザー

Agent

search_site

「オーストラリアでの仕事について確認したい」

関連ページを検索

Agentが必要なページを選択

fetch_site_page

実際のページ本文を取得

回答生成

という処理になります。

Agentには最大ターン数を設定しており、必要に応じて複数回ツールを利用できます。

ページ本文を取得する

search_siteはページの候補を探すためのものです。

検索結果だけではページ本文の詳細は分かりません。

そのため、必要なページについてはfetch_site_pageを使います。

search_site

関連ページのURL

fetch_site_page

実際のHTML

Agent

ここで重要なのは、fetch_site_page自体にはLLMによる要約処理などをさせていないことです。

単純に実際のページを取得する役割にしています。

LLM APIを実装する

APIの入口は、

POST /api/ai

です。

リクエストは、

{
  "message": "テストです。聞こえますか?"
}

という形式です。

WorkerではこのメッセージをAgentに渡します。

const result = await aiRunner.run(
  agent,
  body.message,
  { maxTurns: MAX_TURNS },
);

そして最終的な回答を、

return Response.json({
  output: result.finalOutput,
});

として返しています。

そのため、レスポンスは、

{
  "output": "はい、聞こえます。ご用件をどうぞ。"
}

となります。

非ストリーミングにした理由

当初はLLMの回答をストリーミングすることも検討しました。

しかし今回のAgentは、

質問

search_site

必要ならfetch_site_page

回答生成

という処理を行います。

そのため、回答生成部分だけをストリーミングしても、検索やページ取得を行っている間はユーザーには回答が表示されません。

今回はシンプルさを優先し、最終的な回答をJSONとしてまとめて返す非ストリーミング方式にしています。

CORSを設定する

フロントエンドをローカルで開発している間も、本番のAPIを利用できるようにCORSを設定しました。

許可するOriginは、

http://localhost:4321
https://tatsukiw.me

です。

また、ブラウザからJSONをPOSTするため、CORS preflightとして送られる、

OPTIONS /api/ai

にも対応しています。

if (request.method === "OPTIONS") {
  return new Response(null, {
    status: 204,
  });
}

これによって、

localhost:4321

https://tatsukiw.me/api/ai

という開発中のリクエストもブラウザから実行できます。

フロントエンドを実装する

チャットUIはAstroコンポーネントとして実装しました。

ユーザーのメッセージを送信すると、

const response = await fetch(
  "https://tatsukiw.me/api/ai",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ message }),
  },
);

としてAPIを呼び出します。

APIから返ってきたJSONを、

const data = await response.json();

で取得します。

Talkコンポーネントを利用する

チャットの吹き出しには、サイトで既に使用しているTalk.astroを利用しています。

ユーザー側は、

<Talk align="right" mine>
  ユーザーのメッセージ
</Talk>

AI側は、

<Talk align="left" name="AI">
  応答メッセージ
</Talk>

というデザインです。

ただし、チャットメッセージはJavaScriptによって動的に増えていきます。

AstroコンポーネントをブラウザのJavaScriptから直接生成することはできないため、TalkをあらかじめテンプレートとしてHTMLに出力しておき、それをJavaScriptで複製する方式にしました。

<div id="user-talk-template">
  <Talk align="right" mine>
    <span class="talk-content"></span>
  </Talk>
</div>

<div id="ai-talk-template">
  <Talk align="left" name="AI">
    <span class="talk-content"></span>
  </Talk>
</div>

JavaScript側では、

const message = template.firstElementChild?.cloneNode(true);

として複製し、

content.textContent = text;

でメッセージを差し込みます。

これによって、吹き出しのデザインはTalk.astroに集約したまま、チャットメッセージだけを動的に増やせます。

Markdownとして回答を表示する

LLMから返される文章には、Markdownが含まれる可能性があります。

例えば、

## オーストラリアでの生活

ワーキングホリデーではホテルで働いていました。

- フロント業務
- 接客
- 英語でのコミュニケーション

という回答をそのまま表示すると、Markdown記法が文字として表示されてしまいます。

そこで、markedを使用してMarkdownをHTMLへ変換します。

npm install marked

そしてブラウザ側の<script>で、

import { marked } from "marked";

とします。

APIから回答を取得したら、

const data = await response.json();

assistantContent.innerHTML =
  await marked.parse(data.output);

とすることで、Markdownとして表示できます。

なお、LLMからの出力をHTMLとして挿入するため、実運用ではHTMLのサニタイズについても検討する必要があります。

実際の動作

実際に本番環境へ質問を送ると、例えば、

ユーザー:
オーストラリアではどんなことをしていましたか?

という質問に対して、

Agent

search_site

関連ページを最大3件取得

必要なページを選択

fetch_site_page

ページ本文を取得

回答生成

という処理が行われます。

OpenAIのトレースでは、

Task
 └─ tatsukiw.me Assistant
     ├─ Responses API
     ├─ search_site
     ├─ Responses API
     ├─ fetch_site_page
     └─ Responses API

というAgentの動作を確認できました。

つまり、単純にLLMへllms.txtを渡して回答させるだけではなく、質問に応じてサイト内を検索し、必要なページを取得してから回答するAgentとして動作しています。

この構成にした理由

今回の実装では、あえて大規模な検索基盤を導入していません。

例えば、

などは使用していません。

その代わり、

llms.txt
     +
site-search-index.json
     +
実際のページ
     +
Agent

という比較的シンプルな構成にしています。

サイト自体がそれほど巨大ではないため、まずはこの構成で十分と判断しました。

特にsite-search-index.jsonは、ページのURLだけではなく、

title
description
path
summary

を持たせています。

これによって検索用LLMがページの内容を判断するための情報を取得できます。

セキュリティ・運用面

OpenAI APIキーはソースコードには記述していません。

Cloudflare WorkersのSecretとして、

OPENAI_API_KEY

を登録しています。

そのため、ブラウザからOpenAI APIを直接呼び出すことはありません。

ブラウザ

   │ APIリクエスト

Cloudflare Worker

   │ API Key

OpenAI API

という構造になっています。

また、Cloudflare WorkersのRate Limitingも利用し、APIへのリクエスト数を制限しています。

今後の改善

現時点では、基本的なサイト特化型AIとして動作するところまで実装できました。

今後考えられる改善としては、例えば以下があります。

会話履歴

現在は1回の質問に対して1回の回答を返す構成です。

今後は、

ユーザー:
オーストラリアについて教えて

AI:
......

ユーザー:
そこでどんな仕事をしたの?

AI:
......

のような会話履歴をAgentに渡せるようにできます。

UIの改善

現在はシンプルなチャットUIなので、

などを改善できます。

ストリーミング

必要になればAgents SDKのストリーミング機能を利用して、回答生成中のテキストを逐次表示することもできます。

ただし現在のAgentではサイト内検索やページ取得を行うため、ストリーミングしても検索・取得中の待ち時間そのものは短縮されません。

そのため、現時点では非ストリーミングのJSON APIとしています。

まとめ

今回構築したサイト特化型LLMは、次のような構成になっています。

                    ┌─────────────────┐
                    │   tatsukiw.me   │
                    │   Astro site    │
                    └────────┬────────┘


                    ┌─────────────────┐
                    │ Cloudflare      │
                    │ Workers         │
                    └────────┬────────┘


                    ┌─────────────────┐
                    │ OpenAI Agents   │
                    │ SDK             │
                    └───────┬─────────┘

                 ┌──────────┴──────────┐
                 ▼                     ▼
          ┌─────────────┐      ┌───────────────┐
          │ search_site │      │fetch_site_page│
          └──────┬──────┘      └───────┬───────┘
                 │                      │
                 ▼                      ▼
       site-search-index.json      実際のWebページ
                 │                      │
                 └──────────┬───────────┘

                    ┌──────────────┐
                    │   OpenAI     │
                    │     API      │
                    └──────┬───────┘

                      AIの回答

個人サイトにAIを組み込む場合、最初から大規模なRAG基盤を構築するのではなく、サイトそのものを情報源として、LLMに必要なページを探させるという構成でも実用的なものを作れます。

今回の構成では、サイトのコンテンツを更新すれば検索インデックスもAstroのビルド時に自動生成されるため、AI用データベースを別途管理する必要がありません。

結果として、Astro + Cloudflare Workers + OpenAI Agents SDKを中心とした、比較的シンプルなサイト特化型AIを構築できました。