流式传输持久化对话

CloudflareBeginner
立即练习

简介

模型仍在生成内容时,如果文字已经陆续显示,支持助手会显得更加响应迅速。如果刷新页面不会清空对话,用户也会更信任它。这是两个不同的工程需求:流式传输负责逐步发送响应片段,持久化负责保存已完成的消息,以便稍后恢复同一个命名对话。

在本实验中,你将使用 Cloudflare 支持的聊天集成,同时实现这两种行为:

  1. AIChatAgent 在 Agent 的 SQLite-backed Durable Object 中保存聊天消息和可恢复的流式数据。
  2. streamText() 生成有界的 Workers AI 响应,无需等待完整答案生成完毕。
  3. useAgentChat() 将这些响应片段转换为 React 消息列表,并恢复已保存的历史记录。
  4. 短时有效的签名令牌将每个 WebSocket 请求和历史记录请求限制到一个指定的命名对话。

浏览器客户端由一个小型示例项目提供,因此 React 不是隐藏的前置条件。你只需要编辑当前 Agents SDK 概念所需的 hook 调用和消息渲染部分。实验使用合成的支持文本、一次简短的模型响应和可随时删除的资源。免费配额会与账户中的其他活动共享;如果账户没有剩余的 Workers AI 配额,请停止操作,不要启用付费计划。

在直接进入本课程之前,请先完成 将 LabEx 连接到 Cloudflare 账户 每个新的 LabEx VM 都需要单独进行 Wrangler 授权。建议先完成 S01 和 S02,因为本实验建立在命名 Agent 身份、SQLite 状态和 WebSocket 客户端的基础上,但这两个实验的 VM 和资源不会在本实验中复用。

授权 VM 并配置聊天 Worker

在本步骤中,你将授权全新的 VM,并声明聊天功能所需的三个 Cloudflare 绑定。

每个命名聊天都由一个 SQLite Durable Object 实例提供支持。Worker 还需要一个用于推理的 Workers AI 绑定,以及一个用于会话边界的 secret 绑定。

打开终端,进入准备好的项目目录:

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

授权这个全新的 VM:

npx wrangler login

打开命令显示的链接,按照文档说明,为专用学习账户批准 Wrangler 所需的权限,然后返回终端。确认结构化结果:

npx wrangler whoami --json

查找 "loggedIn": true,确认账户名称,并复制该账户的实际 ID。使用唯一的临时 Worker 名称,明确保存账户 ID:

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 绑定让 Worker 无需嵌入 API 密钥即可访问 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

这里有三个重要限制。maxPersistedMessages 限制保存的聊天记录数量,maxOutputTokens 限制每次模型响应的长度,系统提示词要求模型只用一句话回答。GLM 4.7 Flash 可能会在生成可见文本前,先将令牌预算用于内部推理,因此这个简短的支持流程会明确禁用思考;学习者看到的是简洁答案,而不是空的助手消息气泡。转发 abortSignal 后,当某一轮被明确停止时,SDK 可以取消上游推理。

两个路由钩子都使用提供的 HMAC 验证器。onBeforeConnect 保护 WebSocket 握手;onBeforeRequest 还会保护 /get-messages 等 HTTP 辅助请求。浏览器接收的是签名声明,而不是签名密钥。日志会记录请求 ID 和消息数量,但会有意排除支持文本。

连接受支持的 React 聊天 Hooks

在本步骤中,你将把提供的页面外壳连接到当前受支持的 React hooks。

准备好的 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 传递;令牌十分钟后过期,并且只限定到一个合成对话。

生成类型并构建两端

在本步骤中,你将生成准确的环境类型,并在启动运行时之前编译服务端和浏览器端。

Wrangler 可以根据配置生成准确的绑定类型。先运行它,再执行常规的 TypeScript 和 Vite 构建:

npx wrangler types
npm run check
npm run build

类型检查会将 this.env.AI、Durable Object 命名空间和 secret 绑定连接到声明的 Env。Vite 构建会生成一个 Worker bundle 和一个浏览器 bundle;成功的输出应包含 dist/client/index.html

在本地测试签名边界

在本步骤中,你将启动本地运行时,并在不消耗模型调用的情况下测试访问控制。

Workers AI 是远程绑定,因此 Vite 的本地运行时需要使用 Wrangler 已保存的 OAuth 访问权限。将它直接读入一个短生命周期的 shell 变量,仅传递给子进程,然后立即清除 shell 中的副本:

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 中相同的路由钩子和 secret。

部署并观察持久化流式传输

在本步骤中,你将完成部署,观察一次真实的流式回复,在刷新后恢复这条回复,并证明会话之间彼此隔离。

部署生产构建,然后将生成的签名密钥上传为 Worker secret:

npm run deploy
npx wrangler secret bulk .dev.vars

secret 命令会将值发送到 Cloudflare,而不会把它写入 wrangler.jsonc 或 bundle。不要打印 .dev.vars

保存成功部署时输出的准确 workers.dev origin,然后为 planning 对话创建一个十分钟有效的令牌:

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 只能包含 origin,不能包含末尾斜杠或路径。在当前终端会话中保留令牌,不要将它粘贴到笔记或截图中。

打开完整 URL。初始状态应最终变为 ready,页面应显示该对话没有已保存的消息。发送已准备好的合成问题。观察状态从 submitted 变为 streaming,然后随着文本到达变回 ready

一次流式支持回复后的 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 对话

最后,运行一次独立且具有唯一运行标识的远程探测。它会额外进行一次有界的模型调用,确认响应包含多个流式片段,在重新连接后获取已保存的用户消息和助手消息,检查一个已授权但为空的第二个会话,并拒绝跨会话访问:

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-backed 命名空间。该命名空间是 Cloudflare 在资源级别的视图;planningprivate 和验证器名称是其中彼此隔离的实例:

SQL-backed 的 SupportChatAgent 命名空间

打开 Worker 的日志或可观测性视图,查找 support_chat_turn_started。该事件会显示消息数量等受限元数据,但不会显示学习者的问题或模型答案:

受隐私限制的结构化聊天日志

检查完成后,创建一个删除迁移,只移除本实验的类命名空间:

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

然后确认 Durable Objects 中已找不到所属的 SupportChatAgent 命名空间:

已删除的临时聊天命名空间

在此 VM 仍处于授权状态时,运行经过身份验证的缺失检查:

python3 .labex/verify.py deleted

仅删除 Worker 还不够:明确的 deleted_classes 迁移使有状态命名空间的生命周期可供检查,并防止本实验保存的合成历史记录残留。

撤销此 VM 的授权

在本步骤中,你将在确认云端清理完成后,撤销这个临时 VM 的授权。

云资源已经删除。现在撤销保存在这个临时 VM 中的 OAuth 授权:

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

结构化结果应报告 "loggedIn": false(或者 Wrangler 可能返回未认证状态对应的非零结果)。这一步特意放在最后:清理验证需要有效授权,而退出登录可以在之后保护即将丢弃的 VM。

总结

你使用 Cloudflare 当前的聊天集成,构建了一个支持持久化和流式传输的对话。你完成了以下工作:

  • 扩展 AIChatAgent,并使用有界的 Workers AI streamText() 调用;
  • 使用 useAgent()useAgentChat() 连接提供的 React 页面外壳;
  • 使用会过期且限定到特定会话的签名,同时保护 WebSocket 和 HTTP 历史记录路由;
  • 观察增量状态,在刷新后恢复 SQLite-backed 历史记录,并证明另一个命名对话保持隔离;
  • 检查受隐私限制的 Cloudflare 证据;以及
  • 在撤销 VM 授权前,删除准确的 Agent 类命名空间和 Worker。

下一个实验将使用相同的持久化 Agent 身份执行计划中的支持跟进。计划调度属于不同的生命周期问题:即使没有浏览器保持连接,它也能让任务稍后运行。