소개
모델이 응답을 생성하는 동안 단어가 도착하면 지원 도우미가 빠르게 반응하는 것처럼 느껴집니다. 페이지를 새로 고쳐도 대화가 사라지지 않으면 더욱 신뢰할 수 있습니다. 이 두 가지는 서로 다른 엔지니어링 요구 사항입니다. 스트리밍은 응답 청크를 조금씩 전달하고, 영구 저장은 완료된 메시지를 저장하여 나중에 같은 이름의 대화를 복원할 수 있게 합니다.
이 실습에서는 Cloudflare가 지원하는 채팅 통합을 사용해 두 기능을 모두 추가합니다.
AIChatAgent는 Agent의 SQLite 기반 Durable Object에 채팅 메시지와 재개 가능한 스트림 데이터를 저장합니다.streamText()는 전체 답변이 완성될 때까지 기다리는 대신 제한된 Workers AI 응답을 생성합니다.useAgentChat()은 이러한 청크를 React 메시지 목록으로 변환하고 저장된 기록을 복원합니다.- 짧은 수명의 서명된 토큰은 모든 WebSocket 및 기록 요청을 하나의 이름 있는 대화로 제한합니다.
브라우저 클라이언트는 작은 fixture로 제공되므로 React가 별도의 선행 조건은 아닙니다. 이 Agents SDK 개념에 필요한 현재 훅 호출과 메시지 렌더링만 수정합니다. 시나리오에서는 테스트용 지원 텍스트, 짧은 모델 응답 및 폐기 가능한 리소스를 사용합니다. 무료 할당량은 다른 계정 활동과 공유됩니다. 계정에 Workers AI 할당량이 남아 있지 않다면 유료 요금제를 활성화하지 말고 중지합니다.
이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새 LabEx VM마다 자체 Wrangler 인증이 필요합니다. 이 실습은 이름 있는 Agent ID, SQLite 상태 및 WebSocket 클라이언트를 기반으로 하므로 S01과 S02를 권장하지만, 해당 VM과 리소스는 여기서 재사용하지 않습니다.
VM 인증 및 Chat Worker 구성
이 단계에서는 새 VM을 인증하고 채팅에 필요한 세 가지 Cloudflare 바인딩을 정의합니다.
각 이름 있는 채팅은 하나의 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 이름과 함께 계정 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 바인딩을 사용하면 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
여기에는 세 가지 중요한 제한이 있습니다. maxPersistedMessages는 저장되는 대화 기록의 증가량을 제한하고, maxOutputTokens는 각 모델 응답의 길이를 제한하며, 시스템 프롬프트는 한 문장으로 답하도록 요청합니다. GLM 4.7 Flash는 화면에 보이는 텍스트를 생성하기 전에 토큰 예산을 내부 추론에 사용할 수 있습니다. 따라서 이 짧은 지원 작업 흐름에서는 thinking을 명시적으로 비활성화하여 빈 assistant 말풍선 대신 간결한 답변이 표시되도록 합니다. abortSignal을 전달하면 사용자가 턴을 명시적으로 중지할 때 SDK가 업스트림 추론을 취소할 수 있습니다.
두 라우팅 훅은 제공된 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 핸드셰이크에서는 사용자 지정 authorization 헤더를 추가할 수 없으므로 토큰은 연결 URL에 포함됩니다. 토큰은 10분 후 만료되며 하나의 테스트용 대화에만 적용됩니다.
타입 생성 및 양쪽 빌드
이 단계에서는 정확한 환경 타입을 생성하고 런타임을 시작하기 전에 서버와 브라우저 양쪽을 모두 컴파일합니다.
Wrangler는 구성에서 정확한 바인딩 타입을 생성할 수 있습니다. 일반적인 TypeScript 및 Vite 빌드 전에 실행합니다.
npx wrangler types
npm run check
npm run build
타입 검사는 this.env.AI, Durable Object 네임스페이스 및 비밀값 바인딩을 선언된 Env에 연결합니다. Vite 빌드는 Worker 번들과 브라우저 번들을 각각 하나씩 생성하며, 성공하면 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에 저장하지 않습니다. 이 값은 새로 생성한 API Token이 아니라 기존의 임시 Wrangler OAuth 액세스 정보입니다. 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의 동일한 라우팅 훅과 비밀값을 사용합니다.
배포 및 영구 스트리밍 확인
이 단계에서는 배포하고, 실제 스트리밍 응답 하나를 확인하고, 새로 고친 뒤 응답을 복원하며, 세션 격리를 검증합니다.
프로덕션 빌드를 배포한 다음 생성된 서명 키를 Worker 비밀값으로 업로드합니다.
npm run deploy
npx wrangler secret bulk .dev.vars
비밀값 명령은 값을 wrangler.jsonc나 번들에 저장하지 않고 Cloudflare로 전송합니다. .dev.vars를 출력하지 않습니다.
성공한 배포에서 출력된 정확한 workers.dev origin을 저장한 다음 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에는 끝 슬래시나 경로 없이 origin만 입력합니다. 토큰은 이 터미널 세션에만 보관하고 메모나 스크린샷에 붙여 넣지 않습니다.
전체 URL을 엽니다. 초기 상태는 ready로 안정되어야 하며, 페이지에는 이 대화에 저장된 메시지가 없다는 내용이 표시되어야 합니다. 준비된 테스트용 질문을 전송합니다. 텍스트가 도착하면서 submitted가 streaming으로 바뀌고 다시 ready로 돌아가는지 확인합니다.

표시된 리소스와 답변은 테스트한 폐기용 실행의 예시입니다. 모델 출력은 비결정적이므로 실제 문구는 다를 수 있습니다.
같은 URL을 새로 고칩니다. 완료된 사용자 및 assistant 메시지가 처음부터 다시 시작되지 않고 SQLite에서 복원되어야 합니다.

이제 이름 격리를 확인합니다. 별도로 서명된 URL을 생성하고 엽니다.
PRIVATE_TOKEN="$(node scripts/create-session-token.mjs private)"
printf '%s/?session=private&token=%s\n' "${WORKER_URL%/}" "$PRIVATE_TOKEN"
private 페이지는 인증되지만 다른 이름의 Agent 인스턴스에 속하므로 기록이 비어 있습니다.

마지막으로 실행마다 고유한 독립 원격 프로브를 실행합니다. 이 프로브는 제한된 모델 호출을 한 번 더 수행하고, 여러 스트림 청크를 확인하며, 재연결 후 저장된 사용자 및 assistant 메시지를 가져옵니다. 또한 인증된 두 번째 세션이 비어 있는지 확인하고 세션 간 액세스를 거부하는지도 검사합니다.
python3 .labex/verify.py deployed
채팅 리소스 확인 및 삭제
이 단계에서는 런타임 동작을 Dashboard의 증거와 연결한 다음 이 실습의 리소스만 삭제합니다.
Cloudflare Dashboard에서 Workers & Pages를 열고 정확한 labex-c11-s03-... Worker를 선택한 다음 바인딩을 확인합니다. AI 바인딩과 SupportChatAgent Durable Object 바인딩이 모두 표시되어야 합니다.

Durable Objects를 열고 이 Worker가 소유한 SQL 기반 네임스페이스를 선택합니다. 네임스페이스는 Cloudflare의 리소스 수준 보기입니다. planning, private 및 검증기 이름은 그 안에서 격리된 인스턴스입니다.

Worker의 로그 또는 observability 보기를 열고 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가 인증되지 않은 상태를 나타내는 0이 아닌 종료 결과를 반환할 수 있습니다. 이 작업을 마지막에 수행하는 이유는 정리 확인에 유효한 인증이 필요하고, 로그아웃하면 이후 폐기할 VM을 보호할 수 있기 때문입니다.
요약
Cloudflare의 현재 채팅 통합을 사용해 영구적으로 저장되고 스트리밍되는 지원 대화를 구축했습니다. 다음 작업을 수행했습니다.
AIChatAgent를 확장하고 제한된 Workers AIstreamText()호출을 사용했습니다.- 제공된 React 셸을
useAgent()및useAgentChat()에 연결했습니다. - 만료 시간이 있고 세션 범위가 지정된 서명으로 WebSocket 및 HTTP 기록 라우트를 모두 보호했습니다.
- 점진적으로 변하는 상태를 확인하고, 새로 고친 후 SQLite 기반 기록을 복원했으며, 다른 이름의 대화가 격리된 상태로 유지되는지 검증했습니다.
- 개인정보가 제한된 Cloudflare 증거를 확인했습니다.
- VM 인증을 취소하기 전에 정확한 Agent 클래스 네임스페이스와 Worker를 삭제했습니다.
다음 실습에서는 동일한 영구 Agent ID를 예약된 지원 후속 작업에 사용합니다. 예약은 브라우저 연결이 남아 있지 않아도 나중에 작업을 실행할 수 있게 하는 별도의 수명 주기 문제입니다.



