模特上身图提示词重构 + 图片创作页 UI 调整
后端(模特上身图提示词): - build_model_tryon_prompt_refs 重写:穿戴/非穿戴分流(穿戴=真实穿身替换原衣, 非穿戴=手持/佩戴/使用不动原衣)、每张按 index 变化动作/场景/镜头、负面词尾接、 多图参考序号自适应(参考图1~N=商品,参考图N+1=模特) - 新增 _product_reference_urls:商品参考图真实上传图优先、排除 AI 生成图、可多张(≤3), 无真实图回落 cover - worker run_standalone_image_task 模特分支改用多图取图 + 传 index/n_product 前端(图片创作/工作室): - 生成数量改 1/2/4;图片比例新增「手动输入」(宽:高 两输入框) - 临时隐藏「商品库」按钮 - 模特卡:去掉 // 真人模特,标题改图片底部白字遮罩层 + 单行省略 - 工作室壳负边距对齐 .content padding,修复上下被遮挡/裁切 其他:并入此前未提交的商品页改动、脚本 Agent/格式实测文档与 demo
@@ -0,0 +1,87 @@
|
||||
# 故事板「画风锚点」对比 demo
|
||||
|
||||
> 探针:[`backend/storyboard_style_demo.py`](../../backend/storyboard_style_demo.py) · 图像模型:yunqi/gpt-image-2 · 纯文生图(隔离"锚点"单一变量)
|
||||
> 同一组保温杯 4 分镜,各出两版:**无锚点(现状)** vs **有锚点(锁画风)**。
|
||||
|
||||
---
|
||||
|
||||
## 一、什么是「画风锚点」
|
||||
|
||||
就是把一段**固定的、逐帧逐字相同的风格规格**注进每一帧的提示词,把模型本来自由发挥的画风焊死。本 demo 用的锚点:
|
||||
|
||||
```
|
||||
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
|
||||
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
|
||||
· 布光:统一柔和影棚顺光,同一色温(暖白);
|
||||
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
|
||||
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
|
||||
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
|
||||
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
|
||||
```
|
||||
|
||||
关键:**这段每一帧都一模一样地拼进去**,模型就被反复约束到同一套风格上,不会这帧写实、那帧插画。
|
||||
|
||||
---
|
||||
|
||||
## 二、对比结果
|
||||
|
||||
### 无锚点(现状)—— 连"产出物形态"都在漂
|
||||
|
||||
| 帧 | 实际产出 |
|
||||
| -- | -------- |
|
||||
| 第1镜 钩子 | 单张「分镜卡」:顶部元数据表格(镜号/景别 MCU/机位)+ 一张照片,蓝调办公室,白衬衫女主 |
|
||||
| 第2镜 痛点 | 又一张「分镜卡」,但**布局变了**(元数据挪到左栏)+ 灰西装女主 + 暖调 |
|
||||
| 第3镜 卖点 | **直接画成一整张 4 行分镜缩略表(contact sheet)**——完全不同的产出物 |
|
||||
| 第4镜 CTA | (同样各画各的) |
|
||||
|
||||
→ 不只是画风漂,**连"画一张图还是画一张分镜表"都每次不一样**;元数据布局、色调、人物也各不相同。
|
||||
|
||||
|  |  |  |
|
||||
| --- | --- | --- |
|
||||
| 第1镜:单张卡(表格在顶) | 第2镜:单张卡(表格在左)| 第3镜:4行分镜表 |
|
||||
|
||||
### 有锚点(锁画风)—— 4 帧像同一套片子
|
||||
|
||||
|  |  |  |
|
||||
| --- | --- | --- |
|
||||
| 第1镜 | 第3镜 | 第4镜 |
|
||||
|
||||
→ **统一写实摄影质感、统一米灰色调、统一柔光、同一位女白领、同一只白色保温杯**——明显是一套连贯的片子。
|
||||
画风一致性被锁住了(对比无锚点那组的杂乱一目了然)。
|
||||
|
||||
---
|
||||
|
||||
## 三、demo 意外暴露的第二个问题(比画风更值得修)
|
||||
|
||||
**两组都把图画成了"分镜表/分镜卡"(多格 + 元数据列 + 文字标注),而不是干净的单帧画面。**
|
||||
|
||||
根因:提示词里的 **「导演故事板 / 分镜图」** 这几个字,会让图像模型去画一张"分镜文档"(storyboard sheet),
|
||||
而不是"这一镜的画面"。锚点锁住了**画风**,但没锁住**产出物类型**。
|
||||
|
||||
雪上加霜:线上 DB 模板把代码默认里的 **「一镜一图」** 删掉了(见下),而"一镜一图"正是约束"出单帧、别出多格表"的那句。
|
||||
|
||||
- 代码默认:`…电商竖屏 9:16 导演故事板,**一镜一图**,画面清晰…`
|
||||
- 线上 DB :`…电商竖屏 9:16 导演故事板,画面清晰…`(少了「一镜一图」)
|
||||
|
||||
> 注:线上真实路径走 `image_edit` + 人物参考图,参考图会把结果往"这个人的实拍照"拽,可能比纯文生图更不易出表;
|
||||
> 但"导演故事板"这个词的风险真实存在,demo 已证。
|
||||
|
||||
---
|
||||
|
||||
## 四、改进建议(锚点要同时锁「画风」+「产出物」)
|
||||
|
||||
把 `storyboard_frame` 后台模板的尾段改成**画风锚点 + 产出物锚点**合体,并恢复「一镜一图」:
|
||||
|
||||
```
|
||||
…请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;
|
||||
【统一画风】写实电商摄影棚实拍质感,统一柔和暖白影棚光、统一明亮干净色彩分级、统一中近景居中构图;禁止插画/线稿/漫画/3D卡通。
|
||||
【产出物】只输出单张写实画面(一镜一图);禁止多格分镜表、禁止文字标注/字幕、禁止表格/边框/元数据栏。
|
||||
电商竖屏 9:16,画面清晰,可直接指导视频生成。
|
||||
```
|
||||
|
||||
要点:
|
||||
1. **画风锚点**——锁写实/光线/色调/构图(本 demo 已验证有效)。
|
||||
2. **产出物锚点**——明确"单张画面、禁止分镜表/文字/表格"(治本次暴露的"画成分镜表")。
|
||||
3. **恢复「一镜一图」**——线上被删了,加回。
|
||||
4. **人物/服装跨帧锁**——纯文生图下服装仍会微漂(白上衣 vs 西装);彻底锁要靠线上的 `image_edit` 参考图路径(用同一张人物立绘当参考)。
|
||||
5. **可选固定 seed**——进一步压跨次随机漂移(需确认 yunqi/gpt-image 支持)。
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,117 @@
|
||||
# 故事板画风锚点 demo · 用到的提示词
|
||||
|
||||
> 图像模型:yunqi/gpt-image-2 · 模拟商品:保温杯 · 4 分镜
|
||||
|
||||
|
||||
## 无锚点(现状)
|
||||
|
||||
|
||||
### 第1镜 · 钩子
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】钩子
|
||||
【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第2镜 · 痛点
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】痛点
|
||||
【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第3镜 · 卖点
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】卖点
|
||||
【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第4镜 · CTA
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】CTA
|
||||
【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
## 有锚点(锁画风)
|
||||
|
||||
|
||||
### 第1镜 · 钩子
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】钩子
|
||||
【画面】年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈
|
||||
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
|
||||
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
|
||||
· 布光:统一柔和影棚顺光,同一色温(暖白);
|
||||
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
|
||||
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
|
||||
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
|
||||
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第2镜 · 痛点
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】痛点
|
||||
【画面】女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼
|
||||
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
|
||||
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
|
||||
· 布光:统一柔和影棚顺光,同一色温(暖白);
|
||||
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
|
||||
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
|
||||
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
|
||||
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第3镜 · 卖点
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】卖点
|
||||
【画面】女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏
|
||||
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
|
||||
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
|
||||
· 布光:统一柔和影棚顺光,同一色温(暖白);
|
||||
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
|
||||
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
|
||||
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
|
||||
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
||||
|
||||
### 第4镜 · CTA
|
||||
|
||||
```
|
||||
根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。
|
||||
【镜头功能】CTA
|
||||
【画面】女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击
|
||||
【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】
|
||||
· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;
|
||||
· 布光:统一柔和影棚顺光,同一色温(暖白);
|
||||
· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;
|
||||
· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;
|
||||
· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);
|
||||
· 商品:全片同一只白色保温杯(同一外形/配色/logo)。
|
||||
电商竖屏 9:16 导演故事板,画面清晰。
|
||||
```
|
||||
|
After Width: | Height: | Size: 976 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,409 @@
|
||||
# 出格式能力探针 · 模型实际产出汇总
|
||||
|
||||
> 生成时间:2026-06-24 12:01 · 模拟商品:暖岚 316 保温杯 · 期望 4 镜 / 画幅 9:16
|
||||
|
||||
判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救)
|
||||
|
||||
## 速览矩阵
|
||||
|
||||
| 模型 | freeform | structured | tool |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| **doubao**<br>`doubao-seed-2-0-pro-260215` | ✅·raw✗ 55.7s | ✅·raw✓ 46.0s | ✅·raw✓ 86.7s |
|
||||
| **gpt**<br>`gpt-5.5` | ✅·raw✗ 56.7s | ✅·raw✗ 96.4s | ✅·raw✓ 33.1s |
|
||||
| **gemini**<br>`gemini-3.1-pro-preview` | ✅·raw✗ 29.0s | ✅·raw✓ 33.9s | ✅·raw✓ 20.9s |
|
||||
|
||||
## 原始结构一致性(normalize 之前 · 逐键 diff)
|
||||
|
||||
> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。
|
||||
|
||||
### 策略:freeform
|
||||
|
||||
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
|
||||
| ---- | ----- | ------------- | ---------------- | ------------------ |
|
||||
| doubao | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
|
||||
| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
|
||||
| gemini | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
|
||||
|
||||
→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅
|
||||
|
||||
### 策略:structured
|
||||
|
||||
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
|
||||
| ---- | ----- | ------------- | ---------------- | ------------------ |
|
||||
| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
|
||||
| gpt | ✓ | `segments` | `dialogue,duration,entity_refs,index,narration,product_exposure,role,speaker,visual` | `id,name,ref_index,type,visual_prompt,voice_ref` |
|
||||
| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
|
||||
|
||||
→ segments 键集**有差异**⚠️ · entities 键集**有差异**⚠️
|
||||
|
||||
### 策略:tool
|
||||
|
||||
| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |
|
||||
| ---- | ----- | ------------- | ---------------- | ------------------ |
|
||||
| doubao | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
|
||||
| gpt | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
|
||||
| gemini | ✓ | `segments` | `duration,entity_refs,index,narration,product_exposure,role,visual` | `id,name,ref_index,type,visual_prompt` |
|
||||
|
||||
→ segments 键集**完全同构**✅ · entities 键集**完全同构**✅
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## doubao · `doubao-seed-2-0-pro-260215`
|
||||
|
||||
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:freeform · 55.7s · raw_clean=否
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:办公室久坐总喝凉水?这款保温杯24小时锁温超省心!
|
||||
- **tone**:种草 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):女白领(character)、办公室工位(scene)、暖岚保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 办公室久坐总喝凉水?冬天接的开水没半小时就凉透了? | 近景对准工位上凉掉的玻璃杯,镜头快速拉到女主皱眉摸凉杯子的脸,运镜带轻微晃动感强化痛点,最后扫过桌面空水杯位 |
|
||||
| 1 | 痛点 | 每次想喝口热的还要反复去接水,通勤带的水到公司早就凉透,太麻烦了 | 中景拍女主抱着凉杯子起身走向茶水间,切特写拍她擦旧保温杯漏湿的包的烦躁表情,运镜平缓写实强化共鸣 |
|
||||
| 2 | 卖点 | 这款暖岚316保温杯24小时长效保温,单手弹盖就能开,放包里横放也不洒 | 特写对准保温杯一键弹开的动作,慢放拍316内胆细节,切女主单手接水直接塞包的动作,运镜顺滑突出便捷性 |
|
||||
| 3 | CTA | 现在点下方小黄车就能入手,久坐上班族随时都能喝到温热水啦 | 女主手持保温杯对着镜头微笑展示正面,镜头慢慢推近到保温杯logo和下单弹窗提示,停留3秒强化记忆点 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
正在为暖岚316不锈钢保温杯生成4镜种草向竖屏带货脚本~
|
||||
```json
|
||||
{
|
||||
"hook": "办公室久坐总喝凉水?这款保温杯24小时锁温超省心!",
|
||||
"tone": "种草",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character",
|
||||
"name": "女白领",
|
||||
"visual_prompt": "25岁左右年轻女白领,穿简约通勤衬衫,长相亲切有亲和力,办公淡妆自然",
|
||||
"ref_index": 1,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"type": "scene",
|
||||
"name": "办公室工位",
|
||||
"visual_prompt": "明亮现代的互联网公司工位,桌面放着电脑、笔记本,自然光充足,9:16竖屏构图",
|
||||
"ref_index": 2,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"type": "produ
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:structured · 46.0s · raw_clean=是
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?
|
||||
- **tone**:种草 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):职场女主(character)、办公室工位(scene)、暖岚保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了? | 近景对准女主皱着眉摸了摸手边凉掉的普通水杯,镜头慢慢拉远扫过堆满文件的工位,最后定格在女主失落的表情上。 |
|
||||
| 1 | 痛点 | 早上带的热水中午就凉,灌包里还总洒得到处都是,真的太闹心了。 | 特写女主翻出被水打湿的通勤包,拿着湿纸巾擦包内的文件,镜头给到旁边没扣紧的普通水杯,女主无奈叹气摇头。 |
|
||||
| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,放包里横放也不洒,太适合上班族了。 | 特写女主拿起暖岚保温杯,手指一按就弹开盖子喝了一口,镜头扫过316内胆细节,再给到保温杯塞进包的动作。 |
|
||||
| 3 | CTA | 现在点下方小黄车就能入手,冬天上班随时能喝上热乎的,太舒服啦。 | 女主手持保温杯正对镜头,包装正面朝向观众,镜头慢慢推近到杯身暖岚logo,下方弹出小黄车弹窗动效。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
{"hook":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","tone":"种草","aspect_ratio":"9:16","total_duration":60,"segment_count":4,"entities":[{"id":"c1","type":"character","name":"职场女主","visual_prompt":"25-30岁年轻女白领,穿浅灰色通勤西装,扎低马尾,气质清爽干练,面带亲和力","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"现代化开放式办公室工位,明亮冷白光,桌面摆放笔记本电脑、便签纸、绿植,整洁有氛围感","ref_index":2},{"id":"p1","type":"product","name":"暖岚保温杯","visual_prompt":"奶白色暖岚316不锈钢保温杯,500ml容量,顶部有黑色一键弹盖按钮,杯身印有暖岚极简logo","ref_index":3}],"segments":[{"index":0,"duration":15,"role":"钩子","narration":"你是不是也忙起来连喝口热的都顾不上,到嘴的水早就凉透了?","visual":"近景对准女主皱着眉摸了摸手边凉掉的普
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【doubao · doubao-seed-2-0-pro-260215】策略:tool · 86.7s · raw_clean=是
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:办公室经常喝凉水的朋友快看过来!
|
||||
- **tone**:种草 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):办公室女白领(character)、办公室工位(scene)、暖岚316不锈钢保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了? | 近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的表情,手持镜头轻微晃动带真实感 |
|
||||
| 1 | 痛点 | 冬天想喝口热的太费劲,开会腾不出手拧杯盖,装包里还总洒漏弄湿文件。 | 中景拍女生开会时想喝水拧不开杯盖的窘迫,再切到她翻包看到被水打湿的笔记本的无奈表情 |
|
||||
| 2 | 卖点 | 这款暖岚保温杯24小时保温,单手就能弹开,316内胆用着放心,放包里横放也不洒。 | 特写对准保温杯按动弹盖的动作,再切到女生单手开杯喝水的轻松状态,最后给内胆细节特写 |
|
||||
| 3 | CTA | 现在点下方小黄车就能入手,办公室久坐的打工人真的可以备一个! | 女生手持保温杯正对镜头展示,镜头拉远给到工位全景,下方弹出小黄车弹窗提示 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
{"hook": "办公室经常喝凉水的朋友快看过来!", "tone": "种草", "aspect_ratio": "9:16", "total_duration": 60, "segment_count": 4, "entities": [{"id":"c1","type":"character","name":"办公室女白领","visual_prompt":"25岁左右年轻女生,穿休闲通勤西装,扎低马尾,气质清爽,坐在工位前","ref_index":1},{"id":"s1","type":"scene","name":"办公室工位","visual_prompt":"明亮现代办公区,浅木色书桌,放着电脑、笔记本,暖黄台灯光线柔和","ref_index":2},{"id":"p1","type":"product","name":"暖岚316不锈钢保温杯","visual_prompt":"奶白色500ml保温杯,圆润杯身,黑色一键弹盖按钮,杯身印有暖岚简约logo","ref_index":3}], "segments": [{"index":0,"duration":15,"role":"钩子","narration":"办公室经常喝凉水的朋友快看过来!是不是早上接的水下午就冰了?","visual":"近景对准女生工位上放着的半杯凉水,镜头上摇到女生皱着眉摸水杯的
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## gpt · `gpt-5.5`
|
||||
|
||||
### ✅ 【gpt · gpt-5.5】策略:freeform · 56.7s · raw_clean=否
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:熬夜加班的人,早上还想喝口温热水吗?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(5):年轻白领女主(character)、暖岚316不锈钢保温杯500ml(product)、深夜办公室工位(scene)、早晨通勤包旁(scene)、会议室桌面(scene)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 熬夜加班的人,早上还想喝口温热水吗?这杯我真的离不开。 | 竖屏近景从女主趴在深夜工位抬头开始,镜头缓慢推近她疲惫的脸,再落到桌上的暖岚保温杯,情绪从困倦转到被热水安慰。 |
|
||||
| 1 | 痛点 | 以前普通杯子放一晚就凉,早上赶路还怕漏,包里纸巾都遭殃。 | 镜头切到清晨通勤包旁的中景,女主翻包找东西,先露出被打湿纸巾的尴尬,再手持旧杯皱眉,画面从慌乱转为想解决问题。 |
|
||||
| 2 | 卖点 | 暖岚316内胆很安心,晚上灌水,早上喝还是温的,横放也不洒。 | 竖屏特写展示杯盖弹开和内胆细节,镜头跟随女主把杯子横放进包里再拿出,水面稳定不外漏,画面从细节检查过渡到放心收包。 |
|
||||
| 3 | CTA | 开会单手一按就能喝。想少踩杯子坑,点下方小黄车看看。 | 会议室中景里女主一手看电脑一手按开杯盖喝水,镜头轻微跟拍到杯身正面,再定格包装和杯盖按钮,状态从忙乱变得从容。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
在为暖岚保温杯生成 4 镜办公室痛点脚本…
|
||||
```json
|
||||
{
|
||||
"hook": "熬夜加班的人,早上还想喝口温热水吗?",
|
||||
"tone": "痛点",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character",
|
||||
"name": "年轻白领女主",
|
||||
"visual_prompt": "25岁左右年轻白领女性,黑色中长发,浅色针织衫配西装外套,干净通勤妆容,气质疲惫但利落,适合办公室短视频口播",
|
||||
"ref_index": 1,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"type": "product",
|
||||
"name": "暖岚316不锈钢保温杯500ml",
|
||||
"visual_prompt": "暖岚品牌500ml保温杯,简约磨砂浅米色杯身,杯盖带一键弹盖按钮,杯身有小巧品牌标识,316不锈钢内胆质感,适合通勤办公场景",
|
||||
"ref_index": 2,
|
||||
"voice_ref": null
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【gpt · gpt-5.5】策略:structured · 96.4s · raw_clean=否
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:你是不是也忙到水凉了才想起来喝?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):年轻白领女生(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 你是不是也忙到水凉了才想起来喝?办公室真的需要这种保温杯。 | 竖屏中景拍白领女生盯着电脑加班,伸手摸到普通杯子皱眉,镜头慢慢推近她失落的表情,最后桌角暖岚保温杯虚焦入画。 |
|
||||
| 1 | 痛点 | 早会、改方案、回消息,一上午过去,想喝口热的都像抽盲盒。 | 手持跟拍她在工位和会议桌之间来回切换,普通杯子被遗忘在桌边,画面从匆忙敲键盘转到她拿起冷水杯的无奈停顿。 |
|
||||
| 2 | 卖点 | 暖岚这只316内胆,晚上灌开水,第二天早上喝还是温热的。 | 特写暖岚保温杯旋开杯盖,热气轻轻冒出,镜头从杯口推到316内胆和杯身标识,再切到女生双手捧杯喝水,表情从疲惫变放松。 |
|
||||
| 3 | CTA | 一键弹盖单手喝,横放包里也不怕洒。想省心喝热水,点下方小黄车。 | 近景拍她一手拿文件一手按开弹盖喝水,随后把杯子横放进通勤包再取出,镜头拉回正面手持包装展示,眼神轻松对镜点头。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
在为这款保温杯生成 4 镜办公室痛点脚本…
|
||||
```json
|
||||
{
|
||||
"hook": "你是不是也忙到水凉了才想起来喝?",
|
||||
"tone": "痛点",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character",
|
||||
"name": "年轻白领女生",
|
||||
"visual_prompt": "25岁左右年轻白领女生,齐肩深棕色头发,浅色针织衫配西装外套,妆容干净自然,气质亲和,有轻微疲惫但真实的办公室状态",
|
||||
"ref_index": 1,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"type": "scene",
|
||||
"name": "现代办公室工位",
|
||||
"visual_prompt": "现代城市办公室工位,桌面有电脑、文件、便签和台灯,冷暖混合光,背景有模糊同事与会议室玻璃,9:16竖屏构图,生活化带货质感",
|
||||
"ref_index": 2,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【gpt · gpt-5.5】策略:tool · 33.1s · raw_clean=是
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:你是不是忙到一上午都喝不上热水?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):办公室女白领(character)、现代办公室工位(scene)、暖岚 316 不锈钢保温杯 500ml(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 你是不是忙到一上午都喝不上热水?这杯子我最近天天带。 | 女白领坐在工位前快速回消息,桌角保温杯若隐若现;镜头从电脑屏幕推到她疲惫表情,再落到杯身,情绪从忙乱转向被吸引。 |
|
||||
| 1 | 痛点 | 早上倒的水,中午想喝却凉了,开会还腾不出手拧杯盖。 | 女白领一手拿资料一手翻电脑,想喝水却被普通杯盖卡住;手持跟拍她来回忙碌,桌面纸杯冒气到变冷,画面从急促变无奈。 |
|
||||
| 2 | 卖点 | 暖岚这只316内胆,晚上灌热水,第二天早上喝着还温温的。 | 镜头切到保温杯内胆和倒水特写,女白领按下一键弹盖喝水;由杯口热气细节推到她放松的表情,画面从冷清变得温暖。 |
|
||||
| 3 | CTA | 单手开盖还防漏,通勤包横放也安心。需要就点下方小黄车。 | 女白领把保温杯横放进通勤包再拿起对镜展示;镜头从包内防漏细节拉到杯身正面,最后她微笑点向屏幕下方,动作干脆。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"办公室女白领","ref_index":1,"type":"character","visual_prompt":"年轻女性白领,25-30岁,黑色中长发,浅色针织衫配西装外套,干净自然妆容,气质亲和,有轻微疲惫感但精神利落"},{"id":"s1","name":"现代办公室工位","ref_index":2,"type":"scene","visual_prompt":"现代办公室工位场景,桌面有电脑、文件、台灯和通勤包,暖色自然光,干净通透,竖屏9:16构图,生活化带货风格"},{"id":"p1","name":"暖岚 316 不锈钢保温杯 500ml","ref_index":3,"type":"product","visual_prompt":"暖岚品牌500ml保温杯,简约磨砂杯身,细长便携杯型,一键弹盖结构,杯盖密封圈细节清晰,316不锈钢内胆,适合办公通勤场景"}],"hook":"你是不是忙到一上午都喝不上热水?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1","p1"],"index":0,"narration":"你是不是忙到一上午都喝不上热水?这杯子我最近天
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## gemini · `gemini-3.1-pro-preview`
|
||||
|
||||
### ✅ 【gemini · gemini-3.1-pro-preview】策略:freeform · 29.0s · raw_clean=否
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):女主(character)、办公室工位(scene)、暖岚保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 每天带水杯通勤,不是漏水弄脏包,就是开会忙起来连喝水都腾不出手? | 全景到手持跟拍,女主在工位上急忙翻找通勤包,发现水杯漏水弄湿了文件,表情从焦急到崩溃,镜头推近包里的水渍。 |
|
||||
| 1 | 痛点 | 尤其是天冷了,想喝口热的,还得双手去拧盖子,键盘敲一半真不想动。 | 中景转特写,女主一边单手敲击电脑键盘,另一只手试图去拧传统水杯盖子,怎么也拧不开,无奈地放弃并叹气。 |
|
||||
| 2 | 卖点 | 换上暖岚316保温杯,一键弹盖单手就能喝。晚上灌的热水第二天还烫嘴,横着扔包里也滴水不漏。 | 特写加推镜头,女主单手轻松按键弹开暖岚保温杯喝水,随后画面切到杯子倒置用力摇晃,一滴水也没漏,表情转为惊喜。 |
|
||||
| 3 | CTA | 办公室熬夜党真的少不了它,几十块钱能用好几年,左下角趁有活动赶紧给自己安排上! | 近景固定镜头,女主微笑着把保温杯放在电脑旁,手持杯身向镜头展示质感,手指指向画面左下方,情绪满是推荐感。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
正在为您生成暖岚保温杯的 60 秒 4 镜带货脚本,主打办公通勤痛点……
|
||||
|
||||
```json
|
||||
{
|
||||
"hook": "每天带水杯通勤,不是漏水弄脏包,就是开会忙起来腾不出手?",
|
||||
"tone": "痛点",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character",
|
||||
"name": "女主",
|
||||
"visual_prompt": "20多岁的年轻都市白领,穿着干练的通勤装,妆容精致,略显忙碌",
|
||||
"ref_index": 1,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"type": "scene",
|
||||
"name": "办公室工位",
|
||||
"visual_prompt": "明亮的现代办公室工位,桌上有电脑、文件和键盘,光线自然,充满职场氛围",
|
||||
"ref_index": 2,
|
||||
"voice_ref": null
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"type": "pr
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【gemini · gemini-3.1-pro-preview】策略:structured · 33.9s · raw_clean=是
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:你是不是也一忙起来就忘了喝水,等想起来水早凉透了?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):女主(character)、办公桌旁(scene)、暖岚保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 你是不是也一忙起来就忘了喝水,等想起来去喝,水早就凉透了? | 近景特写:女主坐在办公桌前敲键盘,眉头微皱,拿起普通水杯喝了一口却被冷水冰到,无奈放下。镜头缓慢推近放大烦躁情绪。 |
|
||||
| 1 | 痛点 | 而且随手塞进包里还容易漏水,开会时一手拿文件一手根本拧不开杯盖,太崩溃了。 | 中景手持跟拍:女主从通勤包里拿出一沓被水阴湿的文件叹气。画面一切,她单手抱着文件,另一只手焦急拧旧水杯盖却拧不开。 |
|
||||
| 2 | 卖点 | 换了暖岚保温杯,一键弹盖单手就能喝。晚上倒的开水第二天还是热的,横放也不漏水。 | 特写转全景:特写暖岚保温杯一键弹盖冒出热气。镜头拉开,女主单手轻松饮水,并倒置摇晃杯身展示滴水不漏,表情转为轻松满意。 |
|
||||
| 3 | CTA | 316食品级内胆用着更安心。打工人别在喝水上凑合,趁现在有活动,左下角给自己安排上! | 近景固定镜头:女主微笑着将暖岚保温杯放在办公桌显眼处,手指轻敲杯身,随后指了指屏幕左下方。画面给出杯子内胆的清晰特写。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
{
|
||||
"hook": "你是不是也一忙起来就忘了喝水,等想起来水早凉透了?",
|
||||
"tone": "痛点",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 60,
|
||||
"segment_count": 4,
|
||||
"entities": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "character",
|
||||
"name": "女主",
|
||||
"visual_prompt": "25岁左右职场年轻女性,穿着简约干练的浅色通勤衬衫,化着淡妆,略显疲惫但依旧专注",
|
||||
"ref_index": 1
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"type": "scene",
|
||||
"name": "办公桌旁",
|
||||
"visual_prompt": "明亮现代的办公室桌面,有电脑显示器、整齐的文件和绿色盆栽,自然光从侧面打入,职场通勤风格",
|
||||
"ref_index": 2
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"type": "product",
|
||||
"name": "暖岚保温杯",
|
||||
"visual_prompt": "简约高颜值的500ml不锈钢保温杯,磨砂质感杯身,
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### ✅ 【gemini · gemini-3.1-pro-preview】策略:tool · 20.9s · raw_clean=是
|
||||
|
||||
**规范化成稿:**
|
||||
|
||||
- **hook**:你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?
|
||||
- **tone**:痛点 · **时长**:60s · **画幅**:9:16
|
||||
- **实体**(3):女主(character)、现代办公室(scene)、暖岚保温杯(product)
|
||||
|
||||
| 镜 | role | narration(口播) | visual(画面) |
|
||||
| -- | ---- | ------------- | ----------- |
|
||||
| 0 | 钩子 | 你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上? | 女主坐在办公桌前,拿起一个普通水杯喝水,皱眉发现水已经凉透了,镜头从全景推近到特写,展现她略显疲惫无奈的表情变化。 |
|
||||
| 1 | 痛点 | 早上挤地铁不敢把水杯放包里怕漏,工作忙起来一手敲键盘,根本腾不出手去拧旧杯盖,太难了。 | 女主一手疯狂敲击键盘,另一只手试图去拧开桌上的旧水杯盖子,结果滑了一下没拧开,手持跟拍展现她急躁焦虑的动作情绪变化。 |
|
||||
| 2 | 卖点 | 直到换了这款暖岚保温杯,一键弹盖单手就能喝。316内胆昨晚的热水今天还是温的,放包里横着也不漏。 | 镜头特写保温杯,女主单手大拇指轻轻一按,“啪”地弹开杯盖,紧接着镜头摇到内胆特写,最后展示杯子在包里横放无漏水的状态变化。 |
|
||||
| 3 | CTA | 对咱们久坐熬夜的打工人来说,随时喝口热水太重要了。几十块钱提升幸福感,点左下角小黄车给自己安排上。 | 女主单手拿着暖岚保温杯微笑着喝了一口热水,神情放松,镜头微微拉远给出全身景别,画面从忙碌过渡到惬意享受的状态。 |
|
||||
|
||||
<details><summary>原始输出预览(前 600 字)</summary>
|
||||
|
||||
|
||||
```
|
||||
{"aspect_ratio":"9:16","entities":[{"id":"c1","name":"女主","ref_index":1,"type":"character","visual_prompt":"年轻白领女性,穿着干练通勤装,略显疲惫但妆容精致,适合职场打工人设定"},{"id":"s1","name":"现代办公室","ref_index":2,"type":"scene","visual_prompt":"现代明亮的办公室,光线充足,办公桌上放着电脑显示器、文件和凌乱的文具,充满工作氛围"},{"id":"p1","name":"暖岚保温杯","ref_index":3,"type":"product","visual_prompt":"暖岚500ml不锈钢保温杯,简约纯色设计,带有按键弹盖结构,外观高级有质感"}],"hook":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","segment_count":4,"segments":[{"duration":15,"entity_refs":["c1","s1"],"index":0,"narration":"你是不是也一到下午,杯子里的水就凉透了,开完会连口热茶都喝不上?","product_exposure":"反面对照(拿旧杯子)","role":"钩子","visual":"
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,318 @@
|
||||
# 脚本生成 Agent · 流式 SSE 编排技术文档
|
||||
|
||||
> 对象代码:[`core/backend/apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) 的 `stream_script_agent()`
|
||||
> 端点:`POST /api/projects/{id}/script-agent-stream/` → `text/event-stream`
|
||||
> 一句话定位:把一次「调大模型出脚本」的过程,包装成一条**可见的、可计费的、可断点回滚的** SSE 事件流,给前端真 agent 体感,同时由后端而非模型保证结构化结果的可靠性。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全局视角
|
||||
|
||||
```
|
||||
前端 fetch(POST script-agent-stream) ──SSE──▶ 浏览器逐帧消费
|
||||
│ ▲
|
||||
▼ │ data: {json}\n\n
|
||||
Django StreamingHttpResponse │
|
||||
│ 包裹 │
|
||||
▼ │
|
||||
stream_script_agent() ← 同步生成器,每 yield 一帧
|
||||
│
|
||||
┌────────────┼─────────────────────────────────────────┐
|
||||
│ │ │
|
||||
加载skill 建AITask+预扣额度 调豆包流式SSE
|
||||
(tool) (reserve_credit) (reasoning/delta)
|
||||
│ │ │
|
||||
└──▶ 抽取JSON+规范化 ──▶ 落库ScriptVersion ──▶ charge额度 ──▶ saved/summary/done
|
||||
```
|
||||
|
||||
核心思想三条:
|
||||
|
||||
1. **进度即事件**:内部每一步(加载技能 / 分析商品 / 生成分镜 / 提取实体 / 自检)都吐一张「工具卡」,让用户看到 agent 在干活,而不是对着一个转圈等几十秒。
|
||||
2. **结构化结果由后端兜底**:模型只管「生成」,JSON 的抽取、字段对齐、镜数补齐全在后端做,不信任模型的排版纪律。
|
||||
3. **计费与流式生命周期绑定**:额度预扣(reserve)→ 成功结算(charge)/ 失败或断连释放(release),用 `try/finally` 覆盖包括客户端断连在内的所有退出路径。
|
||||
|
||||
---
|
||||
|
||||
## 1. SSE 帧格式与事件协议
|
||||
|
||||
### 1.1 帧编码
|
||||
|
||||
每一帧都是标准 SSE:
|
||||
|
||||
```python
|
||||
def _sse(obj: dict) -> str:
|
||||
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
|
||||
```
|
||||
|
||||
- `ensure_ascii=False`:中文不转义,前端直接拿到可读文本。
|
||||
- 每帧一个 JSON 对象,必带 `type` 字段,前端按 `type` 分派渲染。
|
||||
- 结尾 `\n\n` 是 SSE 规范的事件分隔符。
|
||||
|
||||
### 1.2 事件类型清单
|
||||
|
||||
| type | 载荷 | 语义 | 是否进入「答案」 |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| `tool` | `{id, label?, status: running\|done\|error}` | 工具卡:内部步骤可视化 | 否(纯进度) |
|
||||
| `reasoning` | `{text}` | 推理模型思考流,逐字 | 否(纯展示) |
|
||||
| `delta` | `{text}` | 模型自然语言前言 | 否(前言,JSON 不外露) |
|
||||
| `draft` | `{draft}` | 规范化后的 ScriptDraft | 是(结构化渲染) |
|
||||
| `saved` | `{script_version_id, version}` | 已落库的 ScriptVersion | 是 |
|
||||
| `summary` | `{text}` | 模型写的收尾交付语 | 是(当 AI 回复气泡) |
|
||||
| `done` | `{}` | 正常结束 | — |
|
||||
| `error` | `{detail}` | 失败(额度已回滚) | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. 逐帧时序详解
|
||||
|
||||
下面按生成器实际 `yield` 顺序拆解,标注每一步的技术意图。
|
||||
|
||||
### 阶段 A · 加载技能(同步、毫秒级)
|
||||
|
||||
```python
|
||||
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
|
||||
skill_loaded = bool(load_ecommerce_skill())
|
||||
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
|
||||
```
|
||||
|
||||
- `load_ecommerce_skill()` 用 `@lru_cache(maxsize=1)`:把 `SKILL.md + references/*.md` 拼成系统提示词,进程内只读一次磁盘。
|
||||
- 同一个 `id: "skill"` 先发 `running` 再发 `done`,前端据 `id` 原地更新同一张卡的状态,而不是堆两张卡。
|
||||
- 缺文件不致命:`load_ecommerce_skill` 有兜底字符串,`skill_loaded` 仍为 True。
|
||||
|
||||
### 阶段 B · 分析商品 + 构建消息(同步)
|
||||
|
||||
```python
|
||||
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
|
||||
# ... 加载基准稿(改稿)、校验镜号、build_agent_messages ...
|
||||
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
||||
```
|
||||
|
||||
这一阶段做了几件关键的前置判断:
|
||||
|
||||
1. **改稿才加载基准稿**:`mode == "revise" and base_version_id` 时 `_load_base_draft` 读出历史稿。基准稿拿不到则 `target_index = None`,单镜改无从谈起,退回整版生成。
|
||||
2. **改稿用基准稿的真实时长**:`effective_duration = base_draft.total_duration or total_duration`。这是修「90s/6镜稿被请求侧默认 60 挤掉尾镜」的关键——前端可能硬编码 60,但改稿必须尊重原稿镜数。
|
||||
3. **镜号越界先于建任务**:`target_index` 非空时校验 `0 <= target_index < seg_n`,越界直接 `yield error` 并 `return`,**绝不建任务/扣费**,避免计费空转的静默 no-op。
|
||||
|
||||
> 注意所有 `target_index` 判断一律用 `is None`,**不能用真值判断**——`0` 是合法镜号(第 1 镜),`if target_index:` 会把第 1 镜误当未指定。
|
||||
|
||||
### 阶段 C · 建任务 + 预扣额度
|
||||
|
||||
```python
|
||||
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
|
||||
try:
|
||||
task = create_ai_task(project=..., task_type=task_type, model_config=..., request_payload={...})
|
||||
except Exception as exc:
|
||||
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
|
||||
return
|
||||
reservation = task.credit_reservation
|
||||
```
|
||||
|
||||
- `create_ai_task` 内部 `@transaction.atomic`:建 `AITask`(CREATED)→ `reserve_credit` 预扣 → 置 RESERVED。预扣失败(余额不足)抛异常,这里转成 `error` 事件优雅返回。
|
||||
- `reservation` 句柄留到后面 charge/release 用。
|
||||
|
||||
### 阶段 D · 调模型流式生成(核心,耗时几十秒)
|
||||
|
||||
这是整个流程最重的一段,包在 `try/finally`(计费兜底)+ 内层 `try/except`(生成失败处理)里。
|
||||
|
||||
```python
|
||||
settled = False # 额度是否已结算
|
||||
try:
|
||||
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
|
||||
full: list[str] = [] # 累积模型正文
|
||||
shown = 0 # 已外露给前端的可见字符数
|
||||
forwarding = True # 是否仍在转发前言(遇到 JSON 起点后置 False)
|
||||
try:
|
||||
task.status = AITask.Status.SUBMITTED; task.save(...)
|
||||
provider = build_provider(model_config)
|
||||
for ev in provider.chat_completion_stream(model=..., messages=messages, temperature=0.85):
|
||||
et = ev.get("type")
|
||||
if et == "reasoning":
|
||||
rpiece = ev.get("text") or ""
|
||||
if rpiece:
|
||||
yield _sse({"type": "reasoning", "text": rpiece})
|
||||
continue
|
||||
if et == "delta":
|
||||
full.append(ev["text"])
|
||||
if forwarding:
|
||||
text = "".join(full)
|
||||
cut = _visible_cut(text)
|
||||
if cut < len(text):
|
||||
forwarding = False
|
||||
visible = text[:cut]
|
||||
if len(visible) > shown:
|
||||
piece = visible[shown:]
|
||||
shown = len(visible)
|
||||
if piece.strip():
|
||||
yield _sse({"type": "delta", "text": piece})
|
||||
elif et == "done":
|
||||
break
|
||||
raw = "".join(full)
|
||||
draft = normalize_draft(raw, aspect_ratio=..., total_duration=effective_duration)
|
||||
if target_index is not None and base_draft:
|
||||
draft = _merge_single_segment(base_draft, draft, target_index, ...)
|
||||
except Exception as exc:
|
||||
_fail_task(task, reservation, str(exc)); settled = True
|
||||
yield _sse({"type": "tool", "id": "generate", "status": "error"})
|
||||
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
|
||||
return
|
||||
```
|
||||
|
||||
#### D.1 两种 delta 的区分(reasoning vs content)
|
||||
|
||||
底层 [`chat_completion_stream`](../backend/apps/ai/providers/volcano.py) 把 OpenAI 兼容 SSE 的 `delta` 拆成两路:
|
||||
|
||||
- `delta.reasoning_content` → `{type: "reasoning"}`
|
||||
- `delta.content` → `{type: "delta"}`
|
||||
|
||||
豆包 seed-pro 这类**推理模型**在出 JSON 前会先思考几十秒,思考期**只发 `reasoning_content`、不发 `content`**。如果不单独转发 reasoning,整个思考期前端零输出 = 假死(用户看到「按黄金结构生成分镜」卡了几十秒以为崩了)。所以 reasoning 逐字下发、纯展示、`continue` 掉不进 `full`(它不是答案正文)。
|
||||
|
||||
#### D.2 前言可见区裁剪(`_visible_cut`)
|
||||
|
||||
模型按运行时输出协议会先说一句口语前言("在为这款保温杯生成 4 镜痛点脚本…"),紧接着吐 ` ```json ` 代码块。前端只该看到前言,不该看到刷屏的 JSON。
|
||||
|
||||
```python
|
||||
def _visible_cut(text: str) -> int:
|
||||
"""可见区终点 = JSON 起点(``` 或第一个 {)。"""
|
||||
cands = []
|
||||
for marker in ("```", "{"):
|
||||
i = text.find(marker)
|
||||
if i != -1:
|
||||
cands.append(i)
|
||||
return min(cands) if cands else len(text)
|
||||
```
|
||||
|
||||
转发逻辑用三个游标协作:
|
||||
|
||||
- `full`:累积**全部**模型正文(含 JSON),用于最后解析。
|
||||
- `shown`:已经 `delta` 出去的可见字符数,保证只增量发新字符、不重发。
|
||||
- `forwarding`:一旦 `cut < len(text)`(即出现了 ``` 或 `{`),说明前言结束、JSON 开始,置 False,此后不再转发任何 `delta`(JSON 不外露)。
|
||||
|
||||
只发 `piece.strip()` 非空的片段,避免把纯空白也当帧发出去。
|
||||
|
||||
#### D.3 抽取与规范化
|
||||
|
||||
`raw = "".join(full)` 是模型完整正文。`normalize_draft(raw, ...)` 负责「不信任模型排版」的全部兜底(抽 JSON、配平括号、挑内容最丰富的 segments 数组、字段模糊匹配、镜数对齐补齐),详见 [`script_agent.py`](../backend/apps/ai/script_agent.py) 的 `_extract_json` / `_resolve_segments` / `_pick_field`。
|
||||
|
||||
#### D.4 精准改一镜的合并
|
||||
|
||||
`target_index is not None and base_draft` 时,模型虽被要求只改第 N 镜并输出完整稿,但后端不信它会乖乖保留其余镜——`_merge_single_segment` 以基准稿深拷贝为底,**只用新稿的第 N 镜替换**,其余镜逐字保持,再整体规范化。模型没产出目标镜则抛错(不静默返回 base 空转计费)。
|
||||
|
||||
### 阶段 E · 自检卡 + draft 事件
|
||||
|
||||
```python
|
||||
yield _sse({"type": "tool", "id": "generate", "status": "done"})
|
||||
yield _sse({"type": "tool", "id": "extract", "label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜", "status": "done"})
|
||||
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
|
||||
yield _sse({"type": "draft", "draft": draft})
|
||||
```
|
||||
|
||||
注意 `extract` / `check` 卡直接发 `done`——它们是**对已完成结果的事后陈述**(实体数、镜数都已知),不是真有独立的耗时步骤,目的是补齐 agent 工作流的叙事完整性。`draft` 事件把结构化稿交给前端做卡片化渲染。
|
||||
|
||||
### 阶段 F · 落库 + 结算额度
|
||||
|
||||
```python
|
||||
try:
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = {"raw": raw[:8000]}
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now(); task.save(...)
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
|
||||
script = persist_script_draft(project=..., task=task, draft=draft, source=source)
|
||||
settled = True # charge 已提交
|
||||
except Exception as exc:
|
||||
_fail_task(task, reservation, f"保存脚本失败:{exc}"); settled = True
|
||||
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
|
||||
return
|
||||
```
|
||||
|
||||
- **charge 与落库同一事务**:`charge_reserved_credit` 和 `persist_script_draft` 在同一个 `transaction.atomic()` 里。落库失败则 atomic 回滚 charge,`_fail_task` 补释放预留——钱和数据强一致。
|
||||
- `settled = True` 标记额度已结算,给最外层 finally 看(见第 3 节)。
|
||||
- `persist_script_draft` 建 `ScriptVersion + ScriptSegment`,并把 entities 回填 `project.metadata`(cast/scenes/script_entities),把 SCRIPT 阶段标 `NEEDS_REVIEW`。
|
||||
|
||||
### 阶段 G · saved / summary / done
|
||||
|
||||
```python
|
||||
yield _sse({"type": "saved", "script_version_id": str(script.id),
|
||||
"version": ScriptVersionSerializer(script).data})
|
||||
summary = _closing_summary(raw)
|
||||
if summary:
|
||||
yield _sse({"type": "summary", "text": summary})
|
||||
yield _sse({"type": "done"})
|
||||
```
|
||||
|
||||
`_closing_summary` 取「最后一个 JSON 对象之后的文字」当 AI 回复气泡——这是模型在协议第 3 步写的口语交付语("这版主打熬夜痛点,钩子用了反差,你可以再让我调 CTA")。去掉收尾的 ``` 围栏,若残留 `{` 或太短(<4 字)则返回空串,由前端兜底默认句。
|
||||
|
||||
---
|
||||
|
||||
## 3. 计费生命周期与断连兜底(最易踩坑处)
|
||||
|
||||
整段生成包在:
|
||||
|
||||
```python
|
||||
settled = False
|
||||
try:
|
||||
... # 阶段 D~G
|
||||
finally:
|
||||
if not settled:
|
||||
_fail_task(task, reservation, "stream aborted (client disconnected)")
|
||||
```
|
||||
|
||||
为什么必须用 `finally` 而不是普通 `except`:
|
||||
|
||||
> 客户端中途断连时,Django 会对生成器调用 `.close()`,在当前 `yield` 处抛 **`GeneratorExit`**。它继承自 `BaseException` 而非 `Exception`,普通 `except Exception` 抓不到。若不处理,预扣的额度会永久冻结(既没 charge 也没 release)。
|
||||
|
||||
`settled` 标志覆盖所有路径:
|
||||
|
||||
| 退出路径 | settled | finally 动作 |
|
||||
| ---- | ---- | ---- |
|
||||
| 正常完成(charge 成功) | True | 不动 |
|
||||
| 生成异常(D.4 except) | True(已 `_fail_task`) | 不动 |
|
||||
| 落库失败(F except) | True(已 `_fail_task`) | 不动 |
|
||||
| 客户端断连(GeneratorExit) | False | `_fail_task` 释放预扣 |
|
||||
|
||||
`_fail_task` 自身也防御性 `try/finally`:先置任务 FAILED,再 `release_credit`,release 失败也吞掉(不让兜底逻辑自身抛异常)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键技术细节备忘
|
||||
|
||||
| 细节 | 说明 |
|
||||
| ---- | ---- |
|
||||
| **同步生成器 + StreamingHttpResponse** | `stream_script_agent` 是普通同步 `def + yield`,不是 async。Django 的 `StreamingHttpResponse` 直接迭代它,每 `yield` 一帧立即下发。 |
|
||||
| **关 nginx 缓冲** | 端点设 `X-Accel-Buffering: no` + `Cache-Control: no-cache`,否则 nginx 会攒够 buffer 才下发,破坏逐帧体感。 |
|
||||
| **DRF 必须挂 SSE renderer** | `@action(..., renderer_classes=[ServerSentEventRenderer])`,否则 DRF 内容协商返回 406。 |
|
||||
| **UTF-8 强制** | 底层 `chat_completion_stream` 设 `response.encoding = "utf-8"`,SSE 不带 charset 时 requests 默认 latin-1 会让中文乱码。 |
|
||||
| **temperature 0.85** | 脚本生成要发散有创意;对比实体提取那条用 0.3(结构化抽取要稳,降 JSON 漂移)。 |
|
||||
| **同 id 工具卡原地更新** | `running → done/error` 复用同一 `id`,前端据 id 更新而非新增卡片。 |
|
||||
| **reasoning 不进 full** | 思考流纯展示,`continue` 跳过累积,避免污染待解析正文。 |
|
||||
| **raw 截断存档** | `task.response_payload = {"raw": raw[:8000]}`,存证据但限长,失败时可回看模型到底吐了啥。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端消费契约(给前端对接者)
|
||||
|
||||
按 `type` 分派即可:
|
||||
|
||||
- `tool`:维护一个 `Map<id, {label, status}>`,渲染成进度卡列表;同 id 更新状态。
|
||||
- `reasoning`:追加到「思考过程」可折叠区(灰字、逐字滚动)。
|
||||
- `delta`:追加到 AI 前言气泡。
|
||||
- `draft`:用结构化数据渲染分镜卡片(hook/tone/segments),可直接编辑。
|
||||
- `saved`:拿 `script_version_id` 标记当前稿,`version` 是完整序列化对象可直接入列表。
|
||||
- `summary`:作为 AI 的收尾回复气泡(没有则用默认句兜底)。
|
||||
- `done`:关闭 loading。
|
||||
- `error`:弹 `detail`,此时后端已回滚额度,前端无需补偿。
|
||||
|
||||
---
|
||||
|
||||
## 6. 涉及文件索引
|
||||
|
||||
| 文件 | 角色 |
|
||||
| ---- | ---- |
|
||||
| [`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) | 本文主体:`stream_script_agent` 编排 + normalize/merge/persist |
|
||||
| [`apps/projects/views.py`](../backend/apps/projects/views.py) | `script_agent_stream` 端点 + `ServerSentEventRenderer` |
|
||||
| [`apps/ai/providers/volcano.py`](../backend/apps/ai/providers/volcano.py) | `chat_completion_stream` 底层 SSE,reasoning/delta 分流 |
|
||||
| [`apps/ai/services.py`](../backend/apps/ai/services.py) | `build_provider` 可插拔分流、`create_ai_task` 预扣 |
|
||||
| [`apps/billing/services/ledger.py`](../backend/apps/billing/services/ledger.py) | `reserve/charge/release_credit` |
|
||||
| [`skills/ecommerce-video-script/SKILL.md`](../backend/skills/ecommerce-video-script/SKILL.md) | 领域知识(系统提示词) |
|
||||
@@ -0,0 +1,311 @@
|
||||
# 脚本 Agent 编排架构方案 · 动态知识装配(流式管道)
|
||||
|
||||
> 状态:设计方案(未落地代码) · 作者:架构评审 · 日期:2026-06-24
|
||||
> 关联代码:[`apps/ai/script_agent.py`](../backend/apps/ai/script_agent.py) · [`apps/ai/services.py`](../backend/apps/ai/services.py) · [`skills/ecommerce-video-script/`](../backend/skills/ecommerce-video-script/)
|
||||
> 关联文档:[脚本Agent流式SSE技术文档.md](脚本Agent流式SSE技术文档.md)(现状) · [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md)(实测数据)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与要解决的问题
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
当前脚本生成把领域知识一次性全量灌入上下文:`load_ecommerce_skill()`(见 [script_agent.py:54](../backend/apps/ai/script_agent.py#L54))
|
||||
用 `glob("*.md")` **无条件遍历全部 references**,整篇拼成系统提示词,每次对话(不论全自动/一句话/改稿/改一镜)
|
||||
都把这 ~28K 字符全量发给模型。
|
||||
|
||||
SKILL.md 里虽写了一张「输入模式路由 / 参考资料索引」表(指明哪个品类该读哪几篇),但**该路由仅作为提示词
|
||||
发给模型,后端并未按它选择性加载**——检索发生在模型的注意力里,不在后端。
|
||||
|
||||
### 1.2 随业务增长的问题
|
||||
|
||||
> **核心痛点:上下文随电商品类数量线性膨胀。**
|
||||
|
||||
现在 5 个 references = 28K 字符。未来叠加更多品类话术(美妆/食品/3C/服饰/家居/母婴/宠物/家电…)
|
||||
与平台调性后,全量灌入会:
|
||||
|
||||
- **上下文臃肿**:单次请求 system prompt 可能涨到数十万字符,逼近/超出上下文窗口;
|
||||
- **成本线性上涨**:每次都付全量知识的 token,即便这单商品只用得上其中一个品类包;
|
||||
- **注意力稀释**:无关品类的话术挤占模型注意力,可能拉低相关品类的发挥;
|
||||
- **缓存难命中**:动态拼接的大 prompt 难以稳定复用 prefix cache。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
把「静态全量灌入」改为「**按需动态装配**」:拿到商品需求后,**只加载与该商品相关的知识模块**,
|
||||
打包给模型,流式生成,再经过滤/提取输出前端。**单次上下文只随"命中的 1-2 个品类包"走,不随品类总量膨胀。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标(验收标准)
|
||||
|
||||
| # | 目标 | 可度量标准 |
|
||||
| - | ---- | ---------- |
|
||||
| G1 | 上下文不随品类总数膨胀 | 单次 system prompt 字数 ≈ 内核 + 命中模块,与品类总数解耦 |
|
||||
| G2 | 输出格式永不因知识缺失而塌 | 任意路由结果下,输出契约恒在 prompt 中 |
|
||||
| G3 | 全程流式 | 路由/装配/生成/提取每阶段都有 SSE 进度事件 |
|
||||
| G4 | 运营可扩品类不改代码 | 加一个品类 = 加一个知识模块(文件/DB),无需改 Python |
|
||||
| G5 | 不引入与任务不匹配的重型框架 | 沿用生成器流式编排,不上状态图运行时 |
|
||||
| G6 | 平滑迁移 | 分阶段落地,每阶段可独立上线、可回滚 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构:六段流式管道
|
||||
|
||||
```
|
||||
商品需求(product + 前置条件)
|
||||
│
|
||||
▼ ① 路由 Router ────────── 识别品类/平台 → 决定加载哪些知识模块
|
||||
│ SSE: tool router "识别品类:美妆洗护 · 平台:抖音"
|
||||
│
|
||||
▼ ② 装配 Assembler ─────── 取「内核 + 命中品类包 + 命中平台调性」
|
||||
│ SSE: tool assemble "装配知识:内核+2模块 共 9.2K 字" ← 可见地证明未膨胀
|
||||
│
|
||||
▼ ③ 打包 Packager ──────── 内核(恒在) + 动态知识 + 商品上下文 + 输出契约
|
||||
│
|
||||
▼ ④ 生成 Generator ─────── 约束解码(tool/structured)流式出结构
|
||||
│ SSE: reasoning(思考) / delta(口语前言)
|
||||
│
|
||||
▼ ⑤ 过滤提取 Extractor ─── 校验/归一/抽实体/扫违规词(强不变量,后端兜底)
|
||||
│ SSE: tool extract "提取实体4个 · 自检通过"
|
||||
│
|
||||
▼ ⑥ 前端 Sink ──────────── draft / saved / summary / done
|
||||
```
|
||||
|
||||
**与现状的本质差异**:②③ 从"glob 全部"变为"只装命中"。①②⑤ 是真实工作步骤,不再是装样子的工具卡。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心设计:知识分两层
|
||||
|
||||
把现有单块 28K 知识拆成**内核(恒在)+ 模块(动态)**两层。
|
||||
|
||||
### 4.1 内核 Kernel(每次必带,小而稳)
|
||||
|
||||
| 内容 | 来源(现状) |
|
||||
| ---- | ---------- |
|
||||
| 黄金结构(钩子→痛点→卖点→CTA)、档位×结构映射 | methodology.md 的结构部分 |
|
||||
| **输出契约(铁律1):字段名锚定、JSON 形状、镜数规则** | SKILL.md 铁律1 + `_OUTPUT_PROTOCOL` |
|
||||
| 写作红线:≤55字、违规词清单、口语化 | methodology.md 红线 + SKILL.md 铁律3 |
|
||||
| 字段纪律:tone/role 枚举、entity 引用合法性 | SKILL.md 铁律1 |
|
||||
|
||||
> ⚠️ **输出契约必须在内核里,永远在。** 这是你们踩过的坑(skills 没进镜像→契约丢失→模型吐散文解析失败)
|
||||
> 的正式解。无论路由加载了哪些品类包,格式硬底线都不会塌。现有 `_EXTRACT_OUTPUT_CONTRACT`
|
||||
> (见 [services.py:385](../backend/apps/ai/services.py#L385))写死兜底,就是这一思想的雏形——把它正式化为"内核"。
|
||||
|
||||
### 4.2 品类模块 Module(按商品命中才加载,多而长)
|
||||
|
||||
| 模块类型 | 例 | frontmatter 选择维度 |
|
||||
| ------- | -- | ------------------- |
|
||||
| 品类话术 | 美妆/食品/3C/服饰/家居… | `applies_to: [category...]` |
|
||||
| 平台调性 | 抖音/快手/小红书/视频号 | `platforms: [...]` |
|
||||
| 钩子库分册 | 痛点提问/反差/数字冲击… | `tone: [...]` 或 always |
|
||||
|
||||
每个模块是一个独立的、带元数据索引的知识单元(不再是一坨大 concat)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 路由机制:怎么选模块
|
||||
|
||||
三种机制,按本场景适配度排序。**推荐 rule-first 混合**。
|
||||
|
||||
### 5.1 元数据路由(主力 · 先落地这个)
|
||||
|
||||
把 SKILL.md 的路由表**从提示词搬进代码**:每个模块 frontmatter 声明它服务的品类/平台,
|
||||
`select_knowledge(product)` 按 `product.category` / 平台前置条件命中。
|
||||
|
||||
- **优点**:零额外调用、确定性、可解释、可单测。电商商品基本都有 category 字段,**80% 情况足够**。
|
||||
- **缺点**:新品类要维护映射——但加品类 = 加一个 `.md` 模块 + 写 frontmatter,**不改 Python**(满足 G4)。
|
||||
|
||||
### 5.2 向量检索 RAG(扩容兜底 · 品类破百再上)
|
||||
|
||||
把知识块 embedding,用商品上下文检索 top-K 相关片段。
|
||||
|
||||
- **优点**:处理模糊/新品类(novel category 自动匹配近邻),可无限扩。
|
||||
- **缺点**:引入检索失败模式(检错块→知识缺失)、需 embedding 基建与运维。**别过早引入。**
|
||||
|
||||
### 5.3 LLM 路由(灵活但加跳)
|
||||
|
||||
用便宜快模型(如 doubao-lite)先分类"该商品属哪类、用哪套话术"。
|
||||
|
||||
- **优点**:最灵活,能理解复杂商品描述。**缺点**:多一次调用 + 延迟。
|
||||
|
||||
### 5.4 推荐:rule-first 混合
|
||||
|
||||
```
|
||||
select_knowledge(product):
|
||||
modules = [Kernel] # 恒在
|
||||
hit = rule_match(product.category, platform) # 5.1 规则命中
|
||||
if hit:
|
||||
modules += hit
|
||||
else:
|
||||
modules += fallback() # 命中不到:回落(全量核心包 or 5.2 检索)
|
||||
return modules
|
||||
```
|
||||
|
||||
**先只做 5.1 规则版,留好 `fallback()` 接口**;品类规模或模糊度上来时,把 `fallback` 换成检索/LLM 路由。
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口设计
|
||||
|
||||
### 6.1 知识模块结构(frontmatter 规范)
|
||||
|
||||
每个 reference 模块在文件头加 YAML frontmatter(或等价 DB 字段):
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: playbook-beauty
|
||||
type: category # core | category | platform | hook
|
||||
applies_to: [美妆, 护肤, 洗护, 彩妆] # 命中这些 category 时加载
|
||||
platforms: [] # 限定平台(空=不限)
|
||||
keywords: [精华, 面膜, 口红, 防晒] # 检索/模糊命中用
|
||||
priority: 10
|
||||
enabled: true
|
||||
---
|
||||
(正文:该品类的话术、语气、卖点侧重…)
|
||||
```
|
||||
|
||||
`core` 类型 = 内核,恒加载;其余按 `applies_to`/`platforms`/`keywords` 命中。
|
||||
|
||||
### 6.2 选择函数(替换 `load_ecommerce_skill`)
|
||||
|
||||
```python
|
||||
# apps/ai/knowledge.py(新增)
|
||||
@dataclass
|
||||
class KnowledgeModule:
|
||||
id: str
|
||||
type: str # core|category|platform|hook
|
||||
applies_to: list[str]
|
||||
platforms: list[str]
|
||||
keywords: list[str]
|
||||
body: str
|
||||
|
||||
def load_registry() -> list[KnowledgeModule]:
|
||||
"""扫 skills/ 下模块(含 frontmatter),或读 DB。缓存。"""
|
||||
|
||||
def select_knowledge(*, product, platform: str | None, mode: str) -> list[KnowledgeModule]:
|
||||
"""rule-first:内核恒在 + 按 category/platform 命中品类包/平台调性;
|
||||
命中不到走 fallback(全量核心 or 检索)。改稿模式可少带选题类模块。"""
|
||||
|
||||
def assemble_system_prompt(modules: list[KnowledgeModule]) -> tuple[str, int]:
|
||||
"""拼 system prompt = 内核(置顶稳定,利于 prefix cache) + 动态模块。
|
||||
返回 (prompt, 字数) —— 字数用于 SSE 上报,可见证明未膨胀。"""
|
||||
```
|
||||
|
||||
`build_agent_messages`(见 [script_agent.py:111](../backend/apps/ai/script_agent.py#L111))
|
||||
的 `system = load_ecommerce_skill() + _OUTPUT_PROTOCOL` 改为
|
||||
`system, n = assemble_system_prompt(select_knowledge(...))`,其中输出契约并入内核。
|
||||
|
||||
### 6.3 SSE 事件扩展
|
||||
|
||||
在现有 `tool/reasoning/delta/draft/saved/summary/done` 基础上,让 ①②⑤ 成为**真实**工具卡:
|
||||
|
||||
| 事件 | 新增/变化 | 载荷 |
|
||||
| ---- | -------- | ---- |
|
||||
| `tool: router` | 新增 | `{label:"识别品类:美妆·抖音", status, meta:{category, platform}}` |
|
||||
| `tool: assemble` | 新增 | `{label:"装配知识 内核+2模块 9.2K字", status, meta:{module_ids, chars}}` |
|
||||
| `tool: generate` | 不变 | 约束解码流式 |
|
||||
| `tool: extract` | 强化 | 真实体提取 + 违规词自检结果 |
|
||||
|
||||
> `assemble` 卡把"这次只装了 9.2K 而非 28K"**可观测地**展示给用户/运维,是 G1 的活体证明。
|
||||
|
||||
---
|
||||
|
||||
## 7. 生成与过滤提取(④⑤)
|
||||
|
||||
### 7.1 生成:约束解码,让 normalize 退居安全网
|
||||
|
||||
结合 [出格式实测-模型产出汇总.md](出格式实测-模型产出汇总.md) 的实测结论:
|
||||
|
||||
- 三模型(豆包/GPT-5.5/Gemini-3.1-pro)的 structured/tool 均可产出 `raw_clean✓` 的契约 JSON;
|
||||
- **freeform 下三家原始输出全 `raw_clean✗`**(靠 `normalize_draft` fuzzy 抢救);
|
||||
- **tool 策略跨模型 segments 键集完全同构**(最稳)。
|
||||
|
||||
→ 生成阶段改用 **tool/structured 约束解码**(schema 须补全 `dialogue/speaker/voice_ref` 等契约字段),
|
||||
内核保留输出契约文字作双保险。`normalize_draft` 从"主力解析器"降为"安全网"。
|
||||
|
||||
> ⚠️ 约束解码与"先写口语前言"的 `_OUTPUT_PROTOCOL` 有张力(实测 GPT structured 被前言污染)。
|
||||
> 落地时**对话气泡(前言/收尾)与结构稿分离**:结构走纯约束,气泡另起轻量一跳或用支持混合流的部件协议。
|
||||
|
||||
### 7.2 过滤提取:强不变量后端兜底
|
||||
|
||||
沿用并简化现有逻辑(约束解码后原始已干净,兜底压力骤降):
|
||||
|
||||
- 镜数对齐(=时长/15)、role/tone 枚举归一、entity_refs 合法性 —— `normalize_draft` 现有能力;
|
||||
- 实体抽取(角色/场景)—— 复用 [services.py](../backend/apps/ai/services.py) 的 `run_extract_entities_task`;
|
||||
- 违规词自检 —— 可前置为真校验节点(发现即标记/可触发重生成)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 为什么不用 LangGraph
|
||||
|
||||
本管道是**线性流水线**(router→assemble→generate→extract→sink):**无环、无 reflect-retry、无多 agent 对话**。
|
||||
线性 + 流式正是现有生成器范式的最佳 altitude。LangGraph 的状态图是为"有环/有分支/要回退/human-in-loop"
|
||||
设计的,**此处上图属过度设计**。真正的编排升级点是"选择性装配"这一层抽象,而非更换运行时。
|
||||
|
||||
> 若未来产品要做「生成→自检违规词→自动修正→再检」的真闭环,或「编剧/审查/提取」多 agent 协作,
|
||||
> 那时再评估 LangGraph / PydanticAI(类型契约+重试)/ 轻量自写 retry。当前不需要。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段迁移清单
|
||||
|
||||
每阶段可独立上线、独立回滚。
|
||||
|
||||
### Phase 1 · 知识分层 + 规则路由(止血膨胀,优先级最高)
|
||||
- [ ] references 加 frontmatter(`type/applies_to/platforms/keywords`);抽出 `core` 内核。
|
||||
- [ ] 新增 `apps/ai/knowledge.py`:`load_registry` / `select_knowledge`(规则版)/ `assemble_system_prompt`。
|
||||
- [ ] `build_agent_messages` 改用 `assemble_system_prompt(select_knowledge(...))`;**输出契约并入内核**。
|
||||
- [ ] SSE 增 `router`/`assemble` 真实工具卡(含字数)。
|
||||
- [ ] 单测:命中/未命中/改稿模式各自加载了哪些模块;内核必含契约。
|
||||
- **验收**:单次 system prompt 字数与品类总数解耦(G1);格式零回归。
|
||||
|
||||
### Phase 2 · 约束解码(让 normalize 退居安全网)
|
||||
- [ ] 定义完整 `ScriptDraft` JSON Schema(含 dialogue/speaker/voice_ref)。
|
||||
- [ ] 生成阶段切 tool/structured(按 provider 能力分流,实测已验证三家可行)。
|
||||
- [ ] 对话气泡与结构稿分离,解决 `_OUTPUT_PROTOCOL` 与约束的张力。
|
||||
- [ ] `normalize_draft` 降级为兜底;保留以防个别模型/中转站不合规。
|
||||
- **验收**:三模型原始输出 `raw_clean✓`;normalize 命中率(需抢救比例)大幅下降。
|
||||
|
||||
### Phase 3 · 检索扩容(品类规模化后才做)
|
||||
- [ ] 知识块 embedding + 向量库;`fallback()` 接入检索。
|
||||
- [ ] 模糊/新品类召回评估。
|
||||
- **验收**:新增品类无需改路由规则即可被正确召回。
|
||||
|
||||
### Phase 4(可选)· 自检闭环
|
||||
- [ ] 违规词/字数校验做成真节点,不过则带错误自动重生成(轻量 retry,非全图)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
| ---- | ---- |
|
||||
| **R1 内核漏放契约 → 某品类下格式塌** | 内核**必含**完整输出契约;单测断言"任意路由结果都含契约";保留写死兜底。 |
|
||||
| **R2 路由漏召(该加载却没加载)→ 模型瞎编** | 漏召比误召危险。规则命中不到**必须回落**(全量核心包 or 检索),严禁裸奔。 |
|
||||
| **R3 约束解码与对话气泡冲突** | 结构稿走纯约束、气泡分离(见 7.1);或用支持混合流的部件协议。 |
|
||||
| **R4 prefix cache 未命中,内核成本没摊薄** | 内核置顶且稳定;实测豆包/中转是否支持 prefix cache 再定。 |
|
||||
| **R5 检索引入新失败模式** | Phase 3 才上;上之前用规则兜底;检索结果可解释、可回退规则。 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 与现有代码映射(速查)
|
||||
|
||||
| 设计组件 | 现状 | 落点 |
|
||||
| ------- | ---- | ---- |
|
||||
| 内核 + 模块拆分 | `load_ecommerce_skill()` glob 全部 | 新 `apps/ai/knowledge.py` |
|
||||
| 路由 `select_knowledge` | SKILL.md 路由表(给模型看) | 新 `knowledge.py`,规则实现 |
|
||||
| 装配 `assemble_system_prompt` | 字符串拼全部 | 新 `knowledge.py`,内核+命中 |
|
||||
| 打包 | `build_agent_messages` system 拼接 | 改 [script_agent.py:122](../backend/apps/ai/script_agent.py#L122) |
|
||||
| 生成(约束解码) | freeform + `_OUTPUT_PROTOCOL` | 改 provider 调用,见 [providers/](../backend/apps/ai/providers/) |
|
||||
| 过滤提取 | `normalize_draft` 当主力 | 降为安全网 [script_agent.py:307](../backend/apps/ai/script_agent.py#L307) |
|
||||
| 流式编排 | `stream_script_agent` 生成器 | 沿用,增 router/assemble 事件 [script_agent.py:561](../backend/apps/ai/script_agent.py#L561) |
|
||||
| 模块运营管理 | `references/*.md` 文件 | frontmatter 文件,或复用 `PromptTemplate` admin DB 模式 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 一句话总结
|
||||
|
||||
把「静态全量灌」改成「**内核恒在 + 品类模块按需装配**」的**线性流式管道**:路由先用规则(SKILL 路由表搬进代码)、
|
||||
扩容再上检索;生成换约束解码让 `normalize_draft` 退居安全网;流式编排沿用现有生成器,**不需要 LangGraph**。
|
||||
如此品类再叠,单次上下文也只随"命中的一两个包"走,**不随品类总量膨胀**。
|
||||