简介
模型仍在生成内容时,如果文字已经陆续显示,支持助手会显得更加响应迅速。如果刷新页面不会清空对话,用户也会更信任它。这是两个不同的工程需求:流式传输负责逐步发送响应片段,持久化负责保存已完成的消息,以便稍后恢复同一个命名对话。
在本实验中,你将使用 Cloudflare 支持的聊天集成,同时实现这两种行为:
AIChatAgent在 Agent 的 SQLite-backed Durable Object 中保存聊天消息和可恢复的流式数据。streamText()生成有界的 Workers AI 响应,无需等待完整答案生成完毕。useAgentChat()将这些响应片段转换为 React 消息列表,并恢复已保存的历史记录。- 短时有效的签名令牌将每个 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。

图中显示的资源和答案来自经过测试的临时运行。由于模型输出具有不确定性,你看到的具体措辞可能不同。
刷新相同的 URL。已完成的用户消息和助手消息应从 SQLite 中恢复,而不是重新开始:

现在证明名称隔离。生成并打开一个单独签名的 URL:
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
private 页面已获得授权,但它属于另一个命名的 Agent 实例,因此历史记录为空:

最后,运行一次独立且具有唯一运行标识的远程探测。它会额外进行一次有界的模型调用,确认响应包含多个流式片段,在重新连接后获取已保存的用户消息和助手消息,检查一个已授权但为空的第二个会话,并拒绝跨会话访问:
python3 .labex/verify.py deployed
检查并删除聊天资源
在本步骤中,你将把运行时行为与 Dashboard 中的证据对应起来,然后只删除本实验的资源。
在 Cloudflare Dashboard 中打开 Workers & Pages,选择名称准确匹配 labex-c11-s03-... 的 Worker,并检查其绑定。你应看到 AI 绑定和 SupportChatAgent Durable Object 绑定:

打开 Durable Objects,选择属于此 Worker 的 SQL-backed 命名空间。该命名空间是 Cloudflare 在资源级别的视图;planning、private 和验证器名称是其中彼此隔离的实例:

打开 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:

然后确认 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 AIstreamText()调用; - 使用
useAgent()和useAgentChat()连接提供的 React 页面外壳; - 使用会过期且限定到特定会话的签名,同时保护 WebSocket 和 HTTP 历史记录路由;
- 观察增量状态,在刷新后恢复 SQLite-backed 历史记录,并证明另一个命名对话保持隔离;
- 检查受隐私限制的 Cloudflare 证据;以及
- 在撤销 VM 授权前,删除准确的 Agent 类命名空间和 Worker。
下一个实验将使用相同的持久化 Agent 身份执行计划中的支持跟进。计划调度属于不同的生命周期问题:即使没有浏览器保持连接,它也能让任务稍后运行。



