MAF 入门(8):多模态实战------让 Agent 看懂图片、处理混合内容
写在前面
前七篇里,我们的 Agent 无论多能干------调工具、编排、查知识库------输入输出始终是纯文字。但回想一下你找客服的经历:有多少人会老老实实打字描述问题?大部分人直接甩一张照片过去:"就是这个,怎么回事?"
对纯文本 Agent 来说,这张照片就是天书。而"多模态"(Multimodal)解决的正是这件事:让一条消息里除了文字,还能装下图片,模型看图之后正常用文字回答你。
这篇我们来看 MAF 里怎么发图、怎么看图、以及带图的消息怎么参与多轮对话。好消息是:你几乎不用学新 API------变的只是消息的构造方式。
一、先搞懂:多模态消息在 MAF 里长什么样
1.1 你一直在用的"语法糖"
第 1 篇我们就写过:
csharp
var response = await agent.RunAsync("你好,帮我查下天气");
传一个 string 进去,看起来消息就是一串文字。但这是语法糖。消息的"本体"是 ChatMessage 类,它的内容清单 Contents 是一个 AIContent 列表------字符串版本会被自动包装成一个 TextContent 塞进去。
也就是说,一条消息从设计上就不是"一坨文字",而是一份内容清单 ,清单里的每一项都是 AIContent 的子类:
| 类型 | 装什么 | 类比 |
|---|---|---|
TextContent |
文字 | 信纸 |
DataContent |
二进制数据:图片/音频/视频的字节 | 把实物塞进箱子寄过去 |
UriContent |
一个 URL,指向网上的内容 | 告诉对方取货地址,让他自己去拿 |
FunctionCallContent / FunctionResultContent |
工具调用的请求与结果 | 第 2 篇见过它们 |
一条"用户发来两张商品图 + 一句话"的消息,解剖出来是这样:
text
ChatMessage (Role = User)
├─ TextContent: "这两件是同一个商品吗?"
├─ DataContent: product-a.jpg 的字节 (image/jpeg)
└─ DataContent: product-b.jpg 的字节 (image/jpeg)
文字和图片在清单里平起平坐。构造这样一条消息,就是把 AIContent 们放进列表:
csharp
var message = new ChatMessage(ChatRole.User,
[
new TextContent("这两件是同一个商品吗?"),
new DataContent(imageBytes, "image/jpeg"),
]);
RunAsync 的调用方式一个字都不用改------agent.RunAsync(message) 照常跑。这就是 MAF 多模态的全部秘密:消息结构天生支持多模态,你以前传 string 只是用了最简形态。
1.2 两条路:DataContent 还是 UriContent?
发图有两种方式,选哪个取决于图片在哪:
- 图片在你手里 (本地文件、用户上传的字节)→
DataContent:图片会转成 base64 编码,随消息一起发给模型。优点是可靠,模型一定能看到;缺点是消息体积膨胀,每轮请求都要重新传一遍。 - 图片在网上 (有个公开 URL)→
UriContent:只发一个地址,模型服务端自己去下载。消息轻,但要保证模型服务端能访问到这个 URL
二、Demo 场景
还是 Contoso 商城客服。这次模拟最真实的客服日常:用户不好好打字,直接甩图过来。
| 场景 | 演示点 |
|---|---|
| 【1】看图识物 | 本地图片 → DataContent,读出商品标签上的信息 |
| 【2】一条消息多张图 | 图文混排,让模型对比两张商品图 |
| 【3】多轮追问 | 带图消息进 AgentSession,下一轮纯文字也能"记得"图 |
| 【4】网络图片 | UriContent,不下载,直接发 URL |
三、关键代码解析
3.1 第一步:换个"看得见"的模型
跑之前先解决一个最容易踩的坑:默认模型不认图。
我们一直用的 qwen3.7-plus 是纯文本模型,你往消息里塞图片,服务端直接回一个 400 错误。看图需要视觉模型 (百炼的 qwen-vl 系列)。所以给配置加一个 VisionModel:
csharp
/// <summary>视觉理解模型(多模态图片输入),百炼可用 qwen3-vl-plus</summary>
public string VisionModel { get; set; } = "qwen3-vl-plus";
ChatClientFactory.Create 顺手支持了指定模型创建客户端:
csharp
public static IChatClient Create(LlmSettings settings, string? model = null) =>
CreateOpenAiClient(settings).GetChatClient(model ?? settings.Model).AsIChatClient();
Demo 里这样创建视觉客户端:
csharp
IChatClient chatClient = ChatClientFactory.Create(llm, llm.VisionModel);
现在 LlmSettings 里住着三个模型,各司其职:
| 配置项 | 模型 | 干什么 |
|---|---|---|
Model |
qwen3.7-plus | 日常文字对话 |
VisionModel |
qwen3-vl-plus | 看图 |
EmbeddingModel |
text-embedding-v3 | 文本转向量(第 7 篇 RAG 用) |
这是真实项目里非常常见的形态:不是一个大模型包打天下,而是按任务分工配多个模型。
3.2 场景 1:本地图片 → DataContent
Demo 里所有"发图"都收敛到一个辅助方法里:
csharp
private static ChatMessage CreateUserMessage(string text, params string[] imageFiles)
{
Console.WriteLine($"用户: {text}");
var contents = new List<AIContent> { new TextContent(text) };
foreach (string file in imageFiles)
{
string path = Path.Combine(AppContext.BaseDirectory, "assets", file);
string mediaType = Path.GetExtension(path).ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "image/jpeg",
};
byte[] bytes = File.ReadAllBytes(path);
contents.Add(new DataContent(bytes, mediaType));
Console.WriteLine($" [图片] {file} ({bytes.Length / 1024.0:F0} KB, {mediaType})");
}
return new ChatMessage(ChatRole.User, contents);
}
拆开看就三步:
- 读字节 :
File.ReadAllBytes把图片文件读成byte[]; - 转内容 :
new DataContent(bytes, mediaType)。第二个参数是 MIME 类型(image/jpeg、image/png......),告诉服务端这堆字节是什么格式------写错了有的服务会直接拒收; - 进清单 :
contents.Add(...),和TextContent并排放进ChatMessage。
场景 1 的调用与运行就这么点:
csharp
AIAgent agent = AgentFactory.CreateStateless(chatClient, SystemInstructions, "VisionAgent");
ChatMessage message = CreateUserMessage(
"这是我们收到的用户实拍图,请读出图里的商品名称、型号、价格等关键信息。",
"product-label.jpg");
AgentResponse response = await agent.RunAsync(message, cancellationToken: cancellationToken);
Console.WriteLine($"助手: {response.Text}");
Agent 的创建和其他篇一模一样,SystemInstructions 里只是多叮嘱了一句"用户消息里可能带有图片"。
3.3 场景 2:一条消息多张图
多张图没有新 API------CreateUserMessage 用了 params,多传几个文件名就行:
csharp
ChatMessage message = CreateUserMessage(
"用户问:图里这两件是同一个商品吗?请说明相同点和不同点。",
"product-a.jpg", "product-b.jpg");
模型会按清单顺序看图。这也是 AIContent 列表设计的直观之处:想加一张图就加一项,消息结构不用变。
3.4 场景 4:网络图片 → UriContent
如果图片已经有个公网 URL,就别下载再传字节了,直接把地址发给模型:
csharp
var message = new ChatMessage(ChatRole.User,
[
new TextContent("请描述这张网络图片的内容。"),
new UriContent(SampleWebImageUrl, "image/jpeg"),
]);
UriContent 和 DataContent 只差构造方式:一个给地址,一个给字节。拉图发生在模型服务端------百炼服务器去访问这个 URL,所以它必须是公网可达的。Demo 里对这一步加了 try-catch,URL 失效时打印可读的提示,而不是让一串英文异常刷屏:
csharp
catch (Exception ex)
{
Console.WriteLine($" [失败] 网络图片不可用或模型拉取失败:{ex.Message}");
Console.WriteLine(" 请把 AgentVisionDemo.SampleWebImageUrl 换成可公网访问的图片链接再试。");
}
3.5 场景 3:带图消息进会话记忆
这是本篇最值得体会的场景。第 3 篇讲过 AgentSession 会自动维护对话历史------而历史里存的本来就是 ChatMessage 列表,带图的消息照样存得进去。
csharp
var historyProvider = new InMemoryChatHistoryProvider();
AIAgent agent = AgentFactory.CreateWithSessionHistory(
chatClient, SystemInstructions, "VisionSessionAgent", historyProvider);
AgentSession session = await agent.CreateSessionAsync(cancellationToken);
// 第一轮:发图(混合消息)
ChatMessage imageTurn = CreateUserMessage(
"这是用户刚发来的商品图,帮我记一下图里的关键信息。",
"product-label.jpg");
AgentResponse first = await agent.RunAsync(imageTurn, session, cancellationToken: cancellationToken);
// 第二轮:纯文字追问,模型只能靠会话历史里的图片回答
var followUp = new ChatMessage(ChatRole.User, "再问一下:刚才那张图里的商品价格是多少?");
AgentResponse second = await agent.RunAsync(followUp, session, cancellationToken: cancellationToken);
第一轮用 RunAsync(ChatMessage, session) 把带图消息发出去;第二轮只是普通文字。如果 Agent 能答出"图里的价格",说明历史里的 DataContent 被原样带给了模型------图片和文字一样,都是会话记忆的一等公民。
对照第 3 篇的 RunAsync(userText, session),这里只是把 string 换成了 ChatMessage,仅此而已。
四、常见坑
1. 给文本模型传图,直接 400。 最常见也最冤。症状是返回 400 Bad Request,错误信息里带 "image" / "multimodal" 之类字样。解法就一句话:确认模型支持视觉(百炼用 qwen-vl 系列),并且客户端确实用 VisionModel 创建的。
2. 图片太大,钱花得不明不白。 DataContent 走 base64 编码,体积比原文件还膨胀三分之一;而且视觉模型普遍按分辨率折算 token------一张 4000px 的照片,可能比整段对话文字还贵。发图前先压缩:长边 1500px 左右,肉眼看清楚就够了。
3. 图片太糊,模型开始"编"。 让模型读标签上的小字(型号、价格),图片分辨率一定要够。字迹模糊时它不会说"看不清",而是自信地猜一个------多模态幻觉和文本幻觉一样真实。关键信息建议截图放大或拍清楚。
4. mediaType 和实际格式不符。 PNG 文件标成 image/jpeg,有的服务端能容错,有的直接报错。按扩展名推断再传,别写死。
5. UriContent 的 URL 模型拉不到。 内网地址(localhost、192.168.x.x)、带鉴权、有防盗链的链接,百炼服务器都访问不了。要么换公网可达的 URL,要么干脆下载下来走 DataContent。
6. 多轮对话里,图片每轮都重新计费。 场景 3 的原理是:历史里的 ChatMessage(含图)每轮都完整发给模型。图片轮次越多、追问越多,成本越高。生产环境常见的做法是:图片轮的答案落库后,把历史里的图片替换成文字摘要("用户发了卫衣吊牌图,型号 CL-2024-W,¥129"),后续轮次只传摘要。这属于进阶优化,知道有这回事就行。
五、小结
| 概念 | Demo 中的实现 | 生产建议 |
|---|---|---|
| 图片内容 | DataContent(bytes, mediaType) |
先压缩,长边 1500px 内 |
| 网络图片 | UriContent(url, mediaType) |
确保模型服务端公网可达 |
| 图文混合 | ChatMessage + AIContent 列表 |
文字放前面,给模型明确指令 |
| 多轮记忆 | RunAsync(ChatMessage, session) |
注意历史里的图片重复计费 |
| 模型分工 | Model / VisionModel / EmbeddingModel |
按任务选型,不追求一个模型全包 |
回头看,这篇几乎没有引入"新东西":RunAsync 还是那个 RunAsync,AgentSession 还是那个 AgentSession,变的只是消息------从一串文字,变成一份可以同时装下文字和图片的内容清单 。这正是 MAF / MEAI 设计上的先见之明:ChatMessage 的内容从第一天起就是 AIContent 列表,所以文字、图片、工具调用结果才能在一条消息里和平共处,也才能无缝流过你前几篇学过的中间件、编排和会话记忆。