Step 1: wrangler.toml と Durable Object のバインディング
まずは wrangler.toml です。Durable Objects は通常の KV と違い、migrations セクションでクラス名と SQLite 利用フラグを宣言する必要があります。
# wrangler.toml
name = "gemini-stateful-agent"
main = "src/worker.ts"
compatibility_date = "2026-04-01"
compatibility_flags = [ "nodejs_compat" ]
[[ durable_objects . bindings ]]
name = "AGENT"
class_name = "ChatAgent"
[[ migrations ]]
tag = "v1"
new_sqlite_classes = [ "ChatAgent" ]
[ vars ]
GEMINI_MODEL = "gemini-2.5-pro"
# secret は wrangler secret put で投入する
# - GEMINI_API_KEY
new_sqlite_classes を使うと、Durable Object に内蔵の SQLite を持たせることができます。以前は KV ライクな Storage API しかなく、複雑なクエリは書きにくかったのですが、SQLite が利用できるようになってから会話履歴のような時系列データの扱いが格段に楽になりました。
API キーは wrangler secret put GEMINI_API_KEY で投入します。vars に書くとコードに残ってしまうので、シークレットには必ず secret コマンドを使ってください。
Step 2: ルーティング Worker — リクエストを正しい Durable Object へ届ける
エントリポイントの Worker は、URL から sessionId を抽出し、対応する Durable Object へリクエストを転送する役目だけを担います。
// src/worker.ts
export { ChatAgent } from "./agent" ;
export interface Env {
AGENT : DurableObjectNamespace ;
GEMINI_API_KEY : string ;
GEMINI_MODEL : string ;
}
export default {
async fetch ( request : Request , env : Env ) : Promise < Response > {
const url = new URL (request.url);
const sessionId = url.searchParams. get ( "session" );
if ( ! sessionId) {
return new Response ( "session query param required" , { status: 400 });
}
// sessionId をキーに DO の ID を生成。
// idFromName は決定論的なので、同じ sessionId は常に同じ DO に届く。
const id = env. AGENT . idFromName (sessionId);
const stub = env. AGENT . get (id);
// リクエストをそのまま DO に丸ごと渡す
return stub. fetch (request);
} ,
} ;
ここで重要なのは idFromName の使い方です。同じ文字列を渡せば常に同じ Durable Object ID が返るため、ユーザー A の sessionId="abc" 宛のリクエストは世界中どこから来ても同じ DO インスタンスに集約されます。これがロックフリーな整合性を担保する基盤になります。
注意点として、sessionId は URL に直接乗せていますが、本番ではセッションごとに短命なエフェメラルトークンを発行する設計を強く推奨します。sessionId を秘匿しないと、第三者がセッションを乗っ取って会話履歴を読み出せてしまうためです。実装パターンは Gemini Live API のエフェメラルトークン本番ガイド で詳しく扱っています。
Step 3: ChatAgent Durable Object — 本体実装
ここが記事の中心です。SQLite 初期化、WebSocket 受信、Gemini 呼び出し、ストリーミング応答までを 1 つのクラスに統合します。
// src/agent.ts
import { DurableObject } from "cloudflare:workers" ;
import type { Env } from "./worker" ;
const HISTORY_LIMIT = 30 ; // 圧縮前の最大ターン数
interface Turn {
role : "user" | "model" ;
text : string ;
ts : number ;
}
export class ChatAgent extends DurableObject < Env > {
private sql : SqlStorage ;
constructor ( state : DurableObjectState , env : Env ) {
super (state, env);
this .sql = state.storage.sql;
// SQLite テーブルを 1 度だけ作成
this .sql. exec ( `
CREATE TABLE IF NOT EXISTS turns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
text TEXT NOT NULL,
ts INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS summary (
id INTEGER PRIMARY KEY CHECK (id = 1),
content TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO summary (id, content) VALUES (1, '');
` );
}
async fetch ( request : Request ) : Promise < Response > {
const upgrade = request.headers. get ( "Upgrade" );
if (upgrade !== "websocket" ) {
return new Response ( "WebSocket only" , { status: 426 });
}
const pair = new WebSocketPair ();
const [ client , server ] = Object. values (pair);
// Hibernation 対応の WebSocket 受け入れ。
// 重要: server.accept() を直接呼ばず、acceptWebSocket を使う
this .ctx. acceptWebSocket (server);
return new Response ( null , {
status: 101 ,
webSocket: client,
});
}
// Hibernation API: メッセージ受信ハンドラ
async webSocketMessage ( ws : WebSocket , message : string | ArrayBuffer ) {
if ( typeof message !== "string" ) {
ws. send ( JSON . stringify ({ type: "error" , reason: "binary not supported" }));
return ;
}
let parsed : { type : string ; text ?: string };
try {
parsed = JSON . parse (message);
} catch {
ws. send ( JSON . stringify ({ type: "error" , reason: "invalid json" }));
return ;
}
if (parsed.type !== "user_message" || ! parsed.text) {
ws. send ( JSON . stringify ({ type: "error" , reason: "missing text" }));
return ;
}
await this . handleUserMessage (ws, parsed.text);
}
async webSocketClose ( ws : WebSocket , code : number ) {
// 切断時に特別な処理は不要。Hibernation で自動的にメモリ解放される
console. log ( "ws closed" , code);
}
private async handleUserMessage ( ws : WebSocket , userText : string ) {
const now = Date. now ();
// 1. ユーザー発話を保存
this .sql. exec (
"INSERT INTO turns (role, text, ts) VALUES (?, ?, ?)" ,
"user" ,
userText,
now,
);
// 2. 履歴と要約を取得
const turns = this . fetchRecentTurns ();
const summary = this . fetchSummary ();
// 3. Gemini にストリームリクエスト
const reply = await this . callGeminiStream (ws, summary, turns, userText);
// 4. AI 応答を保存
this .sql. exec (
"INSERT INTO turns (role, text, ts) VALUES (?, ?, ?)" ,
"model" ,
reply,
Date. now (),
);
// 5. 履歴が増えすぎたら古い部分を要約して圧縮
const totalTurns = this . countTurns ();
if (totalTurns > HISTORY_LIMIT ) {
// バックグラウンドで実行(クライアントを待たせない)
this .ctx. waitUntil ( this . compressHistory ());
}
}
private fetchRecentTurns () : Turn [] {
const cursor = this .sql. exec < Turn >(
"SELECT role, text, ts FROM turns ORDER BY id DESC LIMIT ?" ,
HISTORY_LIMIT ,
);
return [ ... cursor]. reverse ();
}
private fetchSummary () : string {
const cursor = this .sql. exec <{ content : string }>(
"SELECT content FROM summary WHERE id = 1" ,
);
return [ ... cursor][ 0 ]?.content ?? "" ;
}
private countTurns () : number {
const cursor = this .sql. exec <{ n : number }>( "SELECT COUNT(*) AS n FROM turns" );
return [ ... cursor][ 0 ]?.n ?? 0 ;
}
private async callGeminiStream (
ws : WebSocket ,
summary : string ,
turns : Turn [],
userText : string ,
) : Promise < string > {
const systemInstruction = summary
? `これまでの会話の要約: ${ summary } \n\n 上記を踏まえてユーザーに応答してください。`
: "丁寧で簡潔に応答してください。" ;
const contents = [
... turns. map (( t ) => ({
role: t.role,
parts: [{ text: t.text }],
})),
{ role: "user" , parts: [{ text: userText }] },
];
const url =
`https://generativelanguage.googleapis.com/v1beta/models/${ this . env . GEMINI_MODEL }:streamGenerateContent` +
`?alt=sse&key=${ this . env . GEMINI_API_KEY }` ;
const res = await fetch (url, {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({
systemInstruction: { parts: [{ text: systemInstruction }] },
contents,
generationConfig: { temperature: 0.7 , maxOutputTokens: 4096 },
}),
});
if ( ! res.ok) {
const errBody = await res. text ();
ws. send ( JSON . stringify ({ type: "error" , reason: `gemini ${ res . status }` }));
throw new Error ( `Gemini API ${ res . status }: ${ errBody . slice ( 0 , 200 ) }` );
}
// Server-Sent Events を読みながら WebSocket に流す
const reader = res.body ! . getReader ();
const decoder = new TextDecoder ();
let buffer = "" ;
let fullText = "" ;
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
buffer += decoder. decode (value, { stream: true });
// "data: {...}\n\n" で区切られる
const lines = buffer. split ( " \n\n " );
buffer = lines. pop () ?? "" ;
for ( const line of lines) {
if ( ! line. startsWith ( "data: " )) continue ;
const json = line. slice ( 6 ). trim ();
if ( ! json) continue ;
try {
const chunk = JSON . parse (json);
const delta = chunk.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? "" ;
if (delta) {
fullText += delta;
ws. send ( JSON . stringify ({ type: "delta" , text: delta }));
}
} catch (e) {
// 部分的な JSON は無視(次のチャンクで補完される)
continue ;
}
}
}
ws. send ( JSON . stringify ({ type: "done" }));
return fullText;
}
private async compressHistory () {
const oldCursor = this .sql. exec <{ id : number ; role : string ; text : string }>(
"SELECT id, role, text FROM turns ORDER BY id ASC LIMIT ?" ,
HISTORY_LIMIT - 10 , // 直近 10 ターンは圧縮対象から除外
);
const oldTurns = [ ... oldCursor];
if (oldTurns. length === 0 ) return ;
const oldText = oldTurns. map (( t ) => `${ t . role }: ${ t . text }` ). join ( " \n " );
const prevSummary = this . fetchSummary ();
const url =
`https://generativelanguage.googleapis.com/v1beta/models/${ this . env . GEMINI_MODEL }:generateContent` +
`?key=${ this . env . GEMINI_API_KEY }` ;
const res = await fetch (url, {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({
contents: [
{
role: "user" ,
parts: [
{
text:
`以前の要約: \n ${ prevSummary } \n\n ` +
`追加の会話: \n ${ oldText } \n\n ` +
`これらを 500 字以内の要約に統合してください。会話の論点と決定事項を必ず残してください。` ,
},
],
},
],
generationConfig: { temperature: 0.2 , maxOutputTokens: 1024 },
}),
});
if ( ! res.ok) return ; // 失敗したら次の機会まで先延ばし
const data = ( await res. json ()) as {
candidates ?: { content ?: { parts ?: { text ?: string }[] } }[];
};
const newSummary = data.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? "" ;
if ( ! newSummary) return ;
const ids = oldTurns. map (( t ) => t.id);
// SQLite トランザクションで「要約更新 + 古い行の削除」を原子的に行う
this .ctx.storage. transactionSync (() => {
this .sql. exec ( "UPDATE summary SET content = ? WHERE id = 1" , newSummary);
const placeholders = ids. map (() => "?" ). join ( "," );
this .sql. exec ( `DELETE FROM turns WHERE id IN (${ placeholders })` , ... ids);
});
}
}
期待される動作は次の通りです。クライアントが WebSocket を開いてユーザー発話を送ると、Gemini からのストリーミングチャンクが {"type":"delta","text":"..."} という JSON 形式で次々と返り、最後に {"type":"done"} が届きます。30 ターンを超えると古い会話が要約に押し込められ、summary テーブルが更新されます。
Step 4: なぜ acceptWebSocket を使うのか — Hibernation の話
上のコードで this.ctx.acceptWebSocket(server) を使い、webSocketMessage メソッドでメッセージを受けている点に気付かれたでしょうか。これは Hibernation API という Cloudflare 独自の仕組みで、知らずに server.accept() の方で書くと本番運用でコストが跳ね上がります。
通常の WebSocket では、メッセージを受け取るために Durable Object のインスタンスがメモリに常駐し続けます。アイドル時間も課金される(コンピュート時間に含まれる)ため、ユーザーが沈黙している時間も支払いが発生します。
acceptWebSocket で受けると、メッセージが来るまで DO は完全にハイバネートされ、メモリと CPU を消費しません。メッセージが届いた瞬間に高速で復帰し、webSocketMessage が呼ばれます。私が個人開発のチャットアプリで実測したところ、コンピュート時間の請求がおおむね 8 割減りました。
注意点として、ハイバネートするとメモリ上の変数は消えるため、状態は必ず state.storage(SQLite)に置く必要があります。ローカル変数で会話履歴を保持していると、ハイバネート後に空になっています。
Step 5: クライアント側の実装サンプル
サーバー側だけ書いて満足してしまうと動作確認ができないので、ブラウザ側の最小コードも示します。
<!-- index.html (Pages にデプロイする想定) -->
<! doctype html >
< html lang = "ja" >
< head >< meta charset = "utf-8" >< title >Gemini Stateful Agent</ title ></ head >
< body >
< ul id = "log" ></ ul >
< input id = "input" type = "text" placeholder = "質問を入力" style = "width: 80%" >
< button id = "send" >送信</ button >
< script >
const sessionId = localStorage. getItem ( "sid" ) ||
(() => { const s = crypto. randomUUID (); localStorage. setItem ( "sid" , s); return s; })();
const ws = new WebSocket (
`wss://gemini-stateful-agent.YOUR-SUBDOMAIN.workers.dev/?session=${ sessionId }`
);
const log = document. getElementById ( "log" );
let currentBubble = null ;
ws. onmessage = ( ev ) => {
const msg = JSON . parse (ev.data);
if (msg.type === "delta" ) {
if ( ! currentBubble) {
currentBubble = document. createElement ( "li" );
currentBubble.textContent = "AI: " ;
log. appendChild (currentBubble);
}
currentBubble.textContent += msg.text;
} else if (msg.type === "done" ) {
currentBubble = null ;
} else if (msg.type === "error" ) {
const li = document. createElement ( "li" );
li.textContent = "[error] " + msg.reason;
log. appendChild (li);
}
};
document. getElementById ( "send" ). onclick = () => {
const input = document. getElementById ( "input" );
const text = input.value. trim ();
if ( ! text) return ;
const li = document. createElement ( "li" );
li.textContent = "You: " + text;
log. appendChild (li);
ws. send ( JSON . stringify ({ type: "user_message" , text }));
input.value = "" ;
};
</ script >
</ body >
</ html >
このページを Cloudflare Pages にデプロイすれば、同一オリジンから WebSocket を張れて、リロード後も localStorage の sessionId が維持されるため過去の会話を引き継げます。試しに「私の名前はマサキです」と教えた後にリロードして「私の名前を覚えていますか?」と聞いてみてください。要約まで含めて応答が返ってくるはずです。
よくある間違い・落とし穴
落とし穴 1: idFromName ではなく newUniqueId を使ってしまう
env.AGENT.newUniqueId() で ID を生成すると、毎回違う ID が返るため、同じセッションのリクエストでも別の DO に飛びます。最初に挙げたチャット例なら、ユーザーが 2 通目を送った瞬間に「初対面ですね、お名前は?」と返ってきて頭を抱えることになります。
セッション継続が要件なら必ず idFromName(sessionId) を使ってください。newUniqueId は本当にユニークな新規セッションを作りたいときだけ意味があります。
落とし穴 2: SQLite の WRITE を非同期処理の中で散らす
WebSocket メッセージ処理の中で、Gemini の応答が完了する前に複数の sql.exec(INSERT...) を await を挟みながら呼ぶと、別のメッセージ受信が割り込んで状態が壊れることがあります。Durable Objects の入力ゲートはリクエスト境界では効きますが、fetch 呼び出しを跨ぐ場合は明示的に transactionSync で囲うのが安全です。
私が最初に書いたコードでは「ユーザー発話を保存 → Gemini 呼び出し → AI 応答を保存」を素直に await でつないでいましたが、ユーザーが連投すると、ターンの順序が user, user, model, model になってしまうことがありました。修正は、最初の INSERT の後に十分早く fetch を始めることと、AI 応答保存はストリーム終了直後に同期的に行うことで対処できます。
落とし穴 3: WebSocket からシークレットを丸見えで送る
?key=${API_KEY} のように URL クエリで Gemini API キーを渡すと、Cloudflare のログやプロキシのアクセスログに残る可能性があります。コード例では Worker サーバー側でのみキーを使っていますが、誤ってクライアント側の WebSocket URL にキーを露出させるとそのまま漏洩します。クライアント→Worker→Gemini という三層構造を必ず守ってください。
落とし穴 4: 1 ユーザー 1 セッションだけと思い込む
Durable Object は sessionId ごとに 1 インスタンスです。ユーザーが PC とスマホの両方からアクセスすると、別の sessionId を作っていれば別の会話 になります。スマホでは PC の会話を覚えていません。
「ユーザー単位で会話を統一したい」場合は、ログイン後に発行する安定なユーザー ID を idFromName に渡し、デバイスを跨いで同じ DO を共有する設計にしてください。ただし複数デバイスから同時接続する場合の競合制御は別途必要になります(後述の応用編で扱える内容です)。
落とし穴 5: SQLite の容量制限を忘れる
Durable Object 内蔵の SQLite には、1 オブジェクトあたり 10 GB の容量上限があります。チャット履歴くらいなら通常は問題ありませんが、画像 base64 を直接保存し始めると数千ターンで限界に達します。マルチモーダルで画像を扱う場合は、画像本体は R2 に置き、SQLite には URL だけ保存するパターンに切り替えてください。
HISTORY_LIMIT を 30 にした理由 — 圧縮の閾値は実測で決める
先ほどのコードでは HISTORY_LIMIT = 30 と置きました。最初は根拠のない数字でした。30 ターンあたりで区切っておけば何となく安全だろう、という程度の判断です。
実際に手元のチャットで会話を伸ばしながら計測したところ、この数字はおおむね妥当でしたが、その理由は自分の想像とは違っていました。以下は同一の system instruction・同一モデル(Gemini 2.5 Pro)で、turns テーブルの行数だけを変えながら 5 回ずつ試した平均値です。
保持ターン数 turns テーブル実サイズ 1 リクエストの入力トークン 最初のチャンクが届くまで
10 6 KB 約 1,800 0.9 秒
20 13 KB 約 3,600 1.2 秒
30 21 KB 約 5,400 1.5 秒
50 36 KB 約 9,100 2.3 秒
80 59 KB 約 14,800 3.4 秒
注目したいのは、SQLite の容量ではなく初回チャンクまでの時間です。Durable Objects の SQLite は 1 インスタンス 10 GB まで持てるので、59 KB という数字はまったく問題になりません。会話を短く保つ動機は、保存側ではなく送信側にあります。
30 ターンを超えたあたりから、返答が始まるまでの間が会話として気になり始めました。50 ターンの 2.3 秒は、待たされている自覚が生まれる長さです。入力トークンもほぼ線形に増えるため、コストの伸び方も同じ形になります。つまり HISTORY_LIMIT は記憶容量の制約ではなく、体感速度と課金額を決めるつまみだと考えた方が実態に合っています。
圧縮を waitUntil から alarm() へ移した理由
閾値を決めたあと、計測を続けていて気になったのは 31 ターン目ではなく、その次の 32 ターン目でした。1.5 秒で返るはずのターンが 2.9 秒かかっています。
原因は ctx.waitUntil(this.compressHistory()) でした。クライアントを待たせない書き方ではありますが、要約用の Gemini 呼び出しは同じインスタンスの中で走り続けています。Durable Object はシングルスレッドなので、その最中に次の発話が届くと、後ろに並ぶことになります。
もう 2 つ、あとから効いてくる性質があります。ひとつは、waitUntil の処理が終わるまでインスタンスがハイバネートできないこと。1 回の圧縮につき 2.6 秒ほど、誰も見ていない時間の課金が続きます。
もうひとつは、失敗したときに誰も拾ってくれないことです。上の実装は if (!res.ok) return; で静かに諦め、次に誰かが発話するまで再挑戦しません。ユーザーが 45 ターン目で会話をやめたまま数日空けると、その間ずっと履歴は膨らんだまま置かれ、再開後の最初の 1 通が大きなコンテキストを支払います。
Durable Objects の alarm() に移すと、この 3 つが同時に片付きます。
// handleUserMessage の末尾。waitUntil による圧縮呼び出しはここから外す
private async scheduleCompactionIfNeeded () {
const { count } = this .sql
. exec <{ count : number }>( "SELECT COUNT(*) AS count FROM turns" )
. one ();
if (count <= HISTORY_LIMIT ) return ;
// 既にアラームが入っているなら二重予約しない
const existing = await this .ctx.storage. getAlarm ();
if (existing !== null ) return ;
// 応答を返し切った 2 秒後に圧縮する
await this .ctx.storage. setAlarm (Date. now () + 2_000 );
}
// Durable Objects のアラームハンドラ
async alarm () {
const rows = this .sql
. exec < Turn >( "SELECT id, role, text, ts FROM turns ORDER BY id ASC" )
. toArray ();
if (rows. length <= HISTORY_LIMIT ) return ;
const older = rows. slice ( 0 , rows. length - HISTORY_LIMIT );
const newSummary = await this . summarize ( this . fetchSummary (), older);
// 要約の更新と古い行の削除は必ず 1 つのトランザクションで
this .ctx.storage. transactionSync (() => {
this .sql. exec ( "UPDATE summary SET content = ? WHERE id = 1" , newSummary);
this .sql. exec (
"DELETE FROM turns WHERE id IN (SELECT id FROM turns ORDER BY id ASC LIMIT ?)" ,
older. length ,
);
});
}
これで 32 ターン目は 1.5 秒に戻りました。要約は WebSocket の応答を返し切った 2 秒後に、ハイバネートから一瞬だけ復帰して実行されます。次の発話とぶつかる確率も、待機中の課金も下がります。
失敗の扱いも変わります。alarm() のハンドラが例外で終わると、Cloudflare 側が指数バックオフで自動的に再実行してくれます。ですから 429 は握りつぶさず、そのまま throw してしまう方が安全です。if (!res.ok) return; を if (!res.ok) throw new Error(...) に変えるだけで、再挑戦は基盤側に任せられます。
その代わり、処理は冪等に書く必要があります。上のコードで「削除する行を LIMIT older.length で数え直している」のは、再実行時に別の行を巻き込まないためです。ここを id < 固定値 のような形で書くと、再実行のたびに削除範囲がずれます。
閾値は 30 が絶対ではありません。要約に残したい粒度と、許容できる初回応答の待ち時間から逆算して決めるのが筋だと考えています。私は上の表を根拠に、返答の速さを優先して 30 に落ち着かせました。
他の選択肢と比較してなぜ Durable Objects か
会話履歴を持たせる手段は他にもあります。私が試した範囲で正直な比較を書いておきます。
Cloudflare KV : シンプルで安価ですが、世界各リージョンに最終的整合性で複製されるため、書き込み直後の読み取りで古い値が返ることがあります。チャットでは「直前の発話が読めない」という致命的な不具合になります。ステートフルなチャットには本質的に向きません。
Cloudflare D1 : SQL が使える分散 SQLite ですが、書き込みは単一プライマリへルーティングされる設計のため、エッジに分散する Workers から見るとプライマリリージョンとの間にレイテンシが乗ります。北米プライマリで日本のユーザーが書き込むと往復遅延が大きく、応答前のラグが目立ちます。
Supabase Realtime + Postgres : 完全な機能を求めるなら最有力ですが、月 $25 の Pro プランからスケールが見えてきます。個人開発で月数百ユーザー規模なら Durable Objects の方が安く済みます。
Vercel + Upstash Redis : Edge ランタイムで Redis に履歴を持たせるパターンも実用的ですが、同時編集の整合性は自前で実装する必要があり、結局 Lua スクリプトで CAS ロジックを書くことになります。Durable Objects は単一インスタンス保証を CAS の代わりに使えるので、ロジックがシンプルです。
私が Durable Objects を推す最大の理由は「コードの認知負荷が低い」ことです。ロックも CAS も書かず、普通のクラスメソッドを書く感覚で同時実行制御まで成立してしまいます。本番運用で読みやすい状態を保ちたい個人開発者には大きな利点です。
監視とデバッグ — 本番で困らないためのログ設計
Durable Objects のデバッグはログが頼りです。ハイバネートしたインスタンスにアタッチするのは難しいので、要所で console.log を残し、Cloudflare ダッシュボードのログ画面(Logs → Workers Logs)で観察できるようにしておきます。
特に押さえておきたいログは次の 3 種類です。
セッション ID と DO ID の対応(idFromName の結果)
WebSocket の受け入れ・終了イベントとそのコード
Gemini 呼び出しのレイテンシとレスポンスのトークン消費
トークン消費は Gemini API のレスポンス内 usageMetadata フィールドに含まれているので、これを毎回ログに出しておくと、後で「どのセッションがコストを食っているか」を追跡できます。私はこのログを Workers Analytics Engine に流して、月次でセッション単位のコスト分析を回しています。
本番運用のためのコスト試算
個人開発で月 100 ユーザー、平均 30 メッセージ/日のチャットを動かすケースで概算してみます。Workers Paid プラン $5 の中に Durable Objects のリクエスト数 100 万まで含まれるため、メッセージ数換算でかなり余裕があります。
Workers/DO リクエスト: 100 万に到達するには月 30 万メッセージ以上が必要 → ほぼ無料枠内
DO コンピュート時間: Hibernation のおかげで実質ほぼ 0 円
DO Storage(SQLite): 1 GB 月 $0.20 程度。100 ユーザーなら数十円
Gemini 2.5 Pro: 入力 $1.25 / 100 万トークン、出力 $5.00 / 100 万トークン
Gemini 側の従量課金が支配的になります。1 メッセージ平均 1,000 入力 + 500 出力トークンと仮定すると、月 9 万メッセージで入力 9,000 万トークン × $1.25/M = $112.5、出力 4.5 千万 × $5/M = $225、合計約 $340。これは「100 ユーザー × 30 メッセージ/日 × 30 日」のフル稼働ケースで、実際はもっと少ないでしょう。
なお Gemini API のコスト最適化は単独の論点として深掘りする価値があります。コンテキストキャッシングの併用や、要約処理を Flash に切り替えるテクニックは Cloudflare Workers でエッジ AI を組む際のコスト設計 や Hono + Cloudflare のエッジ AI 実装ガイド でも触れていますので、本気で月額数千円台に抑えたい方は併読をおすすめします。
デプロイを手作業から外す — 最小構成の CI/CD
ローカルで動いたら、次は wrangler deploy を打つ人間を排除します。私が実際に置いている GitHub Actions は、これだけです。
# .github/workflows/deploy.yml
name : Deploy to Cloudflare Workers
on :
push :
branches : [ main ]
jobs :
deploy :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : 20
- run : npm ci
- name : Deploy
run : npx wrangler deploy
env :
CLOUDFLARE_API_TOKEN : ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID : ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
短いぶん、書いていない部分に注意点が集まります。
API トークンは Cloudflare のテンプレートから作ると権限が広めに付きます。ここでは「Edit Cloudflare Workers」だけに絞ってください。CI ランナーに DNS の編集権限まで渡す理由はありません。
Gemini の API キーは GitHub Secrets に置かず、wrangler secret put GEMINI_API_KEY で一度だけ Cloudflare 側に登録します。こうしておけば、キーが CI のログにも GitHub の管理画面にも一切現れません。デプロイのたびに env へ足したくなりますが、その必要はありません。
そして、いちばん時間を溶かしたのが migrations タグでした。SQLite を持つクラスを追加したときにタグを v1 のまま置いておくと、デプロイが「migration not applied」という短い文言だけで落ちます。原因が wrangler.toml 側にあると気付くまで小一時間かかりました。クラス構成を触ったらタグを v2 に上げる、と覚えておくと後の自分が助かります。
Cloudflare のダッシュボードから GitHub リポジトリを接続する方式でも同じことができます。それでも Actions を勧めているのは、後からテスト実行やビルドを差し込みたくなる日が必ず来るからです。
1 つの Durable Object で足りなくなるのはいつか
Durable Objects の水平方向のスケールは、ほとんど考える必要がありません。セッション ID ごとにインスタンスが生まれるので、ユーザーが増えれば箱が増えるだけです。共有状態がないので、詰まりようがありません。
窮屈になるのは垂直方向、つまり 1 つのセッションの中です。
たとえば 10 人が同じ部屋で話す共同チャットを作ったとします。10 人ぶんの書き込みが同じインスタンスに集中し、そのインスタンスはシングルスレッドです。上限は「1 つのイベントループが 10 人分のメッセージと 10 回の Gemini 呼び出しをどれだけ捌けるか」で決まります。実測では毎秒数リクエストは通りますが、同じキーである限り CPU を 1 つより増やすことはできません。
対処はインフラではなくデータモデル側で行います。私が有効だと感じた手は 2 つです。
ひとつは、会話の単位で分けてしまうことです。共同チャットがスレッドに分かれる性質を持つなら、スレッドごとに Durable Object を割り当てます。スレッドをまたぐ調整は粗くなりますが、1 インスタンスあたりの負荷は素直に下がります。
もうひとつは、重い処理を外に出すことです。このエージェントで最も遅いのは Gemini の呼び出しなので、ctx.waitUntil で走らせつつ WebSocket にトークンを流し、入力ゲートを掴んだままにしない形にします。ただし応答を受けて状態を書き換える部分は、必ず新しい transactionSync の中で行ってください。ここを外すと順序が入れ替わります。
個人開発の規模でこの天井に当たることは、まずありません。先に地図だけ渡しておく、という程度の話として読んでいただければ十分です。まずは 1 セッション 1 インスタンスで始めて、実際に競合を計測してから設計を見直す順番で構いません。
ローカルだけでテストを完結させる
この構成を選んで良かったと感じる場面のひとつが、ローカル開発です。wrangler dev --local は内部で Miniflare を起動し、Workers・Durable Objects・SQLite の一式を手元で丸ごと再現します。ネットワークを切っても動きます。開発環境にデプロイしてから確認する、という往復が発生しません。
テストの組み方は用途で分けています。
エージェントクラス単体を確かめたいときは、Miniflare を直接インスタンス化して Worker の fetch ハンドラを Vitest から叩きます。WebSocket の流れごと確認したいときは、Playwright をローカルの dev サーバーに向けます。実ブラウザでチャット UI を操作させ、リロードをまたいで会話が続いているかまで検証できます。
Gemini の呼び出しは素の fetch なので、モックも簡単です。私はテスト時だけ URL をローカルの Express スタブに差し替え、決め打ちの SSE ストリームを返させています。速く、無料で、毎回同じ結果になります。
ひとつだけ気を付けたいのは、本物の Gemini はチャンク境界で JSON が途中で切れることがある、という点です。スタブには正常系だけでなく、途中で割れた JSON も混ぜて返させてください。パーサが落ちないことをローカルで確認できていると、本番の深夜に慌てずに済みます。
全体を振り返って — この構成で何が変わるか
ここまで読んでいただき、ありがとうございます。Durable Objects と Gemini API の組み合わせは、個人開発者にとって「会話を覚えてくれる AI」を本番投入できる現実的なゴールラインです。サーバーレスのままステートを持てるという特性は、Vercel + 外部 DB のような従来構成と比べてシンプルさで明確に優位です。
明日からの一歩としては、まず手元で wrangler dev を立ち上げて、上のコードを動かしてみてください。wrangler dev --local でローカル SQLite が使えるので、本番デプロイ前にハイバネーション以外の動作はすべて確認できます。動いてしまえば、あとは UI を整えて wrangler deploy 一発で世界配信です。
私自身、この構成に切り替えてから「サーバーが落ちたらどうしよう」というプレッシャーから解放されました。Cloudflare のエッジに乗せるだけで、リージョン障害も水平スケールもほぼ意識せずに済むのは、個人開発の体力的にも大きな違いです。あなたの次のチャットアプリでも、ぜひ選択肢に入れてみてください。