多模态理解与生成
只会读写文字的 Agent,等于既聋又瞎:它看不懂用户发的截图、听不懂录音,更别提自己画图、剪视频。这篇围绕 DashScope 上 Qwen 与万相两族模型,讲透多模态 Agent 的两条能力主线——「理解」(图片/音频/视频 → 文字)与「生成」(文字/图片 → 图像/视频),并点破背后的两套调用协议与「同步调用 vs 异步任务」的分水岭,适合想给 LangChain.js 应用接上多模态能力的开发者
Leo
2026.09.06 · Updated 2026.09.10
让 Agent 长眼睛又长手:多模态理解与生成一次吃透
为什么写这篇:只会读字的 Agent,等于既聋又瞎
回想你做的第一个 LLM 应用:给模型一段文字,模型回一段文字。够用吗?够,但天花板很低——因为现实世界的问题,从来不是纯文字的:
- 用户往对话框里甩一张截图,问「这个报错怎么解」,文字通道根本接不住;
- 客服 Agent 收到一条用户发来的语音,听不懂,只能回复「请打字描述」;
- 运营想让 Agent 每天自动产出配图、甚至一条短视频,纯文字模型只能干瞪眼。
一句话:理解侧缺「眼睛耳朵」,生成侧缺「手」。 多模态就是给 Agent 补上这两样东西。
这篇以 DashScope(阿里云百炼)上的 Qwen 与万相(Wan)模型 为例,把多模态开发讲透。读完你会发现一件反直觉的事:让模型看懂图片,和让它凭空画一张图,是两种完全不同的事——不只在模型上分家,连调用协议、同步异步都不在一个世界。
先说结论,方便你带着主线往下读:
多模态 Agent = 两条能力主线:理解主线把图片/音频/视频变成文字(吃进去),生成主线把文字/图片变成图像/视频(吐出来)。理解走「OpenAI 兼容协议 + ChatOpenAI」一条路,生成走「DashScope 原生协议」另一条路;图像生成是一次同步调用,视频生成是一次异步任务。两条主线、两套协议、两种节奏,别混。
先看全景:多模态 = 两条主线、两族模型、两套通道
把工程里所有 demo 摊开,其实就两件事:
- 理解主线(media → text):图片理解、音频理解、视频理解;
- 生成主线(text/media → media):文生图、图像编辑、文生视频、图生视频。
画成一张图就是:
能看到两族模型各管一边:Qwen 负责「看懂」,万相负责「创作」。它们在能力上互补,但各自不是一路货色——最明显的证据,是工程里用了两个 SDK、两套参数风格来调它们。
| 通道 | 协议长相 | 用的 SDK 类 | 负责谁 |
|---|---|---|---|
| 理解/对话通道 | OpenAI 兼容(baseURL 指到 dashscope.aliyuncs.com/compatible-mode/v1) | ChatOpenAI(@langchain/openai) | Qwen:识图、听音、看视频 |
| 生成通道 | DashScope 原生(content 是 {text}/{image},不是 type 字段) | MultiModalConversation / VideoSynthesis(dashscope-sdk-official) | 万相:文生图、图像编辑、生视频 |
要点:两个通道用的是同一个 key——DashScope 的
apiKey既能让 ChatOpenAI 走兼容模式,也能让原生 SDK 走自家协议。多模态素材本身不发给模型文件,而是给一个模型能访问到的 URL(详见最后一节「素材从哪来」)。
最小单元:一条消息里塞多种「内容块」
不管哪条主线,代码看起来都像在「拼积木」:一条用户消息,里面不是一个字符串,而是一个「内容块数组」。多模态的全部魔法,都藏在数组里那个块怎么写。
理解通道里,一个块长这样——有个 type 字段告诉模型「你即将收到什么」:
content: [
{ type: 'text', text: '这段音频里说了什么?' }, // 文字块
{ type: 'input_audio', input_audio: { data: 素材, format: 'wav' } }, // 音频块
]
换一种素材,就换 type 和携带数据的字段:图片是 image_url、音频是 input_audio、视频是 video_url。文本仍是 text。文字和其它模态,能同时装进同一条消息——这正是多模态交互的基础(比如「看这张图,告诉我里面有几个人」)。
而万相生成通道则走 DashScope 原生格式,块长这样(注意没有 type 字段,用的是 text / image 两个裸键):
content: [{ text: '一间有着精致窗户的花店' }] // 文生图:只有文字
content: [{ text: '把背景改成下雪', image: 图片URL }] // 图像编辑:指令 + 原图
要点:理解用
type: 'xxx'标记素材类型,生成用{text}/{image}裸键——这是两套协议最直观的差别;一条消息可以混装文字和素材;text是两种格式都认的公共语言。
第一条主线|理解:识图、听音、看视频,全是一个套路
先说结论,理解这一整段其实只有一件事:model.invoke([...内容块])。管你是图是声是视频,套路一模一样——换模型名 + 换内容块类型,其它代码一行不用改。
图片理解:qwen-vl-plus
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: 'qwen-vl-plus',
configuration: { baseURL: process.env.OPENAI_BASE_URL }, // 兼容模式地址
});
const response = await model.invoke([
new HumanMessage({
content: [
{ type: 'text', text: '详细描述这张图片的内容' },
{ type: 'image_url', image_url: { url: 'https://xxx/dog_and_girl.jpeg' } },
],
}),
]);
console.log(response.content);
音频理解:换成 omni 全模态模型
把上面对话模型名换成 qwen-omni 系模型,内容块换成 input_audio,其它照抄:
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: 'qwen3.5-omni-flash', // 改动 1:模型换成全模态 omni
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const response = await model.invoke([
new HumanMessage({
content: [
{ type: 'text', text: '这段音频里说了什么?' },
{
type: 'input_audio', // 改动 2:内容块换成音频
input_audio: { data: 'https://xxx/cherry.wav', format: 'wav' },
},
],
}),
]);
input_audio.data 填媒体 URL 即可;音频理解模型对时长和格式有上限,超长音频需要先转写分段——这是接入时最容易踩的坑。
视频理解:依旧同一个套路
视频只是把内容块换成 video_url,模型继续用 omni:
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: 'qwen3.5-omni-flash',
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const response = await model.invoke([
new HumanMessage({
content: [
{ type: 'text', text: '总结这个视频的主要内容' },
{ type: 'video_url', video_url: { url: 'https://xxx/1.mp4' } },
],
}),
]);
要点:理解通道是一次同步
invoke,模型直接把「看懂的内容」作为文字返回;三份代码的差异只有模型名和内容块类型两行,这就是多模态「统一接口」的价值;音频/视频理解都走 qwen-omni 全模态模型(一只模型同时吃三种输入),图片走专门的 qwen-vl。
理解模型怎么挑:单模态识图用 vl,一石多鸟用 omni
代码写起来一样,但模型选错直接白干——每个模型能吃的输入模态是固定的。挑模型先想清楚「这一路进来的是什么」:
| 输入素材 | 能看它的模型 | 说明 |
|---|---|---|
| 图片 | qwen-vl-plus 等 vl 系 | 视觉语言模型,识图专用 |
| 音频 | omni 系(如 qwen3.5-omni-flash) | 全模态,听声、看视频都行 |
| 视频 | omni 系 | 同上 |
| 图片 + 音频(同一路) | omni 系 | vl 不吃音频,别拿 vl 硬扛 |
要点:一次只进图片 → 用 vl;要同时处理音/视频或混合模态 → 用 omni;vl 和 omni 是「分工」不是「升级关系」,选型先看输入再决定。
第二条主线|生成:万相文生图,一次调用直接拿图
理解是「吃」,生成是「做」。生成这一侧不再走 ChatOpenAI,而改用 DashScope 原生 SDK,因为万相模型不在 OpenAI 兼容协议里。
最朴素的文生图,模型是 wan2.6-t2i,一次同步调用、直接返回图片 URL:
import 'dotenv/config';
import { writeFileSync } from 'node:fs';
import { Configuration, MultiModalConversation } from 'dashscope-sdk-official';
const client = new MultiModalConversation(new Configuration({
apiKey: process.env.OPENAI_API_KEY,
}));
const result = await client.call({
model: 'wan2.6-t2i',
messages: [
{ role: 'user', content: [{ text: '一间有着精致窗户的花店,漂亮的木质门,摆放着花朵' }] },
],
size: '1280*1280', // 输出分辨率,格式「宽*高」
n: 1, // 生成张数
watermark: false, // 关掉「AI 生成」水印
prompt_extend: true, // 让模型自动扩写提示词,出图质量更稳
});
if (result.status_code !== 200 || result.code) {
throw new Error(result.message ?? `Request failed: ${result.status_code}`);
}
const imageUrl = result.output?.choices?.[0]?.message?.content?.[0]?.image;
console.log('image URL:', imageUrl);
// 拿到的 URL 下载落地
const img = await fetch(imageUrl);
writeFileSync('output.png', Buffer.from(await img.arrayBuffer()));
注意返回结构:结果在 output.choices[0].message.content[0].image——生成结果包进了对话式的 choices 结构,把「创作」包装成了一次「多模态对话」的回话,这是 MultiModalConversation 这个类名的由来。
要点:文生图是同步调用,一次
call等到底、直接拿 URL;生成走原生协议,别再想用 ChatOpenAI;想控制出图,把玩prompt_extend(扩写)、size、watermark、n这四个旋钮。
进阶生成:一张图改东改西——万相图像编辑
文生图是「无中生有」,图像编辑则是「有中生有」——给我一张原图,照我的指令改。模型换成 wan2.6-image,玩法变化很小:把「指令」和「原图 URL」塞进同一条 user 消息即可:
const client = new MultiModalConversation(new Configuration({
apiKey: process.env.OPENAI_API_KEY,
}));
const result = await client.call({
model: 'wan2.6-image',
messages: [
{
role: 'user',
content: [
{ text: '把图片背景改成下雪的冬天,人物保持不变' }, // 指令
{ image: 'https://xxx/dog_and_girl.jpeg' }, // 原图
],
},
],
enable_interleave: false, // false = 图像编辑;true = 图文混排生成
size: '1K',
});
这个 enable_interleave 开关值得单独说:它控制着同一条消息里文字和图片的「用法」。
false(默认):图像编辑——消息里的图片是「被编辑的原材料」,文字是「怎么改」的指令,模型输出改完的一张图;true:图文混排生成——文字和参考图是并列的输入,模型按整体描述从零生成,而不是改你那张图。
要点:图像编辑 = 指令 + 原图 URL 同 message;
enable_interleave是「编辑 vs 混排生成」的开关,先想清楚你要的是「改它」还是「照着它重新画」;同样走同步MultiModalConversation,返回结构同文生图。
更难的一档:视频生成是「异步任务」,不是一次调用
图片是静态的,跑一次同步调用能等;视频是几十帧画面,生成要几十秒甚至几分钟——没有 HTTP 请求能扛这么久。所以视频生成换了个模型类 VideoSynthesis,玩法从「等一次调用」变成「提交任务 → 轮询状态 → 拿成片」。
好消息是 SDK 把轮询封装好了:VideoSynthesis.call 内部自动提交任务并轮询至完成,你只写一次调用:
文生视频:wan2.6-t2v
import { Configuration, VideoSynthesis } from 'dashscope-sdk-official';
const client = new VideoSynthesis(new Configuration({
apiKey: process.env.OPENAI_API_KEY,
}));
const result = await client.call({
model: 'wan2.6-t2v',
prompt: '一只橘猫在窗台上晒太阳,微风吹动窗帘,镜头缓慢推进,电影质感',
size: '1280*720', // 文生视频用 size(宽*高)
prompt_extend: true,
duration: 5, // 视频时长(秒)
watermark: false,
});
if (result.output?.task_status === 'FAILED') {
throw new Error(result.output?.message ?? 'Task failed');
}
const videoUrl = result.output?.video_url;
console.log('video URL:', videoUrl);
const video = await fetch(videoUrl);
writeFileSync('output.mp4', Buffer.from(await video.arrayBuffer()));
图生视频:wan2.6-i2v-flash
跟文生视频相比只多一个 首帧参考图——因为视频要有「第一张画面」作为起点:
const result = await client.call({
model: 'wan2.6-i2v-flash',
prompt: '女孩缓缓转头,海风吹动头发,阳光洒在沙滩上,镜头缓慢推进',
img_url: 'https://xxx/dog_and_girl.jpeg', // 首帧参考图,图生视频必填
resolution: '720P', // 图生视频用 resolution,不是 size!
duration: 5,
prompt_extend: true,
});
注意一个隐蔽的坑:文生视频用 size(宽*高),图生视频用 resolution(720P/1080P 档位)——两个字段名不一样,传错了要么报错要么被忽略。
把两段并排看,异步任务的特征一目了然:返回里没有「成片内容」,只有 output.task_status(SUCCEEDED/FAILED…) 和 output.video_url;失败时靠 task_status === 'FAILED' 兜底抛错。
要点:视频生成是异步任务,代码层面由
VideoSynthesis自动提交+轮询,看起来仍是「一次调用」,但你要懂它背后是两个 HTTP 动作(提交、查状态),超长任务建议自己拉长超时;文生视频填size,图生视频填resolution+img_url;成功标志是拿到output.video_url,失败标志是task_status为FAILED。
素材从哪来:媒体必须先能被模型「看见」
回头看前面所有代码,你会发现一个隐藏前提:每个素材都以 URL 形式出现,从来没有「读本地文件」这一步。因为 DashScope 是云端服务,它访问不到你电脑 D:/xxx.jpg——你给的必须是一个公网可达的 URL。
素材上线,无非三种姿势:
| 姿势 | 适用场景 | 说明 |
|---|---|---|
| 公网图床 URL | 测试、演示 | 最快,一行搞定 |
| 对象存储 OSS | 生产、要长期保管原始文件 | 传上去拿到 URL,多模态和多模态 Agent 的标准底座 |
| base64 data URI | 小文件 | 内联在 data 字段,不走公网,但有体积上限 |
其中 OSS 直传是生产标配。最省流量的玩法是前端直传:浏览器把文件直接 POST 给 OSS,服务端只负责用 STS 生成临时签名凭证,文件不经过你的服务器中转。示意如下:
// 服务端:用 ali-oss 生成一个「过期时间 1 天、限制大小」的直传签名
import OSS from 'ali-oss';
const client = new OSS({
region: 'oss-cn-beijing', bucket: 'my-bucket',
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
});
const date = new Date();
date.setDate(date.getDate() + 1);
const policy = client.calculatePostSignature({
expiration: date.toISOString(),
conditions: [['content-length-range', 0, 1048576000]], // 限制 0 ~ 1GB
});
console.log(policy); // OSSAccessKeyId / policy / Signature
浏览器拿到签名后,用 FormData 把 key、policy、signature 连同文件一起 POST 到 OSS 域名,200 即上传成功,此时 host + '/' + 文件名 就是可喂给模型的公网 URL。对象存储怎么选、直传签名里三个字段各是什么,展开讲又是一整篇,这里只记住结论:
要点:喂给多模态模型的永远是 URL,不是本地路径;生产环境素材放对象存储,前端直传(STS 签名)绕过服务器中转,既省带宽又不让文件经过业务服务。
结尾总结:一张决策表 + 一张链路图
兜完两大主线,把「想干什么 → 走哪条通道」收敛成一张表,这是全篇最该背下来的部分:
| 你想让 Agent 做什么 | 通道 | 模型 | 同步/异步 |
|---|---|---|---|
| 看懂一张图片 | OpenAI 兼容 + ChatOpenAI | qwen-vl-plus | 同步 |
| 听懂一段音频 | OpenAI 兼容 + ChatOpenAI | qwen3.5-omni-flash | 同步 |
| 看懂一段视频 | OpenAI 兼容 + ChatOpenAI | qwen3.5-omni-flash | 同步 |
| 根据文字画图 | DashScope 原生 + MultiModalConversation | wan2.6-t2i | 同步 |
| 照着指令改图 | DashScope 原生 + MultiModalConversation | wan2.6-image | 同步 |
| 根据文字生成视频 | DashScope 原生 + VideoSynthesis | wan2.6-t2v | 异步任务 |
| 用首帧图生成视频 | DashScope 原生 + VideoSynthesis | wan2.6-i2v-flash | 异步任务 |
整条链路串起来长这样——理解与生成并行、素材经对象存储供给两端:
多模态开发最迷惑人的地方,是它长得都一样、内里全是分岔:内容块、模型、协议、同步异步,四个维度排列组合。但你只要抓住三根主线就不会乱:
- 分清理解与生成——理解要「看懂并回文字」,生成要「产出媒体」;
- 分清两套协议——Qwen 理解走 OpenAI 兼容(
type内容块),万相生成走原生协议({text}/{image}),别再拿一个 ChatOpenAI 打天下; - 分清两种节奏——图片是同步等结果,视频是异步任务,本质是先提交再轮询。
抓住这三条,再看到任何多模态 Agent 的代码,你都能一眼说出:这是在给模型喂哪只眼、用的是哪条路、要等还是不要等。剩下的,不过是换模型名、换内容块类型、调参数旋钮而已。
Leo
BloggerIndependent developer / Blogger and the maintainer of the original blog “大道至简”. Migrating years of posts and shiyu from WordPress to Next.js.
Reader comments
COMMENTS · 0Leave a comment