テックブログ

GW にホームページ記事生成エージェントを開発した話 — Hono × Mastra × Cloudflare で組む、自分専用の "AI 駆動 CMS"

ゴールデンウィーク期間を利用して、ホームページと記事コンテンツをAIに自動生成させるための、自分専用のCMS(コンテンツ管理システム)を開発した。本稿では、その開発の経緯、技術スタック、アーキテクチャ、そして得られた学びを共有する。 特筆すべきは、いま読者が目にしているこの記事自体が、本システムによってAIが下書きを作成し、筆者が最終仕上げを施したものであるという点だ。いわば「ドッグフーディング」であり、本稿で解説するAI駆動CMSが実際にどのようなアウトプットを生み出すのかを、読者はまさに今、体験していることになる。

経緯

長年、コンテンツ管理にはWordPressを利用してきたが、いくつかの課題を感じていた。ブロックエディタの動作の重さ、プラグイン依存によるセキュリティリスク、そしてPHPとMySQLのスタックが現代のフロントエンド開発フローと乖離しつつある点だ。個人のテックブログならまだしも、顧客に価値を届けるべき事業のウェブサイトとしては、パフォーマンスと開発体験の両面で最適とは言えない状況が続いていた。

  • 記事執筆とサイト管理の体験を、現代的な技術で再構築したかった。
  • AIによる記事生成を前提としたワークフローを、システムレベルで組み込みたかった。
  • SEOを意識した高品質な記事の量産体制を早期に確立したかった。
WordPressに戻るより、自分でAI駆動CMSを作るほうが速い時代になった。

アーキテクチャ全体像

BrowserCloudflare Worker(Hono)D1R2Cloud Run(Mastra Agent)CognitoSESOpenAI APIGemini APILangfuseHTTPSSQLAssetsgRPCAuthEmailTrace
システム全体のアーキテクチャ概要
project/
├─ packages/
│  ├─ api/                    # Hono (Cloudflare Workers)
│  │  ├─ src/
│  │  │  ├─ index.ts          # エントリ: ルートマウント + SPA fallback
│  │  │  ├─ features/         # 機能ごとのルート + service
│  │  │  ├─ ssr/              # 公開ページの SSR
│  │  │  └─ shared/           # middleware / db / lib
│  │  ├─ drizzle/             # マイグレーション
│  │  └─ wrangler.toml
│  │
│  ├─ web/                    # React 19 SPA (管理画面のみ)
│  │  └─ src/
│  │
│  └─ agent/                  # Mastra Agent (Cloud Run)
│     ├─ src/
│     │  ├─ index.ts          # Hono サーバ (Cloud Run のエントリ)
│     │  └─ mastra/
│     │     ├─ agents/        # plan-agent / execute-agent など phase 別
│     │     ├─ tools/         # createTool 定義群
│     │     ├─ workflows/     # phase ディスパッチを担うワークフロー
│     │     ├─ memory/        # @mastra/memory 設定
│     │     ├─ prompts/       # システムプロンプト (.md)
│     │     └─ lib/           # run-context / 共通ユーティリティ
│     └─ Dockerfile
│
└─ terraform/                 # AWS (SES) / Cloudflare の IaC

Hono について

WebサーバーのフレームワークにはHonoを選定した。Cloudflare Workers上で動作させることを前提とした場合、Honoは最有力候補となる。JSON API、サーバーサイドレンダリング(SSR)、静的アセット配信、そして管理画面用のSPAへのフォールバックといった責務を、単一のWorkerに同居させられる点が最大の魅力だ。これにより、APIとフロントエンドでオリジンが統一され、CORS設定が不要になり、httpOnlyなCookieベースの認証も素直に実装できる。デプロイの単位も1つにまとまるため、開発・運用体験が大きく向上する。

なぜ速いのか

Honoのパフォーマンスが高い理由は主に3つある。第一に、RegExpRouterが全ルート定義をコンパイル時に1本の巨大な正規表現に畳み込むことで、リクエストごとのルーティングオーバーヘッドを最小限に抑えている。第二に、WinterCGで標準化されたWeb標準のRequest/Responseオブジェクトを内部で直接扱っており、Node.js固有のオブジェクトとの変換層が存在しない。第三に、依存関係を極限まで削ぎ落としたミニマルな設計により、バンドルサイズが非常に小さく、Isolate(Workersの実行環境)のコールドスタート時間に有利に働く。

項目HonoExpress
ターゲットエッジ環境 (CF Workers, etc.)Node.js
バンドルサイズ極小 (数KB)中 (依存関係による)
型推論強力 (パスパラメータ、JSONボディ等)弱い (要zod-validator等)
Workers対応ファーストクラスアダプタ経由で可能
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { publicRecipeRoutes, adminRecipeRoutes } from './features/recipe/routes'
import { renderRecipePage, renderHome } from './ssr/render'

const app = new Hono()

app.use('*', logger())

// API Routes
app.route('/api/recipes', publicRecipeRoutes)
app.route('/api/admin/recipes', adminRecipeRoutes)

// SSR Pages
app.get('/', renderHome)
app.get('/recipes/:slug', renderRecipePage)

// SPA Fallback (Assets & index.html)
app.get('/assets/*', (c) => c.env.ASSETS.fetch(c.req.raw))
app.get('*', (c) => c.env.ASSETS.fetch(new Request(new URL('/index.html', c.req.url))))

export default app
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const RecipeCreate = z.object({
  title: z.string().min(1),
  servings: z.number().int().positive(),
  ingredients: z.array(z.object({ name: z.string(), amount: z.string() })),
  steps: z.array(z.string()),
})

export const adminRecipeRoutes = new Hono()

adminRecipeRoutes.get('/', async (c) => {
  const items = await listRecipes(c.env.DB)
  return c.json({ items })
})

adminRecipeRoutes.post('/', zValidator('json', RecipeCreate), async (c) => {
  const payload = c.req.valid('json')
  const recipe = await createRecipe(c.env.DB, payload)
  return c.json(recipe, 201)
})

adminRecipeRoutes.get('/:id', async (c) => {
  const recipe = await getRecipe(c.env.DB, c.req.param('id'))
  return recipe ? c.json(recipe) : c.notFound()
})

ルート定義ファイル(routes.ts)の責務は、リクエストの入口でのバリデーションと、レスポンスの整形に限定している。データベースアクセスや複雑なビジネスロジックは、注入されたDBクライアントを介して`service/*.ts`に委譲する構成を採っている。

Zod について

TypeScriptの型定義と、ランタイムでの値の検証を一度に記述できるライブラリがZodである。TypeScriptの型情報はコンパイル時に消去されてしまうため、APIの入力値や、特にLLMからの出力のような信頼できないJSONデータを安全に扱うためには、ランタイムでの検証が不可欠となる。Zodスキーマから`z.infer`を用いてTypeScriptの型を生成できるため、型定義とバリデーションロジックの二重管理を防げる。

本システムでは、APIの入出力スキーマ、30種類以上に及ぶページブロックの定義、そしてLLMが呼び出すToolのスキーマまで、あらゆる境界を越えるデータの型をZodで一元管理している。フロントエンド、API、データベース、そしてLLMという4つの異なる境界で同じスキーマ定義を使い回せる点が、Zodを採用した最大の理由である。特に、`z.preprocess`を用いて、LLMが生成しがちな数値の文字列(例: `level: "2"`)を数値型に正規化するような、防衛的なデータ整形が効果的に機能している。

Mastra について

AIエージェントのオーケストレーションには、TypeScript製のフレームワークであるMastraを選定した。Python環境であればLangGraphやOpenAIのAgents SDKが有力な選択肢となるが、TypeScriptで完結させることを目指す中で、Mastraの持つ①主要LLMプロバイダに依存しない設計、②AgentとToolの宣言的な記述の簡潔さ、③RAG機能のビルトイン、という3点が決め手となった。

import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const suggestIngredientTool = createTool({
  id: 'suggestIngredient',
  description: '料理名と既存の材料から、不足しそうな食材を 1 つ提案する',
  inputSchema: z.object({
    dish: z.string().describe('料理名 (例: ペペロンチーノ)'),
    existing: z.array(z.string()).describe('すでに決まっている材料'),
  }),
  outputSchema: z.object({
    name: z.string(),
    reason: z.string(),
  }),
  execute: async ({ context }) => {
    const { dish, existing } = context
    // 実際には DB や外部 API を叩く。ここではダミー応答。
    return {
      name: 'にんにく',
      reason: `${dish} は香りづけに必須で、${existing.length} 個の材料には未登場のため`,
    }
  },
})
import { Agent } from '@mastra/core/agent'
import { google } from '@ai-sdk/google'
import { suggestIngredientTool } from './tools/suggest-ingredient'
import { memory } from './memory'

export const recipeAgent = new Agent({
  name: 'recipe-agent',
  instructions: `あなたはレシピ作成アシスタントです。
ユーザーが料理名を伝えてきたら、必要な材料を考え、足りない食材があれば
suggestIngredient ツールで補ってください。`,
  model: google('gemini-1.5-flash'),
  tools: {
    suggestIngredient: suggestIngredientTool,
  },
  memory,
})

なぜ Cloud Run にデプロイしたのか

記事1本の生成には、思考とツール実行を繰り返すReActループにより、数十秒から数分単位の時間がかかることがある。この種の長時間処理は、Cloudflare Workersが設けているCPU実行時間の上限(プランによるが、通常は数秒〜30秒)と相性が悪い。一方で、Google Cloud Runはリクエストタイムアウトの上限が最大60分と非常に長く、長尺のストリーミング応答や逐次的なツール実行を中断することなく処理できる。また、Mastraが内部で利用するライブラリ(例: `@mastra/rag`)がNode.js環境を前提としているため、完全なNode.js互換環境であるCloud Runは安全な選択肢であった。リクエストがない間はゼロにスケールダウンするコスト特性も、個人開発のフェーズでは大きなメリットとなる。応答性を担うWorkersと、重い思考を担うCloud Runという役割分担が、このアーキテクチャの勘所である。

他ライブラリとの比較

項目MastraLangGraph.jsOpenAI Agents SDKVercel AI SDK
主目的エージェント構築状態グラフ定義OpenAIエージェント実行UIコンポーネント連携
マルチプロバイダ✗ (OpenAIのみ)
ツール宣言宣言的 (Zod)関数呼び出し宣言的宣言的
RAGビルトイン自前実装ビルトイン限定的
Memoryビルトイン自前実装ビルトイン自前実装
ワークフローコード駆動グラフ定義LLM駆動UIイベント駆動
観測性Langfuse連携LangSmith連携限定的限定的
抽象度
向くケースカスタムエージェント開発複雑な状態遷移OpenAI特化アプリNext.jsでのUI開発

最終的な選定理由は、(1)Agent、Tool、RAG、Memoryというエージェント開発の主要要素が一気通貫で提供されていること、(2)ベンダーロックインを避け、将来的にモデルを自由に差し替えられること、そして(3)Langfuseとの連携による詳細なトレースが可能で、デバッグや挙動分析が容易であること、の3点に集約される。

本システムでの使い方 — ワークフローで agent を制御する

最初はシングルエージェント構成(単一 ReAct ループで全 tool を見せる)で組んでみたが、これでも HP 記事生成程度なら案外動く。ただし phase の区切りが曖昧でプロンプトに頼った制御になりがちで、designLayout を呼ばずに本文に進む / 計画段階で書き込み系を触る、といった揺れが残った。最終的に コード側のワークフローが phase を見て plan-agent / execute-agent を直接 stream するワークフロー形式 に切り替えた。各 agent は phase 専用の tool セットだけを持ち(plan-agent には書き込み系を渡さない等)、フェーズ間の遷移はコード側が握る。実装行数はわずかに増えるが、責務が短いプロンプトに収まる分、出力の再現性が体感で大きく上がった。

XState について

前述のワークフローは、リクエストに含まれる`req.phase`という入力に依存して、実行するagentを切り替えている。では、その`phase`自体は誰が管理するのか。本システムでは、その責務をXState v5で実装された状態マシンに委ねている。`plan`→`review`→`execute`→`done`といった一連の状態遷移と、各状態で受け入れ可能なイベント(例: ユーザーによる承認、修正指示、中断)を宣言的に記述できる。

「今システムはどの状態にあるのか」「次に受け付けるイベントは何か」「許可される副作用(≒agent呼び出し)は何か」がコードとして明示されるため、「計画フェーズでは計画ツールだけを使うように」という指示を自然言語のプロンプトで曖昧に与えるよりも、遥かに強力な制約となる。ワークフローは状態マシンから現在の`phase`を受け取り、それに対応するagentを構築して実行する、という二段構えの縛りによって、システムの安定性を実現している。

この状態マシンのアプローチは、管理画面の記事保存フロー(`draft`→`saving`→`conflict`|`saved`)にも応用している。楽観ロック失敗時の再同期処理や、ユーザーの入力途中での自動保存をデバウンスするロジックなどを、アクターモデルベースでクリーンに実装できた。

XStateはステートマシンをベースとした厳密な状態管理を提供する一方で、JotaiやZustandはより軽量でシンプルな状態管理ソリューションです。それぞれのライブラリは異なるアプローチとユースケースを持っており、プロジェクトの要件に応じて適切に選択することが重要です。ここでは、これらのライブラリの主な違いを比較します。

比較項目XStateJotaiZustand
アプローチステートマシン/アクターモデルアトムベース(最小限のレンダリング)フックベース(宣言的)
複雑な状態管理非常に得意(状態遷移、並行状態、履歴状態)苦手(よりシンプルな状態向け)中程度(複雑化すると冗長になる可能性)
学習コスト高(ステートマシン理論の理解が必要)低(React Hooksの知識があれば容易)低(React Hooksの知識があれば容易)
パフォーマンス中〜高(厳密な再レンダリング制御が可能)高(アトム単位で最適化)高(Store全体ではなく選択された状態のみ監視)
ユースケースUIの複雑な状態遷移、ビジネスロジック、分散システムグローバルなUI状態、小〜中規模な状態グローバルなUI状態、中規模な状態

学んだこと

プロダクト次第ではモデルのコスト差が出力差にならない

過去にデータ分析や数学の問題生成といったタスクでエージェントを構築した際は、GPT-4やClaude 3 Opusのような高性能モデルが明らかに優れた結果を出した。しかし、今回のホームページ記事生成というタスクにおいては、Claude 3 SonnetやGPT-4o mini、Gemini 1.5 Flashといった、より高速で安価なモデルでも、体感的な出力品質に大きな差は生まれなかった。

モデル入力単価 ($/Mtok)出力単価 ($/Mtok)体感品質速度
Claude 3 Opus15.0075.00★★★★★遅い
GPT-4o5.0015.00★★★★★速い
Claude 3 Sonnet3.0015.00★★★★☆速い
Gemini 1.5 Pro3.5010.50★★★★☆速い
GPT-4o mini0.150.60★★★★☆非常に速い
Gemini 1.5 Flash0.350.70★★★★☆非常に速い

これは、一般的な文章生成能力が一定のレベルで飽和しつつあること、そしてホームページの記事生成が、高度なロングテール推論や厳密な計算を必要としないタスクであることが理由だと考えられる。結果として、現状ではコストパフォーマンスに優れる下位モデルを積極的に採用するのが合理的という結論に至った。

シングルエージェントでも案外いけるが、最終的にワークフロー形式に落ち着いた

最初はシングルエージェント構成(単一 ReAct ループで全 tool を見せる)で組んでみたが、これでも HP 記事生成程度なら案外動く。ただし phase の区切りが曖昧でプロンプトに頼った制御になりがちで、designLayout を呼ばずに本文に進む / 計画段階で書き込み系を触る、といった揺れが残った。最終的に コード側のワークフローが phase を見て plan-agent / execute-agent を直接 stream するワークフロー形式 に切り替えた。各 agent は phase 専用の tool セットだけを持ち(plan-agent には書き込み系を渡さない等)、フェーズ間の遷移はコード側が握る。実装行数はわずかに増えるが、責務が短いプロンプトに収まる分、出力の再現性が体感で大きく上がった。

シングル ReAct で組んだ初版でも、簡単な記事生成なら破綻はしなかった。ツール 1 セットを全部見せて「計画から執筆まで自分で考えろ」と任せる構成は実装が短くて済むのも事実。

  • phase の境界がプロンプト依存になり、計画 phase で書き込み系を触ったり、逆に計画を skip して本文に進んだりする揺れが出る
  • プロンプト肥大化で指示無視が増える
  • LLM の自由度が高いぶん、空応答や finishReason='error' のときに何が起きたか追いにくい

今後やりたいこと

  1. 未実装のブロック種別(カラム、タブ、アコーディオン等)を実装し、表現の幅を広げる。
  2. サイト設定ページを設け、AIの挙動ルール(文体、禁則事項など)を管理画面から動的に変更できるようにする。
  3. 任意のドメインで、それぞれ独立したサイトを管理できるマルチテナント機能を追加する。
  4. 生成した記事のPVやコンバージョンを分析し、より効果的なコンテンツをAIが自律的に提案するループを構築する。
  5. Kibelaのように、このCMSを社内ドキュメント管理ツールとしても活用できるように拡張する。

まとめ

全体の総括

Hono + Zod + Mastra + XState + Cloudflare の組み合わせは、個人開発で AI 駆動の Web サービスを作るのにかなり強い。

今回の学び — 初心者にもおすすめできるシンプル構成

今回のアーキテクチャは非常にシンプルで、初心者にもおすすめできる構成に仕上がった。Hono が API・SSR・静的配信を 1 Worker で束ね、Mastra が agent と tool の宣言を最小限に抑え、ワークフローが phase ごとに専用 agent をディスパッチする。各レイヤーで覚えることが少ないので、はじめてエージェントを組む人でも全体像が掴みやすいはず。

読者への呼びかけ

汎用的なエージェントツールは便利だと思う反面、個人や会社の業務に合わせて最適化していくのは案外難しい。最終的には自前でエージェントを実装するのが当たり前になっていくと思う。そんな時代を見据えて — そして何より めちゃくちゃ楽しい ので、ぜひエージェント開発に取り組んでみてほしい。

参考リンク

  • [Hono](https://hono.dev/)
  • [Mastra](https://www.mastra.io/)
  • [Zod](https://zod.dev/)
  • [XState](https://xstate.js.org/)
  • [Cloudflare](https://www.cloudflare.com/ja-jp/)
  • [Google Cloud Run](https://cloud.google.com/run)
  • [Langfuse](https://langfuse.com/)
  • [LangGraph.js](https://langchain-ai.github.io/langgraphjs/)
  • [OpenAI Assistants](https://platform.openai.com/docs/assistants/overview)
  • [Vercel AI SDK](https://sdk.vercel.ai/)