Shortdrama Studio 技术设计:画布、Agent 与异步任务怎样协同

Shortdrama Studio 技术设计:画布、Agent 与异步任务怎样协同

沿着一次镜头生成,拆开 Shortdrama Studio 的技术设计:素材槽位、任务租约、Yjs 协作与 Agent 执行边界,以及那些由真实故障逼出来的取舍。

—次点击13分钟阅读

在上一篇开发手记里,我写了 Shortdrama Studio 怎样从一组分散的短剧素材,慢慢变成一张可以继续编辑的画布。这篇想把视角往下移一层:用户连上一张参考图,再对 Agent 说“用这个角色做下一个镜头”,系统到底要经过哪些步骤?

最开始,这句话很容易被理解成一次模型调用。真正做起来才发现,模型只占其中一段。系统得知道“这个角色”指谁、连线里的图片能不能作为输入、任务是否已经在运行、页面关掉以后谁接着处理,以及另一个协作者会看到什么。这里任何一处含糊,最后都会表现成同一种体验:按钮按下去了,但不知道发生了什么。

下面按 2026 年 5 月上旬这轮开发、截至 5 月 12 日的代码来复盘。文中的图是依据实现重新整理的设计示意;界面图来自匿名本地演示。它们用于解释架构,不代表已经完成一部短剧的全流程生成验收。

先分清三件事:编辑、执行和保存

Shortdrama Studio 的前端是 React 与 TanStack Router,画布使用 React Flow,节点、连线和选中状态放在 Zustand 中,撤销与重做围绕画布状态组织。项目列表等服务端数据交给 TanStack Query;Agent 对话使用 AI SDK 的流式消息;实时协作又有自己的 WebSocket 通道。

这些状态没有全部塞进同一种缓存。拖动节点需要立即响应,没必要每移动一个像素都等待服务器;生成任务却不能靠浏览器里的一个 loading 布尔值决定生死。聊天流还会持续追加文本与工具结果,它与一次普通的项目详情查询也不同。把它们分开,是为了让每种交互有合适的更新节奏。

后端用 Elysia 按项目、画布、Agent、生成任务、素材和权限划分模块。部署入口是一套 Cloudflare Worker:普通 API 进入服务端路由,前端页面由静态资源提供;协作连接和 Agent 执行分别交给两个不同的 Durable Object,生成任务走 Cloudflare Queue。它们属于同一个应用,但承担不同生命周期的工作。

业务数据库本地使用 Bun SQLite,生产配置使用 Turso/libSQL,通过同一套 Drizzle schema 描述。协作房间还有自己的 SQLite 存储,用来保存 Yjs 快照与更新。这两种存储不能混为一谈:房间保存在线编辑文档,业务库承载项目、节点、任务和消息,二者之间需要明确的同步路径。

左右滑动查看完整表格

一类数据

主要保存位置

在系统里的作用

节点和连线

画布表、协作文档

描述内容与依赖关系

生成任务

generation_tasks

记录运行状态、输入和结果

Agent 消息

agent_threads / agent_messages

保存对话及工具执行记录

项目素材

resource_assets 等资源表

让生成结果能够继续复用

我后来反复回到一个判断:画布告诉用户正在做什么,任务表告诉系统实际上做到哪里。 两者可以互相投影,但不能互相随意覆盖。这个边界比选了哪个状态管理库更关键。

连线要变成真正的输入

以一个视频节点为例。它上游连着角色参考图和一段声音,画面上两条线都很漂亮,却不意味着模型真的收到了这两个文件。连线只记录拓扑;运行时还要找到上游产物、识别媒体类型,再判断目标模型是否接受这些输入。

为此,我在两者之间加了素材槽位,也就是 material slot。它不是又一份文件,而是一份“这个输入来自哪里、现在是否可用”的描述。下面把实际契约简化成几个关键字段:

typescript
type MaterialSlot = {
  id: string;
  kind: "image" | "audio" | "video";
  sourceNodeId: string;
  url?: string;
  status:
    | "ready"
    | "pending"
    | "missing"
    | "unsupported";
  accepted: boolean;
};

上游还在生成,就是 pending;节点存在却没有可用产物,需要提示缺失;已经得到图片,但当前模式只支持文字输入,也不能显示成“已接入”。界面把这些区别展示出来,用户才知道下一步应当等待、补充素材,还是换一种生成模式。

兼容性规则集中在 generation catalog。前端用它展示可选模型和素材提示,服务端用它构造真正发给供应商的参数。图片可以匹配参考图、首帧等输入能力,音频与视频也有对应约束。不能只在前端藏掉一个选项,然后默认服务端收到的参数总是正确。

模式选择还保留了一个很实际的取舍:如果用户没有明确选择,系统可以根据上游素材推荐更合适的模式;如果用户已经手动指定,就保留其选择并给出兼容性提示。否则用户刚切回文生图,系统又因为连着参考图自动切走,界面会显得在跟人争控制权。

服务端创建任务时,会解析入边、整理提示词上下文,把 accepted 且 ready 的素材放进图片、音频或视频 URL 参数里。@ 引用则主要用来带入角色设定、文本说明与约束。两者可以共同描述一个镜头,但“引用了这个角色的名字”和“提交了这张角色图”是两件不同的事。

Shortdrama Studio 的真实画布界面;匿名本地示例手动创建了故事、角色、镜头与图片节点,用于说明依赖关系,未调用模型。
Shortdrama Studio 的真实画布界面;匿名本地示例手动创建了故事、角色、镜头与图片节点,用于说明依赖关系,未调用模型。

这里仍然留着一个值得继续完善的地方:任务处理器在执行时还会从数据库中的连线和素材库补齐输入。这样能兼容旧任务并辅助恢复,但也意味着当前输入不全是不可变快照。如果希望几天后能严格复现一次生成,就应进一步记录素材版本和最终提交参数,而不能只记住一个可能变化的 URL。

生成任务必须离开浏览器独立生活

创建节点与生成媒体的时间尺度完全不同。前者通常很快,后者可能要排队、轮询,最后还要整理产物。如果把它们绑在同一次页面请求里,用户刷新一次就可能丢掉过程;浏览器以为失败了,供应商却还在继续生成。

画布生成入口因此先把工作写成一条 generation_tasks 记录,再安排执行。任务里有项目和节点 ID、媒体种类、供应商、模型、payload、result、错误、尝试次数,以及处理者和租约时间。Queue 消息主要携带 task ID 与 run/poll 动作,真正的业务状态留在数据库中。

一次画布生成的主要路径:解析输入、保存任务、投递队列、接管执行、处理供应商结果,再更新素材与画布;这是一张设计示意图。
一次画布生成的主要路径:解析输入、保存任务、投递队列、接管执行、处理供应商结果,再更新素材与画布;这是一张设计示意图。

任务的基础状态是 queued、running、succeeded、failed、cancelled。这个集合看起来很普通,重要的是谁有权把状态往前推进。队列收到消息,并不等于它可以直接再向供应商提交一次请求:同一条消息可能再次被投递,也可能恰好与页面触发的恢复流程相遇。

处理器会先读取任务。终态任务直接返回;正在运行且租约未过期的任务也不重复进入。需要接管时,通过带条件的数据库更新争取执行资格,而不是先查询“没人干活”,再无条件写入“我来干”。后一种写法在两个执行者同时到达时很容易失效。

租约给“正在运行”增加了时间边界。同步生成会定期续租;异步供应商则保存其任务标识,通过后续轮询继续等待结果。空值或无法解析的租约时间统一按过期处理,避免恢复接口认为可以重排、处理器却认为仍有人占用的矛盾。

这里我没有把重试理解成“把所有步骤再来一次”。已经拿到供应商任务标识时,应尽可能查询原任务;同步调用被中断且无法确认结果时,代码也有把它标为失败的分支。对外部系统而言,提交成功但本地没有收到响应始终是一个需要面对的窗口。这套设计能减少重复执行,但不能据此宣称外部生成已经实现严格的 exactly-once。

一次重试,暴露了两份状态的冲突

开发中有一个问题特别能说明为什么需要任务表。某个节点第一次生成失败,用户发起新任务,任务表里已经出现新的 queued 或 running 记录;可是协作房间仍保存着旧画布,其中的 taskId 指向那次失败。旧快照一旦写回,节点又会退回失败态。

从用户角度看,是“重试按钮跳了一下又没反应”。实际上新任务可能正在运行,只是显示层被更旧的数据覆盖了。继续给按钮加 loading 或延长轮询间隔,都没有触到根因。

修复分成两部分。首先,任务创建尽量复用同一节点已有的活跃任务;数据库还用部分唯一索引兜住并发。下面是 schema 中约束的等价 SQL 表达:

sql
CREATE UNIQUE INDEX
generation_tasks_active_node_idx
ON generation_tasks (
  project_id,
  canvas_node_id
)
WHERE status IN ('queued', 'running');

它约束的是同一项目里、同一非空画布节点的活跃任务。它不禁止历史记录,也不代表整个项目只能运行一个镜头。把约束放进数据库,是为了让两个几乎同时到达的入口也必须遵守同一条规则。

其次,在读取或保存画布时对运行状态进行校正:按节点查找活跃任务,优先采用它,再考虑快照引用的旧 taskId。这样用户改了节点位置,或者协作文档被物化到业务库,也不该把新任务重新写成旧失败态。复制项目时同样要清理运行时字段,不能让副本继续背着原项目的任务关系。

这个修复让我更清楚地意识到,节点 JSON 里出现某个字段,不意味着这个字段就归节点快照所有。位置、标题、提示词属于可编辑内容;taskId、运行状态和结果关联还受任务生命周期约束。为了显示方便把它们放在一起,服务端就必须负责重新划清所有权。

Yjs 解决同步,但数据粒度仍然要自己设计

画布协作由 CanvasCollaborationRoom 承接,按项目与文档划分房间,主画布和评论分开。客户端通过 WebSocket 同步 Yjs 更新,房间持久化快照与增量;需要让普通 API、生成处理器或 Agent 读取编辑结果时,再把协作文档物化成业务库里的节点和连线。

这相当于两条互相衔接的路径:在线编辑先进入共享文档,后台执行依赖可查询的业务数据。任务完成后又要更新节点,并让前端通过补丁、同步或查询看到结果。只把“WebSocket 已连接”当成验收标准是不够的,还要观察结果能否经过这条回路重新出现在画布上。

不过,这个阶段的实现有一个边界需要诚实写出来:Yjs 的 workflow map 中,nodes 和 edges 分支保存的仍是序列化后的整组 JSON。它还不是“每个节点的每个字段都是一个独立共享类型”的细粒度模型。因此,接入 Yjs 不等于任意两个人同时改不同字段都能自动无损合并。

这是一种让协作先进入可用流程的实现方式,但后续如果要承受更频繁的并发编辑,节点与字段粒度的共享结构、删除语义,以及与生成结果的合并策略,都值得继续拆细。尤其不能把任务状态当成普通的可撤销文案,随着一次旧画布恢复一起回滚。

权限也在服务端检查。房间元数据会返回 canRead、canWrite 与项目角色,服务端连接和写入路径需要据此限制操作。前端隐藏按钮只是提示,并不能替代这些检查。本地直接运行 Bun API 时没有 Durable Object 绑定,协作会退回 metadata-only 模式;要验证真实房间同步,需要进入配置了绑定的 Worker 环境。

Agent 获得的是工具,不是任意改库的能力

Agent 的价值在于把用户的创作意图变成可继续编辑的结构。它先读项目、画布或素材,可以生成结构化的短剧计划,再通过工具添加文本节点、建立生成节点、连接节点、修改提示词和整理布局。图片、音频和视频生成也要经过相应的任务入口。

这让自然语言操作与手工操作共享同一批业务能力。对话里说“改一下这个镜头”,最终落下来的仍然是一个节点变更;说“生成这个画面”,最终还是一条需要排队、失败和恢复的任务。界面不必相信一段“我已经完成”的文字,可以依据结构化工具结果更新实际工作区。

复杂创意可以交给受控的子任务 Agent,例如分析故事、诊断剧本或优化提示词。但这些子任务不直接持有画布写入工具,只返回建议给父 Agent。这样职责更清楚,也避免多个分析过程一边讨论、一边各自修改同一张画布。

共享会话带来另一个问题:两位协作者同时向同一个线程发送消息,会不会各自读到旧历史,再相互覆盖?最终代码把 Cloudflare 路径放到按项目与线程命名的 AgentRunnerRoom,由房间内的运行状态限制同一线程同时只执行一轮,真正忙碌时返回 409 和重试提示。

这里需要区分本地与云端。早期文档里写过数据库线程锁,但最终 Cloudflare 路径不再依赖这把锁,而由 Agent 房间管理并发;没有该绑定的本地 Bun 路径仍保留带过期时间的数据库锁。若只照着旧架构图写“所有环境统一使用数据库锁”,就会错过当时修复最关键的变化。

流结束之前,也要留下已经完成的工作

把 Agent 放进 Durable Object,并不会让一次对话拥有无限资源。模型请求、数据库读写、工具调用和房间同步仍会消耗执行预算。曾经的一条长工具链撞到 subrequest 限制,随后用户又遇到线程忙碌提示,重试体验很差。

后来的方向是缩短单轮工作:当时默认最多 3 步,硬上限 4 步;更长的创作通过继续对话推进。Agent 房间中的工具还关闭了每一步立刻同步完整 Canvas 房间的路径,改由已写入的业务状态和结构化工具结果衔接界面。这样减少了即时调用,但也要求持续留意协作可见性,不能宣称所有参与者每一步都已经强一致。

消息保存同样不能只等最后一次 onFinish。流可能中断,工具却已经创建了节点。所以实现中在 onStepFinish 增量 upsert 已完成的消息,不用一份中途快照删掉此前记录;正常收尾再做完整保存与清理。执行结束、错误和中止也需要清理房间里的运行状态。

前端重试时,会找到最后一条用户消息,丢弃其后的残缺 assistant/tool 消息,再走正常发送流程。服务端从持久化历史重新构建上下文,避免把一半工具调用连同整份客户端快照重新塞给模型。这个设计保存了已经做过的工作,但仍需要工具侧避免无意义重复,不能把“重发一句话”直接当成整轮操作的幂等保证。

最后要能解释:这次失败停在哪里

生成失败不应只有一块红色标签。供应商没接收请求、远端任务还在运行、取回结果失败、产物整理失败,是不同阶段的问题。task_events 因此按 task ID 记录事件、时间、说明与数据摘要,后台可以从一个失败任务追到相应过程。

日志里也有取舍。供应商响应可能带着大段 base64 媒体,原样存入数据库会让查询和排查变得笨重。实现会省略内联媒体数据,限制过长字符串、数组项数和递归深度,保留判断阶段所需的摘要;事件还有保留期,而不是无限增长。记录越多不一定越有用,能把一次请求的前后连接起来才有价值。

如果继续做下一轮,我会先用同一个项目跑完角色、参考图、配音和视频片段,再补四类边界验证:页面刷新时的任务恢复、重复队列消息、两人同时编辑,以及 Agent 中途断流。随后再推进更细的协作文档和不可变的生成输入记录。它们直接决定创作者敢不敢在已有成果上继续修改。

回看这轮实现,Shortdrama Studio 最重要的技术选择,是让每一段工作都有能被找回的位置:素材有来源,任务有身份,对话有已完成步骤,失败有事件记录。画布把这些内容摆在同一个工作区,Agent 帮人推动流程;真正让工作台能继续用下去的,是它们之间那些并不显眼、却必须讲清楚的边界。

如果想先看这张画布怎样长出来,以及开发期间的界面变化,可以接着读《把短剧创作搬进一张画布:Shortdrama Studio 开发手记》。