AI视频创作Agent实战03:DeepSeek文案裂变与草稿版本控制
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:10 | DeepSeek 文案专用配置与生成任务 |
server/provider.mjs:202 | Chat Completions 请求封装 |
server/index.mjs:1744 | 新版文案生成接口、版本和请求标识校验 |
server/production-repository.mjs:580 | SQLite 草稿条件更新 |
components/video-creation-wizard.tsx:556、:1270 | 保存草稿后提交文案任务 |
lib/script-candidates.ts:33 | 判断结果是否可以回填 |
tests/production-v2.test.mjs、tests/script-candidates.test.mjs | 模型输出规则与前端编辑保护 |
tests/v2-stage1.test.mjs、tests/next-batch-draft.test.mjs | 本地模拟服务、接口冲突及下一份草稿恢复 |
| 环境项 | 项目基线 | 本篇用途 |
|---|---|---|
| Node.js | 本机 24.14.1;项目要求不低于 22.13.0 | HTTP、队列与测试 |
| React | 19.2.6 | 文案编辑、保存状态和轮询回填 |
| TypeScript | 5.9.3 | 前端草稿与候选类型检查 |
| vinext | 1.0.0-beta.5 | 当前页面开发与构建 |
| SQLite | Node.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.count(server/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
下图将失败分支放进实际流程,区分“任务失败”和“结果成功但不应覆盖当前表单”:
可用两个页面手动复核:同时打开同一草稿,在 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 | 草稿版本已前进 | 重新读取并核对修改,而非盲目覆盖 |
| 文案成功但没有覆盖编辑框 | 输入变化或候选已被编辑 | 保留当前编辑,确认是否要重新生成 |
| 重启后显示外部结果待核对 | 上次请求处于不确定状态 | 核对远端结果后决定是否重试 |
最终验收应覆盖“正常生成一次”和“异常时不丢、不乱、不重复”两条线。文案模块输出的是经校验的候选,成片仍依赖素材准备、图片理解、模板和声音方案;自有声音分句与音色克隆目前仍未接入,不能因为可以上传声音样本就认为能完成克隆配音。
系列前两篇分别讨论素材持久化与无声派生、带人声原视频转写与语义切片;本篇把输入推进到可编辑、可追溯的文案版本。后续将继续介绍素材匹配、视频合成及任务恢复。
更多推荐


所有评论(0)