AIチャット実装の記録
Astro + Cloudflare Workers + OpenAI Agents SDKでサイト特化型LLMを実装する
自分のWebサイトに掲載している情報をもとに回答する、サイト特化型のLLMを実装しました。
この記事では、Astroで構築したWebサイトにCloudflare WorkersとOpenAI Agents SDKを組み合わせ、サイト内検索まで行えるAIチャットを構築した方法について説明します。
目次
- 完成した構成
- なぜサイト特化型LLMを作るのか
- 使用した技術
- Cloudflare Workersをバックエンドにする
- サイト情報をLLMに渡す
- サイト内検索を実装する
- なぜサイトマップを使わなかったのか
- 検索用LLMとメインAgentを分ける
- Agentにツールを使わせる
- ページ本文を取得する
- LLM APIを実装する
- 非ストリーミングにした理由
- CORSを設定する
- フロントエンドを実装する
- Talkコンポーネントを利用する
- Markdownとして回答を表示する
- 実際の動作
- この構成にした理由
- セキュリティ・運用面
- 今後の改善
- まとめ
完成した構成
最終的な構成は次のようになっています。
ユーザー
│
│ 質問
▼
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つを使用しています。
public/llms.txtsite-search-index.json- 実際のWebページ
ベクトルデータベースや外部のWeb検索エンジンは使用していません。
なぜサイト特化型LLMを作るのか
一般的なチャットAIに自分のWebサイトについて質問しても、サイトに掲載されている情報を必ずしも正確に参照できるとは限りません。
そこで、LLMにWebサイトそのものを知識として学習させるのではなく、
「このサイトについて質問されたら、このサイトの情報だけを使って回答する」
という仕組みを作ることにしました。
例えば、
オーストラリアではどんな仕事をしていましたか?
という質問に対して、LLMがサイト内を検索し、該当するページを取得して回答します。
この方法なら、サイトの内容を変更した場合にも、LLM自体を再学習させる必要がありません。
使用した技術
今回使用した主な技術は以下です。
| 技術 | 用途 |
|---|---|
| Astro | Webサイト・チャットUI |
| Cloudflare Workers | APIバックエンド |
| Cloudflare Workers Static Assets | Astroの静的ファイル配信 |
| OpenAI API | LLM |
| OpenAI Agents SDK | Agentとツール実行 |
| TypeScript | Worker・フロントエンド |
| Zod | データのバリデーション |
| marked | Markdownの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がページを探すための情報としては、サイトマップよりも、
- タイトル
- 説明
- URL
- ページの概要
を持った専用のインデックスのほうが扱いやすいと判断しました。
そのため、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として動作しています。
この構成にした理由
今回の実装では、あえて大規模な検索基盤を導入していません。
例えば、
- ベクトルデータベース
- Embedding
- RAG専用サービス
- 外部検索エンジン
などは使用していません。
その代わり、
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なので、
- 回答中の表示
- エラー表示
- Markdownのスタイル
- コードブロック
- リンク
- スクロール
- モバイル表示
などを改善できます。
ストリーミング
必要になれば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を構築できました。