Agent 平台

同一张能力表,抄了三份

上游模型的分辨率、时长、提示词长度散落在三处各自维护,各自漂移。一次用户反馈把三处一起翻出来:多出的档位、被砍掉的秒数、翻倍的板数。

STRUCTURE ── 篇幅剖面6 节 · 点击跳转

用户反馈:设置里选完模型,分辨率档不对——Seedance-2.5 只有 480P 和 720P;另外 2.5 的上游支持到 29 秒,故事板那边应该跟着放开。

第一句说得对,2.5 确实只有 480p 和 720p。第二句也对,它确实支持 4~29 秒。

两条都对,说明我们发出去的档位和上游支持的对不上。查下去发现,同一份能力表被抄了三份。

一、三处各自漂移

后端有一张权威表,写清了每个模型支持哪些分辨率、哪些时长:

const MODEL_RESOLUTIONS: Record<VideoModel, readonly VideoResolution[]> = {
  "seedance-2": ["480p", "720p", "1080p", "4k"],
  "seedance-2-fast": ["480p", "720p"],
  "seedance-2-mini": ["480p", "720p"],
  "seedance-2.5": ["480p", "720p"],
  "kling-v3": ["720p", "1080p"],
  "minimax-h3": ["2k", "768p"],
};

前端有一份手抄的副本。它早就漂了,而且漂在两个方向。

多出的档位。 设置页的取档逻辑是写死的规则:「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型,界面上给出 1080P 和 4K。用户选了,选中即报未配价,或者直接被上游拒。

少掉的档位。 Kling 只支持 720p 和 1080p,界面给的是四档。反过来的情况也存在:如果某个模型的档位比默认四档更多,界面也显示不出来。

时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取:

// 前端
return Math.max(4, Math.min(15, value));

// 后端
return Math.max(4, Math.min(15, value));

2.5 传 29 秒,被悄悄砍成 15。用户看不出为什么变短——没有任何提示,界面上显示的就是 15 秒。

二、被砍掉的秒数后面跟着钱

时长那一处还不只是显示问题。

故事板按固定秒数切板。代码里是一个常量:

export const SHOT_SECONDS = 15;

按 15 秒一切。2.5 一板能放 29 秒,硬按 15 秒切,一集会被切成两倍数量的板。

板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切,结果切得和短时长模型一样多,还多花一倍钱。

comic-subshot.ts 里留着上限常量的注释,写明了它只是「模型未知时的保守默认」:

// 上限只是「模型未知时的保守默认」——真正的上限逐模型不同(seedance-2.5 能到 29 秒),
// 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话,选了 2.5 也只切 15 秒一板。

调用方没传。

三、提示词长度:四个数,一个是实测出来的

同一批里还有一个更贵的限制:提示词长度上限。

上游对提示词有硬上限,超过直接拒。各模型不一样,而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败:

模型 seedance-2.5 的提示词不能超过 5000 个字符,当前为 18545 个字符

接入本身没问题,是缺了长度约束。补完之后这张表长这样:

const MODEL_PROMPT_LIMITS: Partial<Record<VideoModel, number>> = {
  "minimax-h3": 7000,
  "seedance-2": 2500,
  "seedance-2-fast": 2500,
  "seedance-2-mini": 2500,
  "seedance-2.5": 5000,
  // 20260809 实测:发 2553 字被硬拒 `prompt: size must be between 0 and 2500`
  "kling-v3": 2500,
};
export const PROMPT_LIMIT_FALLBACK = 50000;

这几个数的来源不同,注释里写清了哪个是实测的:

seedance-2.5 的 5000 是实测出来的,文档只字未提:线上一条 18545 字的脚本被拒,报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200,所以这是 2.5 独有的限制,不能推广到整个系列。加新渠道前先拿超长 prompt 打一次,别等线上炸。

这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的,文档没提的只能等线上撞。

长度约束补齐之后,还有一个配套的预算计算。原先只有 H3 有预算,其余模型返回 null,等于完全不限:

/** 每秒成片对应的脚本篇幅。15 秒 → 7500 字,是人肉审稿与出片效果都合适的密度。 */
export const CHARS_PER_SECOND = 500;
/** 留给用户自己追加修改的余量:脚本刚好顶满上限时,用户加一句就被上游拒了。 */
const PROMPT_BUDGET_MARGIN = 500;

export function scriptCharBudget(targetModel: string | undefined, durationSec?: number): number | null {
  const hardLimit = modelPromptHardLimit(targetModel);
  if (!durationSec || !Number.isFinite(durationSec) || durationSec <= 0) return hardLimit;
  const byDuration = Math.round(durationSec * CHARS_PER_SECOND);
  return hardLimit ? Math.min(byDuration, hardLimit) : byDuration;
}

按 15 秒算出来是 7500 字——上限撤销之后,脚本直接写到 1.8 万字,那也是生成耗时 193 秒的原因。

四、抄一份是必要的,那就测试它

前端为什么不能直接读后端那张表:apps/web 不能 import apps/api。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」,才能只给出跑得通的选项。

抄一份是必要的。那就把「不许走样」变成断言。

新增的共享包文件头写明了它的性质和守卫:

/**
 * 生图 / 视频的**上游真实能力**表。
 *
 * 这是 apps/api 里两张表的镜像……
 *
 * **为什么要抄一份**:前端(apps/web)不能 import apps/api,而节点面板必须知道
 * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。
 * 抄一份就有走样的风险,所以 apps/api 里有一条对比测试逐项核对两边——
 * 谁改了上游表而没同步这里,测试立刻红。
 *
 * 硬规矩:这里只允许出现上游真支持的取值。多给一个选项,用户就会选到一个必然失败的组合。
 */

同时把取值这件事收成单点:

/**
 * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断,
 * 迟早出现「界面给得出、服务端不认」的组合。
 */
export function resolveDynamicOptions(kind: "videoResolution" | "videoDuration", params): readonly { value, label }[]

细粒度那一档也保留了两份清单,且是有意不同:

/**
 * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**:
 * 后者是「按钮行」的精简清单(seedance 只列 7 档,避免一排按钮太长),
 * 而滑块是细粒度选择,应当覆盖服务端真正接受的全部档位——
 * 否则用户拖不到 7/9/11/13/14 秒,白白削掉能力。
 */

五、假绿的七条用例

修的过程中还撞到一个测试问题,值得单独记。

改动完成后跑测试,有一组用例报了这个警告:

This might cause false positive tests

追下去发现是真的假绿。供应商迁移之后,配置加载在某些条件下会抛错,而那个抛错发生在 try 之外,成了未处理的 rejection。用例本身并不感知异常,于是照样通过。

具体是 7 条。

修法是让配置加载把异常收敛成一个明确的错误类型(缺 key 重试没有意义,归为永久失败),并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 beforeEach 会因为跨文件执行顺序失效。

这件事和主题有关:同一份能力表抄三份会漂,同一份配置在三个地方加载也会。 假绿的七条用例,测的是「配置能加载」,而配置在测试环境里根本没被加载。

六、收口之后

同一批里还顺手修了两处同源问题:

  • 漫剧设置页那份模型列表是前端手写的副本,早已和后端漂开:列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID,而真实的 ID 是 seedance-2-fastkling-v3。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。
  • 长篇项目的设置页没有把 videoModel / videoResolution 传给设置组件,onSave 也没往上收。用户选完模型点保存,没有报错,刷新之后回退到原来的值。

三处问题的形式不同,成因是同一个:同一份事实存在多个副本,而且没有一处是权威

收口之后的结构是三份,每一份都有明确职责:

位置职责守卫
后端能力表权威来源被镜像表逐项对比
共享包镜像表跨端取值对比测试(改一边不改另一边即红)
前端 UI 清单只影响展示形态值必须来自共享包,不许写字面量

硬规矩只有一条,写在镜像表里:只允许出现上游真支持的取值。多给一个选项,用户就会选到一个必然失败的组合。

反过来说,少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15,用户看到的是「这个模型没比别的强」,看不到的是我们没把它的能力交出去。

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。