AI视频创作Agent实战03:DeepSeek文案裂变与草稿版本控制

把一段文案发给模型,再把返回结果放进文本框,是演示程序中最容易完成的一步。进入真实创作流程后,问题会变成:用户连点两次是否调用两次?生成过程中修改原文,旧结果还能回填吗?两页同时编辑同一份草稿,后保存的人会不会覆盖前一个人的修改?

本文沿着一个正在开发的视频创作 Agent 的实际链路,拆解文案优化、裂变、候选校验、草稿版本控制和结果恢复。代码基于 2026 年 9 月 8 日工作区快照,文中业务样例均明确为虚构。重点是已经实现的工程机制,不把模型返回一段文字等同于视频生成和平台发布都已完成。

文案生成与草稿保护流程:版本校验、幂等请求、候选验证和回填条件防止覆盖用户新修改

1. 文案是生产输入,需要可验证的结构

新版创作页提供四种文案模式:keep 保留原文,optimize 优化一条,fission 按原文裂变,optimize_fission 先优化再裂变。前两种的目标生成数量必须为 1;新版单次文案生成、候选列表与成片批次的上限为 15 条。

这个 15 是视频数量的上限,与发布平台数量不同。生成 5 条视频并选择 3 个平台,会规划 15 个发布任务,不应该先生成 15 条内容相同的视频。旧任务接口仍限制每个平台最多 5 条、每批合计最多 9 条(server/store.mjs:109);15 条上限属于显式启用 productionBatch 的新版 /v2 创作流程。

虚构样例可以设为:“澄山工作室展示木作打磨过程,仅提供到店体验。”事实锁填写“澄山工作室”。裂变可以分别从工艺步骤、空间观察等角度组织表达,但不能自行增加不存在的价格、效果承诺或客户评价。这些约束一部分写进提示词,另一部分必须在模型返回后用代码验证。

源码位置(相对项目根目录)作用
shared/production-v2.mjs:22模式、15 条上限及草稿结构
shared/production-v2.mjs:327候选数量、字段、重复和事实锁校验
server/script-generation.mjs:10DeepSeek 文案专用配置与生成任务
server/provider.mjs:202Chat Completions 请求封装
server/index.mjs:1744新版文案生成接口、版本和请求标识校验
server/production-repository.mjs:580SQLite 草稿条件更新
components/video-creation-wizard.tsx:556:1270保存草稿后提交文案任务
lib/script-candidates.ts:33判断结果是否可以回填
tests/production-v2.test.mjstests/script-candidates.test.mjs模型输出规则与前端编辑保护
tests/v2-stage1.test.mjstests/next-batch-draft.test.mjs本地模拟服务、接口冲突及下一份草稿恢复
环境项项目基线本篇用途
Node.js本机 24.14.1;项目要求不低于 22.13.0HTTP、队列与测试
React19.2.6文案编辑、保存状态和轮询回填
TypeScript5.9.3前端草稿与候选类型检查
vinext1.0.0-beta.5当前页面开发与构建
SQLiteNode.js 内置 node:sqlite草稿版本持久化

本文描述项目当前适配分支,不将测试中的模型名称当作服务商长期不变的可用性承诺。模型与接口兼容性应在真实接入时另行核对。

2. DeepSeek 文案配置与图片理解配置分开

项目中的 semantic 配置负责 DeepSeek 文案与语义任务,不能因为图片理解配置可用,就认为文案生成也可用。server/script-generation.mjs:10 的原始代码如下:

function scriptConfig(directory) {
  const config = loadSettings(directory).semantic;
  if (!config?.baseUrl || !config?.model || !config?.key)
    throw new UserError(
      'DeepSeek 文案接口尚未配置,请在“接口设置 → DeepSeek 文案与语义切片”保存配置。',
      409,
      'SCRIPT_PROVIDER_MISSING',
    );
  return config;
}

这段代码检查的是配置字段是否齐全,不等于验证凭据有效或远端服务可达。真正调用时仍可能出现鉴权失败、超时和不符合约定的结果。keep 分支不需要调用外部模型,可以直接保留原始文本;其余三种模式在任务创建时检查这份专用配置。

前端使用 POST /api/video/v2/drafts/{draftId}/script-generation 创建任务,随后通过 /api/video/v2/script-generations 刷新状态。后端公共封装使用 /chat/completions,本地测试把基地址设为带 /v1 的地址,因此断言实际请求路径为 /v1/chat/completions。这两个路径分别属于业务 API 和模型 API,排查错误时不要混用。

请求体要求 JSON 对象,并且禁用流式返回。针对当前代码中的 DeepSeek v4 模型分支,使用 thinking: { type: 'disabled' };阿里云分支则有自己的字段。下面是 server/provider.mjs:218 的原始节选:

model: config.model,
stream: false,
...(config.provider === 'aliyun' ? { enable_thinking: false } : {}),
...(config.model.startsWith('deepseek-v4-')
  ? { thinking: { type: 'disabled' } }
  : {}),
response_format: { type: 'json_object' },
messages: [
  { role: 'system', content: system },
  { role: 'user', content },
],

模型输入中的用户原文被当作资料,系统提示明确约束输出结构和不可编造的事实。但提示词只能表达规则,无法替代后端校验,也不能保证自然语言层面完全没有事实偏差。

DeepSeek 官方 JSON Output 文档同样要求指定 json_object、在提示中说明 JSON 格式并合理设置输出长度,还提示可能出现空内容。因此 JSON 模式解决输出格式问题,候选数量、事实锁和重复检查仍由业务代码负责。DeepSeek JSON Output 官方说明

3. 候选校验要落实到确定的失败条件

模型返回的结构为 candidates 数组,每项包含标题、独特角度、正文与选择状态。后端先验证条数,再检查字段和事实锁,最后给通过校验的候选生成 ID。候选列表一旦无效,不会伪造一组成功结果继续进入成片流程。

shared/production-v2.mjs:337 的原始节选:

if (!Array.isArray(value) || (!allowEmpty && !value.length))
  throw new Error('AI 没有返回有效文案候选。');
if (expectedCount !== undefined && value.length !== expectedCount)
  throw new Error(`AI 应返回 ${expectedCount} 条文案候选。`);
if (value.length > MAX_PRODUCTION_VIDEOS)
  throw new Error(`文案候选不能超过 ${MAX_PRODUCTION_VIDEOS} 条。`);

标题最长 60 字,正文最长 2500 字,角度最长 120 字。输出规范里还给模型提示了更短的标题目标,这与服务器接受字段的最大长度不是同一个概念。

比较文本前,项目会转换大小写并移除空白、标点和符号。因此仅改变标点的两条正文仍会被判重复;不同正文如果角度字段完全相同,也会被拒绝。事实锁同样通过归一化后的字符串包含关系检查。这是可执行的基本约束,并不是语义级事实核查:同义表达、隐含承诺、前后矛盾仍需用户审核。

编辑阶段与正式生成阶段还采用不同校验强度。用户把旧正文删空、准备重新输入时,草稿允许暂存不完整候选;进入待生成状态时会严格校验候选字段、重复内容和事实锁。点击“生成视频”还要求已勾选候选数等于 script.countserver/index.mjs:1848),并不是要求草稿中保存的全部候选数等于它。模型新生成候选时,才使用 expectedCount 检查返回总数。这样既保留正常编辑中的短暂空白,也避免混淆草稿列表、模型返回和最终选择三个数量。

4. 幂等键与草稿版本解决不同问题

只在按钮上加 disabled,无法挡住页面刷新、响应丢失后的重试或同一操作的不同请求标识。后端会把生成操作定义为一组稳定字段,server/script-generation.mjs:20 原始代码如下:

const operationSignature = (draft) =>
  JSON.stringify({
    draftId: draft.id,
    draftRevision: draft.revision,
    mode: draft.script.mode,
    count: draft.script.count,
    source: draft.script.current.trim(),
    factLocks: draft.script.factLocks,
  });

requestKey 回答“这是不是同一次提交”,revision 回答“提交基于哪个草稿版本”,签名则约束这个版本具体代表哪一组内容。三个条件合起来,才能防止用旧标识悄悄启动另一份新文案。

相同请求标识与相同签名再次到达时,服务返回已有任务;换了请求标识,但草稿版本和签名相同,也复用任务并保存别名。同一个标识用于新版本,会报 IDEMPOTENCY_CONFLICT;同一个草稿版本出现另一份内容,会报 REVISION_PAYLOAD_CONFLICT。已删除任务也有对应记录,旧请求不能轻易把它复活。

任务队列还限制了处于 queued/running 的文案任务数量,达到 5 个时返回 429。这里的 5 指待处理的文案任务,并不是一批可生成 5 条视频。把不同层次的计数写在一起,是项目扩展后很容易出现的混淆。

5. SQLite 乐观锁让旧页面无法覆盖新草稿

前端自动保存并不是无条件覆盖一个 JSON 文件。每次请求都会携带已知版本,数据库只在版本仍一致时更新,同时把版本加一。server/production-repository.mjs:580 的原始代码如下:

update(id, document, status, expectedRevision) {
  const at = now();
  const result = db
    .prepare(`UPDATE workflow_drafts SET step = ?, status = ?,
    payload_json = ?, revision = revision + 1, updated_at = ?
    WHERE id = ? AND revision = ?`)
    .run(
      document.step,
      status,
      safeJson(document),
      at,
      id,
      expectedRevision,
    );
  return result.changes ? drafts.get(id) : undefined;
},

用虚构的两页面操作说明:A、B 都拿到版本 7;A 先保存,数据库进入版本 8;B 再按版本 7 保存,条件更新不会命中。接口返回版本冲突,前端显示草稿在其他页面更新并提供重新载入入口,而不是把 A 的内容静默抹掉。

这是冲突检测,不是自动合并。用户需要重新核对内容;项目没有宣称提供实时多人协同编辑。自动保存队列负责串行提交当前页的修改,版本号负责跨请求检查,两者各有边界。

点击生成文案时,前端先等待 flushDraft() 完成,再提交服务器确认过的版本。文案任务和草稿也不是同一种存储对象:草稿进入 SQLite,文案任务状态通过现有 store 保存。两类数据各自的恢复路径需要保留,不能把一次 SQL 提交理解为所有外部工作都已完成。

SQLite 支持多个连接同时读取,但同一时刻只有一个写事务;事务中的提交、回滚和锁竞争可以对照官方说明理解。草稿“版本 7 已过期”的含义则来自本项目的条件更新,不是数据库自动理解了表单冲突。SQLite 事务官方说明

6. 结果回填必须尊重生成期间的人工修改

用户在模型处理期间修改了正文,轮询回来时不能简单执行 setCandidates(job.candidates)lib/script-candidates.ts:33 会检查任务完成、草稿内容是否匹配、数量是否一致、候选是否已经接收,以及等待期间候选有没有被编辑。以下为原始节选:

if (
  !draft ||
  !job ||
  job.status !== 'completed' ||
  !scriptGenerationMatches(draft, job) ||
  job.candidates.length !== job.count ||
  hasScriptGenerationCandidates(draft, job) ||
  (draft.script.candidates.length > 0 &&
    JSON.stringify(draft.script.candidates) !== candidatesBeforeRequest)
)
  return undefined;
return job.candidates.map((candidate) => ({ ...candidate }));

末行复制每个候选,避免编辑框与任务快照共享对象。匹配逻辑还比较草稿 ID、原文、模式、条数和事实锁。因此,旧任务即使最终成功,也不能覆盖已经切换到另一份输入的页面。

前后端顺序是:编辑原文 → 保存草稿 → 提交版本与请求标识 → 后端复用或创建任务 → 模型调用 → 候选验证 → 保存任务结果 → 前端核对当前草稿 → 复制候选供用户修改 → 再保存草稿。把这条链走通,才算文案能力真正接入创作页。

React 官方文档将一次渲染中的 state 描述为快照,事件处理函数也使用该次渲染对应的值。异步回填不能只依赖“发起请求时看到的表单”;本文的匹配逻辑额外检查回填时的草稿和人工修改。React:State as a Snapshot

下图将失败分支放进实际流程,区分“任务失败”和“结果成功但不应覆盖当前表单”:

未知或失败

编辑原文和事实锁

先保存草稿与预期版本

数据库版本仍一致

返回冲突并保留待核对修改

提交版本与请求标识

复用已有任务或创建持久任务

保留原文模式

本地建立候选

请求 DeepSeek

响应结果明确且有效

保存原因并等待核对或重试

校验候选并保存任务结果

前端轮询取得候选

输入仍匹配且候选未被人工修改

复制候选到草稿并保存

保留当前编辑而不自动回填

可用两个页面手动复核:同时打开同一草稿,在 A 页改标题并等待保存成功,再在 B 页保存旧版本,预期 B 收到冲突。另一个独立场景是在文案生成期间修改原文,预期旧结果不会自动覆盖新输入。这是读者可执行的验收示例,并非本文新增运行记录;版本冲突部分不需要调用模型,AI 模式生成则仍涉及实际模型请求。

7. 未知结果不能一律自动重试

请求超时只说明本机没有拿到确定响应,远端可能已经完成处理。服务启动时会检查遗留的 sending/unknown 尝试,把相关任务标成 unknown_external。重试接口要求先明确核对,再允许重新请求。

这不能保证服务商侧严格只执行一次,但能避免把网络不确定性直接转换成无限自动重试。普通失败、已完成、正在处理、未知外部结果应分别处理;已完成任务的重试直接返回原任务,不能无条件再计一次生成。

在文案之外,项目已有生成成功后准备下一份草稿的恢复逻辑。队列未真正接受时保留原表单,存储失败时回滚原草稿归档,已归档草稿的迟到自动保存会被拒绝。这些机制的共同目标,是保留用户确认过的输入以及每次生产对应的版本。

8. 如何复跑测试并解释断言

在项目根目录安装好锁定依赖后执行:

node --test tests/production-v2.test.mjs tests/script-candidates.test.mjs
node --test tests/v2-stage1.test.mjs tests/next-batch-draft.test.mjs
npm run test:agent
npx tsc --noEmit

2026 年 9 月 8 日项目已有的全量回归基线为 292 项测试通过,TypeScript 检查通过。本文引用该基线,没有把本地模拟接口测试描述为 DeepSeek 真实账号联调,也没有以测试通过替代实际内容质量评估。

v2-stage1.test.mjs 为三种 AI 模式分别启动本地 HTTP 服务,捕获模型请求并返回构造的候选。它验证候选数量、提示输入、请求路径和请求次数。下面是其中关于幂等冲突的原始断言节选:

assert.throws(
  () => generator.create({ ...draft, revision: 2 }, requestKey),
  (error) =>
    error.code === 'IDEMPOTENCY_CONFLICT' && error.status === 409,
);

测试还断言同一版本换一个请求标识仍返回原任务 ID,最终模型请求计数仍为 1。只断言 HTTP 200 看不到这类重复调用问题,必须同时观察任务身份和调用计数。

其他有价值的验收点包括:15 条候选可保存、16 条被拒绝;正文或角度重复失败;遗漏事实锁失败;仅有图片理解配置不能开启 DeepSeek 文案;保留原文时尝试记录为空;旧版本保存返回 409;轮询不覆盖修改后的标题、正文、角度和勾选状态;新草稿创建失败不会丢掉上一份草稿。

故障现象判断依据修复方向
图片理解可用,文案却无法开始SCRIPT_PROVIDER_MISSING检查独立的 semantic 配置
重试提示提交标识冲突旧 requestKey 对应另一版本保存新版本并使用新的操作标识
AI 返回两条,却要求三条候选数量校验失败检查任务 count 和完整响应,不能补空白凑数
保存时出现 409草稿版本已前进重新读取并核对修改,而非盲目覆盖
文案成功但没有覆盖编辑框输入变化或候选已被编辑保留当前编辑,确认是否要重新生成
重启后显示外部结果待核对上次请求处于不确定状态核对远端结果后决定是否重试

最终验收应覆盖“正常生成一次”和“异常时不丢、不乱、不重复”两条线。文案模块输出的是经校验的候选,成片仍依赖素材准备、图片理解、模板和声音方案;自有声音分句与音色克隆目前仍未接入,不能因为可以上传声音样本就认为能完成克隆配音。

系列前两篇分别讨论素材持久化与无声派生、带人声原视频转写与语义切片;本篇把输入推进到可编辑、可追溯的文案版本。后续将继续介绍素材匹配、视频合成及任务恢复。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐