永続的な会話をストリーミングする

CloudflareBeginner
オンラインで実践に進む

はじめに

モデルが回答を生成している間に単語が少しずつ表示されると、サポートアシスタントは応答性が高く感じられます。また、ページを更新しても会話が消えなければ、より信頼できる印象になります。これらは別々のエンジニアリング要件です。ストリーミングは回答の断片を順次配信し、永続化は完了したメッセージを保存して、同じ名前の会話を後から復元できるようにします。

この実験では、Cloudflare がサポートするチャット統合を使って、両方の動作を追加します。

  1. AIChatAgent は、Agent の SQLite ベースの Durable Object にチャットメッセージと再開可能なストリームデータを保存します。
  2. streamText() は、完全な回答を待つのではなく、制限付きの Workers AI レスポンスを生成します。
  3. useAgentChat() は、これらの断片を React のメッセージリストに変換し、保存済みの履歴を復元します。
  4. 有効期間の短い署名付きトークンによって、すべての WebSocket リクエストと履歴リクエストを、名前付きの 1 つの会話に限定します。

ブラウザクライアントは小さな fixture として用意されているため、React の知識は隠れた前提条件ではありません。Agents SDK のこの概念に必要な、現在のフック呼び出しとメッセージ表示部分だけを編集します。シナリオでは、合成したサポートテキスト、短いモデルレスポンス、破棄可能なリソースを使用します。無料割り当ては他のアカウント利用と共有されます。アカウントに Workers AI の残りの割り当てがない場合は、有料プランを有効にせずに中止してください。

このコースに直接入る前に、LabEx を Cloudflare アカウントに接続するを完了してください。新しい LabEx VM ごとに、独自の Wrangler 認証が必要です。S01 と S02 を先に行うことを推奨します。この実験は、名前付き Agent の識別、SQLite の状態、WebSocket クライアントを前提にしています。ただし、これらの実験の VM とリソースはここでは再利用されません。

VM を認証して Chat Worker を設定する

このステップでは、新しい VM を認証し、チャットに必要な 3 つの Cloudflare バインディングを定義します。

名前付きチャットは、それぞれ 1 つの SQLite Durable Object インスタンスによって管理されます。Worker には、推論用の Workers AI バインディングと、セッション境界用のシークレットバインディングも必要です。

ターミナルを開き、用意されたプロジェクトに移動します。

cd /home/labex/project/persistent-support-chat

新しい VM を認証します。

npx wrangler login

表示されたリンクを開き、専用の学習用アカウントに対して、Wrangler に必要な権限を承認します。その後、ターミナルに戻ります。構造化された結果を確認します。

npx wrangler whoami --json

"loggedIn": true を確認し、アカウント名を確認したうえで、そのアカウントの実際の ID をコピーします。一時的に使う一意の Worker 名とともに、明示的に保存します。

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s03-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "SupportChatAgent", "class_name": "SupportChatAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportChatAgent"] }
  ]
}
JSON

AI バインディングにより、API キーを埋め込まずに Worker から Workers AI にアクセスできます。Workers AI は、ローカル開発中も含め、常に Cloudflare がホストするモデルを使用します。remote: true によって、この動作を明示しています。Durable Object のバインディングはクラス名を対応付けます。ブラウザは後で、別個のインスタンス名 planning を指定します。まだ何もデプロイされていません。

制限付き AIChatAgent を実装する

このステップでは、サーバー側のチャットクラス、制限付き推論、署名付きルーティング境界を実装します。

AIChatAgent は、永続的なチャット履歴と再開可能なストリーム保存機能をベースの Agent に追加します。モデル呼び出しは自分で記述します。チャットプロトコルと永続化は統合機能が処理します。

src/server.ts を作成します。

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { convertToModelMessages, streamText } from "ai";
import { routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { verifySessionRequest } from "./session-auth";

interface Env {
  AI: Ai;
  SupportChatAgent: DurableObjectNamespace<SupportChatAgent>;
  SESSION_SIGNING_KEY: string;
}

export class SupportChatAgent extends AIChatAgent<Env> {
  maxPersistedMessages = 12;

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    console.log(JSON.stringify({
      event: "support_chat_turn_started",
      requestId: options?.requestId ?? "unknown",
      messageCount: this.messages.length,
      continuation: Boolean(options?.continuation)
    }));

    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: "You are a concise support assistant. Answer synthetic questions in one sentence and never request credentials.",
      messages: await convertToModelMessages(this.messages),
      maxOutputTokens: 64,
      temperature: 0,
      abortSignal: options?.abortSignal
    });

    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS

ここでは 3 つの制限が重要です。maxPersistedMessages は保存する履歴の増加を制限し、maxOutputTokens は各モデルレスポンスの長さを制限します。また、system プロンプトは 1 文で回答するよう指示します。GLM 4.7 Flash は、表示されるテキストを生成する前に、トークン予算を内部推論に使うことがあります。そのため、この短いサポートワークフローでは thinking を明示的に無効にしています。これにより、学習者には空のアシスタントバブルではなく、簡潔な回答が表示されます。abortSignal を転送すると、ターンを明示的に停止したときに SDK が上流の推論をキャンセルできます。

2 つのルーティングフックは、どちらも指定された HMAC 検証機能を使用します。onBeforeConnect は WebSocket ハンドシェイクを保護し、onBeforeRequest/get-messages などの HTTP ヘルパーも保護します。ブラウザが受け取るのは署名付きクレームであり、署名シークレットではありません。ログにはリクエスト ID と件数だけを記録し、サポートテキストは意図的に含めません。

サポートされている React チャットフックを接続する

このステップでは、用意されたページの土台を、現在サポートされている React フックに接続します。

用意されている HTML とスタイルは、表示用の土台にすぎません。ここで、その土台を名前付き Agent に接続します。TypeScript と Vite の設定を作成します。

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react(), agents(), cloudflare()]
});
TS

src/client.tsx を作成します。

cat > src/client.tsx <<'TSX'
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function SupportChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";

  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "SupportChatAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, status, error } = useAgentChat({ agent });

  return (
    <main>
      <p className="eyebrow">Cloudflare Agents SDK</p>
      <h1>Persistent Support Chat</h1>
      <p className="session">Conversation: <strong>{session}</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No saved messages in this conversation.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) =>
              part.type === "text" ? <span key={index}>{part.text}</span> : null
            )}
          </article>
        ))}
      </section>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
        input.value = "";
      }}>
        <input name="message" defaultValue="What does pending invoice status mean?" maxLength={160} aria-label="Support question" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed conversation…</p></main>}>
    <SupportChat />
  </Suspense>
);
TSX

useAgent() は、SupportChatAgent:<session> への署名付き WebSocket 接続を管理します。useAgentChat() はその接続に AI チャットプロトコルを重ね、メッセージ、ストリーミング状態、送信処理、初回の履歴復元を担当します。ブラウザの WebSocket ハンドシェイクではカスタム認証ヘッダーを追加できないため、トークンは接続 URL に含めます。トークンは 10 分後に有効期限が切れ、1 つの合成会話だけに限定されます。

型を生成し、両方の側をビルドする

このステップでは、正確な環境型を生成し、ランタイムを起動する前に Worker 側とブラウザ側の両方をコンパイルします。

Wrangler は設定から正確なバインディング型を生成できます。通常の TypeScript と Vite のビルドの前に実行してください。

npx wrangler types
npm run check
npm run build

型チェックによって、this.env.AI、Durable Object の namespace、シークレットバインディングが、宣言した Env に対応付けられます。Vite のビルドでは、Worker 用バンドルとブラウザ用バンドルがそれぞれ 1 つずつ生成されます。成功すると、出力に dist/client/index.html が含まれます。

署名付き境界をローカルでテストする

このステップでは、ローカルランタイムを起動し、モデル呼び出しを消費せずにアクセス制御をテストします。

Workers AI はリモートバインディングなので、Vite のローカルランタイムには Wrangler がすでに保存している OAuth アクセスが必要です。短時間だけ使うシェル変数に直接読み込み、子プロセスにだけ渡した後、シェル内のコピーをすぐに削除します。

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN

この値を表示したり、.dev.vars に保存したりしないでください。これは既存の一時的な Wrangler OAuth アクセスであり、新しく作成した API Token ではありません。CI=true と標準入力のリダイレクトにより、ターミナルが戻った後も Vite プロセスを切り離して実行できます。

URL が表示されるまで待ちます。

until curl -fsS http://127.0.0.1:5173/ >/dev/null; do sleep 1; done
tail -n 12 .labex/dev.log

独立したローカルチェックを実行します。

python3 .labex/verify.py local

このチェックでは、意図的にモデル呼び出しを消費しません。正しく署名された新しいセッションが空の履歴を読み取れること、署名なしのリクエストと別の名前を対象にした有効なトークンが、どちらも HTTP 401 を受け取ることを確認します。ローカルの Miniflare は、.dev.vars にある同じルーティングフックとシークレットを使用します。

デプロイして永続的なストリーミングを確認する

このステップでは、デプロイを行い、実際のストリーミング回答を 1 つ確認し、更新後に復元できることとセッション分離を証明します。

本番ビルドをデプロイした後、生成した署名キーを Worker シークレットとしてアップロードします。

npm run deploy
npx wrangler secret bulk .dev.vars

シークレットコマンドは、値を wrangler.jsonc やバンドルに含めずに Cloudflare へ送信します。.dev.vars を表示しないでください。

デプロイ成功時に表示された正確な workers.dev オリジンを保存し、planning 会話用の 10 分間有効なトークンを作成します。

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

WORKER_URL にはオリジンだけを指定し、末尾のスラッシュやパスは含めません。トークンはこのターミナルセッション内に保持し、メモやスクリーンショットに貼り付けないでください。

完全な URL を開きます。初期状態は ready に落ち着き、ページには保存済みメッセージがないと表示されます。用意されている合成質問を送信してください。テキストが到着するにつれて、submittedstreaming に変わり、その後 ready に戻ることを確認します。

ストリーミングされたサポート回答が 1 つ表示された planning 会話

表示されるリソースと回答は、テスト済みの破棄可能な実行例です。モデル出力は非決定的であるため、実際の文言は異なる場合があります。

同じ URL を更新します。完了したユーザーメッセージとアシスタントメッセージが、最初からやり直すのではなく SQLite から復元されます。

更新後に同じ planning 会話の履歴が復元された状態

次に、名前による分離を証明します。別の署名付き URL を生成して開きます。

PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"

private ページは認証されていますが、別の名前の Agent インスタンスに属しています。そのため、履歴は空です。

別途認証された、履歴が空の private 会話

最後に、実行ごとに固有の独立したリモートプローブを 1 回実行します。このプローブは、制限付きモデル呼び出しを追加で 1 回行い、複数のストリーム断片を確認します。また、再接続後に保存済みのユーザーメッセージとアシスタントメッセージを取得し、認証済みの別セッションが空であることを確認し、セッションをまたいだアクセスを拒否します。

python3 .labex/verify.py deployed

チャットリソースを確認して削除する

このステップでは、ランタイムの動作を Dashboard の証拠に結び付けた後、この実験のリソースだけを削除します。

Cloudflare Dashboard で Workers & Pages を開き、正確な名前が labex-c11-s03-... の Worker を選択して、バインディングを確認します。AI バインディングと SupportChatAgent Durable Object バインディングの両方が表示されます。

AI と SupportChatAgent のバインディングを持つデプロイ済み Worker

Durable Objects を開き、この Worker が所有する SQL ベースの namespace を選択します。namespace は Cloudflare におけるリソースレベルの表示です。planningprivate、検証用の名前は、その中にある分離されたインスタンスです。

SQL ベースの SupportChatAgent namespace

Worker のログまたは observability ビューを開き、support_chat_turn_started を探します。このイベントにはメッセージ件数などの制限されたメタデータが表示されますが、学習者のプロンプトやモデルの回答は含まれません。

プライバシーに配慮して内容を制限した構造化チャットログ

確認後、この実験のクラス namespace だけを削除するマイグレーションを作成します。

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportChatAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

Workers & Pages で Worker が存在しないことを確認します。

破棄されたチャット Worker

次に、所有していた SupportChatAgent namespace が Durable Objects に存在しないことを確認します。

破棄されたチャット namespace

この VM がまだ認証済みの間に、認証付きの不存在チェックを実行します。

python3 .labex/verify.py deleted

Worker だけを削除しても十分ではありません。明示的な deleted_classes マイグレーションによって、状態を持つ namespace のライフサイクルを確認でき、この実験で保存した合成履歴が残されるのを防ぎます。

この VM の認証を取り消す

このステップでは、クラウド側のクリーンアップを確認した後、一時 VM の認証を取り消します。

クラウドリソースはすでに削除されています。次に、この一時 VM に保存されている OAuth 認証を取り消します。

npx wrangler logout
npx wrangler whoami --json || true

構造化された結果に "loggedIn": false と表示されることを確認します。Wrangler が未認証を示すゼロ以外の終了コードを返す場合もあります。これは意図的に最後に行います。クリーンアップの確認には有効な認証が必要ですが、ログアウトすると、その後に破棄する VM を保護できます。

まとめ

Cloudflare の現在のチャット統合を使って、永続化されるストリーミング対応のサポート会話を構築しました。以下を実施しました。

  • AIChatAgent を拡張し、制限付きの Workers AI streamText() 呼び出しを使用しました。
  • 用意された React の土台を useAgent()useAgentChat() に接続しました。
  • 有効期限があり、セッション単位に限定された署名で、WebSocket と HTTP の履歴ルートの両方を保護しました。
  • 状態が段階的に変化する様子を確認し、更新後に SQLite ベースの履歴を復元し、別名の会話が分離されたままであることを証明しました。
  • プライバシーに配慮して内容を制限した Cloudflare の証拠を確認しました。
  • VM の認証を取り消す前に、対象となる Agent クラスの namespace と Worker を削除しました。

次の実験では、同じ永続的な Agent 識別情報を、スケジュールされたサポートフォローアップに使用します。スケジューリングは別のライフサイクル上の関心事です。ブラウザが接続されていない場合でも、後から処理を実行できるようにします。