把 AI 短剧工作台部署到 Cloudflare:Workers、Queues 与双 Durable Object 的分工
沿着 Shortdrama Studio 的实际代码,拆开单 Worker、Turso、R2、生成队列和两类 Durable Object:页面、画布协作、Agent 与异步生成怎样分工,以及最后版本仍需补齐的恢复边界。
做 Shortdrama Studio 时,我很快发现,短剧工作台的“后端”不是一组增删改查接口就能概括的。
用户拖动画布,需要另一端及时看见;Agent 正在拆解剧情,需要持续把进度传回来;一张角色图可能很快生成,一个视频镜头却需要交给外部服务等待结果。用户还会刷新、切页、取消,或者在另一个浏览器打开同一个项目。每一种行为都在问系统:这份状态到底由谁保管,下一步由谁执行?
这篇从部署结构开始,拆开 Shortdrama Studio 怎样把这些工作放到 Cloudflare 上。文章以最后提交 0547e15 为边界,时间是 2026 年 5 月 12 日。前一篇技术设计文章讲过素材槽位、任务租约和画布状态,这次重点看运行时、资源绑定,以及请求离开浏览器以后怎样继续走。
先看全貌:一个入口,三条执行路径
项目的前端是 React 与 TanStack Router,画布使用 React Flow;业务 API 使用 Elysia。生产部署把前端构建产物和 API 放到同一个 Worker 入口下,业务数据库则使用 Turso / libSQL,素材放在 R2。
与此同时,Worker 绑定了 Cloudflare Queues,以及两个不同用途的 Durable Object:CanvasCollaborationRoom 和 AgentRunnerRoom。
从用户动作看,可以把运行路径分为三条:打开项目、修改配置之类的请求走普通 API;画布同步走协作房间;Agent 对话与素材生成分别走流式执行和后台任务。它们共享项目身份、权限和业务数据,却不必共用一个持续运行的请求。
左右滑动查看完整表格
组件 | 在这个版本中的职责 | 不应混淆的边界 |
|---|---|---|
Worker + Static Assets | 页面资源、认证和 API 分流 | 页面加载完成不代表后台任务完成 |
Elysia | 项目权限、素材与任务业务 | 请求结束不等于生成结束 |
Turso / libSQL + Drizzle | 项目、资源、任务与事件记录 | 这里的业务数据库不是 D1 |
CanvasCollaborationRoom | WebSocket、Yjs 更新、协作文档持久化 | 协作文档还需要映射为业务画布 |
AgentRunnerRoom | 当前线程的 Agent 流和取消控制 | 内存中的运行标记不是持久任务队列 |
Cloudflare Queues | 派发生成工作、处理消息重试 | 消息可能重复,消费者仍需检查状态 |
R2 | 用户上传与生成的媒体对象 | 一个外部模型 URL 不等于已保存的素材 |
这个架构里确实同时出现了好几种“有状态”的组件。关键是每一份状态都能解释自己的用途:任务表回答生成进行到哪里,协作房间回答画布当前如何同步,R2 回答最终文件在哪里。不能让浏览器里的一个 loading 同时承担这些责任。
单 Worker 部署:把页面、认证与业务路由分开
部署入口是 apps/server/src/cloudflare/worker.ts。它先安装运行时环境与队列绑定,然后识别协作 WebSocket 和画布物化请求,再将认证路径交给 Better Auth,将普通 /api 路径交给 Elysia,其余请求交给静态资源。
Wrangler 的资源配置可以看到这个分流意图。下面保留项目相关片段,不包含域名和密钥:
{
"main": "apps/server/src/cloudflare/worker.ts",
"compatibility_date": "2026-05-03",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./apps/web/dist",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"]
}
}同一个站点地址下,浏览器可以直接请求相对路径的 API,页面路由也有 SPA 回退。这样减少了前端域名和 API 域名之间的组合配置,但认证仍然要正确设置站点地址、可信来源和 Cookie。合并部署入口不会自动消除认证配置问题。
应用和认证模块使用缓存的动态导入 Promise。第一次请求触发加载,后续请求可以复用;如果导入失败,缓存会被清空,让下一次请求有机会重试。这个细节很小,却能避免把一次初始化失败变成整个实例后续请求都复用的失败结果。
队列事件同样需要安装环境,再加载自己的处理器。不能只在 HTTP 入口配置数据库和绑定,假设后台消费一定先经历过一次页面访问。请求入口和队列入口是两种独立的唤起方式。
本地 SQLite,生产 Turso:共用模型,分别适配运行时
这里容易产生一个误解:既然应用部署在 Cloudflare,数据库是不是也一定使用 D1?这个版本没有这样做。
packages/db/src/client.ts 根据运行环境选择适配器。本地 Bun 开发使用 bun:sqlite;配置了 TURSO_DATABASE_URL 或 LIBSQL_DATABASE_URL 时,使用 libSQL。生产 Worker 需要明确的数据库配置,缺失时会给出配置错误,而不是悄悄创建另一份本地账本式数据文件。
两条路径共用 Drizzle schema 和业务接口,底层连接方式则独立处理。本地 SQLite 很适合快速开发、准备测试数据与复现问题;部署后,项目、角色、分镜、资源、任务和事件记录都需要一个可被不同执行入口访问的业务数据库。
nodejs_compat 也不是“所有 Bun 或 Node 代码可以原样运行”的证明。对这个项目来说,真正的适配点包括数据库驱动、文件处理、流式响应、环境读取和第三方 SDK。尤其不能让生产路径误加载只适合 Bun 的数据库模块。
还有另一种 SQLite:Canvas Durable Object 使用 ctx.storage.sql 保存协作文档的快照与更新。这是房间自己的持久状态,和 Turso 中的项目、任务表不是同一个数据库。两个名字里都有 SQLite,不代表它们可以共用连接、事务或者查询范围。
第一类 Durable Object:让一个项目有自己的协作房间
画布是这个工作台的中心。角色、场景、镜头和生成结果都在上面,节点位置、连线和内容变化需要及时传到其他浏览器。
CanvasCollaborationRoom 把相应项目和文档的协作状态集中到一个房间中。连接建立前,服务端检查项目访问权限;房间保存 WebSocket 的身份与角色上下文,对写入消息继续执行相应限制。能看项目和能编辑项目,是两种不同权限。
房间里的 Yjs 文档负责同步更新,SQLite 存储负责保存快照与更新记录。WebSocket 使用 acceptWebSocket 接入,并通过 attachment 保存恢复连接上下文需要的信息。Cloudflare 的休眠机制允许对象在空闲时离开内存,因此恢复时需要重建状态,不能依赖某个 JavaScript 字段永久存在。Durable Objects WebSocket 文档
但“用了 Yjs”还不能直接推出节点的每个字段都支持细粒度无冲突合并。这个版本的画布映射仍将节点、连线等内容序列化为较粗的 JSON 值。它已经能承担文档同步,却不能在文章里被描述为任意两个用户同时修改同一节点都能自动精确合并。
协作文档还要物化成业务画布
画布不只给在线浏览器看。Agent 工具、任务执行器和普通业务接口也需要读取、更新节点,所以项目保留了协作文档向业务存储物化的路径。
房间的 alarm、空闲处理和物化接口让协作状态进入数据库中的画布表示;生成任务完成后,也会同步持久化画布与协作房间。这样,后台刚生成的资源有机会出现在正在打开的项目里。
这同时要求我们认真处理新旧状态。若任务已完成,旧浏览器稍后保存一份还写着“生成中”的画布,不能把任务结果抹掉。项目已有任务与画布对齐的处理,前一篇技术设计对此做了更细的说明。部署层面要记住的是:协作同步和业务结果是两套状态传播路径,它们必须约定谁拥有哪个字段。
第二类 Durable Object:让 Agent 的流式执行有独立位置
Agent 的需求与画布不同。它需要围绕项目和线程执行一次对话,连续输出进度与结果,还要支持用户取消。
AgentRunnerRoom 维护当前执行的 AbortController 与线程信息,通过 /run 启动流式响应,通过 /abort 发出取消信号。如果同一个房间已经有一轮对话在执行,新请求会得到 409 和稍后重试的提示,避免两个运行过程同时改写同一轮工作。
这里有一个值得单独讲的生命周期问题:返回 Response 对象,不代表流已经结束。
Agent 的响应体是持续产生内容的 ReadableStream。若在构造 Response 后立即清空运行标记,浏览器还在接收上一轮内容时,下一轮请求就可以进入。当前实现把清理放在流真正完成的回调里;如果启动阶段就失败,则立即释放状态。
这个房间的职责是控制当前执行。它并没有因此变成一个能无限运行、自动跨重启续写的工作流引擎。内存运行标记、取消控制器和数据库里的持久任务,可靠性范围不同。真正耗时的图片、视频生成仍需要落到任务系统里,不能让整段制作过程只存在于一条 SSE 连接中。
把两个 DO 拆开以后,职责也更容易测试:画布连接断开后能否恢复文档,是协作房间的问题;同一线程能否重复启动、取消后状态能否释放,是 Agent 房间的问题。它们没有必要共享一个庞大的房间类。
Queues 派发生成工作,任务表保存生成事实
用户点击生成后,应用先创建或取得对应的业务任务,再把任务身份交给队列。消息主体保持很小:
{
"type": "generation_task",
"taskId": "task_example",
"action": "run",
"enqueuedAt": "2026-05-12T06:00:00.000Z"
}消费者按 taskId 重新读取任务和所需参数。任务的状态、资源关联和事件在数据库中,队列消息负责把执行机会送到处理器。浏览器关闭以后,已经入队的工作不必依赖原来那个页面继续保持连接。
队列采用至少一次交付语义,因此同一消息可能被再次处理。项目中的消费者不能把“收到消息”理解成“这是第一次执行”。Cloudflare Queues 交付保证
新的 generation_tasks 路径会检查任务终态、有效租约和执行者身份,再通过条件更新领取工作。已经完成、失败或取消的任务不重新进入生成;有效租约存在时,重复消费也不会再次进入当前执行。租约过期后,处理器依据任务情况重新判断能否接管。
需要注意,代码仍兼容旧的 asset_jobs 消息。这条旧路径没有相同的任务租约和执行者字段。文章里谈新任务的并发保护时,不能把它自动套到所有历史任务类型上。
收到队列消息的确认,与业务成功是两回事
Worker 的队列入口等待处理器返回,再确认消息;未处理的异常触发延迟重试。项目配置的消费批次大小为 1,并发上限为 5,重试上限为 5,同时指定死信队列。这里的数字是这个项目当时的配置,不是平台固定限制。
队列层重试和业务重试需要分开看。任务处理器可能已经捕获模型错误,把任务标记为失败并正常返回;这时消息可以被确认,因为“这次尝试失败”本身已经成为一个保存好的业务结果。只有异常继续抛到消费入口,才会走该入口的消息重试。
如果不区分这两层,很容易出现误判:队列没有报错,就以为视频生成成功;或者任务已经明确失败,还希望队列自动无限重跑。排查时应同时看任务状态、任务事件和队列消费日志。
异步模型有句柄,不等于已经有完整的后台轮询闭环
部分 Provider 提交后只返回任务句柄。处理器会保存 providerJobId 与下次查询时间,后续通过 poll 读取结果,而不是一直占着最初的生成请求等待。
这里核对最后版本时,有一个边界必须说清楚:队列消息结构虽然支持 action: "poll" 和延迟发送,当前新任务的常见提交、查询实现并没有在每次等待后自动串起下一条延迟 poll 消息。项目还依靠状态刷新、项目活跃任务恢复等入口推进查询。
因此,这个版本不能被包装成“关掉所有页面后,任何异步任务都会由队列自行轮询到结束”的完整方案。已入队的执行和外部 Provider 的工作可以继续,但最终状态何时被本地查询、收集,需要看上述后续入口是否触发。这也是继续完善部署方案时,我会优先补齐的部分。
现有恢复接口会扫描项目的活跃任务,取消已失去画布节点的任务,对满足条件的任务重新入队,或查询已有 Provider 句柄的结果。它提供了恢复手段,却不能替代一个已经可靠部署的周期调度器。
两个不能被队列掩盖的一致性窗口
第一个窗口发生在数据库和队列之间。创建任务、发送消息是两个外部操作,不能因为两行代码写在同一个函数里,就认为它们共同处于一个原子事务。数据库写入成功但派发失败时,需要让任务还能被找到、恢复,而不是只在请求日志中留一条错误。
第二个窗口发生在模型服务和本地数据库之间。模型平台可能已经接收生成请求,Worker 却没能把返回的句柄写下来。简单重新提交,可能带来第二次外部生成;有租约也不能消除这个时间窗口。
当前代码通过任务身份、状态判断、句柄恢复和中断处理减少重复执行,但不能据此宣称外部生成“恰好一次”。如果要继续增强,我会分别考虑可靠派发记录,以及 Provider 能力允许时的幂等键、查询恢复。这些是后续设计方向,不是这个版本已经完整实现的功能。
R2:把模型返回结果变成项目自己的素材
生成完成还差最后一段路。模型可能返回一个远程 URL,也可能直接返回图片内容;结果若只留在 Provider 的临时地址中,项目刷新以后是否可读,就受制于另一个服务的保留策略。
Shortdrama Studio 把结果解析、文件发布和业务资源保存拆开。处理器识别 Provider 输出,将文件内容交给存储服务;存储服务写入 R2,再将对象标识、访问地址、类型、大小等信息交给资源与任务记录。确认产物后,任务结果才能带着可展示的资源同步回画布。
R2 在这个项目里通过 Better Upload / S3 兼容客户端接入。Wrangler 并没有为它声明一个直接使用的 R2 binding;客户端由 bucket、访问凭证、账户或 endpoint、公开访问地址等配置构建。两种接法都能出现在 Cloudflare 项目中,阅读部署文件时不能看到没有 binding 就断言“没有用 R2”。
用户上传与模型生成也有不同入口。用户上传需要应用授权与项目范围;生成产物由服务端整理后发布。两者最终进入同一套资源模型,才能被角色、分镜和素材库引用。
五月的修复里有一个很具体的例子:图片输出既可能是 HTTPS 地址,也可能是 data:image。如果下载逻辑只接受远程地址,模型已经返回图片,后续资产处理仍会失败。这样的错误不在“模型是否生成成功”这一步,而在结果格式与素材存储之间。
这让我更愿意把生成成功定义为一条完整链路:Provider 返回可解析结果,文件保存完成,业务资源与任务结果落库,画布得到对应引用。只看到一张临时预览图,还不足以说明这条链路已经闭合。
部署顺序与环境配置,也是技术方案的一部分
仓库的生产部署脚本按下面的顺序执行:
bun run db:migrate:prod && bun run build:web && wrangler deploy先迁移生产数据库,再构建前端,最后发布 Worker。数据库迁移没有塞进每一次 Worker 冷启动,这样普通流量和队列唤起不会各自尝试修改 schema。
与此同时,Cloudflare 的 DO migrations 与业务数据库迁移是两套不同机制。前者在 Wrangler 中登记 Durable Object 类及存储类型,后者由项目脚本更新 Turso 的业务表。上线清单里要同时检查两者,不能只看到其中一个成功。
配置组 | 需要核对的内容 |
|---|---|
站点与认证 | 站点地址、认证地址、可信来源、认证密钥 |
数据库 | Turso / libSQL 地址、访问凭证、迁移结果 |
协作与 Agent | 两类 DO binding、对应迁移、房间路由 |
生成任务 | 队列生产者、消费者、死信队列与并发配置 |
模型服务 | 各 Provider 的密钥、服务地址与启用配置 |
文件存储 | R2 bucket、S3 凭证、对象访问地址 |
配置文件中的 AI binding 也不意味着所有模型都改走 Workers AI。这个版本还有独立的 Provider 接入,用于文字、图片、视频与配音;真正走哪个服务,需要看模型目录和处理器选择。
如果继续规范发布,我会要求迁移具备兼容窗口,并独立准备数据库恢复方案。回退 Worker 版本不会自动撤销数据库迁移,发布成功也不会自动证明某个真实账号能完成登录、生成和素材读取。
怎样验收一个真正能运行的短剧工作台
对这种应用,只检查首页返回 200 很容易漏掉关键问题。我会围绕每条运行路径构造场景:
场景 | 重点观察 |
|---|---|
两个浏览器打开同一项目 | 节点更新是否传播,只读成员能否被正确限制 |
WebSocket 断开再连接 | 文档是否恢复,身份和角色是否仍然正确 |
同一线程连续启动两次 Agent | 第一轮仍在流式输出时,第二轮是否被阻止 |
Agent 中途取消 | 执行状态是否释放,下一轮能否正常开始 |
同一生成消息重复投递 | 有效租约与终态是否阻止重复执行 |
Provider 返回异步句柄 | 句柄是否保存,后续查询由哪个入口推进 |
生成返回 data URL 或远程 URL | 两种结果能否整理为可读取的 R2 素材 |
队列派发失败或执行中断 | 任务能否被定位,恢复操作是否有记录 |
生成后刷新画布 | 结果是否保留,旧快照会不会覆盖新状态 |
日志也要围绕同一任务串起来。项目已有任务事件,记录请求、响应、耗时、Provider 状态与错误;Worker 的可观测性配置负责接住执行入口的日志。排查时应从 taskId 查起,逐步对照 Provider 句柄、资源对象和画布节点,而不是只截取最后一条异常。
这些是基于当前实现整理的验收路径,不能当作所有生产场景都已经通过的测试报告。特别是异步任务无人值守轮询、外部调用的不确定窗口、协作文档的合并粒度,仍需要继续做真实条件下的验证。
这次部署让我重新理解了“后端”
在 Shortdrama Studio 里,后端已经不只是一个接收 JSON、返回 JSON 的服务。它同时处理短请求、长连接、流式对话、排队执行、文档持久化和媒体发布。
Cloudflare 的价值,是提供了这些运行单元,让我能按工作性质分配入口和状态。应用自己仍然需要决定:哪个状态以数据库为准,哪个动作可以重试,哪一次外部调用可能重复,用户重新打开页面时又该怎样恢复。
回看这版实现,我最想保留的是这种清楚的分工:Worker 接请求,协作房间保存在线文档,Agent 房间控制当前对话,队列派发生成工作,数据库记录业务结果,R2 留住可继续使用的素材。下一阶段要做的,就是把这些分工之间尚未闭合的恢复路径继续补齐。
