Convex Agent Component 与 Cloudflare Agents SDK:关页后继续生成并恢复聊天

This article is extracted from the chat log with AI. Please identify it with caution.

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(持久接收),而不是客户端认为发送完成。 模型供应商仍可能超时、限流或拒绝请求,所以任何方案都不应承诺“回答必然成功”;它们应该持久化 completedfailed 终态,避免 UI 永久停在 streaming

结论对照#

维度Convex Agent ComponentCloudflare AIChatAgentCloudflare Think durable submission
关页后继续生成支持;mutation 调度的 action 不依赖页面连接支持;客户端断开后服务端默认继续生成并缓冲 chunks支持;submission 先落 durable ledger,再异步运行
重开后恢复query 读取持久化 messages;saveStreamDeltas 可恢复 partial output同一 Agent instance 从 SQLite 读取消息,并补发缓冲 chunksuseAgentChat 兼容;还可查询 submission 状态
明确的快速 ACK强;mutation Promise 成功表示消息和调度原子提交sendMessage() 返回 void,没有独立的 durable ACK Promise强;返回 submissionIdstatusaccepted
幂等重试Convex mutation 本身具备幂等执行保证;业务仍应保存稳定请求 ID普通 sendMessage() 不应被当作业务幂等接口原生 idempotencyKey;同键重试返回原 submission
Worker/进程重启恢复数据和调度由 Convex 托管需显式启用 chatRecovery;其默认值是 falseThink 默认启用 chatRecovery
失败重试scheduled action 是 at-most-once,默认不自动重试;生产级用 Convex WorkflowchatRecovery 处理 turn 中断;复杂长任务可接 Cloudflare Workflowsdurable recovery + submission 状态;复杂多步骤仍可接 Workflows
与现有 Workers + TanStack 的边界增加独立后端;浏览器最好直连 Convex query/mutation同一个 Worker、Durable Object 与 React chat hook同一个 Worker;TanStack Mutation 可直接接 ACK RPC
transcript 最佳数据源原生 useUIMessages,不应强塞进 TanStack QueryuseAgentChatuseAgentChat;submission/run metadata 可交给 TanStack Query

只针对当前问题,可以把选型压缩成两句话:

  1. 正常关闭标签页并稍后回来:两者都可以。
  2. 当前技术栈下优先 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。下面是可运行核心,Envwrangler 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: truecancelOnClientAbort: false 本来就是当前默认值,这里显式写出是为了固定产品语义。浏览器普通 abort、卸载或断线只停止本地消费;服务端继续生成和写 chunks。用户显式调用 stop() 时则始终会取消服务端 turn;不要在组件 cleanup 中调用它。

需要区分两类恢复:

  • Client reconnectuseAgentChat 自动重连,服务端补发 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,再在推理开始前快速返回;同时提供 idempotencyKeypending / 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
Queryconversation list、metadata、attachments、submission/run status;必要时对 REST 状态做 polling 降级
Mutation发起 durable submission 并等待 ACK;重试复用同一个 client-generated ID
Agent transcriptCloudflare 使用 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。

不能忽略的失败场景#

上线前至少验证以下场景:

  1. 发送请求尚未拿到 ACK 时立刻关页:重开后不能错误显示为已接收。
  2. 拿到 ACK 后关页:服务端完成回复,重开同一个 conversationId 可见。
  3. 生成一半断网再联网:已缓冲的 chunks 不丢失、不重复。
  4. 生成中发布 Worker 新版本或触发 Durable Object restart:chatRecovery 能恢复,或写入明确的 terminal error。
  5. 同一个 messageId 重试两次:只产生一个 submission/用户消息。
  6. 模型超时、429、永久错误:状态从 running 进入可观察的 failed/error,不会无限旋转。
  7. 同一用户连续提交两条消息:确认 queue/concurrency 顺序符合产品预期。
  8. 用户尝试访问他人的 conversationId:Worker/Convex 鉴权明确拒绝。
  9. 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 全家桶,建议按下面顺序落地:

  1. 用 TanStack Router 保存稳定 conversationId,并把它作为 Agent name
  2. 普通聊天以 AIChatAgent + useAgentChat 起步,显式设置 resume: truecancelOnClientAbort: false 与有上限的 chatRecovery
  3. 如果 UI 必须在某个准确时刻显示“服务端已接收,可以关页”,升级为 Think submitMessages() + @callable(),TanStack Mutation 只负责等待这个 ACK。
  4. transcript 只以 Agent SQLite/useAgentChat 为权威源;TanStack Query 保存列表和业务 metadata。
  5. 只有任务包含长时多步骤、外部事件或独立步骤重试时,才加入 Cloudflare Workflows。

只有在团队已经采用 Convex,或希望把事务数据库、reactive query、Agent messages 与调度集中到 Convex 时,才建议选择 Convex Agent Component。它的 mutation ACK 更天然,但在当前栈里会增加运行平台、认证与数据边界。

版本快照与一手资料#

2026-08-14 查询 npm registry 的 stable latest 快照如下;这只是调研基线,不代表这些任意版本组合都经过兼容性验证:

版本
agents0.20.1
@cloudflare/ai-chat0.10.1
@cloudflare/think0.15.1
ai7.0.65(本文 Cloudflare 示例)
workers-ai-provider4.0.0
@convex-dev/agent0.6.4
convex1.44.0
@convex-dev/react-query0.1.0
@tanstack/react-query5.101.4
@tanstack/react-start1.168.44

本文的 Cloudflare Agent server/client 片段已用表中 agents@cloudflare/ai-chat@cloudflare/thinkaiworkers-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 相关:

Convex 相关:

关联阅读#

本文共 6288 字,创建于 Aug 14, 2026

相关标签: Cloud, Serverless, TypeScript, AI, Convex, ByAI