一个 30 集的项目,用户点了十几轮资产提取。
账单上是 198 次模型调用,共 145.77 元。其中约 95% 是重复的。
一、每轮都从第 1 集开始
提取按 3 集一批,一个 30 集的项目是 10 批。
原先的实现是:不管上次跑到哪,每次提取都从第 1 集重跑到第 30 集。去重只在入库那一步做——已经有的资产不重复写库,但模型的调用照发,费用照扣。
于是用户为了修 2 批失败,把已经成功的那 8 批又花了一遍钱。而界面上的失败提示写的是「可再点一次」。
这句话本身没错。错的是再点一次的代价没有被说清,也没有被限制。
二、记账的粒度是「批次」
修法是记断点。粒度取批次,不是集也不是任务:
export interface ExtractBatchRecord {
readonly episodeNos: readonly number[];
readonly scriptHash: string;
readonly extractedAt: string; // ISO 8601
}
一个批次成功的条件是:跑完、解析成功、写进项目的 settings。
判定键是「集号组合 + 正文哈希」:
export function buildBatchScriptHash(batchScriptText: string): string {
return createHash("sha256").update(batchScriptText).digest("hex").slice(0, 16);
}
带上哈希是为了处理剧本被改过的情况。集号组合相同但正文变了,哈希不同,这一批就要重跑。
解析失败的批次不记录,下次仍会重试。
三、一处必须只有一份实现
这是这次改动里最容易埋雷的地方。
喂给模型的文本,和用来算哈希的文本,必须是同一份:
/**
* 这个函数是断点续传的地基,只能有这一份实现:
* 喂给 LLM 的文本和算 hash 的文本必须逐字节相同,否则 hash 永远对不上,
* 跳过判定永不命中——表现是「修了断点续传但还在全量重跑」,且测试用同一份
* fixture 时两边照样一致、照样全绿,线上才发现。
*/
最后半句是关键。如果两处各自拼文本,测试里两边都从同一个 fixture 拼,结果一定相同,用例全绿。只有线上真实的剧本内容才可能让两边出现差异——一个换行、一个前缀、一个空格。
所以文本构造被收口到一个函数:
export function buildBatchScriptText(episodes): string {
return episodes.map((ep) => `## 第 ${ep.episodeNo} 集\n${ep.scriptText}`).join("\n\n");
}
四、预估和实际必须走同一套规划
只告诉用户「这次会跑几批」不够,还要让他们知道要花多少钱。所以加了一个只读的预估端点。
这个端点不触发模型调用,但它必须和真正提取走同一套规划逻辑:
// 收集正文、读断点、算批次都在 service 里,和真正提取共用同一套——
// 路由自己抄一份的话,预估数字迟早和实际跑的批次对不上
返回的字段是六个:总集数、待处理集数、总批数、待跑批数、跳过批数、预计点数。
这里有一个不起眼但要紧的口径:
/**
* 待处理的「集数」= 各待跑批次的集数之和。不能取最大集号:
* 只剩最后一批(28/29/30 集)要跑时,最大集号是 30,界面会写成「本次将处理 30 集」,
* 而实际只跑 3 集——这个数字正是用户判断该不该花钱的依据,不能骗人。
*/
预估点数的单价来自实测:
/**
* 每批 LLM 调用的经验点数(生产实测 198 次均值 73.6 点)
* 仅用于给用户看的预估,不用于实际计费
*/
export const ESTIMATED_POINTS_PER_BATCH = 75;
那 198 次正好就是这次事故的 198 次。
五、确认框要说的三件事
前端在提取前弹一个确认框。它要说清三件事:跑多少、花多少、以及重提的代价。
本次将处理 X 集(共 Y 集)的 P 批剧本(共 Q 批)。
预计消耗 N 点算力(约 ¥M)。
剧本改过想重新提取的话,全量重提会把这 Q 批重跑一遍,按首次提取的量重新扣费。
第三句是这个框存在的理由。全量重提是一个合法操作——用户改了剧本,就该重跑。但它的代价要写明,而不是藏在按钮背后。
按钮文案跟着状态变:待跑批数为 0 时,按钮从「确认提取」变成「仍要全量重提」。
失败提示也改了。原先写「可再点一次」,现在说明只重跑没成功的批次。
六、settings 是整列读改写
断点记录存在项目的 settings 字段里。这个字段是 JSON,整列读改写,不支持部分更新。
所以写入必须拿锁:
await prisma.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${projectId}, 0)::bigint)`;
同一把锁已经用在剧本和角色的写入上。三个地方写同一个 JSON 字段,共用一把锁。
用 JSON 字段存断点,代价是没有独立的表、没有索引,查询要走 jsonb 函数。收益是零迁移——这张表的其他部分已经在用同一个字段存生产状态,再加一块比新开一张表更贴合现有结构。
预估端点的候选项目查询写成了一条 SQL,按 LIMIT 200 限制单轮扫描量:
SELECT id FROM "ComicWorkflowProject"
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(
COALESCE((settings->'scriptGeneration'->'tasks'), '[]'::jsonb)
) AS task
WHERE task->>'status' IN ('queued', 'running')
AND task->>'updatedAt' < ${beforeIso}
)
LIMIT 200
七、可恢复的前提是「知道做到哪了」
这类长流程任务的设计,难点从来不在「怎么继续」,而在「怎么知道该从哪继续」。
记进度的位置有三档可选:
- 记任务:一个项目可能有几十个任务,任务之间的依赖关系要重建。
- 记集:粒度太细,集与集之间本来就要合并成批送模型。
- 记批次:粒度与实际的模型调用对齐。
选了批次。理由是它正好是一个「要么成功、要么没有发生过」的单位——批次里的 3 集要么一起被模型处理了,要么下次重新来。没有中间态。
哈希那一条也来自同一个思路:断点的有效性取决于输入有没有变。只记「第 1~3 集做过了」不够,还要记「当时那 3 集的正文是什么样」。哈希是这句话最省的表达。
这两条合起来,断点续传才成立:进度记录必须和它依赖的输入绑定。只记进度不记输入,改了内容之后要么错误跳过,要么完全失效——两种结果都不会报错。
■