Cloudflare Workers でClaude APIを呼び出す実装を書いたことがある方なら、一度はこの問題にぶつかったことがあるのではないでしょうか。「リクエストをまたいで会話の文脈が消える」という問題です。
Workers はリクエストごとに独立して起動するため、本質的にステートレスです。KVやD1でメッセージ履歴を保存する方法もありますが、同時リクエストが来たときの整合性、コンテキストウィンドウの増大管理、セッションのライフサイクル管理を自前でやろうとすると、かなり複雑になります。
私はこの問題を複数のプロジェクトで経験し、最終的に Cloudflare Durable Objects(DO) を使った設計に落ち着きました。DOは「グローバルに唯一のステートフルオブジェクト」としてWorkers上で動き、WebSocketとの相性も抜群です。Claude APIのストリーミングレスポンスをそのままWebSocket経由でブラウザに流しながら、会話履歴をDO内のStrorageに永続化できます。
ここではその設計と実装を全公開します。コードはすべて動作確認済みで、本番投入できるレベルを目指しています。
Durable Objects がAIセッション管理に適している理由
まず、なぜDOなのかを整理します。代替手段との比較が判断の参考になるかと思います。
KV(Workers KV)との違い
KVは結果整合性モデルです。「書いた直後に読んだら古い値が返ってくる」可能性があります。会話履歴の更新(メッセージ追加)と読み取りが頻繁に交互に起きるAIセッション管理には、正直向いていません。
D1(SQLite)との違い
D1はRowデータを正確に管理できますが、「このセッションへのリクエストを1つのDOが独占処理する」というアクター的な排他制御はできません。複数のWorkerインスタンスが同じD1のセッション行を競合して更新する状況が起きえます。
DOの強み
DOの設計思想は「名前でルーティングされた唯一のアクター」です。env.AGENT.idFromName("session-abc123") とすることで、session-abc123 へのリクエストは世界のどこから来ても、必ず同じDOインスタンスにルーティングされます。このインスタンスは一度に1リクエストしか処理しないため、ロックなしでストレージを安全に更新できます。
さらに Hibernation API を使えば、WebSocket接続中でもWorkerのCPU消費を抑えつつ、メッセージを受け取ったときだけ起動できます。長時間のAI会話セッションには非常に経済的です。
アーキテクチャ全体図
実装に入る前に、全体の構造を把握しておきましょう。
ブラウザ → Worker Router → Durable Object (AgentSession)
├── DO Storage (会話履歴)
├── Claude API (Anthropic)
└── WebSocket (ブラウザとの双方向通信)
コンポーネントの役割は次の通りです。
- Worker Router: リクエストをセッションIDに基づいて適切なDOにルーティングする
- AgentSession DO: 1セッション = 1インスタンス。会話履歴の保持・Claude API呼び出し・レスポンスのストリーミングを担当
- DO Storage: メッセージ履歴・セッションメタデータ・コンパクション済み要約を永続化
Durable Object の実装:基本骨格
まず、AgentSession DOの骨格から始めます。Hibernation APIを使うため、fetch ではなく webSocketMessage / webSocketClose ハンドラを実装します。
// src/agent-session.ts
import Anthropic from "@anthropic-ai/sdk";
interface Message {
role: "user" | "assistant";
content: string;
timestamp: number;
}
interface SessionState {
messages: Message[];
compactedSummary?: string;
lastActivity: number;
totalTokensUsed: number;
}
export class AgentSession implements DurableObject {
private state: DurableObjectState;
private env: Env;
private client: Anthropic;
private sessionData: SessionState = {
messages: [],
lastActivity: Date.now(),
totalTokensUsed: 0,
};
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
this.client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
// 初期化時にStorageからセッション状態を復元
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<SessionState>("session");
if (stored) {
this.sessionData = stored;
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.headers.get("Upgrade") === "websocket") {
return this.handleWebSocket(request);
}
if (url.pathname === "/history") {
return new Response(
JSON.stringify(this.sessionData.messages),
{ headers: { "Content-Type": "application/json" } }
);
}
if (url.pathname === "/reset") {
await this.resetSession();
return new Response("Session reset", { status: 200 });
}
return new Response("Not found", { status: 404 });
}
private async handleWebSocket(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernation APIを使用 — この方式でないとCPU時間を常時消費する
this.state.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
// Hibernation API: メッセージ受信時のみ起動
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
if (typeof message !== "string") return;
let parsed: { type: string; content?: string };
try {
parsed = JSON.parse(message);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
if (parsed.type === "message" && parsed.content) {
await this.processUserMessage(ws, parsed.content);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
// セッションのアクティビティ記録
this.sessionData.lastActivity = Date.now();
await this.state.storage.put("session", this.sessionData);
}
private async resetSession(): Promise<void> {
this.sessionData = {
messages: [],
lastActivity: Date.now(),
totalTokensUsed: 0,
};
await this.state.storage.put("session", this.sessionData);
}
}コア実装:Claude API との統合とストリーミング
次が核心部分です。processUserMessage の中で、会話履歴の管理とClaude APIへのストリーミングリクエストを行います。
// AgentSession クラスのメソッドとして追加
private async processUserMessage(ws: WebSocket, userContent: string): Promise<void> {
// 1. ユーザーメッセージを履歴に追加
const userMessage: Message = {
role: "user",
content: userContent,
timestamp: Date.now(),
};
this.sessionData.messages.push(userMessage);
// 2. コンテキスト上限チェック(後述のコンパクションロジック呼び出し)
const messages = await this.buildMessagesForApi();
// 3. Claudeへのストリーミングリクエスト
ws.send(JSON.stringify({ type: "start" }));
let assistantContent = "";
try {
const stream = await this.client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system: this.env.SYSTEM_PROMPT || "You are a helpful AI assistant.",
messages,
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
const text = chunk.delta.text;
assistantContent += text;
ws.send(JSON.stringify({ type: "delta", text }));
}
}
// 4. 最終メッセージオブジェクトからトークン使用量を取得
const finalMessage = await stream.getFinalMessage();
this.sessionData.totalTokensUsed += finalMessage.usage.input_tokens + finalMessage.usage.output_tokens;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
ws.send(JSON.stringify({ type: "error", message: errorMsg }));
// エラー時はユーザーメッセージをロールバック
this.sessionData.messages.pop();
await this.state.storage.put("session", this.sessionData);
return;
}
// 5. アシスタントの回答を履歴に追加
const assistantMessage: Message = {
role: "assistant",
content: assistantContent,
timestamp: Date.now(),
};
this.sessionData.messages.push(assistantMessage);
this.sessionData.lastActivity = Date.now();
// 6. セッション状態をストレージに永続化
await this.state.storage.put("session", this.sessionData);
ws.send(JSON.stringify({ type: "end" }));
}
private async buildMessagesForApi(): Promise<Anthropic.MessageParam[]> {
// コンパクション済み要約があれば先頭に配置
const apiMessages: Anthropic.MessageParam[] = [];
if (this.sessionData.compactedSummary) {
apiMessages.push({
role: "user",
content: `[Previous conversation summary]\n${this.sessionData.compactedSummary}`,
});
apiMessages.push({
role: "assistant",
content: "Understood. I'll continue with that context in mind.",
});
}
// 直近のメッセージのみ追加(コンパクション後の残存メッセージ)
for (const msg of this.sessionData.messages) {
apiMessages.push({ role: msg.role, content: msg.content });
}
return apiMessages;
}このコードで気をつけているポイントが2つあります。
まず、エラー時にユーザーメッセージをロールバックしています。エラーが出てもメッセージが履歴に残ると、次のリクエストで「答えの存在しない質問」が送られ続けてClaudeが混乱する可能性があります。
もう一点は stream.getFinalMessage() でトークン使用量を取得していることです。ストリーミング中は usage フィールドが途中でしか返ってこないため、最終メッセージオブジェクトを待って取得するのが確実です。
コンテキストウィンドウ管理とコンパクション
長時間の会話が続くと、メッセージ履歴が膨らんでトークンコストが指数的に増加します。これはAIセッション管理で最もよく詰まるポイントです。
私が使っているパターンは「要約式コンパクション」です。直近のN件を除いた古いメッセージ群を一度だけClaudeに要約させ、その要約を先頭に配置して古いメッセージは削除する方法です。
// AgentSession クラスのメソッドとして追加
private readonly MAX_MESSAGES_BEFORE_COMPACTION = 20;
private readonly KEEP_RECENT_MESSAGES = 6;
async checkAndCompact(): Promise<void> {
if (this.sessionData.messages.length < this.MAX_MESSAGES_BEFORE_COMPACTION) {
return; // コンパクション不要
}
const toSummarize = this.sessionData.messages.slice(
0,
this.sessionData.messages.length - this.KEEP_RECENT_MESSAGES
);
const recent = this.sessionData.messages.slice(-this.KEEP_RECENT_MESSAGES);
const summaryPrompt = toSummarize
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
.join("\n\n");
try {
const summaryResponse = await this.client.messages.create({
model: "claude-haiku-4-5-20251001", // 要約にはHaikuを使ってコスト削減
max_tokens: 1024,
messages: [
{
role: "user",
content: `以下の会話を、後続のAIが文脈を把握できるように200〜400文字で要約してください。重要な決定・合意事項・技術的な詳細を漏らさず含めてください。\n\n${summaryPrompt}`,
},
],
});
const summary = summaryResponse.content[0].type === "text"
? summaryResponse.content[0].text
: "";
// 既存の要約があれば連結して蓄積
this.sessionData.compactedSummary = this.sessionData.compactedSummary
? `${this.sessionData.compactedSummary}\n\n---\n\n${summary}`
: summary;
// 古いメッセージを削除し直近だけ残す
this.sessionData.messages = recent;
await this.state.storage.put("session", this.sessionData);
} catch (error) {
// コンパクション失敗は致命的ではない — そのまま継続
console.error("Compaction failed:", error);
}
}コンパクションにHaikuを使う設計は、コスト的にかなり重要です。Sonnet 4.6 で要約するとトークンコストが大きくなりすぎます。Haiku 4.5 は日本語の要約も十分な品質で、Sonnet比でトークン単価が約7分の1程度です。
processUserMessage の冒頭でこのメソッドを呼ぶことで、メッセージ追加のたびに上限チェックが走ります。
private async processUserMessage(ws: WebSocket, userContent: string): Promise<void> {
// コンパクションチェックを先に実行
await this.checkAndCompact();
// ... 以降は前述のコード
}Alarm APIでセッションの自動クリーンアップ
長期間使われていないセッションをそのままにすると、DO Storageのコストが積み上がります。Alarm APIを使った自動クリーンアップを実装しておくと安心です。
// wrangler.toml に [[durable_objects.alarms]] は不要(自動で使える)
// AgentSession クラスに追加
private readonly SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24時間
// アラームセット(DOの初回起動時 or メッセージ受信時に更新)
private async resetAlarm(): Promise<void> {
const expireAt = Date.now() + this.SESSION_TTL_MS;
await this.state.storage.setAlarm(expireAt);
}
// アラーム発火時 = セッション期限切れ → ストレージ削除
async alarm(): Promise<void> {
const lastActivity = this.sessionData.lastActivity;
const elapsed = Date.now() - lastActivity;
if (elapsed >= this.SESSION_TTL_MS) {
// セッションが24時間アクティビティなし → 全データ削除
await this.state.storage.deleteAll();
console.log(`Session expired and cleaned up. Last activity: ${new Date(lastActivity).toISOString()}`);
} else {
// まだアクティブなら次の期限を更新
await this.resetAlarm();
}
}setAlarm はDO Storage APIの一部で、指定時刻に alarm() メソッドを呼び出してくれます。WebSocketが閉じているセッションでも動作するため、メモリの外側でライフサイクルを管理できます。
Worker Router の実装
DOへのルーティングはシンプルです。セッションIDはURLパスかクエリパラメータから取得します。
// src/index.ts
export { AgentSession } from "./agent-session";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// /agent/{sessionId} または /agent/{sessionId}/history などにマッチ
const match = url.pathname.match(/^\/agent\/([a-zA-Z0-9_-]+)(\/.*)?$/);
if (!match) {
return new Response("Not found", { status: 404 });
}
const sessionId = match[1];
// 認証チェック(本番ではJWT検証など)
const authHeader = request.headers.get("Authorization");
if (!authHeader || !isValidToken(authHeader, env)) {
return new Response("Unauthorized", { status: 401 });
}
// セッションIDからDOのIDを生成(名前ルーティング)
const id = env.AGENT.idFromName(sessionId);
const stub = env.AGENT.get(id);
// リクエストをDOに転送
return stub.fetch(request);
},
};
function isValidToken(authHeader: string, env: Env): boolean {
const token = authHeader.replace("Bearer ", "");
return token === env.API_SECRET; // 本番ではJWT検証を推奨
}wrangler.toml の設定
name = "my-ai-agent"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[vars]
SYSTEM_PROMPT = "You are a helpful assistant specialized in software development."
[[durable_objects.bindings]]
name = "AGENT"
class_name = "AgentSession"
[[migrations]]
tag = "v1"
new_classes = ["AgentSession"]よくある落とし穴と対処法
実際に運用して詰まったポイントを3つ共有します。
落とし穴1: blockConcurrencyWhile を忘れると初期化競合が起きる
constructor でStorageからデータを読み込む際、blockConcurrencyWhile を使わないと、Storageの読み込みが完了する前にリクエストが来てしまい、空のセッション状態で処理が走る問題が起きます。コンストラクタでは必ず使いましょう。
落とし穴2: WebSocketエラー時のセッション破損
ネットワーク断等でWebSocketが突然切断された場合、webSocketClose が呼ばれない場合があります。重要なメッセージの永続化は processUserMessage 内でClaude APIのレスポンス受信後すぐに行い、接続切断に依存しない設計にするのが安全です。
落とし穴3: コンパクション中のリクエスト競合
DOは一度に1リクエストのみ処理しますが、コンパクション処理(Claude API呼び出し)は非同期で時間がかかります。この間に別のWebSocketメッセージが届いた場合、DOは前のリクエストが完了するまでキューに入ります。長時間のコンパクション中にユーザーがメッセージを送ると、「応答が来ない」と感じる可能性があります。
対策として、コンパクションが走っている間はWebSocketに {"type": "compacting"} を送ってUIでインジケーターを表示するか、コンパクション判定を非同期化してリクエストと分離するアプローチがあります。私は前者(UI側で表示)を採用しています。
本番デプロイ前のチェックリスト
最後に、本番投入前に確認すべき点をまとめます。
認証については、今回のサンプルは単純なAPIトークン比較ですが、本番ではJWTを使った検証と、セッションIDとユーザーIDの対応検証(別ユーザーが他人のセッションにアクセスできないか)が必要です。
コスト管理については、sessionData.totalTokensUsed を定期的にモニタリングし、異常なトークン消費を検知するアラートを設定しましょう。1セッションあたりの上限値を設けて、コスト暴走を防ぐ仕組みも有効です。
Cloudflare Analytics Engineを使えば、セッション数・トークン使用量・レイテンシをリアルタイムで可視化できます。Cloudflare AI Gateway を使った本番モニタリング設計も合わせて読んでいただくと、DO + Gateway の組み合わせで観測性が大きく向上します。
DO の詳細な設計方針については、Cloudflare Queues と Claude API の非同期パイプライン設計も参考になります。同期(WebSocket)と非同期(Queue)の使い分けがわかると、ユースケースに応じた選択ができます。
全体を振り返って
Durable Objects × Claude API の組み合わせは、サーバーレス環境でのAIセッション管理において今のところ最もバランスの取れた選択肢だと感じています。
今日試すなら、まず wrangler deploy でこの基本実装を動かしてみてください。WebSocket接続・メッセージ送受信・セッション履歴の確認まで動いたら、次のステップでコンパクション閾値とAlarm TTLを自分のユースケースに合わせて調整していくと良いと思います。
コンパクション設計を変えるだけでトークンコストが大幅に変わるので、本番前にいくつかのパターンで試してみることをお勧めします。