给 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

这里有两个容易踩的坑:

  1. thought: true 过滤。不过滤的话,模型的推理碎碎念会混进给用户的文本,极端情况下思考内容里如果夹带 inlineData 还会错位取图。虽然请求里已经 includeThoughts: false,但响应侧仍然防御性过滤——对模型返回的结构永远不要只信请求侧的开关
  2. 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 + 转存错误

重试分两层:

  1. LLM 层自动重试:工具返回的错误会作为 tool 消息回到对话上下文,LLM 可以看到"生成失败:超时",自行决定换个 prompt 或降规格重试——这是工具化抽象的免费红利。
  2. 用户层手动重试:对话卡片和工坊页面都提供"重新生成",本质是同 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. 多模型切换

模型不是硬编码,而是三层可替换:

  1. 模型作为参数MediaWork.model 逐作品记录,图片可选 gemini-3.1-flash-image-preview / gemini-3-pro-image-preview,视频可选 seedance 系列的 pro/fast 变体——fast 版出片快但质量略低,把选择权交给用户(或 LLM 按场景决定)。
  2. SPI 作为接缝:接外部图片 API(DALL-E 等)就是新增一个 implements MediaGenerationGateway,配置切换实现即可,上层 MediaAppService / Controller / 工具定义零改动。
  3. 工具描述跟随模型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 变成平台能力的分水岭。