CLAUDE LABEN
FORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられていますFORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられています
記事一覧/API & SDK
API & SDK/2026-05-04上級

Cloudflare Durable Objects × Claude API でサーバーレスステートフルAIエージェントを本番構築する — セッション管理・コンテキスト永続化・スケール設計の実装

Cloudflare Durable Objects と Claude API を組み合わせてサーバーレスかつステートフルなAIエージェントを本番構築する完全ガイド。セッション管理・コンテキスト永続化・コンパクション・スケール設計まで、動作するコードと実装の落とし穴を全公開します。

Cloudflare Durable ObjectsClaude API115サーバーレス2ステートフルエージェントセッション管理5本番設計2Cloudflare Workers14

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を自分のユースケースに合わせて調整していくと良いと思います。

コンパクション設計を変えるだけでトークンコストが大幅に変わるので、本番前にいくつかのパターンで試してみることをお勧めします。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API & SDK2026-07-04
Claude apps gateway の発表を読んで、個人開発の管制面を作り直した話
セルフホスト型 Claude apps gateway の設計を管制面と実行面の分離として読み解き、個人開発の規模に縮約します。アプリ別コスト帰属・モデル許可リスト・fail-closed の支出上限を Cloudflare Workers で実装した記録です。
API & SDK2026-06-20
Cloudflare AI Gateway を Claude の手前に置くと、見えるはずの数字が見えなくなる — 運用で効いた計装メモ
Cloudflare AI Gateway を Claude API の前段に置いたあと、コスト按分・セマンティックキャッシュの誤ヒット・フォールバックの静かな品質低下・予算の実効化で実際につまずいた箇所と、その手当てをコード付きでまとめます。
API & SDK2026-05-14
Claude API でアプリ内 AI チャットを実装して踏んだ6つの罠 — 本番投入までの設計記録
Claude APIをアプリ内チャットに組み込んで本番で直面した6つの設計ミスを、個人開発での実運用経験から解説。コンテキスト設計、ストリーミング、セッション管理、コスト監視の実装パターンを動くコード付きで紹介します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →