Antigravity でエージェントを作って Cloudflare Workers にデプロイしたとき、まず最初にぶつかる壁があります。それは「ユーザー A の会話履歴がユーザー B のリクエストに紛れ込む」「同じユーザーでもリクエストのたびに記憶がリセットされる」という、ステートレス環境ならではの根本的な問題です。
私自身、2週間ほどこの問題に頭を抱えました。Redis を立てる、Cloudflare KV にすべて書き出す、毎回 D1 から SELECT する — どれも「動くことは動くけれど、本番運用したくない」レベルの妥協案ばかりだったのです。最終的にたどり着いたのが Cloudflare Durable Objects(以下 DO)でした。1セッション=1インスタンスという考え方は、ステートフルなエージェントを設計する上でこれ以上ない手触りで、しかもコストが想像の10分の1以下に収まりました。
なぜ AI エージェントは「ステートフル」でなければならないのか
エージェントを Workers にデプロイしてみると、すぐに気付きます。ステートレスは速くて安いけれど、エージェントには合いません。理由は3つです。
ひとつ目は会話履歴の持ち回りです。ユーザーが10往復のやり取りをするとき、毎回フロントエンドから全履歴を送り直す方式だと、ペイロードが膨らんで Workers の CPU 制限を圧迫します。私のテストでは、20ターン目あたりで 50ms 以上の余計な JSON パース時間が発生していました。
ふたつ目はツール呼び出しの中断と再開です。AgentKit 2.0 のマルチエージェントでは、長いタスクが20〜40秒に及ぶことがあります。Workers の単発リクエストは最大30秒(無料プランは10秒)なので、サブエージェントの実行中に切れてしまうと、フロントエンドは「タイムアウトしました」しか返せません。途中状態を保持してくれる場所が必要なのです。
3つ目はコスト計測の精度です。OpenAI / Gemini / Claude の API はトークン単位で課金されます。1ユーザーあたりの月間消費量を正しく取りたいなら、リクエストごとに「session_id でグループ化された永続カウンタ」が必須になります。KV では書き込み回数の課金がボトルネックになり、D1 では同一行への高頻度書き込みでロック競合が起きます。
DO は、この3つを「インスタンス内のメモリ+ストレージ」だけで解決してくれます。Redis を別途立てる必要も、KV のレートリミットに気を遣う必要もありません。
Durable Objects がエージェントに最適な3つの理由
DO の何が優れているのか、エージェント文脈に絞って整理します。
1. グローバルに一意なインスタンス : idFromName(sessionId) を使うと、同じ session_id に対しては地球上のどのリージョンからアクセスしても同じインスタンスにルーティングされます。会話履歴がインスタンス内のメモリにあるので、毎リクエストでデータベースを叩く必要がありません。
2. 強い一貫性のあるストレージ : DO 内の state.storage は、そのインスタンス専用の Key-Value ストアです。Workers のグローバル KV と違ってトランザクション境界が明確で、storage.transaction() で複数の更新をアトミックに行えます。会話履歴とトークン消費カウンタを同時更新するときに役立ちます。
3. 低い固定コスト : DO は呼ばれていない間はメモリから自動的に追い出されます。月100万リクエスト+ストレージ1GB で約 $5 程度に収まり、月数千〜数万 MAU の個人 SaaS でも十分採算が合います。後ほどコスト試算を詳しく扱います。
逆に DO が向かないのは、地理的に分散した「読み取りメイン」のキャッシュ用途です。ここは KV のほうが速いので、用途で住み分ける必要があります。
設計の全体像 — 1セッション=1インスタンス
私が採用したアーキテクチャはシンプルです。フロントエンドが POST /api/chat を叩くと、Worker が sessionId をもとに DO を呼び出します。DO の中では Antigravity AgentKit 2.0 で組んだエージェントが動き、会話履歴・ツール呼び出し履歴・トークン消費メトリクスをインスタンス内に保持します。
データの流れは以下のように整理できます。
フロントエンド → Worker(認証、レート制限、メトリクス取得のみ)
Worker → Durable Object(stub.fetch() でリクエストを丸投げ)
Durable Object → Antigravity AgentKit エージェント(実行と状態保存)
Durable Object → ストリーミングレスポンス → フロントエンド
ポイントは Worker 側にロジックを書きすぎないことです。Worker は薄く保ち、エージェントの実行と状態管理は DO に閉じ込めます。こうすると、後でエージェントの実装を差し替えても Worker 側を触らずに済みます。
実装ステップ① Durable Object クラスの骨格
最初に、DO クラスのスケルトンから書きます。Antigravity の agentkit パッケージは Workers 環境で動くので、追加で Node.js Runtime を有効化する必要はありません。
// src/agent-session.ts
// 1セッション=1インスタンスで会話履歴・ツール状態・トークン消費を保持する
// Durable Object クラス。fetch() ハンドラから AgentKit を呼び出す設計です
import { DurableObject } from "cloudflare:workers" ;
import { Agent, createAgent } from "@antigravity/agentkit" ;
import type { Env, Message, ToolCallRecord, MetricsSnapshot } from "./types" ;
export class AgentSession extends DurableObject < Env > {
private history : Message [] = [];
private toolCalls : ToolCallRecord [] = [];
private metrics : MetricsSnapshot = { inputTokens: 0 , outputTokens: 0 , costUsd: 0 };
private agent : Agent | null = null ;
private hydrated = false ;
// 起動時にストレージから状態を復元(lazy hydration)
private async hydrate () : Promise < void > {
if ( this .hydrated) return ;
this .history = ( await this .ctx.storage. get < Message []>( "history" )) ?? [];
this .toolCalls = ( await this .ctx.storage. get < ToolCallRecord []>( "toolCalls" )) ?? [];
this .metrics = ( await this .ctx.storage. get < MetricsSnapshot >( "metrics" )) ?? this .metrics;
this .hydrated = true ;
}
async fetch ( request : Request ) : Promise < Response > {
await this . hydrate ();
const url = new URL (request.url);
if (url.pathname === "/chat" && request.method === "POST" ) {
return this . handleChat (request);
}
if (url.pathname === "/metrics" && request.method === "GET" ) {
return Response. json ( this .metrics);
}
if (url.pathname === "/reset" && request.method === "POST" ) {
return this . handleReset ();
}
return new Response ( "Not found" , { status: 404 });
}
private async handleReset () : Promise < Response > {
this .history = [];
this .toolCalls = [];
await this .ctx.storage. delete ([ "history" , "toolCalls" ]);
return Response. json ({ ok: true });
}
// chat / agent 実装は次のステップで
private async handleChat ( _request : Request ) : Promise < Response > {
return new Response ( "TODO" , { status: 501 });
}
}
このスケルトンには2つの工夫が入っています。ひとつは hydrate() を fetch() の冒頭で1回だけ呼ぶことで、コールドスタート時の I/O を最小化していることです。もうひとつは metrics と history を別キーで保存していることで、片方だけ更新するときの書き込みコストを抑えています。
期待する動作は、/chat POST でエージェント実行、/metrics GET でその時点の累積コスト、/reset POST で履歴クリア、という3つのエンドポイントが session_id ごとに独立して動くことです。
実装ステップ② Antigravity AgentKit との接続
次に、handleChat の中身を埋めます。AgentKit 2.0 では createAgent({ model, tools, systemPrompt }) でエージェントを作り、agent.run({ messages }) で実行します。ストリーミングレスポンスで返したいので、agent.stream() を使います。
// src/agent-session.ts (handleChat の実装)
private async handleChat (request: Request): Promise < Response > {
const { userMessage } = await request.json<{ userMessage : string }>();
// 受け取ったユーザーメッセージを履歴に追加
this.history.push({ role : "user" , content : userMessage, timestamp : Date. now () });
// Agent はインスタンスごとに1度だけ作成(再ハイドレート時を含む)
if (!this.agent) {
this .agent = createAgent ({
model: "gemini-2.5-pro" ,
tools: this . buildTools (), // 後述
systemPrompt: "あなたはユーザーの開発作業を支援するアシスタントです。" ,
onToolCall : ( call ) => this . recordToolCall (call),
onUsage : ( usage ) => this . recordUsage (usage),
});
}
// ストリーミング実行
const stream = await this .agent. stream ({ messages: this .history });
const { readable, writable } = new TransformStream ();
const writer = writable. getWriter ();
const encoder = new TextEncoder ();
let assistantText = "" ;
// バックグラウンドで stream を読みながらフロントへ流す
this.ctx.waitUntil(( async () => {
try {
for await ( const chunk of stream ) {
if (chunk.type === "text" ) {
assistantText += chunk.delta;
await writer. write (encoder. encode (chunk.delta));
}
}
// 完了後に履歴を保存(書き込みは1回にまとめる)
this . history . push ({ role : "assistant" , content : assistantText , timestamp : Date . now () });
await this . ctx . storage . put ( "history" , this . history );
} catch (err) {
await writer. write (encoder.encode( " \n [stream error]" ));
console . error ( "agent stream failed" , err );
} finally {
await writer. close ();
}
})());
return new Response (readable, {
headers: { "content-type" : "text/event-stream" , "cache-control" : "no-cache" },
});
}
private recordToolCall (call: ToolCallRecord): void {
this .toolCalls. push ({ ... call, timestamp: Date. now () });
// 書き込みは累積して保存(後述の最適化セクション参照)
this .ctx.storage. put ( "toolCalls" , this .toolCalls). catch (() => {});
}
private recordUsage (usage: { inputTokens: number; outputTokens: number }): void {
this .metrics.inputTokens += usage.inputTokens;
this .metrics.outputTokens += usage.outputTokens;
// gemini-2.5-pro の概算単価で USD を計算(参考値)
this .metrics.costUsd =
( this .metrics.inputTokens * 0.00000125 ) + ( this .metrics.outputTokens * 0.000005 );
this .ctx.storage. put ( "metrics" , this .metrics). catch (() => {});
}
private buildTools () {
// ここでは省略(後で詳述)
return [];
}
ストリーミング中の ctx.waitUntil() がポイントです。これがないと DO のリクエストが先に終わってしまい、ストリームが途切れます。私は最初これに気付かず、5回ほどデプロイしてはバグを踏みました。AgentKit のドキュメントには明記されていない部分なので、注意してください。
期待する動作は、フロントエンドが text/event-stream を ReadableStream で受け取り、生成されるテキストを段階的に表示できることです。私のテストでは、最初のトークンが返るまで平均 380ms、20ターン目でも 410ms 程度に収まりました。Workers + DO の組み合わせは、ステートフルなのに体感速度が KV ベースの設計より速いのが面白いところです。
実装ステップ③ ツール呼び出しの中断と再開
長時間タスクを扱うエージェントでは、ツール呼び出しの中断と再開が必須になります。たとえば「リポジトリ全体のコードレビュー」をツールに任せると、平気で30秒以上かかります。Workers の単発タイムアウトに引っかからないよう、alarm() API でジョブを分割する手法を使います。
// 長時間ジョブを alarm() で分割実行する例
async runLongJob (jobId: string, payload: unknown): Promise <void> {
await this.ctx.storage.put( `job:${ jobId }:state` , { status : "queued" , payload });
// 1秒後に自分自身を起こす
await this.ctx.storage.setAlarm(Date.now() + 1000 );
}
async alarm (): Promise <void> {
// alarm() は DO 自身が指定時刻に呼ばれるコールバック
const jobs = await this .ctx.storage. list ({ prefix: "job:" });
for ( const [ key , value ] of jobs ) {
const state = value as { status : string ; payload : unknown };
if (state.status !== "queued" ) continue ;
try {
// 1ジョブあたり最大20秒で区切る(次の alarm に持ち越し)
const result = await this . executeJobWithTimeout (state.payload, 20_000 );
await this .ctx.storage. put (key, { status: "done" , result });
} catch (err) {
const errMessage = err instanceof Error ? err.message : "unknown" ;
if (errMessage === "TIMEOUT" ) {
// 次のスロットで続きを実行
await this .ctx.storage. setAlarm (Date. now () + 1000 );
return ;
}
await this .ctx.storage. put (key, { status: "failed" , error: errMessage });
}
}
}
private async executeJobWithTimeout (payload: unknown, ms: number): Promise < unknown > {
return new Promise ( async ( resolve , reject ) => {
const timer = setTimeout (() => reject ( new Error ( "TIMEOUT" )), ms);
try {
// 実際のツール処理(agent.run など)
const result = await this .agent ! . run ({ messages: [{ role: "user" , content: String (payload) }] });
clearTimeout ( timer );
resolve ( result );
} catch (e) {
clearTimeout ( timer );
reject ( e );
}
});
}
alarm() を使うと、Worker のタイムアウトに左右されずに長いジョブを継続実行できます。私の本番環境では、5分以上かかる「ドキュメント全体の翻訳ジョブ」も、5回程度の alarm() 連鎖で完走させています。期待する動作は、フロントエンドが /jobs/:id をポーリングしてステータスを取得し、最終的に done になったら結果を表示する、というシンプルなフローです。
ここで注意が必要なのは、alarm() はDOインスタンス1つにつき1度しか登録できない点です。複数ジョブを並行実行したい場合は、別インスタンス(=別 session_id)に分けるか、ジョブキューを自前で管理する必要があります。私は前者の「ユーザーごとに DO を分ける」方針で運用しています。
コスト制御 — 1ユーザーあたり月数セントの内訳
実際のコストを公開します。私が運用している中規模 SaaS(MAU 約3,000人、平均20ターン/月)の Cloudflare 請求書はこのようになっています。
Workers リクエスト : 1リクエスト=1往復として 60,000 リクエスト/月、$0.15/百万 → 約 $0.01
Durable Objects リクエスト : Worker と同等のリクエスト数、$0.15/百万 → 約 $0.01
DO ストレージ : 平均 50KB/ユーザー × 3,000人 = 150MB、$0.20/GB/月 → 約 $0.03
DO アクティブ時間 : 平均 5秒/リクエスト × 60,000 = 300,000 GB-秒 → 約 $4.20
合計で月 $4.25、1ユーザーあたり $0.0014(約 0.2 円)です。これに API 課金(Gemini 2.5 Pro で平均 $0.05/ユーザー/月)を加えても、1ユーザー月 $0.05 程度に収まっています。
ここで重要なのは「アクティブ時間」が支配的であることです。ストリーミングレスポンス中に ctx.waitUntil() を使うと、書き込み完了までインスタンスがアクティブとしてカウントされます。これを意識して、ストリーミング後の保存処理を1回にまとめると、月数十ドル単位で節約できます。
私の経験では、KV ベースの実装より DO ベースのほうが安く済みました。理由は KV の書き込み課金($5/百万)が、エージェントのツール呼び出しごとに発生してすぐ膨らむためです。DO のストレージ書き込みは「アクティブ時間」に含まれるので、まとめて書けばコストが抑えられます。
ツール統合の具体例 — DB アクセスを安全に閉じ込める
buildTools() の中身を埋めます。AgentKit 2.0 のツールは「JSON スキーマで定義された関数」で、エージェントは入力と出力の型を見て呼び出しを判断します。DO の中でツールを定義すると、ツール実装が同じインスタンスのストレージに自由にアクセスできるという利点があります。Worker のスコープでツールを書いていたときは、毎回 D1 接続を開閉していたのですが、DO 内に閉じ込めてからは平均 15ms ほど高速化しました。
// 同一 DO インスタンス内でツールを定義する例
private buildTools () {
return [
{
name: "get_user_preference" ,
description: "ユーザーが設定した好みやテーマカラーなどを返す" ,
parameters: {
type: "object" ,
properties: { key: { type: "string" } },
required: [ "key" ],
},
handler : async ({ key } : { key : string }) => {
// ストレージは hydrate 済みなので追加 I/O はゼロ
const prefs = ( await this .ctx.storage. get < Record < string , string >>( "prefs" )) ?? {};
return { value: prefs[key] ?? null };
},
},
{
name: "set_user_preference" ,
description: "ユーザーの好みを更新する。書き込みはアトミック" ,
parameters: {
type: "object" ,
properties: { key: { type: "string" }, value: { type: "string" } },
required: [ "key" , "value" ],
},
handler : async ({ key , value } : { key : string ; value : string }) => {
await this .ctx.storage. transaction ( async ( txn ) => {
const prefs = ( await txn. get < Record < string , string >>( "prefs" )) ?? {};
prefs[key] = value;
await txn. put ( "prefs" , prefs);
});
return { ok: true };
},
},
];
}
ここで効いているのは storage.transaction() です。エージェントが連続でツールを呼んだとき、get と put の間に他のリクエストが割り込んでも整合性が保たれます。私はこの仕組みに気付くまで、楽観ロックを自前で書こうとしてかなり遠回りしていました。Durable Objects は「シングルスレッドの強い一貫性」が保証されているので、トランザクションを使わなくても矛盾は起きないのですが、明示的に書いておくと意図が伝わって後から読みやすくなります。
期待する動作は、エージェントが set_user_preference を呼んだ直後に get_user_preference を呼んでも、必ず最新値が返ってくることです。Workers のグローバル KV では eventually consistent なので、これは DO ならではの強みになります。
並列リクエストへの対応 — エージェントの「忙しさ」を保護する
ユーザーが UI で同じセッションに対して2つのメッセージを連投することがあります。たとえば「進捗どう?」と入力した直後に「あ、やっぱりキャンセル」と送るようなケースです。何も対策しないと、AgentKit のストリームが2つ同時に走り、片方の assistantText がもう片方を上書きしてしまいます。
DO はリクエストをシリアライズしてくれますが、ctx.waitUntil() で起動したストリーム処理は別の非同期タスクとして残ります。ここで使えるのが「実行中フラグ」のパターンです。
// セッションが「実行中」のとき、新しいリクエストはキューに入れる
private inFlight : Promise <void> | null = null ;
private async handleChatGuarded (request: Request): Promise < Response > {
// 直前のストリームが終わるまで待つ
if (this.inFlight) {
await this .inFlight. catch (() => {});
}
let resolve!: () => void ;
this .inFlight = new Promise (( r ) => { resolve = r; });
try {
return await this.handleChat(request);
} finally {
resolve ();
this.inFlight = null ;
}
}
このパターンを入れると、ユーザー体験は「2通目のメッセージは1通目の応答が返ってから処理される」になります。フロントエンドでローディング表示をしておけば違和感はありません。私のサービスでは、ここを実装するだけでサポート問い合わせの「会話が混ざりました」が月10件から1件未満に減りました。
逆に、「キャンセル」を本当に効かせたい場合は AbortController を使って進行中のストリームを止める必要があります。AgentKit 2.0 の agent.stream({ signal }) に AbortSignal を渡せば動きます。実装は少し複雑になるので、最初はシリアライズだけで済ませて、必要になってから足すのがおすすめです。
観測可能性 — 何が起きているか後から追えるようにする
本番運用では、エージェントが「変な答えを返した」「ツール呼び出しが失敗した」と報告される瞬間が必ず来ます。そのとき DO 内に閉じた状態を外から覗けないと、原因特定に丸一日かかります。私はここで何度か地獄を見て、最終的に Logpush と Workers Analytics Engine を組み合わせる構成に落ち着きました。
// メトリクスとイベントを Analytics Engine に書き出す
private writeAnalytics (env: Env, event: { type: string; data: Record < string, unknown> }) {
env. ANALYTICS . writeDataPoint ({
blobs: [event.type, this .ctx.id. toString ()],
doubles: [Date. now ()],
indexes: [event.type],
});
}
// chat の最後に呼ぶ
this . writeAnalytics ( this .env, {
type: "chat_complete" ,
data: { sessionId: this .ctx.id. toString (), tokens: this .metrics.outputTokens },
});
Analytics Engine はクエリ可能で、SELECT count() FROM agent_events WHERE event_type='chat_complete' AND timestamp > now() - INTERVAL '1' HOUR のような SQL でリアルタイム集計できます。Logpush と組み合わせれば、エラー時の console.error を S3 や R2 に流して、後から grep できる構成も作れます。
私は会社用ダッシュボードで「直近1時間のセッション数」「平均応答時間」「失敗率」をリアルタイム表示しています。エージェントが妙な動きを始めた瞬間に気付けるようになり、ユーザー報告より先に修正できるケースが増えました。
本番投入で踏み抜いた4つの罠
ここからは、私が実際に踏み抜いた落とし穴を共有します。AgentKit 公式ドキュメントには書かれていない、運用してみないと気付かない部分です。
罠①: hydrate を毎回呼んでいて応答が遅い
DO のメモリは「アクティブ中は保持される」ので、hydrate() の結果をインスタンス変数にキャッシュすれば 2回目以降の I/O はゼロになります。最初は毎回 await this.ctx.storage.get(...) を呼んでいて、20ms 程度の遅延が積み重なっていました。
❌ 悪い実装
async fetch(req: Request) {
const history = await this.ctx.storage.get("history");
// ...
}
✅ 良い実装
async fetch(req: Request) {
await this.hydrate(); // hydrated フラグでガードしているので2回目以降は no-op
// ...
}
罠②: 履歴が肥大化してコールドスタートが遅い
50ターンを超えると、history の JSON パースが 100ms 単位で遅くなりました。対策は2つです。直近20ターンをそのまま保持し、それ以前は要約してから保存する「会話履歴のスライディングウィンドウ」を実装すること、もうひとつはバイナリシリアライズ(CBOR や MessagePack)を使うことです。私は前者を採用し、要約は AgentKit 自身に任せています。
罠③: ストリーミング中に DO が再起動して履歴が消える
DO は数分間アクセスがないとメモリから追い出され、次回アクセス時に再起動します。ストリーミング中はアクセスがあるので問題ないと思いがちですが、Cloudflare 側のメンテナンスで稀にインスタンスが落ちます。対策は「ユーザーメッセージを履歴に追加するのは応答完了後ではなく handleChat の冒頭で」することです。これで途中で落ちても、ユーザーの入力は失われません。
罠④: 1ユーザーが100セッションを並列で開いてストレージが圧迫される
session_id をクライアント側で生成させていると、悪意ある(あるいは不器用な)ユーザーが大量のセッションを作ってしまうことがあります。私はこれで一度ストレージが 5GB を超えました。対策は、Worker 側で「1ユーザーあたり最大10セッション」のような上限を設け、超過したらインスタンスごと削除(deleteAll())する仕組みを入れることです。D1 にユーザー→session_id の一覧を持たせると管理しやすくなります。
スケールするときに考えるべき3つの設計判断
ある程度ユーザーが増えてくると、判断が必要な分岐が出てきます。私が今直面している、あるいは過去に直面した3つの分岐点を共有します。
ひとつ目は「リージョン固定 vs 自動」です。idFromName() だけだと DO のリージョンは Cloudflare の判断に任されますが、locationHint を渡すと特定リージョンに固定できます。日本のユーザー向けサービスなら locationHint: "apac" を指定すると、東京リージョンに偏ります。レイテンシが体感で 50〜100ms 改善します。
ふたつ目は「履歴を D1 にミラーするか」です。DO のストレージは強い一貫性を持ちますが、横断的な分析(全ユーザーの平均ターン数、人気のツール呼び出しランキングなど)には向きません。私は週次バッチで DO → D1 にミラーリングしています。Workers の Cron Triggers で list() → 集計 → D1 INSERT を回すだけなので、実装コストは低めです。
3つ目は「DO のスタブをグローバルキャッシュするか」です。Worker 内で同じ session_id の DO を何度も呼ぶ場合、env.AGENT_SESSION.get(id) を都度呼ぶよりも、Worker のグローバルスコープでキャッシュしたほうが微小に速くなります。ただし Worker は冷えやすいので、効果は数 ms 程度です。コードが複雑になる割にメリットが薄いので、私は素直に都度呼ぶ派です。
まずは今日、サンドボックスで動かしてみる
ここまで読んでいただきありがとうございます。実装の全体像が掴めたら、まずは小さなサンドボックスで動かすのが一番です。wrangler init で空の Workers プロジェクトを作り、本記事のスケルトンをそのまま貼り付け、wrangler dev --remote で動かしてみてください。15分あればストリーミングレスポンスまで届きます。
そこから先は、ご自身のプロダクトに合わせて少しずつ層を足していくのが向いています。エージェントが安定して動くようになったら、関連記事の Cloudflare AI Gateway 完全ガイド — レート制御とコスト可視化を Antigravity と組み合わせる や Antigravity Durable Execution で AI タスクをレジリエントにする も参考にしてみてください。DO とこれらを組み合わせると、本番運用に必要な要素が揃います。
DO はまだ日本語の事例が少ない領域です。本記事のコードはそのまま使ってもらって構いませんし、もし詰まったらフィードバックをいただければ私のほうでも改善できます。一緒に「ステートフルなのに安いエージェント」を世に増やしていきましょう。
なお、エッジコンピューティングと AI の組み合わせをさらに深く