AI 参与说明(Agent:
/root、/root/convex_research、/root/cloudflare_research、/root/tanstack_research):本文由 Agent 根据产品官方文档、稳定版源码示例与 npm registry 版本信息辅助调研、撰写和校验。结论按 2026-08-14 的公开资料整理;Cloudflare Agents SDK、Think、Convex Agent Component 与 TanStack 的 API 仍会演进,落地时应以项目 lockfile 和一手文档为准。
Convex Agent Component 和 Cloudflare Agents SDK 都能实现:用户的消息被服务端确认后关闭标签页,模型继续在服务端生成,再次打开同一会话时看到完整回复或正在生成的部分内容。
如果现有底层已经是 Cloudflare Workers + TanStack Start,本文建议优先采用 Cloudflare Agents SDK:普通聊天使用 AIChatAgent;若产品必须给出可验证的“已持久接收,可以关页”确认,则使用 Think 的 submitMessages() + @callable()。这条路径不需要再引入一套数据库、实时订阅和调度后端。
如果最看重的是简洁、明确的事务确认语义,Convex 的 mutation 中执行 saveMessage() 和 ctx.scheduler.runAfter(0, ...) 更直接:mutation 成功即代表消息与后台调度已经原子提交。不过此时 Convex 会成为 Agent 的主要数据与调度后端,Cloudflare Worker 更像 TanStack SSR/BFF 入口。
先定义“发送成功”#
按钮被点击、乐观消息出现在页面、WebSocket 开始发送,都不能证明服务端已经接收。至少应区分四个状态:
| 状态 | 含义 | 此时关页是否安全 |
|---|---|---|
local | 只存在于浏览器内存或 optimistic UI | 否 |
accepted / queued | 用户消息和后台任务已持久化,服务端返回 ACK | 是 |
running | 服务端正在调用模型或执行工具 | 是,但仍可能失败 |
completed / failed | 已持久化最终回复或终态错误 | 已结束 |
因此本文中的“发送成功”特指第二层:服务端已经 durable accept(持久接收),而不是客户端认为发送完成。 模型供应商仍可能超时、限流或拒绝请求,所以任何方案都不应承诺“回答必然成功”;它们应该持久化 completed 或 failed 终态,避免 UI 永久停在 streaming。
结论对照#
| 维度 | Convex Agent Component | Cloudflare AIChatAgent | Cloudflare Think durable submission |
|---|---|---|---|
| 关页后继续生成 | 支持;mutation 调度的 action 不依赖页面连接 | 支持;客户端断开后服务端默认继续生成并缓冲 chunks | 支持;submission 先落 durable ledger,再异步运行 |
| 重开后恢复 | query 读取持久化 messages;saveStreamDeltas 可恢复 partial output | 同一 Agent instance 从 SQLite 读取消息,并补发缓冲 chunks | 与 useAgentChat 兼容;还可查询 submission 状态 |
| 明确的快速 ACK | 强;mutation Promise 成功表示消息和调度原子提交 | sendMessage() 返回 void,没有独立的 durable ACK Promise | 强;返回 submissionId、status、accepted |
| 幂等重试 | Convex mutation 本身具备幂等执行保证;业务仍应保存稳定请求 ID | 普通 sendMessage() 不应被当作业务幂等接口 | 原生 idempotencyKey;同键重试返回原 submission |
| Worker/进程重启恢复 | 数据和调度由 Convex 托管 | 需显式启用 chatRecovery;其默认值是 false | Think 默认启用 chatRecovery |
| 失败重试 | scheduled action 是 at-most-once,默认不自动重试;生产级用 Convex Workflow | chatRecovery 处理 turn 中断;复杂长任务可接 Cloudflare Workflows | durable recovery + submission 状态;复杂多步骤仍可接 Workflows |
| 与现有 Workers + TanStack 的边界 | 增加独立后端;浏览器最好直连 Convex query/mutation | 同一个 Worker、Durable Object 与 React chat hook | 同一个 Worker;TanStack Mutation 可直接接 ACK RPC |
| transcript 最佳数据源 | 原生 useUIMessages,不应强塞进 TanStack Query | useAgentChat | useAgentChat;submission/run metadata 可交给 TanStack Query |
只针对当前问题,可以把选型压缩成两句话:
- 正常关闭标签页并稍后回来:两者都可以。
- 当前技术栈下优先 Cloudflare;若“成功发送”的 ACK 必须严格可证明,使用 Think
submitMessages(),不要只看useAgentChat的本地状态。
推荐架构:Cloudflare Workers + TanStack Start + Agents SDK#
flowchart TD A["TanStack Router: /chat/$conversationId"] --> B["稳定的 Agent instance name"] B --> C["Think submitMessages 或 AIChatAgent chat protocol"] C --> D["Durable Object SQLite: messages / submissions / chunks"] D --> E["服务端模型与 server-side tools"] E --> D F["标签页关闭"] -. "不取消服务端 turn" .-> E G["重新打开同一 conversationId"] --> B D --> G
这里最重要的不是 React 状态,而是三个服务端不变量:
conversationId是稳定、可恢复的路由 ID;同一会话始终映射到同一个 Agent instance。- 消息或 submission 在返回 ACK 之前已经写入 Durable Object SQLite。
- 模型调用只依赖 server-side tools;如果 turn 正在等待浏览器工具、浏览器本地文件或人工批准,关页后当然无法自动完成。
方案 A:普通聊天用 AIChatAgent#
安装当前所需包;生产项目应锁定精确版本:
npm install agents @cloudflare/ai-chat ai workers-ai-provider服务端 Agent 使用 SQLite 保存消息和 stream chunks。下面是可运行核心,Env 由 wrangler types 生成:
// src/agents/chat-agent.ts
import {
AIChatAgent,
type OnChatMessageOptions,
} from "@cloudflare/ai-chat";
import {
convertToModelMessages,
streamText,
type GenerateTextOnFinishCallback,
type ToolSet,
} from "ai";
import { createWorkersAI } from "workers-ai-provider";
export class ChatAgent extends AIChatAgent<Env> {
// resumable streaming 只处理浏览器断线;chatRecovery 还处理
// Durable Object eviction、部署或资源限制导致的 turn 中断。
override chatRecovery = {
maxAttempts: 6,
noProgressTimeoutMs: 5 * 60_000,
maxRecoveryWork: 100,
terminalMessage: "回答生成过程中断且无法恢复,请重试。",
};
override async onChatMessage(
_onFinish: GenerateTextOnFinishCallback<ToolSet>,
options?: OnChatMessageOptions,
) {
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
abortSignal: options?.abortSignal,
});
return result.toUIMessageStreamResponse();
}
}wrangler.jsonc 必须注册 SQLite-backed Durable Object;main 指向后文的 TanStack custom server entrypoint:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "chat-app",
"main": "src/server.ts",
"compatibility_date": "2026-08-14",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI" },
"durable_objects": {
"bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }
]
}React 客户端必须把 URL 中持久化的 conversationId 作为 name。不要在组件每次 mount 时生成新 ID:
// src/routes/chat.$conversationId.tsx
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import type { ChatAgent } from "../agents/chat-agent";
export function Chat({ conversationId }: { conversationId: string }) {
const agent = useAgent<ChatAgent>({
agent: "ChatAgent",
name: conversationId,
});
const chat = useAgentChat({
agent,
resume: true,
cancelOnClientAbort: false,
});
return (
<main>
{chat.messages.map((message) => (
<article key={message.id}>
{message.parts.map((part, index) =>
part.type === "text" ? <span key={index}>{part.text}</span> : null,
)}
</article>
))}
<form
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const text = String(data.get("message") ?? "").trim();
if (!text) return;
chat.sendMessage({ text });
form.reset();
}}
>
<input name="message" />
<button type="submit" disabled={chat.status !== "ready"}>
发送
</button>
</form>
</main>
);
}resume: true 和 cancelOnClientAbort: false 本来就是当前默认值,这里显式写出是为了固定产品语义。浏览器普通 abort、卸载或断线只停止本地消费;服务端继续生成和写 chunks。用户显式调用 stop() 时则始终会取消服务端 turn;不要在组件 cleanup 中调用它。
需要区分两类恢复:
- Client reconnect:
useAgentChat自动重连,服务端补发 SQLite 中的 chunks,再接回 live stream。 - Durable Object restart:进程被驱逐或部署更新会切断原模型连接;
AIChatAgent.chatRecovery默认是false,必须像上例显式开启。恢复可能重新续跑模型和工具,不是对原供应商字节流的原样重播,因此要设置次数、无进展时间和成本上限。
这个方案已经能解决普通“关标签页”场景,但 useAgentChat.sendMessage() 的公开返回类型是 void。它适合实时聊天 UX,却没有一个可供 TanStack Mutation await 的 durable ACK。若产品只需在服务端 echo 出相同 message ID 后显示“已发送”,可以把 server-authoritative transcript 当作 ACK;若需要明确的 RPC 确认,则使用下一种方案。
方案 B:严格 ACK 用 Think submitMessages()#
Think 是 Agents SDK 上层的 agent harness,与 useAgentChat 协议兼容。它的 submitMessages() 会先把工作写入 durable submission ledger,再在推理开始前快速返回;同时提供 idempotencyKey 与 pending / running / completed / aborted / skipped / error 状态。
先安装 Think,并把 durable submission 包成浏览器可调用的 RPC:
npm install @cloudflare/think// src/agents/chat-agent.ts
import { Think } from "@cloudflare/think";
import { callable } from "agents";
import { createWorkersAI } from "workers-ai-provider";
type SubmitInput = {
messageId: string;
text: string;
};
export class ChatAgent extends Think<Env> {
getModel() {
return createWorkersAI({ binding: this.env.AI })(
"@cf/moonshotai/kimi-k2.6",
);
}
@callable()
async submitUserMessage(input: SubmitInput) {
const result = await this.submitMessages(
[
{
id: input.messageId,
role: "user",
parts: [{ type: "text", text: input.text }],
},
],
{ idempotencyKey: input.messageId },
);
return {
submissionId: result.submissionId,
status: result.status,
accepted: result.accepted,
};
}
}@callable() 使用 TC39 decorators。Vite 项目要加入 agents/vite,并且不要设置 TypeScript legacy 的 experimentalDecorators: true:
// vite.config.ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
agents(),
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart(),
react(),
],
});同时按官方建议让 tsconfig.json 继承 agents/tsconfig,或至少将 target 设置为 ES2021。
客户端用 TanStack Query 的 Mutation 管理“提交并等待 ACK”,但 transcript 仍交给 useAgentChat:
import { useMutation } from "@tanstack/react-query";
import { useAgent } from "agents/react";
import type { ChatAgent } from "../agents/chat-agent";
function useSubmitTurn(conversationId: string) {
const agent = useAgent<ChatAgent>({
agent: "ChatAgent",
name: conversationId,
});
return useMutation({
mutationFn: (input: { messageId: string; text: string }) =>
agent.stub.submitUserMessage(input),
retry: 2,
// 只让同一会话的提交串行;它不是幂等保证。
scope: { id: conversationId },
});
}
function Composer({ conversationId }: { conversationId: string }) {
const submitTurn = useSubmitTurn(conversationId);
async function submitText(text: string) {
// ID 在 mutationFn 外生成;网络重试必须复用同一个值。
const input = { messageId: crypto.randomUUID(), text };
const ack = await submitTurn.mutateAsync(input);
// Promise resolve 后才显示“服务端已接收,可以关闭页面”。
return ack;
}
// 表单调用 submitText(text),并根据 submitTurn.status 渲染 ACK 状态。
return null;
}不要再为同一条消息同时调用普通 chat.sendMessage(),否则会创建两个 turn。Think 只在 submission 开始执行时才把消息追加到 Session;如果希望 queued 消息在重开后立即显示,应把 submissionId 与 pending UI 存进自己的会话索引,或通过 inspectSubmission() / listSubmissions() 读取。TanStack 的 mutationKey 也不是服务端 idempotency key,真正去重的是上例中的稳定 messageId。
与 TanStack Start 合并 Worker entrypoint#
TanStack Start 可以通过 custom server entrypoint 同时导出 Agent Durable Object,并把 /agents/... 请求交给 Agents router。下面是集成骨架;requireSession() 和 assertConversationOwner() 是项目必须实现的认证与所有权检查:
// src/server.ts
import startHandler from "@tanstack/react-start/server-entry";
import { routeAgentRequest } from "agents";
export { ChatAgent } from "./agents/chat-agent";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
if (url.pathname.startsWith("/agents/chat-agent/")) {
const session = await requireSession(request, env);
const encodedId = url.pathname.split("/")[3];
if (!encodedId) return new Response("Not found", { status: 404 });
const conversationId = decodeURIComponent(encodedId);
await assertConversationOwner(env, session.userId, conversationId);
const response = await routeAgentRequest(request, env, {
props: { userId: session.userId },
});
if (response) return response;
}
return startHandler.fetch(request, env, ctx);
},
} satisfies ExportedHandler<Env>;conversationId 只是 Durable Object 路由键,不是授权凭据。服务端必须校验当前用户是否拥有该会话;不能因为 ID 难猜就省略权限检查。跨域 WebSocket 应使用短期签名 token,不要把长期 secret 放进 URL。
Convex 的标准实现#
如果选择 Convex,官方推荐的异步模式恰好对应本文需求:mutation 先保存 prompt,再在同一事务内调度 internalAction。mutation 成功后,客户端可以关闭;action 之后调用模型并保存 assistant message。下面是官方模式的核心代码;chatAgent、模型 Provider 与 authorizeThreadAccess() 是项目自身定义,不是可省略的伪实现。
// convex/chat.ts
import {
listUIMessages,
saveMessage,
syncStreams,
vStreamArgs,
} from "@convex-dev/agent";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { components, internal } from "./_generated/api";
import { internalAction, mutation, query } from "./_generated/server";
import { chatAgent } from "./agent";
export const sendMessage = mutation({
args: { threadId: v.string(), prompt: v.string() },
handler: async (ctx, { threadId, prompt }) => {
await authorizeThreadAccess(ctx, threadId);
const { messageId } = await saveMessage(ctx, components.agent, {
threadId,
prompt,
skipEmbeddings: true,
});
const scheduledId = await ctx.scheduler.runAfter(
0,
internal.chat.generateResponse,
{ threadId, promptMessageId: messageId },
);
return { messageId, scheduledId };
},
});
export const generateResponse = internalAction({
args: { threadId: v.string(), promptMessageId: v.string() },
handler: async (ctx, { threadId, promptMessageId }) => {
const result = await chatAgent.streamText(
ctx,
{ threadId },
{ promptMessageId },
{
saveStreamDeltas: {
chunking: "word",
throttleMs: 250,
},
},
);
// 后台 action 必须消费完整 stream,不能启动后直接 return。
await result.consumeStream();
},
});
export const listThreadMessages = query({
args: {
threadId: v.string(),
paginationOpts: paginationOptsValidator,
streamArgs: vStreamArgs,
},
handler: async (ctx, args) => {
await authorizeThreadAccess(ctx, args.threadId);
const page = await listUIMessages(ctx, components.agent, args);
const streams = await syncStreams(ctx, components.agent, args);
return { ...page, streams };
},
});React 客户端应使用 Agent Component 原生 hooks,因为它们直接支持 pagination、message status 和 stream deltas:
import {
optimisticallySendMessage,
useUIMessages,
} from "@convex-dev/agent/react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
function ConvexChat({ threadId }: { threadId: string }) {
const { results: messages } = useUIMessages(
api.chat.listThreadMessages,
{ threadId },
{ initialNumItems: 20, stream: true },
);
const sendMessage = useMutation(api.chat.sendMessage).withOptimisticUpdate(
optimisticallySendMessage(api.chat.listThreadMessages),
);
async function submit(prompt: string) {
await sendMessage({ threadId, prompt });
// 只有 Promise resolve 后,才显示“服务端已接收”。
}
// 根据 messages 渲染 transcript,表单调用 submit(prompt)。
return null;
}Convex 的保证边界非常清楚:
- 从 mutation 调度函数与 mutation 其他写入是原子的;mutation 成功则后台函数保证已被调度,mutation 失败则两者都不提交。
saveStreamDeltas把 chunks 分组写入数据库,重开页面后useUIMessages(..., { stream: true })可以继续看到 partial output。- 调度不会继承原 mutation 的
ctx.auth。必须在 mutation 中鉴权,再向 internal action 传服务端确认的内部 ID,或由 action 重新查所有权;不能相信客户端直接传入的userId。
不过 scheduled action 是 at-most-once,瞬时错误后默认不会自动重试。MVP 可以直接使用 runAfter();若产品要求更强的 eventually-complete 语义,应把模型调用放进 Convex Workflow,并显式开启 action step retry:
await step.runAction(
internal.chat.generateResponse,
{ threadId, promptMessageId },
{ retry: true },
);重试仍不代表模型请求 exactly-once:供应商已经收到请求、但平台尚未记录结果时发生故障,第二次尝试可能重复调用并计费。应复用同一个 promptMessageId,并给外部副作用设置 idempotency key。
TanStack 各层应该负责什么#
无论选择哪一方,TanStack 都不负责让关页后的任务继续运行。它负责页面、路由、服务端入口和 UI 数据协调:
| TanStack 层 | 推荐职责 |
|---|---|
| Router / Start | /chat/$conversationId、SSR、认证入口、custom Worker entrypoint |
| Query | conversation list、metadata、attachments、submission/run status;必要时对 REST 状态做 polling 降级 |
| Mutation | 发起 durable submission 并等待 ACK;重试复用同一个 client-generated ID |
| Agent transcript | Cloudflare 使用 useAgentChat;Convex 使用 useUIMessages,不要在 Query cache 再保存第二份权威消息 |
TanStack Query 的 refetchInterval 只在页面存在 active observer 时运行。标签页关闭后 polling 停止是正常行为;服务端后台执行绝不能依赖它。重新打开页面时再 query/订阅 queued / running / completed / failed 状态即可。
如果选 Convex,@convex-dev/react-query 可以与原生 Convex hooks 共存,并能让普通 Convex query 通过 TanStack Query 接收 reactive push。但该 adapter 截至本文整理时仍是 beta,并非所有 Convex React 能力都可用;Agent transcript 的 pagination 与 streaming 仍建议使用原生 hook。
不能忽略的失败场景#
上线前至少验证以下场景:
- 发送请求尚未拿到 ACK 时立刻关页:重开后不能错误显示为已接收。
- 拿到 ACK 后关页:服务端完成回复,重开同一个
conversationId可见。 - 生成一半断网再联网:已缓冲的 chunks 不丢失、不重复。
- 生成中发布 Worker 新版本或触发 Durable Object restart:
chatRecovery能恢复,或写入明确的 terminal error。 - 同一个
messageId重试两次:只产生一个 submission/用户消息。 - 模型超时、429、永久错误:状态从
running进入可观察的failed/error,不会无限旋转。 - 同一用户连续提交两条消息:确认 queue/concurrency 顺序符合产品预期。
- 用户尝试访问他人的
conversationId:Worker/Convex 鉴权明确拒绝。 - turn 需要 client-side tool 或人工批准时关页:UI 明确显示
waiting_for_input,不要宣称后台能自行完成。
不要把普通 Worker ctx.waitUntil() 当作本问题的持久任务系统。它适合延长请求后的有限工作,不提供这里需要的 durable message ledger、断线续流、重启恢复和可查询 run state。聊天使用 Agents 的 SQLite/fiber;复杂长时、多步骤或等待外部事件的流程再使用 Cloudflare Workflows 或 Convex Workflow。
最终选择#
对 Cloudflare Workers + TanStack 全家桶,建议按下面顺序落地:
- 用 TanStack Router 保存稳定
conversationId,并把它作为 Agentname。 - 普通聊天以
AIChatAgent + useAgentChat起步,显式设置resume: true、cancelOnClientAbort: false与有上限的chatRecovery。 - 如果 UI 必须在某个准确时刻显示“服务端已接收,可以关页”,升级为 Think
submitMessages() + @callable(),TanStack Mutation 只负责等待这个 ACK。 - transcript 只以 Agent SQLite/
useAgentChat为权威源;TanStack Query 保存列表和业务 metadata。 - 只有任务包含长时多步骤、外部事件或独立步骤重试时,才加入 Cloudflare Workflows。
只有在团队已经采用 Convex,或希望把事务数据库、reactive query、Agent messages 与调度集中到 Convex 时,才建议选择 Convex Agent Component。它的 mutation ACK 更天然,但在当前栈里会增加运行平台、认证与数据边界。
版本快照与一手资料#
2026-08-14 查询 npm registry 的 stable latest 快照如下;这只是调研基线,不代表这些任意版本组合都经过兼容性验证:
| 包 | 版本 |
|---|---|
agents | 0.20.1 |
@cloudflare/ai-chat | 0.10.1 |
@cloudflare/think | 0.15.1 |
ai | 7.0.65(本文 Cloudflare 示例) |
workers-ai-provider | 4.0.0 |
@convex-dev/agent | 0.6.4 |
convex | 1.44.0 |
@convex-dev/react-query | 0.1.0 |
@tanstack/react-query | 5.101.4 |
@tanstack/react-start | 1.168.44 |
本文的 Cloudflare Agent server/client 片段已用表中 agents、@cloudflare/ai-chat、@cloudflare/think、ai、workers-ai-provider 与 @tanstack/react-query 版本执行 tsc --noEmit。另需注意,@convex-dev/agent@0.6.4 当前声明的 ai peer dependency 是 ^6.0.35,而 workers-ai-provider@4.0.0 使用 AI SDK 7;两套代码是备选实现,不要未经依赖校验就把它们安装进同一个 package。若要并行评估,使用独立 workspace 或分别锁定兼容的 Provider 版本。
Cloudflare 相关:
- Chat agents:消息持久化、resumable streaming 与 chatRecovery
- Think:programmatic submissions
- Think Getting started
- Callable methods
- Agent routing 与稳定 instance name
- TanStack Start on Cloudflare Workers
- Long-running agents
- Run Workflows from an Agent
Convex 相关:
- Agent usage:save prompt 后异步生成
- Agent streaming 与持久化 deltas
- Agent messages
- Scheduled Functions 的事务、重试与 auth 语义
- Agent Workflows
@convex-dev/agent@0.6.4async streaming 官方示例- Convex with TanStack Query
- Convex with TanStack Start