模特上身图提示词重构 + 图片创作页 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
This commit is contained in:
@@ -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) | 领域知识(系统提示词) |
|
||||
Reference in New Issue
Block a user