第 7 课 照片绘本:DeepSeek 看图写故事,豆包配插画,两个模型接力 | 大模型多模态专题

返回专题

做完你会得到什么

一个「照片绘本」App:选一张家里小猫的照片,半分钟后得到一页绘本——题目、两三段小故事,配一张温暖绘本风的插画。不满意可以「换个结局」,或者换成「中国水墨」风格「重画插画」。

照片绘本界面预览

图为执行最终 .aia 里的真实积木生成的布局预览,不是真机截图;AI 的回答与照片、生成的图片都是示意。

难度 用时 前置 用到的模型
★★★ 60 分钟 第 3 课(JSON 解析)、第 4 课(文生图) DeepSeek(看图写故事)+ 豆包 Seedream(画插画)

下载本课 .aia(PhotoStory.aia)

接力是怎么跑的

照片 ──压缩──▶ DeepSeek.ChatWithImage ──GotText──▶ 解析 JSON
                                                   ├─ title  → 标签_故事题目
                                                   ├─ story  → 标签_故事
                                                   └─ scene  ──+风格──▶ Doubao.GenerateImage ──GotImage──▶ 图像_插画

第一棒让 DeepSeek 做它擅长的「看懂照片、写故事」,并顺手写一句画面描述(scene);第二棒豆包不用看照片,只按这句描述画。这样插画和故事内容一定对得上。

为什么不直接把照片交给豆包图生图?因为图生图会照着照片的构图画,画出来是「照片变画风」(那是第 5 课);这里要的是故事里的场景——小猫坐在月亮船上,照片里可没有月亮船。

准备

准备什么 说明
DeepSeek API Key 同第 1 课(标签名 deepseekKey)
火山方舟 API Key 同第 4 课(标签名 doubaoKey),并已开通 Seedream

两个 Key 用和前几课一样的标签名存在微数据库里。同一台手机上做过前几课的话,打开本课 App 两个 Key 已经在了——前提是用同一个 App(伴侣调试时都是 AI 伴侣这一个 App,所以共用)。

界面组件

组件 命名 关键属性 / 作用
密码输入框 ×2 密码输入框_密钥、密码输入框_密钥2 各配一个保存按钮
下拉框 下拉框_风格 元素字串:温暖绘本,中国水墨,吉卜力动画,儿童蜡笔
图像 图像_照片、图像_插画  
标签 标签_故事题目、标签_故事 题目标签也当卡片标题用
按钮 按钮_换结局、按钮_重画 默认不启用
DeepSeek(拓展) DeepSeek1 设计器里把 StreamMode 设为假(要完整 JSON)
Doubao(拓展) Doubao1 默认属性
Web 客户端 Web客户端1 只用来解码 JSON

分步搭

第 1 步:两个 Key,一个状态行

两个 Key 各存各的,状态行把「哪个已填、哪个未填」说清楚。

when Screen1.Initialize() {
  组件美化1.SetAsCard(垂直布局_密钥)
  组件美化1.SetAsCard(垂直布局_照片)
  组件美化1.SetAsCard(垂直布局_故事)
  // 要拿完整 JSON 再解析,写故事这一步不用流式(设计器里已关掉 StreamMode)
  密码输入框_密钥.Text = 微数据库1.GetValue("deepseekKey", "")
  密码输入框_密钥2.Text = 微数据库1.GetValue("doubaoKey", "")
  DeepSeek1.APIKey = 密码输入框_密钥.Text
  Doubao1.APIKey = 密码输入框_密钥2.Text
  显示密钥状态()
}

when 按钮_保存密钥.Click() {
  var 密钥 = trim(密码输入框_密钥.Text)
  if 密钥 == "" {
    底部提示条1.Show("先粘贴 DeepSeek 的 API Key")
  } else {
    密码输入框_密钥.Text = 密钥
    微数据库1.StoreValue("deepseekKey", 密钥)
    DeepSeek1.APIKey = 密钥
    显示密钥状态()
    底部提示条1.Show("DeepSeek API Key 已保存")
  }
}

when 按钮_保存密钥2.Click() {
  var 密钥 = trim(密码输入框_密钥2.Text)
  if 密钥 == "" {
    底部提示条1.Show("先粘贴火山方舟的 API Key")
  } else {
    密码输入框_密钥2.Text = 密钥
    微数据库1.StoreValue("doubaoKey", 密钥)
    Doubao1.APIKey = 密钥
    显示密钥状态()
    底部提示条1.Show("火山方舟 API Key 已保存")
  }
}

procedure 显示密钥状态() {
  if DeepSeek1.APIKey == "" or Doubao1.APIKey == "" {
    标签_密钥状态.Text = join("还没填完:DeepSeek ", choose(DeepSeek1.APIKey == "", "未填", "已填"), ",豆包 ", choose(Doubao1.APIKey == "", "未填", "已填"), "。写故事用 DeepSeek,画插画用豆包,两个都要。")
  } else {
    标签_密钥状态.Text = "两个都已保存,只存在这台手机上。"
  }
}

第 2 步:第一棒——看图写故事,约定三个字段

提示词同时约定内容(给 6 到 10 岁孩子、温暖、200 字左右)和格式(只回 JSON,三个字段)。scene 的要求最讲究:要写清主角、动作、场景,不要出现文字和对话——生图模型画字常常画成乱码。

global 正在忙 = false
global 画面 = ""

when 按钮_拍照.Click() {
  照相机1.TakePicture()
}

when 照相机1.AfterPicture(image) {
  写故事(image)
}

when 图像选择器_相册.AfterPicking() {
  写故事(图像选择器_相册.Selection)
}

procedure 写故事(图片) {
  if 正在忙 {
    底部提示条1.Show("还在写上一个故事,稍等一下")
  } else if DeepSeek1.APIKey == "" or Doubao1.APIKey == "" {
    底部提示条1.Show("先在上面把两个 API Key 都填好")
  } else {
    var 小图 = 图片处理1.Compress(图片, 1024, 80)
    if 小图 == "" {
      底部提示条1.Show("这张图片处理不了,换一张试试")
    } else {
      图像_照片.Picture = 小图
      图像_插画.Picture = ""
      标签_故事题目.Text = "③ 故事"
      标签_故事.Text = ""
      开始忙("正在看图写故事……")
      DeepSeek1.StartNewChat()
      DeepSeek1.ChatWithImage("看这张照片,为 6 到 10 岁的孩子写一个温暖的小故事。只输出一个 JSON 对象,不要任何解释,不要代码块标记。对象有三个字段:title 是故事题目(不超过 12 个字);story 是故事正文(200 字左右,分 2 到 3 段);scene 是一句插画画面描述(不超过 60 字,写清主角、动作和场景,不要出现文字和对话)。", 小图)
    }
  }
}

两个 Key 都填好才开始:第一棒跑完、第二棒才发现豆包没 Key,白白花了一次写故事的钱。

第 3 步:解析,然后交棒

解析与第 3 课相同:从第一个 { 截到最后一个 } 再解码。拿到之后马上调用「画插画」——这就是接力的那一下。

给缺失字段兜底:dictget(结果, "scene", dictget(结果, "title", …)) 的意思是「没有 scene 就用 title,再没有就用一句默认的」,保证第二棒手里一定有东西可画。

when DeepSeek1.GotText(text) {
  // 与第 3 课相同:从第一个 { 截到最后一个 } 再解码
  var 开头 = startsat(text, "{")
  var 结尾 = 0
  var 结果 = ""
  for i = 1 to textlength(text) {
    if segment(text, i, 1) == "}" {
      结尾 = i
    }
  }
  if 开头 > 0 and 结尾 > 开头 {
    结果 = Web客户端1.JsonTextDecodeWithDictionaries(segment(text, 开头, 结尾 - 开头 + 1))
  }
  if isdict(结果) and dictget(结果, "story", "") != "" {
    标签_故事题目.Text = join("③ ", dictget(结果, "title", "无题"))
    标签_故事.Text = dictget(结果, "story", "")
    // 画面描述缺失时退回用题目,保证第二棒有东西可画
    画面 = dictget(结果, "scene", dictget(结果, "title", "一个温暖的童话场景"))
    画插画()
  } else {
    结束忙("故事没按约定的格式返回,再选一次照片试试。")
  }
}

模型在 JSON 字符串里写的 \n,解码后就是真正的换行,标签里会分段显示。

第 4 步:第二棒——画插画

画面描述 + 风格 + 固定要求(色彩柔和、不要文字)拼成生图提示词。「重画插画」只重跑这一棒,不重写故事、不再花 DeepSeek 的钱。

procedure 画插画() {
  开始忙("故事写好了,正在画插画……")
  Doubao1.GenerateImage(join(画面, "。", 下拉框_风格.Selection, "风格的儿童绘本插画,色彩柔和,画面里不要出现任何文字。"))
}

when 按钮_重画.Click() {
  if not 正在忙 and 画面 != "" {
    画插画()
  }
}

when Doubao1.GotImage(imageUrl) {
  图像_插画.Picture = imageUrl
  结束忙("完成!可以换个结局,或换一种风格重画插画。")
}

中途验证:选一张照片。状态行先「正在看图写故事……」,几秒后题目和故事出现、状态变成「正在画插画……」,再过十几秒插画出来。

第 5 步:换个结局(多轮对话)

「换个结局」用普通 Chat:对话里已经有照片和上一版故事,只需说「换一个不同的结局,格式不变」。新故事回来走的还是第 3 步的 GotText,于是自动重画对应的插画。

when 按钮_换结局.Click() {
  if not 正在忙 {
    开始忙("正在换一个结局……")
    DeepSeek1.Chat("换一个完全不同的结局,其余要求不变,同样只输出包含 title、story、scene 三个字段的 JSON 对象。")
  }
}

procedure 开始忙(状态) {
  正在忙 = true
  标签_状态.Text = 状态
  按钮_拍照.Enabled = false
  图像选择器_相册.Enabled = false
  按钮_换结局.Enabled = false
  按钮_重画.Enabled = false
}

procedure 结束忙(状态) {
  正在忙 = false
  标签_状态.Text = 状态
  按钮_拍照.Enabled = true
  图像选择器_相册.Enabled = true
  按钮_换结局.Enabled = 标签_故事.Text != ""
  按钮_重画.Enabled = 画面 != ""
}

追问里要再提一次格式(「同样只输出包含 title、story、scene 的 JSON」),否则模型第二轮很容易放飞成一段普通文字。

第 6 步:分清是哪一棒出的错

两个模型的错误都进同一个「出现错误时」。用 functionName 区分:豆包生图是 GenerateImage,DeepSeek 是 ChatWithImage 或 Chat。JSON 解码失败(JsonTextDecodeWithDictionaries)会紧接着在 GotText 里按「格式不对」处理,这里跳过,免得提示两遍。

when Screen1.ErrorOccurred(component, functionName, errorNumber, message) {
  // 解码失败会紧接着在 GotText 里按「格式不对」处理,这里不重复提示
  if functionName != "JsonTextDecodeWithDictionaries" {
    结束忙(join(choose(functionName == "GenerateImage", "画插画", "写故事"), "失败:", 错误说明(message)))
  }
}

function 错误说明(message) {
  return choose(containstext(message, "401"), "API Key 不对,检查是否抄全。", choose(containstext(message, "402"), "DeepSeek 账户余额不足。", choose(containstext(message, "ModelNotOpen"), "生图模型还没开通,到火山方舟「开通管理」开通 Seedream。", choose(containstext(message, "Sensitive"), "内容被判定为不适合生成,换一张照片试试。", choose(containstext(message, "429"), "请求太频繁了,等几秒再试。", message)))))
}

第二棒失败时故事已经在屏幕上了,「重画插画」仍然可点,直接重试第二棒即可。

这一课的要点

  • 让第一个模型为第二个模型写输入:scene 字段就是写给豆包的提示词。这是多模型 App 最常用的模式。
  • 各司其职:看图写作给对话模型,画画给生图模型;能只重跑一棒就不全部重来。
  • 结构化输出 + 多轮对话:追问时重申格式,GotText 一处解析,所有入口共用。
  • 出错要说清是哪一步:functionName 区分来源,失败后保留已经得到的结果。

常见问题

现象 原因与处理
提示「先在上面把两个 API Key 都填好」 两个都要。状态行会写哪个未填
「写故事失败:API Key 不对」 DeepSeek 的 Key 有问题;「画插画失败:API Key 不对」则是火山方舟的
故事出来了,插画一直没出 第二棒在画,十几秒到几十秒;失败时状态行会写「画插画失败:…」,点「重画插画」重试
「故事没按约定的格式返回」 偶发。DeepSeek 的 StreamMode 要是假(设计器里关掉),流式时 GotText 不会触发
插画和故事对不上 检查 画面 有没有取到 scene;也可以在提示词里要求 scene「必须描写故事高潮的场景」
插画里有乱七八糟的字 生图模型画字不可靠。scene 和生图提示词里都写「不要出现文字」
换结局后故事没变 追问里没重申 JSON 格式,模型回了普通文字,被当成「格式不对」。照第 5 步的写法

平台说明

  • 安卓、鸿蒙都能运行;两个拓展组件在鸿蒙上都有同名实现。
  • 业务检查见验证记录:两个 Key 的状态与保存、少一个 Key 不开始、三个字段解析与换行保留、风格拼进生图提示词、只重画不重写、换结局带着照片与上一版、缺 scene 兜底、格式错误与截断、两棒分别出错的提示都逐条执行过。

学完以后

7 课做完,你已经掌握了把大模型接进 App 的几乎所有套路:识图、思考、结构化输出、生图、图生图、视频、多模型接力。回到专题首页看看还能怎么组合。

文档反馈