给 Agent 平台加"画画"和"拍视频"能力,表面上是对接两个生成 API,实际上是一条完整的管线:生成能力要被抽象成 LLM 可调用的工具、图片和视频的同步/异步模型完全不同、产物要转存到自己的对象存储、失败要可重试、生成的作品还要能被打分形成质量闭环。这篇文章复盘我们的内部 Agent 平台在媒体生成上的设计取舍。
1. 两个入口,一条管线
媒体生成有两个产品入口,但后端共用同一条管线:
| 场景 | 描述 | 交互形态 |
|---|---|---|
| 对话中生成 | Agent 对话里自然语言描述需求,LLM 调用工具生成 | "帮我画一张赛博朋克风格的城市夜景,16:9" → 工具卡片直接出图 |
| 创意工坊 | 独立页面 /studio,直接输 prompt + 调参 |
左侧参数面板(模型/宽高比/分辨率/数量),右侧作品瀑布流 |
关键决策是对话入口复用工具机制:文生图、文生视频就是两个内置工具 bt_generate_image / bt_generate_video,Agent 配置里勾选激活后,LLM 自己决定何时调用——平台不做任何"如果用户说画画就调画图工具"的硬编码。
2. 总体架构
前端: 聊天页 (ToolCallBlock 扩展) + Studio 页
│ HTTP REST / 工具调用
Boot: MediaController ToolGatewayImpl
POST /api/media/image case bt_generate_image →
POST /api/media/video case bt_generate_video →
GET /api/media/works
MediaAppService
→ @Async 调用 Domain SPI
MediaGenerationGateway (Domain SPI)
│
Infra: ModelGatewayMediaClient implements MediaGenerationGateway
图片: POST {mediaBase}/api/v1/images/generations
Base64 内联数据 → 解码 → 对象存储
视频: POST {mediaBase}/api/v1/videos
{id, status} → 轮询 GET /api/v1/videos/{id}
completed → 下载 video_url → 对象存储
MediaWorkMapper → MySQL media_work 表
对象存储 bucket: agent-media (签名 URL 访问)
分层上严格遵守平台既有的 COLA 约束:Domain 层只放实体和一个 SPI 接口,模型网关对接细节全部关在 Infrastructure 层的一个实现类里。
3. Domain 抽象:一个作品实体 + 三个方法
MediaWork 实体——图片和视频共用:
| 字段 | 类型 | 说明 |
|---|---|---|
workId |
String PK | gen_ + nanoid(16) |
spaceId / userId |
— | 空间隔离与归属 |
type |
String | IMAGE / VIDEO |
model |
String | 实际使用的生成模型 |
prompt |
String | 原始提示词 |
config |
JSON | {aspectRatio, imageSize, seconds, size} |
externalTaskId |
String | 模型网关任务 ID(视频用) |
status |
String | PROCESSING / COMPLETED / FAILED |
progress |
Integer | 0-100(视频用) |
result |
JSON | {urls:[], mimeType, text, duration} |
error |
String | 失败原因 |
rating / ratingComment / ratingDimensions |
— | 评分闭环(见 §7) |
SPI 只有三个方法,恰好对应"同步"与"异步任务"两种生成范式:
public interface MediaGenerationGateway {
MediaWork generateImage(MediaWork work); // 同步:调用即得结果
MediaWork submitVideo(MediaWork work); // 异步:提交任务,拿 externalTaskId
MediaWork checkVideo(MediaWork work); // 异步:查一次任务状态
}
图片走 generateImage 一把梭;视频拆成 submit/check 两步,状态机留在平台侧,网关只负责无状态的单次查询——这样轮询策略(间隔、超时)可以独立调整,且服务重启后靠数据库里 PROCESSING 状态的记录就能恢复轮询,不丢任务。
4. 图片生成:同步调用 + Base64 转存
我们对接的内部模型网关,图片接口是 Gemini Content API 风格:
// POST /api/v1/images/generations
{
"model": "gemini-3.1-flash-image-preview",
"contents": [
{ "role": "user", "parts": [{ "text": "生成一只可爱的猫咪,卡通风格" }] }
],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": { "aspectRatio": "1:1", "imageSize": "1K" },
"thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": false }
}
}
响应里图片不是 URL,而是base64 内联数据,且 parts 数组里混着模型的中间思考:
{
"candidates": [{
"content": {
"parts": [
{ "text": "(中间推理过程…)", "thought": true },
{ "text": "这是一只可爱的卡通风格猫咪:", "thought": false },
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo..." }, "thought": false }
]
}
}]
}
处理管线:
POST → candidates[0].content.parts
→ 过滤 thought:true 的 part(★ 中间思考必须跳过)
→ 提取 inlineData (mimeType + data)
→ Base64 解码 → byte[]
→ 上传对象存储: agent-media/{spaceId}/IMAGE/{workId}_{n}.png
→ work.result = {"type":"image","urls":["签名URL"],"mimeType":"image/png","text":"描述文字…"}
→ work.status = COMPLETED
这里有两个容易踩的坑:
thought: true过滤。不过滤的话,模型的推理碎碎念会混进给用户的文本,极端情况下思考内容里如果夹带 inlineData 还会错位取图。虽然请求里已经includeThoughts: false,但响应侧仍然防御性过滤——对模型返回的结构永远不要只信请求侧的开关。- base64 必须转存,不能透传。内联 base64 体积大、无法 CDN、没法做签名鉴权,而且模型网关不保证数据持久。统一解码上传到自有对象存储(路径带 spaceId,与平台空间隔离一致),前端拿 24h 有效的签名 URL。
5. 视频生成:异步状态机与轮询
视频生成是分钟级任务,同步等待不可行。模型网关提供的是标准的异步任务接口:
// POST /api/v1/videos
{ "model": "doubao-seedance-2-0", "prompt": "...", "seconds": 5, "size": "1920x1080" }
// → { "id": "vid_abc123", "status": "queued", "progress": 0 }
// GET /api/v1/videos/vid_abc123 (轮询)
// → { "id": "vid_abc123", "status": "completed", "progress": 100, "video_url": "https://.../output.mp4" }
平台侧的状态机:
submitVideo:
POST → 拿到 taskId
→ work.externalTaskId = taskId, work.status = PROCESSING
→ @Async 启动轮询(每 3s 一次,最长 10min)
checkVideo(每 tick):
GET /api/v1/videos/{taskId}
status = queued/processing → 更新 work.progress,继续
status = completed → 下载 video_url → 上传对象存储
→ work.status = COMPLETED, result 落库
status = failed → work.status = FAILED, error 落库
轮询超 10min → work.status = FAILED, error = "生成超时"
外部状态 queued → processing → completed/failed 被收敛为内部三态 PROCESSING / COMPLETED / FAILED——对外只暴露自己的状态机,前端不需要理解任何模型网关的状态词汇。
几个刻意的取舍:
- 轮询而不是 webhook:模型网关是内部服务,没有回调机制,轮询是最低对接成本;3s 间隔对分钟级任务足够平滑,progress 还能给前端一个进度条。
- completed 后立即转存:
video_url是模型网关侧的临时地址,有过期时间,必须立刻下载转存到自己的对象存储。转存失败视同生成失败处理。 - 超时即失败,不静默重试:10 分钟没完成直接落 FAILED,避免僵尸任务占着轮询线程。重试的决策交给上一层(见 §6)。
对话场景里有个体验细节:视频工具调用是"提交即返回"——LLM 立刻拿到任务 ID 并告诉用户"视频正在生成",前端凭 workId 轮询 GET /api/media/works/{id} 拿进度。这让 LLM 的流式回复不会被分钟级任务卡死。
6. 失败处理与重试
失败在这套系统里是一等公民,不是异常路径:
| 失败点 | 处理 |
|---|---|
| 图片 API 调用异常 | work.status = FAILED,error 落库,工具返回结构化错误给 LLM |
| 图片响应无 inlineData | 同上(模型拒答/安全拦截时会只回文本) |
| 视频任务 failed | FAILED + 外部错误信息原文落库 |
| 视频轮询超时(10min) | FAILED + "生成超时" |
| 视频下载/转存失败 | FAILED + 转存错误 |
重试分两层:
- LLM 层自动重试:工具返回的错误会作为 tool 消息回到对话上下文,LLM 可以看到"生成失败:超时",自行决定换个 prompt 或降规格重试——这是工具化抽象的免费红利。
- 用户层手动重试:对话卡片和工坊页面都提供"重新生成",本质是同 prompt 再提交一次;对话里更自然的做法是直接说"把色调改成黄昏暖色调",LLM 改 prompt 后调工具生成新图,旧作品保留在历史里。
我们不做一个容易被诱惑加上的东西:平台侧自动重试。生成失败的常见原因是 prompt 触发安全拦截或模型能力边界,原样重试成功率低、成本双倍。让重试带着"人的修改意图"或"LLM 的调整"发生,单位成本的成功率高得多。
7. 评分闭环:人工打分 + 模型自评
生成的作品不是终点。media_work 上挂了三个评分字段:rating(1-5 总分)、ratingComment(文字评价)、ratingDimensions(多维度评分 JSON),构成一个双通道闭环。
人工通道:用户在作品卡片上打 1-5 分、可选写评论。如果提交的是多维度评分(构图、色彩、相关性…),服务端自动算均值回填总分:
public MediaWork rateWork(String workId, Integer rating, String ratingComment, String ratingDimensions) {
var work = getWork(workId);
if (ratingDimensions != null && !ratingDimensions.isBlank()) {
work.setRatingDimensions(ratingDimensions);
// 有维度评分时自动计算总体评分(各维度均值四舍五入)
List<Map<String, Object>> dims = JsonUtil.fromJson(ratingDimensions, ...);
double avg = dims.stream().map(d -> d.get("score"))...average()...;
rating = (int) Math.round(avg);
}
work.setRating(rating);
work.setRatingComment(ratingComment);
...
}
模型通道(AI 建议分):作品完成后 @Async 触发一次自动评价——把图片/视频连同原始 prompt 一起喂给一个视觉理解模型,要求它按预置维度打 1-5 分并严格以 JSON 格式返回。这里有整套防御性解析:
- 响应先剥 markdown 代码围栏再解析;
- 每个维度单独查找得分,解析不到就默认 3 分(中位分)并 clamp 到 [1,5];
- 整体解析失败只记 warn 日志,绝不影响主流程——评分是增强,不是关键路径。
评价结果同样落 ratingDimensions,前端标注为"AI 建议分"与人工分并列展示。闭环的价值在于:低分作品 + 评论沉淀为 prompt 改进的语料,维度分的分布能暴露模型短板(比如"相关性"常年低分说明 prompt 转写环节丢了用户意图),后续做模型选型对比时也有了量化依据。
8. 多模型切换
模型不是硬编码,而是三层可替换:
- 模型作为参数:
MediaWork.model逐作品记录,图片可选gemini-3.1-flash-image-preview/gemini-3-pro-image-preview,视频可选 seedance 系列的 pro/fast 变体——fast 版出片快但质量略低,把选择权交给用户(或 LLM 按场景决定)。 - SPI 作为接缝:接外部图片 API(DALL-E 等)就是新增一个
implements MediaGenerationGateway,配置切换实现即可,上层 MediaAppService / Controller / 工具定义零改动。 - 工具描述跟随模型:
bt_generate_image的工具 description 里写明当前可用模型和参数枚举(宽高比、分辨率档位),LLM 据此正确填参——模型清单变化时同步更新工具定义,这属于"工具描述是 prompt 的一部分"的常规维护。
还有一个容易忽略的配置决策:媒体接口的 base URL 独立配置。图片/视频在 /api/v1/...,与聊天补全的路径不同,虽然同属一个模型网关、复用同一把 API Key,但 base URL 独立配置让媒体服务将来迁移/拆分时不牵连聊天链路。
9. 工具化收尾:让前端认识"图片结果"
最后一块拼图是对话里的渲染。工具结果约定了一个结构化载荷:
{"type":"image", "urls":["https://…/xxx.png"], "mimeType":"image/png", "text":"这是一只…"}
聊天页的 ToolCallBlock 检测 result.type === "image" 就从通用的 JSON 展示切换为 <img> 卡片,附带下载/放大/重新生成;视频同理切换为 <video>。工具协议里留一个 type 字段,让前端有能力为不同媒介做特化渲染,比让前端去猜 URL 后缀健壮得多。
10. 回顾:这条管线的设计要点
| 决策 | 理由 |
|---|---|
| 生成能力做成内置工具 | LLM 自主决定何时生成,零硬编码编排 |
| 图片同步 / 视频 submit+check 拆 SPI | 状态机留在平台侧,轮询可恢复、策略可独立调整 |
| 一切产物转存自有对象存储 | 模型侧 URL 会过期;base64 不可直接对终端 |
过滤 thought: true part |
模型中间思考绝不能漏进用户可见产物 |
| 失败落库为一等状态,不做平台侧自动重试 | 原样重试成功率低;让 LLM/用户带着调整意图重试 |
| 人工评分 + 视觉模型自评双通道 | 作品质量可度量,反哺 prompt 与模型选型 |
| 模型逐作品记录 + SPI 接缝 | 多模型并存、外部 API 可插拔 |
媒体生成的"接 API"部分一个下午就能写完,真正的工作量全在这条管线上——而这部分恰恰是把生成能力从 demo 变成平台能力的分水岭。