開発misc-devメモ

Cloudflare で AI エージェント基盤を作る 4 ステップ — Hono → Queues → Workers AI → Agents SDK

結論

Cloudflare 上に AI エージェントの土台を据えるなら、次の順で積む。

  • Step 1: Hono で最小の Worker を 1 本書いて wrangler deploy する
  • Step 2: Webhook を受けて Cloudflare Queues に積み、即 200 を返す「門番 Worker」を作る
  • Step 3: Worker から AI Gateway 経由で LLM を呼び、レスポンスをキャッシュする
  • Step 4: Agents SDK + Durable Objects で「会話状態を持つエージェント」を 1 体動かす

Wrangler のセットアップとアカウント連携は完了している前提で、各ステップに動くコード動作確認の叩き方を載せる。1 ステップ 30 分〜2 時間。

⚠️ 着手の前に — 経済合理性チェック(後から読み返す自分へ)

この計画書は学習動機と「将来必要になったときの備忘」として置いている。いま現に自分用に回っている自動化を Cloudflare に移す経済合理性は基本ない。理由は LLM の課金構造だ。

経路LLM コスト月額固定費
Claude Code (Max $200/月)限度内なら $0 / 呼び出し$200
Cloudflare AI Gateway → Anthropic Sonnet3 / 1M 入力, 15 / 1M 出力$5 + 従量
Cloudflare Workers AI (Llama 3.1 8B 等)1k token あたり約 $0.001$5

Claude Code のサブスクは定額で、レート上限内なら追加コストはゼロ。一方 Cloudflare AI Gateway 経由はゼロベースで token 従量課金が積み上がる。

自分用ワークロードをざっくり試算すると次のとおり(Sonnet 相当を AI Gateway 経由で使う前提)。

ワークロード移行した場合Claude Code 定額内
毎朝のダイアリ生成 (~50k token)約 $9 / 月$0
Twitter 下書き (~20k)約 $3 / 月$0
決算ビート要約(日次)約 $5 / 月$0
月次 ODM / Korea / Japan スクレイピング要約約 $2 / 月$0
合計約 $20 / 月$0

Claude Max を既に払っているなら、移行した分が丸ごと純損失になる。

この計画書を開いてよいタイミング

次のどちらかが満たされた瞬間にだけ、このページを開く。

  • 他人(読者 / クライアント / 家族)がエンドポイントを叩く必要が出た — Claude Code のサブスクは多人数共有不可。Webhook 受口や読者向けチャットは Cloudflare で立てるしかない
  • PC を起動していない時刻に外部からトリガーが入る — クライアントが LINE / メールで資料送信、外部 Webhook など。GitHub Actions の cron でカバーできない時間帯依存ジョブも含む

それ以外(自分用の cron 自動化、まとめ、要約、X 下書き、データ取得)は Claude Code + ローカル skill + GitHub Actions cron で完結する。Cloudflare の Cron Trigger Worker に乗せたくなったときは、まず Claude Code の定額枠が本当に詰まっているかを確認すること。

なぜこの順番か

きっかけは yusukebe「AI エージェントはCloudflareに賭けろ」 と、それを引用して「Cloudflare Workers + Queues で Webhook の 3 秒ルールを突破した」という X の運用報告だった。後者は、コールドスタートのある Cloud Run の前段に Workers を置くことで、Zoom Webhook / Slack App の「リクエストから 3 秒以内に 200 を返さないとリトライ扱い」という制約を突破した話だ。

この 2 本を合わせて、AI エージェントに必要な要素を分解するとこうなる。

要素Cloudflare 上の手段4 ステップ対応
HTTP/WS の入口WorkersStep 1
非同期処理・タスクキューQueuesStep 2
LLM 呼び出しWorkers AI / AI GatewayStep 3
状態保持(セッション・履歴)Durable ObjectsStep 4
エージェントランタイムAgents SDKStep 4

Agents SDK は上 4 つを抱き合わせたフレームワークなので、いきなり Step 4 から入っても動きはする。ただし Worker の挙動・Queues の動き・LLM の呼び方を体験せずにフレームワークの中身が分かるかというと無理なので、下から積む。

前提

  • Wrangler が wrangler whoami で自分のアカウントを返す
  • Workers Paid Plan ($5 / 月) に加入済み(Queues / Durable Objects / Workers AI のほとんどがここから解放される)
  • Node.js 20+ / pnpm

未加入なら Step 1 までは Free でも動く。Step 2 以降で Paid Plan が必要になる。

Step 1: Hono で最小の Worker を 1 本書いてデプロイする

目的

「自分の wrangler 環境で、TypeScript で書いた HTTP ハンドラがエッジから返ってくる」状態を作る。以降のステップは全部この上に乗る。

コード

pnpm create hono@latest cf-agent-step1
# テンプレ選択: cloudflare-workers
cd cf-agent-step1
pnpm install

src/index.ts を書き換える。

import { Hono } from 'hono'

type Bindings = {}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/', (c) => c.text('hello from cf-agent step 1'))

app.get('/ping', (c) =>
  c.json({
    ok: true,
    cf: c.req.raw.cf,
    ts: new Date().toISOString(),
  })
)

export default app

動作確認

pnpm dev          # localhost:8787 で起動
curl http://localhost:8787/ping
pnpm deploy       # ⇒ https://cf-agent-step1.<account>.workers.dev/ にデプロイ
curl https://cf-agent-step1.<account>.workers.dev/ping

cf フィールドにエッジで判定された国コード等が入っていれば成功。ここで wrangler tail を別タブで叩いておくと、本番ログがリアルタイムに流れる。これで Step 1 終わり。

Step 2: Webhook + Queues で「3 秒ルール」を突破する門番を作る

目的

外部サービスからの Webhook を受け取って、署名検証だけして即 200 を返す。重い処理は Queue に逃がす。これが yusukebe 記事の引用ポストが言っていた構成そのもの。

構成

[ 外部サービス ] --POST--> [ Producer Worker ] --send--> [ Cloudflare Queue ]
                                |
                                └--200 OK (3秒以内)
                                                              |
                                                              v
                                                     [ Consumer Worker ]
                                                              |
                                                              v
                                                  ( 外部 API / DB / 通知 )

Queue を作る

wrangler queues create agent-jobs

wrangler.toml

name = "cf-agent-step2"
main = "src/index.ts"
compatibility_date = "2026-06-01"

# Producer 側(Queue にメッセージを送る)
[[queues.producers]]
queue = "agent-jobs"
binding = "AGENT_JOBS"

# Consumer 側(Queue からメッセージを取り出して処理する)
[[queues.consumers]]
queue = "agent-jobs"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "agent-jobs-dlq"

コード

import { Hono } from 'hono'

type Bindings = {
  AGENT_JOBS: Queue
  WEBHOOK_SECRET: string
}

const app = new Hono<{ Bindings: Bindings }>()

// HMAC-SHA256 で署名検証する小さな関数
const verifySignature = async (body: string, signature: string, secret: string) => {
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))
  const expected = Array.from(new Uint8Array(mac))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
  return expected === signature
}

app.post('/webhook/:source', async (c) => {
  const source = c.req.param('source')
  const body = await c.req.text()
  const signature = c.req.header('x-signature') ?? ''

  if (!(await verifySignature(body, signature, c.env.WEBHOOK_SECRET))) {
    return c.json({ error: 'invalid signature' }, 401)
  }

  await c.env.AGENT_JOBS.send({
    source,
    payload: JSON.parse(body),
    receivedAt: new Date().toISOString(),
  })

  return c.json({ ok: true }, 200)
})

// Consumer ハンドラ
export default {
  fetch: app.fetch,
  async queue(batch: MessageBatch, env: Bindings) {
    for (const msg of batch.messages) {
      try {
        const job = msg.body as { source: string; payload: unknown }
        console.log('process job', job.source, job.payload)
        // ここで時間のかかる処理(外部 API、DB 書き込み、LLM 呼び出し)
        msg.ack()
      } catch (e) {
        msg.retry()
      }
    }
  },
}

動作確認

wrangler secret put WEBHOOK_SECRET   # 適当な文字列を入力
pnpm deploy

# 別タブで Consumer のログを流す
wrangler tail

# 署名付きで叩く
BODY='{"hello":"queue"}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "<さっき入れた文字列>" -hex | awk '{print $2}')
curl -X POST https://cf-agent-step2.<account>.workers.dev/webhook/slack \
  -H "content-type: application/json" \
  -H "x-signature: $SIG" \
  -d "$BODY"

wrangler tailprocess job slack { hello: 'queue' } が出たら成功。HTTP レスポンスは数十 ms で 200 が返り、重い処理は別レーンで進むという構造が手元で動いた状態になる。

確認しておく観点

  • 故意に署名を壊して 401 が返ること
  • Consumer 側で throw してリトライが効くこと(max_retries: 3 で DLQ 行き)
  • Cloudflare ダッシュボードの Queues タブで「Backlog」「Producer rate」「Consumer rate」のメーターが動くこと

Step 3: AI Gateway 経由で LLM を呼んでキャッシュする

目的

エージェントの「推論」部分を 1 本通す。OpenAI / Anthropic / Workers AI のどれを使っても、AI Gateway を前段に置くとログ・キャッシュ・レート制限・コスト計測が一元化できる。

AI Gateway を作る

Cloudflare ダッシュボード → AI → AI Gateway → Create Gateway。 名前 agent-gateway で作ると、エンドポイントが https://gateway.ai.cloudflare.com/v1/<account_id>/agent-gateway/<provider>/... の形で発行される。

Workers AI 経由で動かす最小コード

wrangler.toml に追記:

[ai]
binding = "AI"
app.post('/chat', async (c) => {
  const { prompt } = await c.req.json<{ prompt: string }>()

  // Workers AI(Cloudflare 自前のモデル)を AI Gateway 経由で叩く
  const result = await c.env.AI.run(
    '@cf/meta/llama-3.1-8b-instruct',
    { prompt },
    {
      gateway: {
        id: 'agent-gateway',
        cacheTtl: 3600,           // 同じ入力なら 1 時間キャッシュ
        skipCache: false,
      },
    }
  )

  return c.json(result)
})

OpenAI / Anthropic を AI Gateway 経由で叩く場合は、SDK の baseURL を AI Gateway のエンドポイントに差し替えるだけ。

動作確認

curl -X POST https://cf-agent-step2.<account>.workers.dev/chat \
  -H "content-type: application/json" \
  -d '{"prompt":"give me one sentence haiku about cloudflare"}'

# 同じ prompt を 2 回叩く → 2 回目は AI Gateway のキャッシュヒットでミリ秒応答

AI Gateway のダッシュボードに「Requests」「Cached」「Tokens」「Cost」が積み上がる。この時点で「Webhook → Queue → LLM 呼び出し」のループが Cloudflare 内で完結する

Step 4: Agents SDK + Durable Objects で「状態を持つエージェント」を 1 体動かす

目的

ここまでの Worker / Queue / LLM 呼び出しに、エージェントごとの永続状態WebSocket でのリアルタイム同期を足す。Agents SDK は内部で Durable Objects を使ってそれを実現する。

雛形

pnpm create cloudflare@latest cf-agent-step4 -- --template=cloudflare/agents-starter
cd cf-agent-step4
pnpm install

雛形には次のものが含まれる。

  • src/server.ts — Worker のエントリ。routeAgentRequest()/agents/<agent-name>/<id> を Durable Object に振り分ける
  • src/agent.tsAgent クラスを継承した自分のエージェント定義
  • wrangler.toml — Durable Objects の bindings と migrations が初期化済み

エージェントを 1 体作る

src/agent.ts:

import { Agent } from 'agents'

type State = {
  history: Array<{ role: 'user' | 'assistant'; content: string }>
}

export class ChatAgent extends Agent<Env, State> {
  initialState: State = { history: [] }

  async onMessage(connection, message: string) {
    const userMsg = { role: 'user' as const, content: message }
    this.setState({ history: [...this.state.history, userMsg] })

    const result = await this.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
      messages: this.state.history,
    })
    const reply = { role: 'assistant' as const, content: result.response }

    this.setState({ history: [...this.state.history, reply] })
    connection.send(JSON.stringify(reply))
  }
}

動作確認

pnpm dev
# ブラウザのコンソールで
const ws = new WebSocket('ws://localhost:8787/agents/chat-agent/my-session-1')
ws.onmessage = (e) => console.log('reply:', e.data)
ws.onopen = () => ws.send('hello, who are you?')

ブラウザを閉じて開き直しても、同じ my-session-1 に繋ぎ直せば履歴が残っている。これが Durable Objects の「ID ごとに 1 インスタンス」が効いている瞬間。

Step 2 の門番と繋ぐ

Step 2 の Consumer Worker から、

// Consumer 内
const id = env.CHAT_AGENT.idFromName(job.payload.sessionId)
const stub = env.CHAT_AGENT.get(id)
await stub.fetch('https://internal/agents/chat-agent/' + job.payload.sessionId, {
  method: 'POST',
  body: JSON.stringify({ message: job.payload.text }),
})

のように呼び出せば、「Webhook で受けたメッセージを、Queue 経由で、対応するエージェント Durable Object に渡す」フローが組める。これがエージェント基盤の最小完成形

条件が満たされた時に着手するチェックリスト(保留中)

冒頭の経済合理性チェックを通過したら、上から順に消化する。

  • Step 1 の Worker をデプロイ
  • Step 2 の Producer / Consumer を本物の Webhook 送出元(クライアント業務 LINE Bot / 読者フォーム / 外部サービス)で叩く
  • Step 3 の AI Gateway のキャッシュヒット率を測る(読者向けチャットなら同じ質問が積もる前提でキャッシュ設計)
  • Step 4 の ChatAgent を Step 2 の Consumer に繋ぐ
  • エージェントの履歴に長文を入れ続けたときの DO ストレージ消費を観察する
  • Code Mode(LLM に TypeScript を書かせて Dynamic Workers で実行する形)を別記事で試す

参考