Skip to content

14 Agent 对话与 Skill 体系深挖问答(首页 AI 构思助手) ​

这一册讲的不是"我们接了个大模型",而是一条把自然语言变成付费视频任务的编排链路: 意图分类 → 提示词分层编译 → 流式产出脚本/提示词 → 用户确认 → 提交生成任务 → 落 Agent 台账。 字节面试官问这条链路,真正在测的是:你有没有把 LLM 的不确定性关进一个可运营、可审计的工程盒子里。 每题统一四段式:【考点】(面试官在测什么)→ 【口述答案】(背这段)→ 【备注讲解】(不要背出声,是理解用的)→ 【代码依据】(IDE 里能跳过去验证)。 诚实纪律:本册严格区分"真 Agent 运行时"与"意图分类 + 固定工作流",凡只有意图识别没有工具循环的地方一律直说;代码里没有的效果数字一律不编,未核实的写"我未核实"。 阅读顺序建议:先看第〇节时序总览,再按 Q1→Q22 顺序读。


〇、一页总览(先在纸上画出来再开口) ​

┌─ 前端 AssistantChat(AssistantPanel 只是启动卡,本体在 components/AssistantChat.tsx)─┐
│  ① 素材:图片走 /video/upload(OSS);视频走 /video/assistant/chat/upload-video          │
│  ② 路由层取 settings → resolveAssistantPromptLayerInputs(body)                        │
│       客户端只传 skillId / categoryId;提示词文本一律服务端按 id 取                      │
│  ③ 服务端 createAgentRun() ← 先建 durable run,再调模型                                │
│       ensureAgentRunStep(intent_analysis, ordinal=10) → running                       │
└──────────────────────────────────┬───────────────────────────────────────────────────┘
                                   │
        ┌──────────────────────────▼──────────────────────────────────────────┐
        │ attachConversationRoute(两级意图)                                   │
        │  第 0 级:正则确定性路由 resolveDeterministicConversationRoute         │
        │          命中就直接定 action,不调模型                                 │
        │  第 1 级:快速文本路由(无图无帧,max_tokens 256, temperature 0.1)     │
        │          失败 → routeDegraded=true,回退旧版整段提示词                  │
        └──────────────────────────┬──────────────────────────────────────────┘
                                   ▼
        ┌──────────────────────────────────────────────────────────────────────┐
        │ 正式意图解析 streamChatIntent(多模态 + 分层提示词)                    │
        │  system = L0 底座 + L1 模式层 + L2 品类 systemFragment                 │
        │                    + L3 玩法 systemFragment + L0 收口句                │
        │  user   = 用户消息 + 素材 + productContext + 品类/玩法 userFragment     │
        │                    + 当前脚本 + 上次参数 + 界面参数                     │
        │  三段式状态机:meta → reply → prompt(首行 JSON 坏则转 degraded)        │
        │  事件:meta → delta / script_delta → done(+ agent_run 旁路)           │
        └──────────────────────────┬──────────────────────────────────────────┘
                                   ▼
   action=reply/script → completeAgentRun,结束
   action=clarify      → createAgentInterrupt(人机确认点,step → waiting_input)
   action=generate     → ensureAgentRunStep('generation'),等用户点确认
   action=batch        → ensureAgentRunStep('batch_plan') → /chat/batch-plan-stream
   action=video_edit   → ensureAgentRunStep('video_edit_plan'),等用户点确认
                                   │
                                   ▼ 用户确认后
        POST /video/canvas/seedance-node/create(必须带 agentRunId + agentStepId)
          ├─ claim 提交权(submission_state: idle→claimed→submitted)
          ├─ 认领失败 → 409 AGENT_SUBMISSION_UNCERTAIN(宁可报错也不重复扣费)
          └─ 已有 task_id → 返回 { idempotent: true }

三句话总结这条链路(口述时先说这三句)

  1. 它有 Agent 的账本,但没有 Agent 的工具循环——"工具"是"提示词里约定 JSON schema + 服务端解析",全仓库没有一处 provider tool calling。
  2. 提示词是分层编译出来的,不是拼字符串——L0 底座压过所有可运营层,运营写错越权句式在入库时就被剥离。
  3. 静默截断在这条链路上被定为事故——超预算就整段丢弃并告警,宁可规则不生效,也不给模型半句话。

⚠️ 路径陷阱(本册最重要的一条读码纪律):这个仓库有一批 4~5 行的 re-export 壳文件,require 的是 .js 路径而磁盘上只有 .ts——因为后端跑在 tsx 下(scripts/run-backend.cjs:18),loader 会把 ./x.js 映射到 ./x.ts。例如 backend/services/shotPromptWizard.ts:1-4 只是壳,真身是 backend/services/shots/shotPromptWizard.ts(1476 行);viralRemakeService.ts 的真身是 backend/services/viral/viralRemakeService.ts:493。 本册涉及的 6 个核心文件(assistantChatRemakeLong.ts 439 行、assistantReferenceVideoAnalyzer.js 745 行、assistantProductLock.js 164 行、assistantScriptImport.ts 382 行、storyWorkflow.ts 407 行、scriptDialogueValidation.ts 130 行)我都读过全文,都不是壳。


一、整体架构与请求流 ​

Q1. 用户在首页对话框里打一句话,这条请求到你系统里都发生了什么? ​

【考点】 你有没有一条端到端的主线,还是只知道自己在改的那一段。

【口述答案】 分五步。 第一步,路由层解析分层输入。 POST /api/video/assistant/chat/parse-stream 进来,路由先取一次 settings,调 resolveAssistantPromptLayerInputs 把客户端传的 skills[].id 和 categoryId 翻译成服务端权威的玩法包对象和品类包对象。客户端只能传 id,不能传提示词文本——因为它会进 system 提示词,让客户端可控等于把系统提示词注入接口开给所有用户。 第二步,先建台账再调模型。 登录用户会先 createAgentRun 建一条 durable run,落 agent_runs 表,然后 ensureAgentRunStep 建一个 intent_analysis 步骤(ordinal=10)并推到 running,把 agent_run 事件推给前端。所以前端首屏就能显示"正在分析你的需求"。这一步是观测能力,不是主链路前置条件——它抛异常会被 catch 掉,主链路继续跑,注释写得很直白:"避免用户明明已生成成功却只看到 network error"。 第三步,两级意图识别。 先跑正则确定性路由,命中就直接定 action;没命中才调一次快速文本路由(只发文字语义和"有哪些素材",不发图片也不抽视频帧,max_tokens 256、temperature 0.1)。快速路由失败就 routeDegraded 回退旧版整段提示词。 第四步,正式意图解析。 streamChatIntent 用分层编译好的 system + user发起流式调用,走三段式状态机:meta(首行单行 JSON,含 action/mode/duration/assets/options)→ reply(逐字 delta)→ prompt(静默累积,只等 ===PROMPT=== 之后的内容)。 第五步,按 action 分派。 reply/script 直接完成;clarify 会 createAgentInterrupt 建人机确认点;generate/batch/video_edit 会建一个"等待确认"的步骤,计划阶段完全不碰积分,用户点确认后才逐条提交生成任务。

【备注讲解】 这题的关键不是背步骤,而是说出三个"顺序不可颠倒":

  • 台账先于模型调用:反过来就会"模型调用成功但台账没建",这一轮没有 runId,后面生成任务的幂等扣费闸门直接失效。
  • 客户端 id 先于服务端取文本:安全边界建立在这一步,也是 Q11 的主题。
  • 事件先落库再下发:assistantChatService 的回调是同步签名,所以路由层用一条 promise 顺序队列(enqueueAssistantEvent)保证"meta 的落库与追问 interrupt 创建完成"之后才向浏览器推后续事件。代码注释原话:"通过顺序队列保证 meta 的落库与追问 interrupt 创建完成后,才向浏览器推后续对话事件。"如果这里用 Promise.all 并行推,前端就会先收到 delta、后收到 meta,整个三段式状态机在前端就崩了。

【代码依据】 backend/routes/video.ts:5824(parse-stream 入口)、video.ts:5657-5664(assistantPromptLayerInputs,配置读不到就退回"无分层")、video.ts:5901-5937(createAgentRun + intent_analysis 步骤)、video.ts:5938-5947(台账失败不挡主链路 + 注释)、video.ts:5950-5953(同步回调 → 顺序队列)、video.ts:6135-6137(await assistantEventChain)、backend/services/assistantChatService.js:2976-2995(resolveDeterministicConversationRoute / attachConversationRoute / routeDegraded)、assistantChatService.js:2912-2946 与 :2948-2968(快速路由请求与解析,:2953 max_tokens: 256)、:2908-2911("快速路由不重复上传图片/抽帧"注释)、assistantChatService.js:4581-4591(streamChatIntent 入口)。


Q2. 这是"真 Agent"还是"意图分类 + 固定工作流"?请如实说。 ​

【考点】 字节最爱问的一题:你能不能给自己祛魅。把固定流程包装成自主 Agent,一追问就崩。

【口述答案】 我如实拆成两半讲。 有 Agent 形态的部分:我们有一份真正落库的执行台账——agent_runs / agent_run_steps / agent_run_events / agent_interrupts / agent_run_artifacts 五张表;run 有 running / waiting_input / succeeded / failed / cancelled 五个状态,step 有七个状态外加三个提交态 idle/claimed/submitted,错误分类六类;有人机确认点(agent_interrupts,clarify 时创建、用户选完 resolve);有事件序号游标,前端断线可以按 Last-Event-ID 从上一个 seq 补齐。这部分的形态和带 interrupt 的图运行时是一样的。 没有 Agent 实质的部分,也是我要主动交底的:我们没有工具循环。全仓库 grep tool_calls / function_call 是零命中——我们没用 provider 的 function calling。我们的"工具"是提示词里约定输出 JSON、服务端解析后分派:模型输出 action=batch 就调批量计划端点,输出 edit_operations 就编译成视频编辑计划。 所以严格说,这是**「一次意图分类 + 一张固定工作流图 + 一个持久的执行台账」,不是"模型自己决定调哪个工具、调几次、看结果再决定下一步"。 因此我也绝不会声称我们有这些能力:没有工具调用轮次上限、没有 max_steps、没有 Agent 级超时、没有 token 预算硬闸、没有模型自主的重试决策。因为这根本不存在一个"循环"**,也就没有"怎么防止无限调用"的问题——那个问题在我们的架构里不成立。 真正的防重复发生在"钱"这一层:提交生成任务必须带 agentRunId + agentStepId,服务端用 submission_state 抢占,认领不到就返 409 AGENT_SUBMISSION_UNCERTAIN,宁可报错也不重复扣费。

【备注讲解】 这题的答法值得单独记:先给出"我有什么"的硬证据,再明确给出"我没有什么"。 主动说出"我们没有 tool calling,工具是提示词协议"比被面试官挖出来强十倍,因为这说明你分得清 agentic loop 和 workflow orchestration 这两个经常被混为一谈的概念。 可以再补一句抽象:"判断一个系统是不是 Agent,不看它有没有 LLM,看控制流由谁决定。 我们的控制流由服务端的 action 分派表决定,模型的输出只是一个分类结果 + 一个参数包——所以是我们编排它,不是它编排自己。" 如果被追问"那你们为什么还要做台账",答案在 Q20。

【代码依据】 backend/migrations/20260815120000_agent_runs.up.sql:4-111(五张表 + CHECK 约束)、backend/services/agentRunStore.ts:4-6(run/step/error 状态联合类型)、:95-96(终态集合)、:287-386(claimAgentRunStepSubmission / recordAgentRunStepSubmission / releaseAgentRunStepSubmission)、backend/routes/video.ts:4055-4077(生成端点强制 runId+stepId、{idempotent:true}、409 AGENT_SUBMISSION_UNCERTAIN)、video.ts:3241-3242(台账设计意图注释)。

全仓库 grep -rln "tool_calls\|function_call" backend/ → 无命中(我实际跑过)。


Q3. 系统里有两条对话链路,它们分别是什么?为什么会有两条? ​

【考点】 看你是否知道系统的真实形状——包括不体面的部分。

【口述答案】 两条都在 /api/video 下(routes/index.ts:21 挂 assistantRoutes、:72 挂主路由)。 第一条是老链路:POST /api/video/assistant/chat,实现在 backend/routes/assistant.ts,是一个裸字节透传:把 messages 前面拼一个静态 system,转发到上游 /chat/completions,然后把上游 SSE 原样 res.write 给浏览器;前端按 choices[0].delta.content 自己取 token。它没有意图解析、没有 Skill、没有提示词分层、没有 Agent 台账。 关键事实:这条链路的唯一消费方已经是死代码。 前端 hook useChatStream.ts 在 src/main.tsx:16-17 被注释掉了——原话是"AI 电商助手悬浮球暂时隐藏(保留组件备用)"。所以这条链路的实际状态是:代码在、路由在、没人用。 第二条是主链路:POST /api/video/assistant/chat/parse-stream,就是 Q1 讲的那一整条,生产消费方是 useAssistantChat.ts(5228 行)。 为什么会有两条:第一条是最早接模型时做的通用问答入口,第二条是后来做"对话生成"时新建的链路——共用底层模型配置,但完全独立编排。老链路的代码价值只剩"组件备用"。 我会主动说这在工程上是要收敛的:两条链路共存意味着 system 提示词、错误文案、限流口径都可能漂移,而"两份清单只改一份"在我们仓库是反复出过事故的模式。另外老链路还有一个潜在 bug 我读到了:if (data === '[DONE]') break; 只 break 了内层 for,外层 while 会继续读 socket——目前无害只因为服务端发完 [DONE] 就 res.end()。

【备注讲解】 主动指出"我们有两条并行实现,而且老那条已经没人用了"是很硬的加分点,因为它证明你读过全仓库而不是只读自己的模块。 但表达上要克制:不要说成"老代码是垃圾",而是"它有明确的适用边界(纯问答),只是不该再往里加功能;当前状态是备件。"同时把真正的风险点出来是口径漂移,而不是"代码丑"——这才是有工程价值的判断。

【代码依据】 backend/routes/index.ts:21(require('./assistant'))、:72(app.use('/api/video', assistantRoutes))、backend/routes/assistant.ts:11(老链路入口)、:24-27(拼静态 system)、:31-38(上游 stream: true)、:48-51(SSE 响应头)、:53-63(字节级透传循环)、:56(onClientDisconnect(res, () => reader.cancel()))、src/features/assistant/hooks/useChatStream.ts:37-46(调 /api/video/assistant/chat)、:66-71(自己按 OpenAI 原生 delta 解析)、:68([DONE] 只 break 内层的潜在 bug)、src/main.tsx:16-17(消费组件已被注释掉)、src/features/assistant/hooks/useAssistantChat.ts:2268-2275(主链路 parseViaStream)。


二、Skill 体系 ​

Q4. Skill 的数据结构长什么样?谁定义、存在哪、怎么下发到前端? ​

【考点】 数据建模 + 运营化意识。这题答好了,面试官会认为你做过"给运营用的系统"。

【口述答案】 Skill 在我们内部叫玩法包,定义在 backend/services/assistantSkillCatalog.ts。 数据结构:核心字段是 id / name / category / description / promptTemplate / requiredInputs / optionalInputs / enabled / sortOrder;分层改造后新增了可选的 videoType(口播/多人剧情/复刻/展示/真人种草五选一)、systemFragment(进 system 的稳定约束)、userFragment(进 user 的本轮规则)、appliesTo(限定品类)、preferredMode、preferredWorkflow。新字段全部可选,不填就退化成改造前行为——这是老配置零迁移的关键。 谁定义:代码里有一份 DEFAULT_ASSISTANT_SKILLS 内置目录,当前 42 条,分 12 个展示分组(电商复刻 12 条、商品营销 6 条、内容改编 5 条、模特与数字人 4 条、品牌内容 3 条、画面与分镜 3 条、复刻与编辑 3 条、电商创意 2 条,以及口播带货/剧情短片/电商转化/热门玩法各 1 条)。但运行时权威是数据库 settings 键 assistant_skill_config,运营在管理端 admin/src/features/settings/components/AssistantSkillManager.tsx 增删改排序停用。 版本化升级:配置带 schemaVersion,当前是 4。升级时只补"配置版本号之后新增的内置玩法 ID"(SKILL_UPGRADES 登记 v2/v3/v4),绝不整表 merge——整表 merge 会把运营早先主动删掉的内置玩法一并复活。并且加了两道闸:完全自定义的目录(一个内置 ID 都没有)原样返回;首版整表补齐只对 v1 裸数组生效。 怎么下发:publicAssistantSkills() 把 systemFragment / userFragment 剥掉再出网——前台只需要 id 就能选,服务端再按 id 取权威文本。另外 resolveSkillsByIds 刻意保持用户的选择顺序,不是目录顺序。

【备注讲解】 这里最值钱的一句话是:"提示词文本不出网,前端只传 id。" 这既是安全边界,也解决了一个运营问题:运营改了提示词,不需要发版、不需要用户刷新,下一次请求就生效。 "只补增量的版本升级"是个可以推广的原则:配置升级要能区分"运营删的"和"系统新增的",否则每次加内置项都会把运营的删除操作撤销掉。这也解释了为什么必须引入 schemaVersion——设计文档里写得很清楚,原来那段"有 viral-remake 但没有 viral-hook-remake 就判定为老目录"是隐式嗅探,"再叠一次这种猜测必然踩坑"。

【代码依据】 backend/services/assistantSkillCatalog.ts:9-37(AssistantSkillTemplate 接口)、:39-49(skill() 构造器:新字段可选 + enabled 默认 true)、:52-388(DEFAULT_ASSISTANT_SKILLS 42 条)、:393-405(SKILL_SCHEMA_VERSION = 4 + SKILL_UPGRADES)、:410-496(parseAssistantSkillConfig 版本化增量补齐 + 两道前置闸;:459-463 隐式嗅探只对 v1 生效)、:504-513(resolveSkillsByIds 保持用户选择顺序)、:521-525(publicAssistantSkills 剥掉两个 fragment)、backend/services/settingsStore.ts:86-87(两个目录的默认值)、:127-131(双向规范化信封)、docs/architecture/assistant-skill-prompt-layering.md:83-85(为什么必须加 schemaVersion)。

42 这个数字和 12 个分组是我用脚本解析 DEFAULT_ASSISTANT_SKILLS 数出来的,不是估的。


Q5. 能举几个真实的 Skill 说明它们到底约束了什么吗? ​

【考点】 你是真的运营过这套东西,还是只知道有个 catalog 文件。

【口述答案】 我挑三类讲,因为它们代表三种不同的约束层次。 第一类,与品类正交的玩法包(有 systemFragment,共 7 条):

  • ecom-voiceover 电商口播——约束"单人正面中景、人物位置与景别全片稳定、口型表情视线与语音必须同步、口播稿必须可朗读(短句为主,语速按每秒 4~5 个汉字估算时长)、画面必须与当前这句话讲的卖点对应、商品必须被真实拿取展示"。
  • multi-role-drama 多人剧情——约束"每个角色必须有固定名字/性别/年龄段/外形/发型/着装/声音特征并跨镜一致、对白一句一行以「名字:」开头、禁止把多轮问答合并成一行、禁止删减任何一问一答、角色关系与核心冲突必须在前 3 秒建立、同一镜头同时开口不超过 2 个角色",并 preferredWorkflow: 'story' 建议切剧情链路。
  • standard-remake 原版复刻 / flexible-remake 灵活复刻——这两个特殊,见 Q7。 第二类,故意不设 videoType 的横切玩法(3 条):refined-set-design 精致置景、shot-design-variety 镜头设计、shot-rhythm-control 分镜节奏管控。它们不是"一种视频类型",而是对任意类型的修饰,所以 videoType 留空。分镜节奏管控里塞的是很具体的数字:全片 15 镜左右、每镜严格不超过 3 秒、禁止跳景别、禁止慢推慢拉、口播固定机位、特写用摇镜、口播台词准确无错别字。 第三类,只有单框 promptTemplate 的老式玩法,比如 viral-shot-remake 爆款分镜复刻(逐镜锁定起止时间/景别/机位/镜头运动/主体动作/转场/节拍,输出一一对应的复刻分镜,不得合并或漏掉镜头,相邻镜头必须显式传递上一镜末尾的姿势、商品位置、视线和光线)、viral-lookbook-remake 爆款 Lookbook 复刻、digital-avatar 数字人口播(requiredInputs: ['口播稿'])等。

【备注讲解】 我举的三个层次其实对应一个设计判断:"品类"和"玩法"是两个正交维度,不是一张平铺列表。 设计文档里算过这笔账——品类 10 个 × 视频类型 12 个直接铺成列表是 120 条,运营改一条通用口播规则要改 10 遍,而且必然漂移。所以做成两组独立的包,运行时拼装。 另外要主动说一个已知没做完的地方:refined-set-design 我加了 appliesTo: ['beauty'],因为这套置景语言(纯色哑光桌布、托盘内 2~3 瓶 + 小样、摆件前后景层次、材质 ≤3 种)是按美妆台面写的,注释里写明"用在 3C/食品上会给出错误的道具指令"。但 appliesTo 目前只告警、不剔除——理由是"玩法是用户显式选的,替他静默丢掉更难排查"。这是有意的取舍,不是漏做。 还有 5 条有 videoType(voiceover / multi_role_drama / remake×2),viral-remake 那条其实没有 videoType——因为它是"电商复刻"分组里的老式单框玩法,只有 standard-remake/flexible-remake 这两条新的带。

【代码依据】 assistantSkillCatalog.ts:54-81(ecom-voiceover,:69 "每秒 4~5 个汉字")、:82-110(multi-role-drama,:99 禁止合并多轮问答)、:112-136(refined-set-design,:122-123 appliesTo: ['beauty'] + 为什么)、:137-159(shot-design-variety)、:160-185(shot-rhythm-control,:172 15 镜/≤3 秒、:178 台词无错别字)、:189-218(standard-remake)、:219-248(flexible-remake)、:249-258(viral-remake,无 videoType)、:289-298(viral-shot-remake,"不得合并或漏掉镜头")、:376(digital-avatar,requiredInputs: ['口播稿'])、backend/services/assistantPromptComposer.ts:194-203(appliesTo 只告警不剔除 + 理由注释)。

videoType 出现 5 次(含解析层 1 次),实际赋值 4 处::64 voiceover、:92 multi_role_drama、:199 remake、:229 remake。


Q6. Skill 怎么"解锁"?为什么提示词内容要卖钱? ​

【考点】 商业设计 + 计费幂等。这是这套系统里少见的"把提示词当商品"的部分,很出彩。

【口述答案】 玩法包的完整提示词内容可以单独查看,收费 10 积分,实现在 assistantSkillUnlockStore.ts。 内容口径:buildSkillContentSections 把三段拼起来——promptTemplate 作"玩法说明"、systemFragment 作"系统约束"、userFragment 作"执行规则",空片段不产空段。这跟提示词编译层读的是同一份服务端配置,所以客户端传什么都换不来全文。 免费预览:未解锁只回前 1/3(SKILL_CONTENT_PREVIEW_RATIO = 1/3),同时返回 totalChars 和 previewChars,前端据此画"还有多少没解锁"。 扣费幂等做在三层:

  1. 用户级 Redis 锁(acquireCreditLock)把整个解锁流程包住;
  2. 锁内先查 assistant_skill_unlocks,已解锁直接免费返回(alreadyUnlocked: true),双击/并发第二发不再扣费;
  3. 扣费走复用的 consumeUserCredits(行锁 + 台账 + 企业计费拆分),并且如果积分扣成功但解锁行写库失败,立刻 restoreUserCredits 原路退回——绝不出现"扣了钱没解锁"。表上还有 UNIQUE(user_id, skill_id) 兜底。

【备注讲解】 这题的真正考点是第 3 条:"扣了钱没解锁"是资损,必须原路退回。 我们仓库里退款有个反复出现的坑叫"池子不对"——用户反复"生成→失败"就能把充值余额洗成赠送余额,所以退款要带上原来的 split(gift/paid 拆分)而不是简单加回总数。这里调 restoreUserCredits 时把 charge.split 原样透传,就是为了避免这个。 可以再抽象一句:"付费功能的三步(扣钱、落权、返回)里,只要中间两步不是原子的,就必须给每一步配一个反向操作。"

【代码依据】 backend/services/assistantSkillUnlockStore.ts:14-19(10 积分 + 1/3 预览常量)、:30-46(三段内容拼装)、:52-67(buildSkillContentView 未解锁只回 previewText)、:75-111(解锁流程:用户锁 → 已解锁短路 → 扣费 → 落行失败原路退回并透传 split)、:94-97(ON CONFLICT DO NOTHING)、backend/routes/video.ts:5619-5632(content 路由)、:5634-5654(unlock 路由;:5644-5646 积分不足走 402 引导充值)。


Q7. Skill 跟"链路路由"是什么关系?为什么说玩法名同时是路由词? ​

【考点】 最能体现"你有没有处理过同一个语义有两个入口"的一题。

【口述答案】standard-remake(原版复刻)和 flexible-remake(灵活复刻)是两个特殊玩法:它们既是 L3 提示词片段,也是复刻方式卡上的前两个选项。同一件事有两个入口,口径必须只有一份。 入口 A:方式卡。 用户点「灵活复刻」,displayText 带上方式词,normalizeMeta 按正文识别出 routingChoice。 入口 B:# 快捷指令。 用户在输入框选中「灵活复刻」玩法,skillRoutingChoice(input) 按玩法 id 识别(映射表 SKILL_ROUTING_CHOICE:standard-remake → reference-video-remake、flexible-remake → flexible-video-remake)。 两者产出同一个 routingChoice,所以下游(action=video_edit、分析器的 flexible 档、计划卡)完全共用。 优先级是正文压过玩法标签:卡片上刚点的选项是本轮的确定性事实,而玩法标签是会话级的、用户不主动摘就一直挂着。 两道闸限制玩法路由的作用域:① 只认玩法 id(白名单枚举,不含任何提示词文本),所以不构成注入面;② 必须真的有可编辑源视频——否则挂着标签的每一句闲聊都会被拽进视频编辑,再降级成"没有找到可编辑的视频"。 两条方式的差别只有一处:改动范围。原版复刻只改用户点名的元素;灵活复刻改用户点名的 + AI 补的关联元素(一次最多补 3 项,每项单独成条、标"AI 补充"并写清理由,供用户在计划卡上逐条删除)。

【备注讲解】 这题的教训值得单独讲:"命名在这里是硬约束,不是风格问题。" 改名或改 id 会让 #灵活复刻 静默退化成"只挂了个提示词标签"——链路还是原版复刻,AI 一条关联元素都不会补,而且不报错。所以 assistantSkillCatalog.test.ts 里有一条专门钉住这两组值的用例(一组按名字认 resolveReplaceModeChoice,一组按 id 认 SKILL_ROUTING_CHOICE)。 可以推广成原则:"当一个语义有两个入口时,不要让它们各自往下走,而是让它们在上游汇合成同一个中间表示。" 我们在上游汇成 routingChoice,下游就完全不用知道用户是从哪进来的。 另外可以主动指出一个未拦截的冲突:口播 + 多人剧情同时选中是互相矛盾的组合,目前既不拦截也不告警(设计文档已列为待办)。

【代码依据】 backend/services/assistantChatService.js:3345-3368(SKILL_ROUTING_CHOICE + skillRoutingChoice 的两道闸 + 注释)、:3394-3404(normalizeMeta 里 routingChoice 的正文识别优先级)、backend/services/assistantWorkflowIntent.js:723-731(resolveReplaceModeChoice 按名字认方式词,四个取值)、assistantSkillCatalog.ts:186-188("玩法名就是路由词,改名前务必同步那里的映射表")、:189-248(两个玩法的 fragment 与 preferredWorkflow)、docs/architecture/assistant-skill-prompt-layering.md:241-271(第十七节:命名是硬约束)、:238(口播+剧情冲突未拦截)。


三、提示词分层与 token 预算(本册最重要的一节) ​

Q8. 你们的提示词是怎么组织起来的?分了几层,谁压过谁? ​

【考点】 提示词工程的结构化能力。只会写一句 system 的人答不出这题。

【口述答案】 五层,越靠后越贴近本轮指令,但都压不过 L0:

  • L0 平台底座:identity / 合规 / 输出格式 / 字段规则,代码常量,不可运营;
  • L1 模式层:standard / story / conversation / script 四选一,由 resolveSystemPrompt 的 resolveBaseSystemPrompt 裁决,不可运营;
  • L2 品类层(CategoryPack):「这个行业怎么拍才对」的稳定知识,可运营,能同时写 system 和 user;
  • L3 玩法层(SkillPack):「这条视频的结构怎么排」,可运营,能同时写 system 和 user;
  • L4 会话层:用户消息、素材清单、productContext、当前脚本。 L0 必须永远压过 L2/L3,我做了两道防线:
  1. 入库时清洗:运营片段里出现 忽略以上、ignore previous、无视上述、覆盖以上、system:、你现在是、<|...|> 这类越权句式(共 7 条正则),直接剥离,并统计剥离条数用于告警;
  2. 拼装时收口:system 侧所有运营片段后面强制追加一句——「以上品类与玩法规则是对平台规则的补充;与平台身份、合规、输出格式或字段规则冲突时,一律以平台规则为准。」 否则运营写错一句就能把合规层冲掉。

【备注讲解】 这里可以讲一层抽象,面试官很吃:"提示词的优先级应该是显式数组序,而不是指望模型理解。" 我们不是写一句"以下规则优先级更高"就完事,而是用物理顺序表达优先级——不可运营的底座在最前,可运营的附加规则拼在后面,最末尾放一句显式收口。模型对"谁先谁后"的注意力倾向是概率性的,但字符串顺序是确定性的;能把确定性拿到手的地方就不交给模型。 第二道防线(收口句)的价值在于:即使清洗漏了一条,最末尾的收口仍然在语义上给它降级。这是纵深防御,不是冗余。 顺带一个真实细节:resolveBaseSystemPrompt 里剧情模式的优先级最高——"即使客户端误带 conversation/script,也不能退回首页提示词并污染剧情链路"。

【代码依据】 backend/services/assistantPromptComposer.ts:1-13(五层注释)、:41-50(各层预算常量)、:57-66(OVERRIDE_PATTERNS 七条越权句式正则)、:68-69(PROMPT_LAYER_GUARD_LINE)、:77-84(sanitizePromptFragment 返回 strippedCount)、:186-220(system 侧拼装:品类在前、玩法在后)、:251(systemBlocks.join() 后接收口句)、backend/services/assistantChatService.js:2615-2636(resolveSystemPrompt = 底座 + withPromptLayerAppendix;:2626-2628 剧情模式优先级最高)、docs/architecture/assistant-skill-prompt-layering.md:31-49(第三节)。


Q9. 为什么 L2/L3 要同时能写 system 和 user 两处?拼装顺序是什么? ​

【考点】 你有没有想过"同样一段约束放 system 还是放 user 有区别"。

【口述答案】 因为这两处的性质不同:

  • systemFragment 放长期不变的约束——品类禁忌、镜头语言偏好、合规红线。放 system 遵守度更高,而且能吃 prompt cache(前缀稳定)。
  • userFragment 放本轮具体动作——结构、镜头数、输出格式要求。紧挨用户消息,优先级更贴近当前指令。 拼装顺序(composeAssistantPromptLayers): system 侧——品类包在前(稳定知识),玩法包在后(本轮结构),每个片段按用户选择顺序追加,最后统一加收口句。 user 侧——先 【当前商品品类】品类名 + userFragment,再把玩法拼成一个 【本轮已选 Skill】(作为创意翻译规则执行,不得忽略): 块,每行 - 玩法名:片段文本。 为什么 system 侧是"品类在前玩法在后":品类是跨轮稳定的行业知识,玩法是本轮的结构要求;把稳定前缀放前面既符合"从一般到具体"的阅读顺序,也让缓存前缀更长。 还有一个工程细节:分层编译结果在同一次请求内只算一遍——assistantPromptLayers(input) 用 Object.defineProperty 把结果挂成不可枚举的 __promptLayers,因为 resolveSystemPrompt 和 buildIntentRequest 各要取一半(前者取 systemAppendix,后者取 userAppendix),不能算两遍。

【备注讲解】 "吃 prompt cache"这点值得展开一句,因为它是为什么稳定内容要放前面的真实理由,不只是审美:前缀稳定 = 缓存命中 = 既有延迟收益也有成本收益。这也解释了为什么用户消息、素材清单这些每轮都变的东西全部塞进 user 侧,一点不往 system 里混。 另外要主动说清一个老路径兼容约束:user 侧我们刻意保留了 【本轮已选 Skill】 这个既有块格式,目的是让没选 Skill 时输出与改造前逐字一致。这种情况下 systemAppendix 和 userAppendix 都是空字符串,链路行为零变化——这是"加功能不能改老路径"的纪律。设计文档里把它写成一条明确的回归项:"未选 Skill / 品类未识别时,输出必须与改造前逐字一致"。

【代码依据】 backend/services/assistantPromptComposer.ts:167-255(composeAssistantPromptLayers 全流程;:184 "品类在前、玩法在后"注释;:222 "保持既有块格式,老配置输出逐字不变")、:246-248(【本轮已选 Skill】 块文本)、:174(未选玩法且未命中品类时两个 appendix 都为空)、assistantChatService.js:2563-2600(assistantPromptLayers 单请求内复用 + __promptLayers)、:2852(skillContext = assistantPromptLayers(input).userAppendix)、:2867-2868(userAppendix 拼进 user 消息)、docs/architecture/assistant-skill-prompt-layering.md:139(回归项)。


Q10. token 预算怎么记的?为什么不截断,而是"整段丢弃"? ​

【考点】 ★ 本节核心题。答好了面试官会认为你有"事故驱动"的工程判断。

【口述答案】 预算表在 PROMPT_LAYER_BUDGETS,按字符数记账:品类 systemFragment 800、品类 userFragment 400、玩法 systemFragment 600、玩法 userFragment 800;外加两个条数上限:maxSkillSystemFragments = 3、maxSkillUserFragments = 3。 超限的处理方式是「整段丢弃 + 告警」,绝不中途 slice。 理由我写在代码注释里:"半句规则模型照样会执行,产出一个谁也没写过的约束,属于 ed2ebfb1 那类静默截断事故。"三个"必须出声"的点,一个都不能静默:

  1. 超预算:玩法「X」userFragment:${length} 字超出 ${limit} 字预算,整段未生效;
  2. 数量上限导致的丢弃:这是最容易漏的一处——「玩法「X」systemFragment 因超出 3 段上限未生效」。注释原文:"数量上限导致的丢弃同样要出声:静默少一条 system 约束和中途截断是同一类事故";
  3. 模板变量:未知变量名、因变量缺值被删掉的行数,都进 warning。 这些 warning 由 assistantChatService 统一打到日志:console.warn('[AssistantPromptLayers]', ...),注释写明这是"运营写错模板/超预算的唯一发现途径"。 运行期是兜底,真正的闸在保存时:管理端保存就硬校验(validatePromptFragments → collectPromptFragmentErrors),超限直接保存失败,外加前端实时字数条 + 超限红字。运行期这条路只在有人直接改库时才会走到。

【备注讲解】 有两句话必须说出来: 第一句:「静默截断必须出声」是一条通用原则,不是这一处的补丁。 判断标准是"用户/运营能不能发现规则没生效"。.slice(0, 1000) 的问题不在于它截断了,而在于截断之后没有任何人知道——错误被一个正常的返回值掩盖了。 第二句:为什么是"整段丢弃"而不是"运行期抛错"。 抛错会让一条坏配置打掉整个对话(运营手抖一次全站用户不能聊天);整段丢弃的语义很干净——规则要么全生效,要么不生效,不会出现"半条规则"。真正需要硬拦的地方是管理端,因为那里是唯一有人能修的地方。 同类判断在脚本正文上我用了相反策略——不做静默切片,改成"超了就显式报错"(见 Q13),因为脚本被截掉的是尾部镜头的台词,丢一句都是内容事故。 要主动交底的缺口: 这个模板变量永远渲染不出来。buildPromptTemplateVars 会去读 pc.brandTone,但全仓库没有任何地方生产 brandTone——normalizeAssistantProductLock 和 normalizeAssistantProductContext 都不输出这个字段。结果是引用它的模板整行被删掉(这至少是"出声"的行为,不是静默),但这个变量名实际上是死的。我全仓库 grep 确认过。

【代码依据】 backend/services/assistantPromptComposer.ts:36-50(预算常量 + "绝不做中途 slice" + :38 引用 ed2ebfb1 + :46-48 为什么提到 3 段)、:130-150(prepareFragment:清洗→渲染→预算,超限返回空文本 + 告警)、:145-148(超预算告警文案)、:205-213(数量上限丢弃同样要出声 + 注释)、:232-237(user 侧数量上限)、:282-300(validatePromptFragments 管理端硬校验)、:94-123(renderPromptTemplate:缺值/未知变量整行删除,不留 )、:258-278(buildPromptTemplateVars;:273 读 brandTone)、backend/services/assistantChatService.js:2592-2595(warning 落日志 + "唯一发现途径")、backend/routes/admin.ts:762-768(保存时按同一份预算逐个校验)、docs/architecture/assistant-skill-prompt-layering.md:114-125(第八节)。

brandTone 无生产者是我全仓库 grep 确认的:唯一出现处就是 assistantPromptComposer.ts:273 那次读取。


Q11. 客户端能不能改系统提示词?你们的边界画在哪? ​

【考点】 提示词注入的安全边界。这是"AI 应用"岗的送分题,也是送命题。

【口述答案】不能。 边界画在"值 vs 引用"上:客户端只能传 id,文本一律以服务端配置为准。 具体三道:

  1. 解析:resolveAssistantPromptLayerInputs(settings, body) 只从 body 里取 skills[].id 和 categoryId,然后 resolveSkillsByIds 从 settings 的 assistant_skill_config 里把服务端对象取出来。客户端传上来的 promptTemplate 一律不参与 system 层。
  2. 下发时已经剥掉了:publicAssistantSkills() 返回给前端的对象里没有 systemFragment / userFragment(promptTemplate 保留,因为老客户端还在用它兜底 user 侧)。
  3. 兜底路径也守住了:assistantPromptLayers 里如果拿不到服务端解析出来的 skillPacks,会回落到 input.skills,但只映射 id / name / promptTemplate 三个字段——也就是只可能进 user 侧,systemFragment 无论如何拿不到。 为什么这么紧张:systemFragment 会进 system 提示词,里面有合规红线、输出格式和字段规则。让客户端可控就等于把系统提示词注入接口开放给所有用户——这不是"少一个字段"的问题,是"任何人都能改我的合规层"的问题。 另外路由层还有个降级设计:settings 读不到时不能挡住对话,会退回 { skillPacks: [], categoryPack: null, categorySource: 'none' },等价于改造前行为。

【备注讲解】 这题可以提炼成一条可迁移的规则:"只要一段文本会进入 system 提示词,它的来源就只能是服务端。 客户端的输入只能是选择(选哪个 id),不能是内容(写什么文本)。" 还有一层是分层解耦带来的额外收益:因为前台只拿 id,运营改提示词不需要发版、不需要用户刷新——安全边界和运营效率在这里是同向的,不冲突。 最后主动交底一个已知缺口:GET /api/video/public/assistant-category-packs 这个端点已经有了(backend/routes/publicConfig.ts:94-96),但前端还没有消费它(我在 src/ 下 grep 过,零命中)——品类目前只有 productContext.category 自动识别这一条路,用户显式的品类选择器、以及"点掉识别错的品类"的逃生舱都还没做。设计文档把它列为 P1,并且强调"自动识别必须配逃生舱,让用户一眼看出「系统认为这是美妆」,这是自动识别能上线的前提"。 另外品类包没有管理端 CRUD——assistant_category_pack_config 目前只能改代码默认值,运营改不了(玩法包可以改)。

【代码依据】 backend/services/assistantPromptComposer.ts:167-175(composeAssistantPromptLayers 的安全前提注释)、:310-344(resolveAssistantPromptLayerInputs:只取 id / categoryId / productCategoryText,:324-328)、backend/services/assistantChatService.js:2563-2582(回落分支只映射三个字段 + 注释)、backend/services/assistantSkillCatalog.ts:515-525(publicAssistantSkills 刻意剥掉两个 fragment)、backend/routes/video.ts:5657-5664(配置读不到退回"无分层")、backend/routes/publicConfig.ts:94-96(品类包公开端点,前端未消费)、docs/architecture/assistant-skill-prompt-layering.md:87-97(第五节:品类确定优先级 + 绝不硬塞默认品类 + 逃生舱)、:234-239(P1 待办:品类包无 CRUD)。


四、流式输出与降级 ​

Q12. 后端的 token 流是怎么产出的?SSE 事件协议是什么? ​

【考点】 你真的手写过流式,还是只调过 SDK。

【口述答案】 两层流,都是手写的,没有用任何封装库。 第一层:上游 → 我们。 调上游 /chat/completions 开 stream: true,拿 response.body.getReader(),用 TextDecoder 增量解码,然后自己解析 SSE 行:缓冲、按 /\r?\n/ 切、lines.pop() 留下不完整的尾行,只有 startsWith('data:') 的行才处理(所以 : ping 心跳和 event:/id:/retry: 行天然被忽略),[DONE] 跳过,JSON.parse 失败就 continue,不炸整条流。 这里有两个真实的坑我处理了:

  • finish_reason 是截断信号:finish_reason === 'length' || 'max_tokens' 说明模型被输出上限截断,要置 streamTruncated,后面走续写逻辑。不看这个字段就会把半份脚本当完整脚本交付。
  • 网关可能返回"累积快照"而不是增量:有些 OpenAI 兼容网关用 message.content 返回不断变长的全文。所以我写了 createStreamTokenExtractor:一见到 delta.content 就标记 sawDelta 走增量路径;如果只有 message.content,就把它转成后缀再往下发,保证客户端"只追加"的契约不被破坏。 第二层:我们 → 浏览器。 我们重新包装成带类型的事件,不是字节透传:data: ${JSON.stringify(event)}\n\n,头是 text/event-stream + X-Accel-Buffering: no,结束时写 data: [DONE]。 事件是一个以 type 为判别字段的扁平联合:meta(意图包 + options + clarification)、delta(回复逐字)、script_delta(脚本卡逐字,可带 scriptIndex)、media_status(参考素材分析进度)、done、error,以及旁路的 agent_run(完整 durable run/step/interrupt 记录)。 两个设计细节:① SSE 响应头延迟到首个事件才发——这样校验类错误(素材超限)还能走 400 JSON,前端统一处理;② 每个事件带 agentRunId,前端据此把消息和台账关联起来。

【备注讲解】 "我们重新包装而不是字节透传"是个有分量的判断,理由值得说出:因为我们有比 token 更多的东西要传——意图结构、脚本分片、素材分析状态、台账事件。字节透传只能传文本,那就没有 meta,也就没有"1~3 秒内提前弹积分确认"这个体验。协议是被产品需求倒逼出来的。createStreamTokenExtractor 那二十几行也值得单独提:"和第三方网关打交道,最贵的不是功能,是它不遵守你以为的契约。 同一个 stream: true,不同网关一个给增量、一个给快照。所以我在解析层做了一次归一化,让上层永远只看到增量。"这段经验迁移价值很高。 还有一处不对称必须主动交底:我们只用了 per-line 解析、没有用 \n\n 事件边界,这对我们的协议安全(每个事件就是一行 data:),但意味着服务端一旦忘了 res.end(),客户端会永久挂住——因为 [DONE] 在前后端都只被当成 no-op,不是终止条件。

【代码依据】 backend/services/assistantChatService.js:4519-4547(createStreamTokenExtractor:增量/快照归一化)、:4826-4844(handleSseChunk:缓冲、data: 前缀、[DONE] 跳过、JSON.parse 容错、:4836-4838 上游 error 抛出、:4840 finish_reason 截断信号)、:4846-4857(getReader + TextDecoder + for await 兜底)、backend/routes/video.ts:5882-5895(sendEvent 延迟发头 + data: ${JSON.stringify})、:6156(data: [DONE])、:5820-5823(meta → delta → done 顺序契约)、:4617-4638(buildMetaEvent 字段清单)、assistantChatService.js:4612-4615(script_delta 投影器)、:5962-6002(media_status 事件驱动台账)、src/features/assistant/hooks/useAssistantChat.ts:2287-2289(前端 per-line 解析 + [DONE] no-op)、:2350-2356(buffer + decoder.decode() 冲尾,多字节 UTF-8 跨 chunk 安全)、:2298-2300(errorKind: 'curated' 必须跟完整条链路,否则成稿文案会被换成统一模板)。


Q13. 三段式状态机是什么?上游截断了怎么办?降级链怎么设计的? ​

【考点】 流式场景下的容错设计,最能体现"你想过失败"。

【口述答案】 状态机有四个相:meta → reply → prompt → degraded。 meta 相:累积 token 直到第一个 \n,把首行当单行 JSON 解析(顺手剥掉可能的 ```json 围栏)。解析出来的是意图包:action、mode、duration、assets、options 等。首行解析失败就把整个 phase 置成 degraded。reply 相:确认 meta 之后,后续文本是给用户看的回复,逐段以 delta 发出。细节:结尾可能正好是 ===PROMPT=== 标记的前半截,所以要扣住尾巴不发(flushReplyDelta(PROMPT_MARKER.length)),等下一个 chunk 确认了再放,否则用户会看到半截标记符。 prompt 相:见到 ===PROMPT=== 之后的内容是给视频模型的提示词,静默累积不发前端。脚本卡另走 script_delta 投影器,而且是"见到首个【镜头N】才开始推"——因为整段直转发会把模型跑偏写出的 source_asset: / edit_operations: 这类计划字段一个字不落地流进可执行分镜脚本,前端再把整坨当成一个 5s 假镜头渲染出来。 degraded 相:一旦首行不是合法 JSON,或者 meta 缓冲超过 4000 字符(说明模型没按单行 JSON 输出;META 带 units 清单时可达 ~1500 字符,所以阈值给足余量),就放弃流式语义,全量缓冲到结束再统一解析。 降级后还有兜底链:phase === 'degraded' || !meta 时,先尝试从全量文本里提取 JSON;再不行就用 fallbackText 当脚本交付,并把响应标上 degraded: true,让前端知道这是降级产物。 最后是输出截断续写:如果 streamTruncated 且 action 是 script,按脚本结构分流——逐镜脚本走镜头号锚点续写(增量进投影器,模型重写开头时前缀校验会扣住不发,卡片不会重复);模型不守规矩输出五段式时按段标题/时间轴秒数续写,增量不进投影器、随 done 一次性交付;纯口播稿没有结构锚点,不续写,交给质量门和下游确定性包装兜底。续写请求本身失败也不会毁掉已流式交付大半的整轮——保留截断的 promptBuf 照常走。

【备注讲解】 抽象出来是:"流式系统的核心矛盾是「增量已经发出去了,但你还不知道整段对不对」。" 我们的解法是分层降级 + 永不回滚已发内容:

  • 能在流内判定的(首行 JSON)就早判定、早降级;
  • 判不了的(整段语义)就全量缓冲,用延迟换正确性;
  • 一旦发出去了就不再撤回——所以降级只影响"后面怎么发",不改"已经发了什么"。 另外要说清 degraded 是保守的可用性选择,不是错误:它意味着"放弃流式体验换取正确解析"。所以它只标 degraded: true 而不报错。区分"降级"和"失败"很重要——降级是设计内路径,失败才是异常。 还有一个"提前放弃"的细节值得提:抽帧和快速路由都是为了不让路由本身成为延迟与 token 大户。快速路由只发文字不发图,参考视频也不再重复发送完整 video_url,因为"重复直读既浪费上下文,也会让 10/50 MiB 等不同模型限制反向卡死整个对话 Agent"。

【代码依据】 backend/services/assistantChatService.js:4593-4615(四个 phase + replyEmitted + deferredMeta + streamTruncated + scriptDeltaProjector)、:4762-4776(meta 相解析;:4768 4000 字符阈值 → degraded;:4774-4776 解析失败 → degraded)、:4746-4754(flushReplyDelta 的 holdTail 扣尾 + :4748 注释)、:4607-4615(脚本卡只收逐镜正文的原因)、:4894-4920(截断续写分流::4909 story / :4916 标准)、:4931(if (phase === 'degraded' || !meta) 全量兜底解析)、:407(PROMPT_MARKER = '===PROMPT===')、:418(SCRIPT_MAX = 5)、:113-118(assistantResponseMaxTokens)、:420-423(单份 16000 / 追加 8000 / 上限 48000 / 批量单份 12000)、:2882-2884(不重复发完整 video_url 的原因)。


Q14. streamFallback.ts 到底解决什么问题? ​

【考点】 这题是诚实陷阱。文件名叫 fallback,如果你吹成一个复杂的重试引擎,翻代码就穿了。

【口述答案】 我先说实话:streamFallback.ts 只有 19 行,它不是引擎,它是一个判定函数。 它导出两样东西:

  1. 一个常量 ASSISTANT_STREAM_FIRST_EVENT_TIMEOUT_MS = 20_000。它的语义很具体:因为 parse-stream 延迟到首个事件才发响应头,而客户端的计时器在收到响应头就被清掉了(apiClient.ts:470-475),所以这 20 秒是**"到首事件"的预算,不是整个流的时长**。注释原话:"parse-stream 延迟响应头到首个 SSE 事件,因此这里同时也是「首事件」超时。"
  2. 一个纯函数 shouldFallbackAssistantStream(error),判断这个错误值不值得用一次性端点重试。返回 true 的是:HTTP 404(这个部署还没有 parse-stream 端点,也就是旧后端)、405、超时、网络错误,以及一批网络类文案正则(network error、failed to fetch、socket hang up、econnreset、网络异常/中断……)——其中还有一条很关键:流式解析未返回结果。业务 4xx 和协议内容错误一律返回 false。 "空流"这个触发条件是真实存在的:流正常关闭但一个 done 都没收到时,前端会抛的正是 流式解析未返回结果,所以"连接干净关闭但什么都没有"也会降级。 真正执行降级的地方在 useAssistantChat:catch 到错误后 if (shouldFallbackAssistantStream(e)) final = await parseViaPost(payload); else throw e;——回落到一次性端点 POST /assistant/chat/parse。 为什么可以安全重试:因为意图解析本身不创建图片/视频任务、也不扣积分。注释写得很清楚:"意图解析本身不创建图片/视频任务,也不扣积分,因此流中途遇到瞬时网络错误时可以安全地用一次性端点恢复。" 已经流出来的内容不会被回放,也不会被回滚:它只当作"地板"用——resolveCompletedAssistantReply(streamedContent, finalReply) 的逻辑是一次性回复非空就用它,否则保留流式内容。所以用户看到的补偿效果是:气泡里的部分文字保留,然后被非流式回复替换;已经渲染的脚本卡不回滚。

【备注讲解】 这题的正确答法是先自曝尺度:"它不是引擎,只有 19 行,而且只有一个调用点"——然后讲清楚为什么 19 行就够。这比吹成重试框架强得多,因为它体现了一个判断: "降级逻辑的价值在于「判定边界划得准」,不在于代码量大。 这份判定的核心就是把错误分成两类:没建流之前的基础设施问题(可以重试) 和 流已经建起来之后的业务/协议问题(不能重试)。" 必须主动交底三个缺口(这些是这题真正的加分点):

  1. 没有读阶段超时。20 秒只覆盖"到响应头"。首事件之后如果流卡住,reader.read() 没有任何 deadline,也不会抛错,所以 shouldFallbackAssistantStream 永远走不到——用户就是无限转圈。
  2. 没有中止。意图解析这条流前后端都没有 AbortController / 连接断开传播。前端 postStream 根本没传 signal;后端只调了 trackClientDisconnect 把标志位记下来,但从没调 onClientDisconnect,也没把 signal 传进 streamChatIntent。后果是:用户关掉标签页,上游 LLM 调用会继续跑完并继续计费。对照组是画布那三条流,它们是真的取消上游的(video.ts:3474/:3583 都调了 onClientDisconnect(res, () => upstreamReader?.cancel?.()))。
  3. AbortError 不在判定列表里——所以即使将来加了中止,这个函数也不会认为"用户主动取消"需要降级。

【代码依据】 src/features/assistant/streamFallback.ts:1-19(全文;:3-4 20 秒首事件超时 + 语义注释、:6-9 为什么可以安全重试、:10-18 判定分流含 流式解析未返回结果)、src/features/assistant/hooks/useAssistantChat.ts:92-93(导入)、:2272-2275(postStream 传 timeout 但不传 signal)、:2276-2279(getReader + TextDecoder)、:2358(if (!final) throw new Error('流式解析未返回结果'))、:2362-2380(parseViaPost,返回只有 intent/reply/options,丢掉 clarification/agentRunId/agentInterrupt/referenceVideoAnalyses)、:2903-2910(降级判定与调用 + 注释)、:2914-2917(resolveCompletedAssistantReply)、src/features/assistant/conversationSafety.ts:4-6("一次性回复非空则用它"的地板语义)、src/shared/lib/apiClient.ts:441-446 与 :470-475(计时器只覆盖到响应头)、src/features/assistant/__tests__/streamFallback.test.ts:9-47(纯函数单测 + 首响应超时被转成 TIMEOUT)、backend/routes/video.ts:5880(只 trackClientDisconnect)、:3474/:3583(画布流真的取消上游,作为对照)、backend/services/httpStreamLifecycle.ts:6-9(为什么 SSE 必须观察响应侧 close)、:31(取消失败要吞掉)。

另外:streamFallback 全仓库只有一个调用点(useAssistantChat.ts:2905)+ 它自己的测试,所以它是单点判定,不是可复用机制。useChatStream.ts 那条老链路不适用它。


五、剧本、分镜、复刻与批量编排 ​

Q15. 参考视频是怎么被分析的?"原版复刻"和"灵活复刻"差在哪? ​

【考点】 多模态理解链路 + 你有没有把"保真"当成一个可执行约束而不是口号。

【口述答案】分析器是 assistantReferenceVideoAnalyzer.js,外面包了一层带缓存的 assistantReferenceVideoAnalysis.js。关键参数都是硬编码的:分析超时 5 分钟、最多 20 帧、最多 40 个镜头、源片最长 300 秒、单镜钳到 0.5~15 秒、关键帧压到 720px 宽、兜底规划时长 60 秒且明确"不当成事实"。抽帧按视频时长均布取桶中点,关键帧 URL 带处理参数压宽——这里有个踩过的坑写在注释里:"原先只做 snapshot,4K/8K 视频可能产出超过上游 10 MiB 单图限制的 JPEG,表现成「参考视频稍大就分析失败」。" 它不只做视觉:ASR 转写和视觉调用并行跑(说话人分离,默认 3 人,可用 DASHSCOPE_ASR_SPEAKER_COUNT 覆盖,设 0 关闭)。ASR 的逐句原文按时窗覆盖每个 shot 的 copywriting,完整文本保留在 transcript 里不截断。说话人归属是三级策略:ASR speaker id 投票到视觉角色名 → 单说话人镜头或数量恰好匹配时按序配对 → 都不行就不加前缀。 模型与参数:优先 config.assistantLlm,没配全就回落 config.llm(默认 ARK / qwen-vl-plus);temperature 0.2、max_tokens 8192,豆包 Seed 系模型带 thinking: {type:'disabled'}。 输出 schema:顶层是 success / styleSummary / characters[] / shots[] / transcript / videoUrlUsed / durationSec / durationSource / meta,shot 字段是 title / duration / description / copywriting / speakers[] / supplement,character 是 {name, description};下游 DTO 再归一成 url / label / durationSec / durationSource / styleSummary / characters / transcript / shots / method / transcriptMethod。durationSource 有可信度枚举 known / probed / clamped / unknown,把"这个时长是探测出来的还是兜底猜的"一路传下去。 校验是手写归一化,没有 JSON-schema 库:剥 <think>/推理标签/代码围栏 → 取首个 { 到末个 };解析不出抛"视频理解模型没有返回可解析的 JSON";零有效镜头抛"没有返回有效分镜"。字段逐个封顶(title 100、description 900、copywriting 500、per-shot speakers ≤6 × ≤8 字、characters ≤8 去重、styleSummary 1200),镜头时长归一化必须先于"过小镜头合并"(注释说是刻意的顺序)。 最关键的诚实点:分析器里没有复刻模式。 customNote 是硬编码且对所有模式完全一样的——所以"原版复刻"和"灵活复刻"在分析阶段产生的 DTO 完全相同,分析器行为不分档。两者的差别只在下游合成编辑操作时体现:

  • 空操作时的兜底不同:原版复刻的兜底是"未指定的画面内容保持不变",灵活复刻的兜底是"AI 自行补齐关联元素";
  • autoDerived 只在 routingChoice === 'flexible-video-remake' 时才被采信。autoDerived 的含义是"AI 替用户补的、用户没点名的关联改动",在计划卡上渲染成虚线框的「AI 补充」卡片,也是唯一可以单独删除的那一类。
  • 可检测的目标类型也不在分析器里,而在 assistantChatService.js:132:product | character | background | clothing | action | atmosphere | subtitle,其中 product/character/background/clothing 是需要参考图的子集,action/atmosphere/subtitle 走"从不要求参考图"的另一套模板。 保真约束最终落在提示词上:standard-remake 的 systemFragment 明写"原片的镜头数量、逐镜时长、景别、机位、运镜、动作节奏、转场与音频关系必须完整保留,禁止降级成一段全新文生视频""只替换用户明确点名的元素,未点名的画面内容一律保持原样,不得自行增删,也不得顺手优化""用户的需求本身有缺口时如实指出缺口并保持原样,不要替用户脑补"。

【备注讲解】 我要主动纠正一个我自己一开始也以为的判断:"保真度不是模型参数,是「改动范围」的定义"。 因为分析器根本不分档,所以两种复刻的差异被收敛成了一句话——原版只改点名的,灵活改点名的加关联的,且补齐项必须单独成条、打标、可删除、最多 3 项。差异收敛成一个可判定条件之后,它才能同时约束提示词、计划卡展示形态和下游合成。

【代码依据】 backend/services/assistantReferenceVideoAnalyzer.js:14-32(超时/帧数/镜数/时长/单镜上下限/720px/兜底 60s)、:78-81(10 MiB 单图限制事故注释)、:101-123(均布抽帧)、:174-250(ASR 覆盖 copywriting + 全文保留)、:225-228("同一句 ASR 原文被切两遍写进两处 → 重复口播 + 台词翻倍撞门禁"事故注释)、:288-350(三级说话人归属)、:352-370(说话人分离参数)、:382-469(过小镜头合并 + 时长归一化)、:471-507(逐字段封顶 + 零镜头抛错)、:499、:503-505(归一化先于合并是刻意的)、:514-517("2 秒对标视频被要求拆 4 镜 → 用户无论怎么改都提交不了"2026-09-11 事故注释)、:560-575(模型与采样参数)、:604-605(无 JSON 抛错)、:609-639(durationSource 可信度)、:671-678(ASR 与视觉并行)、:708-724(返回 schema)、backend/services/assistantReferenceVideoAnalysis.js:9-18(30 分钟缓存 / 50 条 / 并发 2 / 60000 字符预算)、:32-81(稳定 DTO)、:103-106(assertTranscriptAvailable:ASR 不可用就硬失败,不许"提取脚本")、:127-131(customNote 硬编码,所有模式相同)、:132-136(零镜头在 DTO 层再拦一次)、:137-142(转写失败不许进缓存)、backend/services/assistantChatService.js:132-133(目标类型 + 需参考图子集)、:3209-3221(action/atmosphere/subtitle 不强求参考图)、:3322-3336(灵活复刻的空操作兜底)、:3542-3551(autoDerived 只在 flexible 时采信)、:3151-3153(autoDerived 挂载)、backend/services/assistantWorkflowIntent.js:723-761(resolveReplaceModeChoice 四取值 + 原始/照原片/1:1 另走路由模型)、assistantSkillCatalog.ts:202-217、:232-247(两条玩法的 fragment)。


Q16. 原片超过单次编辑上限(30 秒)时长视频复刻怎么做的? ​

【考点】 分段编排 + 进程重启后的行为。注意陷阱:文件名很像"服务",其实是纯函数。

【口述答案】 先纠正一个容易搞错的点:backend/services/assistantChatRemakeLong.ts 不是服务,是一个纯的、无 IO 的装配器(439 行)——它把"参考长视频 + 替换素材"装配成 script + segments 负载,交给既有的 startLongVideoGenerationFromScript。它没有 LLM 调用、没有 Redis、没有内存状态,依赖里只有纯 helper。 为什么要有这条路:因为原片超过单次编辑上限(seedance-2.5 是 30 秒),前端计划卡的确认动作就改走 POST /assistant/chat/remake-long:

  1. 复用分镜预分析(parse-stream 里已经跑过一次,走 30 分钟缓存,所以近乎免费);
  2. 按模型时长边界做有序批次分配:这是一个保序动态规划,评分是五键字典序——先保证不出现 <4 秒的短批,再最少批次,再最均衡。单镜超过上限先等分拆开;如果整镜无法同时满足上下界,就按总时长切批、只拆批次边界镜头,逐镜秒数严格守恒——不补帧、不改速。
  3. 按批次切原片:ffmpeg 强制 720p / H.264 baseline / 30fps / +faststart,并且 -an 剥掉音轨;
  4. 逐段替换提示词后调长视频生成链路;完成后自动拼接并回贴原片整段音轨。 和 tools.ts generate-long 相比有两个有意的简化:替换素材是全局生效(没有逐段勾选),并且保留原片音轨(没有默认配音、不做 TTS 逐字替换)。 真正的运行时和状态在别处——backend/services/longVideo/generator.js。这里我要如实讲三个关键事实:
  • 进度是轮询的:GET /long/status/:taskId,前端每 3 秒轮询一次;连续 8 次失败后提示"进度轮询暂时中断,重新打开本会话会继续查询任务"。
  • 任务状态是进程内 Map(const taskStore = new Map()),进程重启就丢。代码里有一句很直白的注释:"那个只读内存任务,进程重启后 404"。读路径会降级到 video_history + video_tasks 兜底。
  • 有一个 Redis 租约做多进程互斥:shatang:longvideo:lease:${taskId},TTL 90 秒、每 30 秒续期,token 是 pid-Date.now()-rand;SET ... EX ttl NX 抢、EX ttl XX 续、Lua 比较后删除释放。抢不到就抛"长视频任务已由其它进程接管"。另外有一份 shatang:lvtask:${taskId} 快照,TTL 86400(约 24 小时)——但没有任何进程内消费者去恢复它(全仓库只有那一次 redis.set)。
  • Redis 不可用时租约直接跳过,只打一条 warning 说多进程部署必须配 Redis。 这条链路上唯一的硬超时是分析器的 5 分钟;remake-long 这个 POST 本身没有客户端超时(前端 api.post 没传 timeout,而 apiClient 只在传了 timeout 时才装计时器)。

【备注讲解】 这题最有价值的抽象是"保序 + 字典序评分 + 秒数守恒"这条分段原则:我们不追求"每段一样长",而是把约束按优先级排成字典序——先消除最差情况(过短批次),再优化批量数,最后才谈均衡。因为短批不是"不美观",而是会直接撞台词门禁、导致用户提交不了。 第二个值得说的是"租约(lease)"这个手法本身:一个进程声称"我还活着"不算数,要由它持续续期一个会过期的外部信物。心跳会因进程假死而不准,但"信物过期"是一个不会被伪造的事实。 必须主动交底的不足:① shatang:lvtask: 快照写了没人读,所以"崩溃可恢复"只是理论上;② 任务状态在内存 Map 里,这套链路目前不能水平扩展;③ 这条 POST 没有客户端超时,前面还有一段 5 分钟的同步分析等待。

【代码依据】 backend/services/assistantChatRemakeLong.ts:1-13(定位与两条有意简化)、:110(REFERENCE_VIDEO_MIN_SEC = 4)、:121-153(五键字典序评分)、:162-173(超长单镜先等分)、:183-203(DP 分配)、:227-256(按总时长切批 + 只拆边界镜 + 秒数守恒,不补帧不改速)、:273-277(产出 script + segments)、:296-302(空镜头/越界批次抛错)、:333-334(计划时长取原镜秒数之和,不被实测参考片段时长覆盖)、:15-17(依赖里没有 LLM / Redis)、backend/routes/video.ts:4966-4972(端点与注释)、:5005-5013(复用 30 分钟分析缓存)、:5017-5025(建批次 + 取模型上限)、:5099(冻结日志:崩溃可恢复 + 幂等退款基础)、:5115-5124(catch 里按"有没有写成冻结"分流退款)、backend/services/assets/remakeSegmentCutter.js:13、:63-75(ffmpeg 720p/H.264/30fps/-an)、backend/services/longVideo/generator.js:2313(内存 Map)、:2318-2322(Redis 缺失跳过租约 + warning)、:2323-2348(租约:90 秒 TTL / 30 秒续期 / NX 抢 / XX 续 / Lua 释放)、:2327-2330("已由其它进程接管")、:2354-2356(getTask 只读内存)、:2496-2500(shatang:lvtask: 快照 TTL 86400)、:3558-3560(台词门禁的强制提交逃生舱)、:3567(长视频侧同口径门禁)、backend/routes/longVideo.ts:1065("只读内存任务,进程重启后 404")、:1174-1200(状态查询 + 降级到 video_history/video_tasks)、src/features/assistant/hooks/useAssistantChat.ts:746-748(3 秒轮询)、:764(8 次失败后的提示)、:1505-1531(remake-long POST,没传 timeout)、src/shared/lib/apiClient.ts:245-247(只在传 timeout 时才装计时器)。


Q17. 台词容量校验和台词保真分别是什么?为什么前端也要有一份? ​

【考点】 ★ 陷阱题:这是两条不同的规则,在两个完全不同的文件里。 混着答会显得很外行。

【口述答案】先分清两件事,它们不是一回事。第一件:台词容量校验(能不能念得完),实现在前端 src/features/assistant/scriptDialogueValidation.ts。

  • 规则本体在 shared/shot-beat-allocation.json,前后端共用一份 JSON:dialogueMaxCharsPerSecond = 4(给情绪、换气、多角色切换预留空间)、dialogueSpeechCharsPerSecond = 4.5、shotDurationStepSeconds = 0.1、minEngineSeconds = 4、dialogueMinFillRatio = 0.7,以及按模型的单段上限(seedance-2.5 与 vs-2.5 是 30 秒,默认 15 秒)。前端直接 import 这份 JSON,所以数字不可能两端不一致。
  • 校验逻辑 findScriptDialogueCapacityIssues:把脚本切成镜头块,对每块统计发声字数,算出该镜的发声窗口,字数 > 窗口 × 4 就报问题。
  • 这个函数只"报告",不"拦截":它返回 {shotIndex, shotLabel, duration, characterCount, maxCharacters, compressed} 列表;真正的拦截在后端 storyboardNodeService.js,抛的中文是「系统已停止提交,避免模型截断或漏掉尾句。请缩短台词或延长/拆分镜头。」,从提交路由和长视频计划校验两处调用。长视频侧还有个强制提交逃生舱(跳过台词容量这一项检查)。 为什么前端也要有一份,两个原因都是踩过的:
  1. 后端的发声窗口和脚本声明的秒数不一样。 单条直出时后端会把每镜按下单总秒数等比重算,台词预算给的是重算后的窗口。只按声明秒数预检会两头出错——声明 10s 的镜头下单 8s 时实际只分到 4s,前端放行、后端 400;声明 4s 下单 12s 实际分到 6s 时前端却把一个后端本会接受的任务拦死。所以前端复刻了同一套 0.1s 步长的分配算法(allocateShotBeatSeconds:按比例向下取整、保底 1 秒、余数按小数部分发、负数时从最长镜头回收),黄金用例也放在共享 JSON 里(11 组),前端 vitest 和后端 node:test 都循环跑,任何一端漂移先红测试。
  2. 发声字数的口径必须只有一份。 我原来用 parseScriptToIdeaShots().dialogue 取台词,它靠"台词:"标签锚定并贪婪吃到块尾,会把"音效:"这类制作注释算成台词(误杀),又完全提取不到无标签的裸说话人行(漏网)——两个方向都实测复现过。现在改成从镜头块原文按共享规则抽(buildSpokenContext + countBlockSpokenCharacters),并且花名册按整份脚本建:镜头 1 写"台词:(小明急切)小明:快走"、镜头 5 只剩裸行"小明:等等我"时,只看单块会把镜头 5 判成画面,前端少算、后端多算,两端立刻分家。缺秒数时兜底 5s 必须与后端 extractStoryboardShotSeconds(block) || 5 同口径,否则分配权重两端就不一样了。 第二件:台词保真(有没有被改名/合并/丢句),它是剧情模式 system 提示词里的一块规则(assistantChatService.js:2271-2279),不是 scriptDialogueValidation.ts 里的东西。 提示词里的原话我背得下来:「台词保真(线上事故:快问快答类剧情的问答轮次被合并、句子被丢、说话人被改名——格式规则里的"优先压缩台词/共享字数上限"压过了消息内嵌的 ASR 保真提示)。本块优先级高于上面 SCRIPT_SHOT_FORMAT_RULES 的字数与压缩条款。」它检查三件事:① ASR 逐句转写是事实源,按时间顺序每句都要,不许概括/合并/改写/跳过,一问一答占一行;② 名字: 前缀是权威的,不许改名、不许把两个角色合并、不许把画内对白降级成旁白,可以加情绪标注但不得改动台词正文;③ 用户粘贴的 名字:内容 行同样权威。

【备注讲解】 这题的抽象价值在于:"双端校验的危险不在于重复,而在于「看起来一样」。" 两份实现最难查的 bug 是口径差一点点——一个按声明秒数、一个按重算秒数,两边都不报错,只是边界附近的任务时好时坏。我们的解法是把可变量(数字)和可算法(分配)都收敛到共享层:数字放 JSON 两端 import;分配算法逻辑上还是两份,但用共享黄金用例钉住。 诚实地讲这是次优解:理想情况是算法也只有一份(比如后端提供预检接口,前端不自己算),我们受限于前端要离线即时反馈才做了镜像实现。这是已知的技术债,靠测试维持一致。 还有一个我认为很值钱的判断,"用提示词规则去修一个「上一块提示词压过下一块」的问题"——台词被合并/改名,根因不是模型不听话,而是我们自己的格式规则("优先压缩台词")在优先级上压过了 ASR 保真规则。解法不是加强措辞,而是显式声明这一块的优先级高于上面那块。这跟 Q8 的收口句是同一个手法:优先级要用显式声明来钉,不要指望模型的默认倾向。 另外这条规则在机制上还有一层保护:提示词分层预算是"整块丢弃 + 告警",不会出现"半条台词规则"(Q10)。 已知局限也交底:容量门禁有个用户改不动的场景——"台词是原片说的,秒数是原片给的";dialogueMinFillRatio(填充率 0.7)明确不作为提交门禁,因为会杀掉大量存量脚本;同文件里那个旧的计数函数已被标 @deprecated,因为口径和后端门禁不一致。

【代码依据】 shared/shot-beat-allocation.json(rules 全量 + 11 组黄金用例 + dialogueMinFillRatio 明文说明不是门禁)、src/features/assistant/scriptDialogueValidation.ts:1-19(共享 import + 三个同源常量 + :15-16 "后端 clamp 必须同口径")、:21-29(返回值只是 issue 列表)、:31-40(为什么复刻分配算法 + 黄金用例双端跑 + 空数组必须回退声明秒数)、:41-69(allocateShotBeatSeconds;:48-49 "直接 round 会差 1-2 秒")、:71-80(@deprecated 旧计数函数 + 为什么口径不对)、:90-129(findScriptDialogueCapacityIssues;:94-96 从块原文抽;:98-101 花名册按整份脚本建;:103-108 无台词镜头也计入权重 + 5s 兜底同口径)、src/features/assistant/scriptDialogueValidation.test.ts、backend/services/storyboardNodeService.js:589-616(真正的拦截与中文文案)、backend/routes/video.ts:3734 / :3966(提交路由调用点)、backend/services/longVideo/generator.js:3558-3560(强制提交逃生舱)、:3567(长视频计划侧同口径)、backend/services/assistantChatService.js:2271-2279(台词保真提示词块原文,:2271-2274 事故说明 + 优先级声明、:2275-2277 三条检查)、backend/services/assistantPromptComposer.ts:36-43(整块丢弃而非切片,机械层面的保护)、backend/services/__tests__/assistantScriptShotFormat.test.ts:572-591 与 assistantPromptComposer.test.ts:141(引用 ed2ebfb1 的回归测试)。

ed2ebfb1 我核过:commit 主题是「fix(剧情模式): 台词保真规则缺失——ASR/粘贴对白被压缩合并丢句」。assistantPromptComposer.ts:38 原文是"半句规则模型照样会执行,产出一个谁也没写过的约束,属于 ed2ebfb1 那类静默截断事故"。 scriptDialogueValidation.ts 只有前端这一份——没有 shared/ 版本、也没有后端同名文件(我按 basename 和「台词保真」全仓库搜过)。


Q18. 批量生成一批视频是怎么编排的?失败了怎么续跑? ​

【考点】 ★ 陷阱题:系统里有两套批量,答混了就露馅。

【口述答案】 先纠正一件事:我们有两套互相独立的批量,不能混着讲。第一套:首页对话的计划卡批量,实现在 src/features/assistant/batchRunner.ts(注意:不在 assistant-batch/ 目录下)。

  • 编排:用户在计划卡上点"确认生成"后才走到这里,计划阶段完全不碰积分。每条独立走与单条生成完全相同的端点 /video/canvas/seedance-node/create,然后各自轮询。所以计费、真人检测、写 video_history 的行为与单条生成完全一致。
  • 并发:RUN_CONCURRENCY = 3,实现是共享游标的自排空 worker 池(while (cursor < items.length) items[cursor++]),不是 Promise.all 分批。
  • 失败隔离:单条失败只 onPatch(index, {status:'failed'}) 并写友好文案,不中断其余条目,整个 runBatchItems 函数不抛错。
  • 续跑:resumeBatchItems 只挑 item.taskId && !item.videoUrl 的条目,只重新轮询、绝不二次创建供应商任务;轮询窗口过期时保留 taskId,这样另一个打开同一会话的客户端还能继续恢复。超时文案也很实在:"任务仍在后台执行,稍后可在「历史记录」中查看结果"。 第二套:灵感工坊批量,在 src/features/assistant-batch/。
  • 编排方式完全不同:一次 HTTP 提交(POST /video/long/batch-from-script),后端才是编排者——后端 Promise.all 扇出,每条启动一个独立的长视频任务,逐条错误隔离 + 逐条退积分。
  • 它故意没有"内存批次对象":一批就是 N 个独立长视频任务,聚合状态靠 taskId 轮询,进程重启后从 video_history 回退重建。
  • 节流在服务端:按批大小下调单任务的分段并发(>=5 条降 1、>=3 条降 2、否则 3),并且每条错开 2500ms 投递,避免 10 条任务同时冲上游。前端只有一个 5 秒聚合轮询,没有条目都在终态就自己停。
  • 上限 10 条,生成路径两端都拦:前端 BATCH_MAX_ITEMS = 10,后端也校验并返回"一次最多批量生成 10 条"。
  • 续跑:点单条"重新提交"会把 1 条当成一个批次提交到同一个端点(保证计费和分段口径不变),然后把新 taskId 换进 run,但 batchId 不变,这样轮询和完成通知仍然是统一的。条目匹配优先按 taskId、只在找不到时回退按 label——因为 label 是用户可编辑的、可能重名。 状态持久化(指第一套和工坊的前端部分):走手写 localStorage,没有用 Zustand persist。工坊三个键:基线 assistant-batch-base-v1、运行态 assistant-batch-run-v1、工作区草稿 assistant-batch-workspace-v1,外加一个 sessionStorage 来源键。绑定键是 base.createdAt——草稿或运行态如果绑定的是另一份基线,就判过期丢弃;patchBatchBase 刻意保留 createdAt 不被重置。运行态和本地项目列表都有 7 天 TTL(读时惰性清理),并且本地留最近 30 批,用来扛"提交成功但服务端没落库"。

【备注讲解】 这题最有价值的是主动指出"两套批量"并说清本质差异: "第一套是前端跑的后台任务,第二套是后端跑的后台任务。" 第一套依赖浏览器标签页活着(所以状态放模块级 store,跳页不丢,关掉只剩 taskId 续查);第二套提交即脱手(进程重启都能恢复,代价是没有前端实时进度、只能聚合轮询)。 我会主动交底看法:""一次 HTTP 提交 + 服务端扇出"是更耐用的形态,因为状态在服务端;前端逐条提交更灵活(可逐条编辑/重试),但可靠性依赖浏览器。 如果重做,首页批量也应该走服务端扇出,前端只负责展示。" base.createdAt 这个绑定键值得单独说,它是"用一个不可变字段当版本锚"的通用手法:不用给草稿再发明 version 号,用一个语义上本来就唯一且不该变的值当身份就够了。前提是所有更新路径都必须保护它不被覆盖——所以 patchBatchBase 那个注释是必须的,"不加注释,下一个人『优化』成 saveBatchBase 就悄悄坏了"。 要主动交底的缺口:工坊的 Excel 导入路径不受生成路径那个 10 条上限约束(两套上限没打通);批量计划和 Excel 导入那几条路由没有登录校验、也没有按用户配额,只有通用 IP 限流(60 次/分钟)。

【代码依据】 src/features/assistant/batchRunner.ts:19(RUN_CONCURRENCY = 3)、:3-9(计划阶段不碰积分 + 计费/历史与单条一致 + 状态由模块级 store 负责)、:49-111(runOne:单条提交 + 轮询 + 超时文案)、:113-140(自排空 worker 池 + 单条 catch 不抛)、:142-151 与 :185(resumeBatchItems 只轮询不重建 + 保留 taskId 的英文注释)、backend/routes/longVideo.ts:798(批量端点)、:804-810(MAX_BATCH_ITEMS = 10 → 400)、:851-879(整批前置扣费)、:882(按批大小下调分段并发)、:894-896 / :923(2500ms 错开)、:899-934(Promise.all 扇出 + 逐条隔离 + 逐条退款)、:772-777(无内存批次对象 + 用 video_history 重建)、:1108(批量状态查询)、src/features/assistant-batch/hooks/useAssistantBatch.ts:233-242(一次 HTTP 提交)、:23-24 / :156-169(5 秒聚合轮询 + 终态自停)、:285-341(单条重提为 1 条批次、batchId 不变)、:312-320(优先按 taskId 匹配 + 为什么)、:221-231(ETA 按观测吞吐算,不用 run.createdAt)、src/features/assistant-batch/types.ts:116(BATCH_MAX_ITEMS = 10)、src/features/assistant-batch/batchDraft.ts:11-16(三个 localStorage 键 + session 键)、:64-67(patchBatchBase 必须保留 createdAt + 为什么)、:106-107 / :128-134(基线不匹配判过期)、:156-180(7 天 TTL 惰性清理)、src/features/assistant-batch/batchProjectsLocal.ts:1-14 / :22 / :136-150(本地留最近 30 批扛服务端写失败)、src/features/assistant-batch/AssistantBatchWorkspace.tsx:190-221(带 batchId 挂载时重建并 restoreRun)、src/features/assistant/runnerState.ts:1-11(它不是批量状态机,是 /assistant/run 页的快照工具,三层恢复优先级)。

三处路径纠正,都是简报里写错的:batchRunner.ts / runnerState.ts / scriptLibraryExport.ts 都在 src/features/assistant/ 下,不在 src/features/assistant-batch/。


Q19. Excel 剧本导入导出是怎么做的?粘贴脚本的 AI 识别呢? ​

【考点】 ★ 也是陷阱题:exceljs 不在你以为是后端那个文件里。

【口述答案】 先澄清:Excel 的读写全在前端用 exceljs 做(src/features/assistant-batch/scriptImport.ts:16),而后端那个 assistantScriptImport.ts 不是 Excel 导入,它是"粘贴脚本文本 → AI 识别镜头",走 POST /assistant/script/import-parse。两件事完全不同。 Excel 导入(前端):

  • 表头识别是正则 + 顺序敏感的。四类列各有中英文候选:镜头名 核心埋点|埋点|镜头名|分镜名|名称|标题|标签|备注|说明|要点|卖点、台词 台词|文案|口播|旁白|配音、时长 时长|时间|duration|秒数、画面 画面|内容|分镜|视觉|音效|场景描述|镜头|脚本。
  • "匹配器的顺序即优先级"这点很要命,注释里记了实测 bug:一列只归属第一个命中的用途,「镜头名称」必须先被 note 接住,否则会被 vis 的「镜头」抢走,真正的「画面」列因为槽位被占而整列丢失。还有一条反向的:内容里带「画面」「台词」字样的数据行会被误认成表头,把区块切得稀碎。
  • 时长列识别两种"方言":累积时间轴(0-4s/4-10s → 用 end 减去上一段 end)和独立值/区间(取上界)。靠"相邻性 ≥60%"判断是哪种。
  • 坏行是跳过、不是整文件拒绝:空行和无画面无台词的行(小计、分隔行)跳过;时长列缺失时故意不写 (Ns),让下游 AI 自己估(注释写明了)。
  • 顺序上我把 AI 从"先跑"改成"最后兜底",注释原文:"顺序很关键(原来是反的,AI 先跑,本地兜底)"。一个能被列完整解析的表,整个导入过程零网络请求;解析不出表头才回落到 AI 语义切分。 Excel 导出(前端,exportVariantScripts.ts):纯前端 ExcelJS.writeBuffer() + Blob + objectURL 下载,一个批次一个 .xlsx、一个变体一个 worksheet、一镜一行,表头是 序号 / 镜头名 / 时长(秒) / 画面 / 台词,首行冻结。往返锚点:第 2 行嵌一个 变体编号(导入用·请勿修改),导入时全表扫描 /\bbv-[0-9a-z]+-[0-9a-z]+(?:-\d+)?\b/i 把它捞回来——所以商家自己插行、改 sheet 名、重排列都不会错位。后端没有对应的 Excel 导出端点(我搜过 xlsx/export/download 相关路由,只有两个无关的)。 粘贴脚本 AI 识别(后端 assistantScriptImport.ts):它只解析、不落库、不扣积分——限流是这条链路唯一的成本闸。它最核心的机制是 snapToSource:把模型输出只当作"指向原文的指针",最终写进脚本格的字符串永远是从原文里切出来的。匹配走两级:先去空白紧凑匹配,再退化到"只留字母数字和汉字"的骨架匹配,带标点回扩(≤3 字),并且每个字段用单调前进的游标,避免重复行回吸到第一次出现的位置。输入上限 40000 字符(超了是拒绝不是截断,因为截断会丢镜头),按 6000 字符分块、最多 8 块、并发调用后按序号还原顺序;temperature: 0、max_tokens: 8000。镜头时长清洗后硬顶 60 秒。"旁白/口播"刻意不在字段标签表里——因为它们是说话人,不是标签。 降级路径要分清:assistantScriptImport.ts 自己内部没有正则兜底,失败就抛("大模型没能从这段文本里认出镜头")。真正的兜底在前端 scriptImportApi.ts:后端故意返回 HTTP 200 + success:false,让前端在抛错和业务失败两种情况下都回落到本地正则(normalizeStoryScriptFormat → parseScriptToIdeaShots),并返回 source:'regex' + fallbackReason。本地解析出的镜头 dialogueVerbatim: true(因为没有模型碰过它),如果一个镜头都没解析出来,就把整段文本当成一个 5s 镜头返回,而不是返回空表。

【备注讲解】snapToSource 是这题最值得讲的点,抽象出来是一条很硬的原则: "凡是要把 LLM 的输出写进结构化字段的地方,尽量让模型只输出「选择」而不是「内容」。" 模型给出的片段可能被改写一个字,但只要我用它去原文切片,用户看到的就一定是自己粘进来的原文——幻觉在结构层被消灭了,而不是靠提示词祈祷它别瞎编。 第二处值得说的是"能确定性解析的就别调 AI"。我把 AI 从第一道改成最后一道兜底,换来的是绝大多数导入零网络请求——既快又不会因为模型抽风而整体失败。这是"把 AI 用在只有 AI 能做的地方"的具体落地。 还有两处出声的降级(和 Q10 的纪律一致):台词没能逐字匹配上的镜头标 dialogueVerbatim = false 并计入中文 warning;缺时长的镜头标 durationFromSource = false、默认 5 秒并告警。不静默。 最后一个我喜欢的细节:"这条链路不扣积分,所以限流是它唯一的成本闸"——这是一个很清醒的成本意识表达。

【代码依据】 src/features/assistant-batch/scriptImport.ts:16(import ExcelJS)、:153-155("匹配器的顺序即优先级" + 「镜头名称」必须被 note 先接住 + 实测整列丢失)、:182-183(数据行含「画面/台词」被误判成表头)、:158-164(四类表头正则)、:203-245(时长两种方言 + ≥60% 相邻判定)、:288-300(重建镜头块;:292 label 截 14 字;:294-296 缺时长故意不写秒数)、:334 / :345(空行与小计行跳过)、:384-394("顺序很关键(原来是反的,AI 先跑,本地兜底)")、:398-399(列解析成功则零网络请求)、:466-483(.xlsx/.xls vs 纯文本分支)、src/features/assistant-batch/exportVariantScripts.ts:11(ExcelJS)、:27-29(变体编号行 + 表头)、:54-63(嵌入 id)、:66-67(冻结首行)、:101-103(文件名截 40 字)、:112-137(writeBuffer + Blob 下载)、src/features/assistant-batch/importVariantScripts.ts:14 / :34-45(全表扫描捞变体 id,容忍插入行/改名/重排)、backend/services/assistantScriptImport.ts:4-7(为什么存在:"正则只认得两种写法:虚线分区、【镜头N】标题……正则一律判成「单镜」,整段脚本被塞进一个镜头 —— 这就是「识别不好」的全部原因")、:9-12 / :186-221(snapToSource 两级匹配 + 单调游标)、:110-130 / :145-169(紧凑/骨架匹配 + 标点回扩 ≤3 字)、:132-138("旁白/口播"刻意不在标签表 + 为什么)、:24-29(40000 上限 / 6000 分块 / ≤8 块)、:80-82(temperature: 0、max_tokens: 8000)、:21-22(SCRIPT_IMPORT_MODEL || config.llm.model)、:263-273(ParsedImportShot)、:269-270 / :357-361(dialogueVerbatim 标记 + warning)、:275 / :362-365(缺时长标 durationFromSource + 默认 5s + warning)、:277-282(时长硬顶 60s)、:322-324 / :353-355(内部无正则兜底,直接抛)、src/features/assistant/scriptImportApi.ts:62-73(parseScriptLocally:正则兜底 + dialogueVerbatim:true + 5s 兜底)、:74-83(零镜头时返回整段为一个 5s 镜头而不是空表)、:86-119(抛错和业务失败都回落 + source:'regex' + fallbackReason)、backend/routes/assistant.ts:71-77(只解析不落库不扣积分 + 为什么用 200 包业务失败)、:86(consumeUserActionQuota('assistant-script-import'))。

全仓库 grep "exceljs" 命中:package.json:37 与 src/features/assistant-batch/{scriptImport,exportVariantScripts,importVariantScripts}.ts、src/features/assistant/scriptWorkbookExport.ts:1、src/features/batch-production/*。后端 assistantScriptImport.ts 里零命中。


六、Agent 运行记录、eval 与边界 ​

Q20. Agent 运行记录是怎么设计的?一次运行怎么被记录和回放? ​

【考点】 可观测性与可恢复性。这是"AI 应用工程化"最硬的加分项。

【口述答案】数据模型是 5 张 PostgreSQL 表(一个 migration 文件建完):agent_runs(一次运行)、agent_run_steps(步骤)、agent_run_events(事件流)、agent_interrupts(人机确认点)、agent_run_artifacts(产物),子表全部 ON DELETE CASCADE。migration 开头写着它的定位:"权威执行状态在这里,不在浏览器快照里"。 状态机:run 有 5 个状态(running / waiting_input / succeeded / failed / cancelled),step 有 7 个(多 pending 和 skipped),外加提交态三值 idle / claimed / submitted,错误分类 6 类(temporary / user_fixable / permission / safety / permanent / cancelled)。 每步记什么:身份与顺序(step_key / ordinal / kind / title)、生命周期(status / attempt / started_at / completed_at)、经济副作用的幂等守卫(idempotency_key / submission_state / submission_claimed_at)、供应商挂载(task_id / credit_ref),以及完整的 input_snapshot / output_snapshot JSONB 和错误三元组。表上还有 UNIQUE(run_id, step_key) 和 UNIQUE(run_id, idempotency_key)。 三个我很满意的工程细节:

  1. 事件序号是数据库自增的,不是应用计数:UPDATE agent_runs SET last_event_seq = last_event_seq + 1 ... RETURNING 再插事件,在同一个事务里。所以多实例、多进程并发都不会重号或漏号。
  2. 归属校验在每个写函数里:所有变更函数都要传 userKey,靠 SELECT ... FOR UPDATE 锁 (id, user_key)。拿到别人的 runId 也改不了。
  3. 回放是 seq 游标,不是进程内订阅:POST /assistant/runs/:id/events/stream 读 Last-Event-ID 头或请求体的 lastEventSeq,按 seq 取最多 500 条事件推下去,没有新事件就写 : ping 心跳,一个连接最多活 25 秒,前端按 seq 重连。注释原话:"DB 是真源;此短连接会轮询最多 25 秒。客户端按 seq 重连,因此多实例、进程重启和网络抖动都不会丢事件,也不会依赖单进程 EventEmitter。" 前端是步骤时间线卡片(AgentStepTimeline):每步显示标题、outputSnapshot.summary、可展开的 preview、错误分类中文、taskId;当 submission_state === 'claimed' 时显示"正在提交;不会重复扣费或重复创建任务"的安抚文案。但要说清楚:前端不是回放器——事件流只被当作失效触发器(推进 lastEventSeq 后 invalidateQueries),渲染始终来自 run 详情查询,而不是从事件 payload 重放。

【备注讲解】 我最想讲的是它同时是"观测"和"钱的幂等闸门"这两件事: /video/canvas/seedance-node/create 强制要求带 agentRunId + agentStepId,缺了 400;服务端先 claim 提交权,认领不到返 409 AGENT_SUBMISSION_UNCERTAIN;已有 task_id 就返回 { idempotent: true }。所以前端重试、网络抖动、用户连点,都不可能重复建任务。 这里有一条隐藏的设计判断值得点出来:"我们让「不确定」也成为一种显式状态。" 提交到一半连接断了,我们不知道任务建没建成——这时候既不能当成功(漏任务)也不能当失败(重复扣费),所以给 409 让上游去查。"不确定"必须有地方安放,否则一定会被某个 catch 分支静默处理成两个错答案之一。 还有一条我自己踩出来的:recordAgentRunStepSubmission 里有一段"复活失败的步骤"逻辑。注释原文:"任务已成功提交是比旧 status 更新的事实:上次提交失败把 step 置成 failed 后,用户重试成功时会走到这里。若不复活,前端随后的 running/succeeded 同步会被 transitionAgentRunStep 的 terminal 校验拒绝("Agent run step is already terminal"),台账永远停在 failed 而任务实际在跑。" 本质是"终态不可变"和"外部事实可以推翻本地状态"这对矛盾,解法是允许一次由外部事实触发的、受控的终态复活。 取消是分情况的:requestAgentRunCancellation 先数 task_id IS NOT NULL 和 submission_state = 'claimed',只有两个都为 0 才硬取消,否则返回 submitted_tasks_must_be_cancelled_by_provider / provider_submission_in_flight。因为已经提交给供应商的任务,取消必须走供应商,不能只改我们的台账。 还有一个为"浏览器不在场"准备的设计:addAgentRunArtifactForTask 只按 taskId 回填产物、不信任何客户端传的 runId,并按 (run_id, step_id, url) 去重——这样生成任务在后台完成后,即使浏览器早就关了,产物也能挂回正确的步骤。

【代码依据】 backend/migrations/20260815120000_agent_runs.up.sql:1-3(定位注释)、:4-19(agent_runs 列 + 5 状态 CHECK)、:21-25((user_key, conversation_id) 与活跃 run 的部分索引)、:27-56(steps:两个 UNIQUE + 三个 CHECK)、:64-75(events:PK (run_id, seq) + idx_agent_run_events_replay)、:77-96(interrupts:5 状态 + UNIQUE(run_id, id))、:98-111(artifacts + trace 索引)、:113-134(三个 updated_at 触发器)、backend/services/agentRunStore.ts:4-6(状态联合类型)、:95-96(终态集合)、:199-222(事务内自增 seq)、:224-231(FOR UPDATE 归属校验)、:233-253(create / ensure step)、:287-386(claim / record / release 提交权)、:327-343(终态复活 + 事故注释)、:387-467(transitionAgentRunStep)、:469-527(complete / createInterrupt)、:571-619(按"有没有花钱"分流的取消 + 两种拒绝原因)、:624-687(addAgentRunArtifact / addAgentRunArtifactForTask 只信 taskId + 三元组去重)、:689-721(getAgentRunDetail / listAgentRunEvents seq 游标 / isTerminalAgentRun)、backend/routes/video.ts:3241-3250(鉴权:req.authTrusted + 非 guest,否则 401;无限流)、:3256-3267(GET run 详情)、:3269-3311(事件 SSE:Last-Event-ID 或 body 游标、500 条/页、: ping、25 秒短连接、retry: 1200)、:3313-3438(steps / complete / interrupt resolve / cancel / artifacts 五组路由)、:4055-4077(生成端点强制 runId+stepId、{idempotent:true}、409)、:4093 / :4139(失败 release / 成功 record)、src/features/agent-run/components/AgentRunCard.tsx:12-21(5 状态中文标签,含 waiting_input 特判"等待确认生成")、AgentStepTimeline.tsx:21-31(错误分类中文)、:41-68(摘要/preview/taskId/claimed 安抚文案)、src/features/agent-run/hooks/useAgentRunEventStream.ts:36-48(事件只用于 invalidateQueries,不用于渲染)、src/features/agent-run/api.ts:25-35(running|waiting_input 时 2500ms 轮询)。


Q21. 意图分类做过 eval 吗?你怎么保证改了词表不会把老 case 改坏? ​

【考点】 "你有没有把 LLM 系统当软件工程做"。这题答得好非常加分。

【口述答案】 有,我做了一个 205 条用例的意图 eval 数据集,落在 backend/evals/agent-intents.v1.json,覆盖 11 个类别:video_edit 54 条、video 29 条、clarification 21 条、script 18 条、chat 16 条、material_workflow 14 条、safety_refusal 13 条、image 10 条、batch 10 条、long_video 10 条、permission 10 条。 关键点:这个数据集不是只校验形状,它真的执行确定性分类器。 每条用例声明期望值(action、replacementTarget、replacementTargets、remakeRoute、aspectRatioMentioned、replacementDescription、fastRoute),测试断言实际调用真实函数的结果与声明一致:

  • 声明了 replacementTarget 的用例(≥20 条)必须与 inferReferenceReplacementTarget 一致;
  • 声明了 replacementTargets 的(≥3 条,且强制至少有一条真·多目标)必须与 inferReferenceReplacementTargets 一致;
  • 声明了 remakeRoute 的必须与 resolveReferenceRemakeRoute 一致,并且 ask / original / flexible / script / generate 五条路由都必须有覆盖。 为什么加"强制多目标"和"五条路由全覆盖"这两个约束——这是 eval 最容易犯的错,注释原文说得很清楚:"数据集这一层过去只校验形状,从不执行——「换背景被判成换模特」这类 target 判错因此在 eval 里毫无落点";以及"只放单目标用例的话,这层 eval 对「只换了其中之一」完全失明"、"只堆 ask 用例的话,script/generate/original 分支回归时会全部失明"。所以我不只断言"有覆盖",而是断言覆盖的形状。 这套 eval 抓到过真实事故。注释里记了一条:2026-08-26 线上,用户说"分析架构和剧本+做相同的复刻",因为词表漏了「剧本」这个词,被判成裸复刻直进 video_edit,兜底指令"按原版复刻保持不变"烧掉了一次全片积分。修法是补词表 + 在这层 eval 里钉住。

【备注讲解】 这题的重点在两个抽象: 第一,"三层必须一起动"。 设计文档定的规矩是:改了词表(CategoryPack.aliases 或意图词表),就要同步改提示词和 eval fixture。 只改词表不改 eval,下次就会有人再把词改回去;只改提示词不改词表,模型分类和确定性路由会打架。 第二,"eval 的价值在于断言形状,不只是断言结果"。 一个只有 100 条 chat 用例的数据集,"覆盖率 100%"毫无意义。所以我加了分布约束(每个类别必须存在)和特征约束(必须有多目标、必须有五条路由)。这是把"我测过了"变成"我能证明我测到了"。必须主动交底这套 eval 的边界:

  • 它只覆盖确定性分类器(纯函数),不覆盖 LLM 的实际输出——所以它是回归网,不是评测榜。LLM 分类质量的离线打分机制我未核实是否存在。
  • 它不跑真实模型调用,所以改提示词本身不会被它拦住。
  • 它不从 Agent 台账回收 case——agent_runs 里其实躺着最真实的线上输入分布,但我们没有任何离线消费它的流程。这是我下一步最想做的:从台账里捞线上真实 message 回灌 eval 集,让数据集跟着真实流量长。 另外设计文档已经定了一个最小覆盖矩阵:3 品类(美妆/男装/女装)× 3 玩法(口播/多人剧情/开箱)= 9 组,每组断言 action/workflow/mode 正确、品类禁忌被遵守(关键词断言)、台词保真不回退;还要求一条回归项:"未选 Skill / 品类未识别时,输出必须与改造前逐字一致"。而且文档记了一个 eval 真实抓到的词表缺口:「洗发水」原先不命中美妆包,后来补齐了洗发/沐浴/防晒等别名并把别名上限放宽到 40。

【代码依据】 backend/evals/agent-intents.v1.json(version 1,205 条,11 类)、backend/services/__tests__/agentIntentEvalDataset.test.ts:7-35(EvalCase 类型与加载)、:38-49(≥100 条 + 11 类必须都存在 + id 唯一)、:51-66(replacementTarget 真跑 + "数据集这一层过去只校验形状,从不执行"的注释)、:68-82(replacementTargets ≥3 且强制含多目标 + "只放单目标用例会失明")、:84-100(remakeRoute 五条路由全覆盖 + 2026-08-26 事故注释)、backend/services/assistantWorkflowIntent.js:723(resolveReplaceModeChoice)、:749(resolveReferenceRemakeRoute)、:564(inferReferenceReplacementTarget)、:673(inferReferenceReplacementTargets)、docs/architecture/assistant-skill-prompt-layering.md:127-141(第九节:三层一起动 + 最小覆盖矩阵 + 回归项 + options.fetchImpl 注入的测试注意事项)、:189(eval 抓到词表缺口)。

205 条和各类别数量是我用脚本解析 JSON 数出来的。


Q22. 这条链路现在有哪些明确的问题?如果重做你会怎么改? ​

【考点】 字节必问的自我批判题。准备好的不足比准备好的亮点更有杀伤力。

【口述答案】(挑 3~4 条说,不要全倒) 1. 名不副实的"Agent"。 我们有 Agent 的账本,但没有 Agent 的控制流——没有工具循环、没有 max_steps、没有 Agent 级超时、没有 token 预算硬闸、没有模型自主的重试决策。现状是"一次分类 + 一张固定工作流图 + 持久台账"。要么把名字改回"工作流",要么真的把工具循环做出来,不要停在"看起来像 Agent"的中间状态。 2. 主聊天流是唯一一条"什么都不防"的流。 我把它和同仓库其他流比对过:Agent 事件流有 Last-Event-ID 续传 + retry: 1200 + : ping;批量计划和批量改脚本流有 15 秒心跳(注释写了原因:"规划阶段可能安静数十秒零字节输出,nginx 默认 60s 无数据就掐连接")。而 parse-stream 既没有心跳,也没有读阶段超时,还从不中止上游——它只 trackClientDisconnect 记了个标志位,从没调 onClientDisconnect,也没把 signal 传进 streamChatIntent。后果有两个:首事件之后流卡住会让用户无限转圈(20 秒超时只覆盖到响应头);用户关掉标签页,上游 LLM 会继续跑完并继续计费。对照组的画布流是真的取消上游的。 3. preferredWorkflow 是死字段。 玩法包里声明了 preferredWorkflow: 'story' | 'reference_video_remake' | 'script_to_video',但运行时没有任何消费方——我全仓库 grep 过,只有 catalog 定义、持久化解析和测试断言,没有一处读它来影响 resolveSystemPrompt 的裁决。设计文档写的是"尚未接进裁决",到现在仍然如此。现在"多人剧情"玩法会附带一段 story 提示词,但不会真的把链路切到 story 模式——这正是设计文档警告过的 75fcb974 那类"会话内切换模式后走错 create 链路"的坑。 4. 台账没有保留策略、回放没有测试、命名还会骗人。 agent_run_events 每步迁移写一行、payload 里还塞完整 step/run 对象,没有任何 DELETE / TTL / 分区——只有 store、migration、test 三个文件引用它,一直在涨(同仓库其他子系统都有清理任务,所以这不是"我们的风格如此")。回放逻辑一行测试都没有:agentRunStore.test.ts 只有 3 个用例(行映射、migration 含 5 张表、一个 $3::varchar 类型推断回归),没有一条真的跑 listAgentRunEvents 的游标,因为 test 导出里根本没暴露 appendEventTx / listAgentRunEvents。 5. 缺口清单(一并交底,都很具体): 没有任何生产者,那个模板变量是死的(引用它的行会被整行删掉——至少是出声的,不是静默);storyWorkflow.ts 名字像状态机,其实是纯 helper(真正的状态就是那段脚本文本,落在会话快照的 chatStoryScript 里);appliesTo 只告警不剔除;品类包没有管理端 CRUD;GET /public/assistant-category-packs 端点有了但前端未消费;批量计划 / Excel 导入这几条路由没有登录校验也没有按用户配额(只有通用 IP 限流 60 次/分钟),而全仓库 consumeUserActionQuota 只有两处调用点;工坊 Excel 导入不受生成路径那个 10 条上限约束。

如果重做,我会改四处: 第一,把"意图分类"和"工作流"显式分成两个概念——工作流的每一条都写成状态机的边(action × 前置条件 → 目标步骤),用一个函数统一裁决,而不是散在 normalizeMeta、switchAssistantWorkflowTrace、路由 handler 三处。 第二,把 Agent 台账做成真正的资料来源:加保留策略(按 run 完成时间分区或 TTL),并且从台账回灌 eval 集——最真实的输入分布躺在数据库里没人用,这是最容易拿到的收益。 第三,工具调用要么不做,要么做彻底:如果要做,就把 action 分派改成 provider function calling + 显式 max_steps + 每步 token 预算,并且把所有"工具"的副作用统一收口到那个已有的提交闸门上(这个闸门是最值钱的既有资产)。 第四,把流的基础设施补齐并收敛成一条链路:给 parse-stream 加心跳、读阶段超时、断连中止上游;把老透传链路标 deprecated 只保留纯问答,不再往里加功能。

【备注讲解】但有一点我会保留:"先建台账、再调模型"的顺序,和"提交生成任务必须带 agentRunId + agentStepId"这个幂等闸门。 前者保证任何一轮对话都可追溯,后者保证任何重试都不会重复扣费。这两条是我用事故换来的不变量,重构不能动。 交底的三条纪律:① 每条不足都要能说出"为什么当初这么做"(不是懒,是有历史约束)② 每条都要有"要改怎么改" ③ 不要交底那些"改起来没意义"的不足。另外不要一次说完——留两三条给面试官"挖出来",让他挖到一两条你准备好的,比你把缺点全倒完更真实。

【代码依据】 全仓库 grep -rn "preferredWorkflow" → 只有 assistantSkillCatalog.ts:36/95/201/231/453-455(定义与持久化)和 assistantSkillCatalog.test.ts:73/175(测试断言),运行时零消费;全仓库 grep -rln "tool_calls\|function_call" backend/ → 无命中;grep -rn "agent_runs" backend/(含 delete/cleanup/retention/prune)→ 只有 store、migration、test,无保留策略;backend/services/__tests__/agentRunStore.test.ts:7/18-27/29-37(3 个用例,均非回放行为)、backend/services/agentRunStore.ts:723-728(test 导出不含 appendTx / listAgentRunEvents);心跳对照:backend/routes/video.ts:6212-6220(批量计划 15 秒心跳 + nginx 60s 原因注释)、:6457-6460(批量改脚本心跳)、:3301(Agent 事件流 : ping)、:5824-6173 范围内无 setInterval 无 : ping;:5880(只 trackClientDisconnect)对照 :3474 / :3583(画布流真的 onClientDisconnect 取消上游);grep "drain\|writableNeedDrain\|highWaterMark" 在 video.ts/assistant.ts/assistantChatService.js/httpStreamLifecycle.ts → 零命中(无背压处理);backend/middleware/rateLimiter.ts:26(通用规则 60 次/分钟)、:37-52(两个按用户配额的动作)、:83-111(Redis INCR+PEXPIRE,fail-open);全仓库 consumeUserActionQuota 仅 backend/routes/assistant.ts:86 与 backend/routes/scriptWriter.ts:631 两处;backend/routes/video.ts:6180 / :6328 / :6343 / :6360(批量计划与 Excel 相关路由无鉴权无线程配额);backend/services/assistantPromptComposer.ts:273(brandTone 唯一出现处)、:194-203(appliesTo 只告警);src/features/assistant/storyWorkflow.ts:1-13(不是状态机,是纯 helper);docs/architecture/assistant-skill-prompt-layering.md:234-239(P1 待办清单)、:112(75fcb974 那个"会话内切换模式后走错 create 链路"的坑)、:230(appliesTo 不剔除的理由)。


口述速记版(60~90 秒,突击复习用) ​

首页 AI 对话这条链路,本质是把自然语言变成付费视频任务的编排层。 一次请求:进 /api/video/assistant/chat/parse-stream → 路由先按 id 从 settings 解析出服务端权威的品类包和玩法包(客户端只能传 id,不能传提示词文本,否则等于把 system 提示词注入接口开给所有人)→ 先建 Agent 台账再调模型(任何一轮都可追溯,台账挂了也不挡主链路)→ 两级意图(正则确定性路由 → 快速文本路由 256 token、不发图不抽帧 → 正式多模态解析)。 提示词分五层:L0 平台底座不可运营、L1 模式层四选一、L2 品类包、L3 玩法包、L4 会话层。L0 永远压过 L2/L3,靠两道防线:入库时剥离 忽略以上/ignore previous 这类越权句式(7 条正则),拼装时在末尾加一句显式收口。优先级用物理顺序表达,不指望模型理解。token 预算按字符记账(品类 800/400、玩法 600/800、各最多 3 段),超限一律「整段丢弃 + 告警」,绝不 slice——"半句规则模型照样会执行",那是 ed2ebfb1 那类台词丢句事故。数量上限导致的丢弃同样要出声,这是最容易漏的一处。 流式是手写 SSE 双层:上游 token 自己按 data: 行解析,finish_reason=length 当截断信号触发续写,还要兼容"网关返回累积快照而不是增量";下游重新包装成 meta / delta / script_delta / done / agent_run 事件。三段式状态机 meta → reply → prompt,首行 JSON 坏或超 4000 字符就转 degraded 全量缓冲。已发出的内容永不回滚,降级只影响后面怎么发。streamFallback 只有 19 行,是个纯函数:判 404/405/超时/网络错误/"流式解析未返回结果"才回落到一次性端点,业务 4xx 不重试。能安全重试的唯一前提是意图解析不花钱、不建任务。Agent 台账 5 张表,run 5 状态、step 7 状态 + idle/claimed/submitted 三提交态,事件 seq 由数据库事务内自增,前端按 Last-Event-ID 补齐、25 秒短连接。它同时是钱的幂等闸门:提交生成任务必须带 agentRunId + agentStepId,认领不到就 409 AGENT_SUBMISSION_UNCERTAIN——"不确定"必须显式安放,否则一定被某个 catch 静默处理成两个错答案之一。 台词有两条不同的规则:容量校验(能不能念完,前端 scriptDialogueValidation.ts 只报告、后端 storyboardNodeService 才拦截,数字共用 shared/shot-beat-allocation.json,每秒 4 字)和台词保真(有没有被改名/合并/丢句,是剧情模式提示词里的一块规则,优先级显式压过格式规则)。 我如实交底:这是**"意图分类 + 固定工作流 + 持久台账",不是自主 Agent**——没有 tool calling(全仓库零命中)、没有工具循环、没有 max_steps、没有 Agent 级超时、没有 token 预算硬闸;preferredWorkflow 是死字段; 没有生产者;台账没有保留策略、回放没有测试;主聊天流是唯一一条没有心跳、没有读超时、关页不中止上游的流。 但有两件东西我保留:先建台账再调模型,和提交必须带幂等键。这是用事故换来的。

持续学习,持续构建。