MAF 入门(8):多模态实战——让 Agent 看懂图片、处理混合内容

0 阅读8分钟

MAF 入门(8):多模态实战——让 Agent 看懂图片、处理混合内容

写在前面

前七篇里,我们的 Agent 无论多能干——调工具、编排、查知识库——输入输出始终是纯文字。但回想一下你找客服的经历:有多少人会老老实实打字描述问题?大部分人直接甩一张照片过去:"就是这个,怎么回事?"

对纯文本 Agent 来说,这张照片就是天书。而"多模态"(Multimodal)解决的正是这件事:让一条消息里除了文字,还能装下图片,模型看图之后正常用文字回答你。

这篇我们来看 MAF 里怎么发图、怎么看图、以及带图的消息怎么参与多轮对话。好消息是:你几乎不用学新 API——变的只是消息的构造方式。


一、先搞懂:多模态消息在 MAF 里长什么样

1.1 你一直在用的"语法糖"

第 1 篇我们就写过:

var response = await agent.RunAsync("你好,帮我查下天气");

传一个 string 进去,看起来消息就是一串文字。但这是语法糖。消息的"本体"是 ChatMessage 类,它的内容清单 Contents 是一个 AIContent 列表——字符串版本会被自动包装成一个 TextContent 塞进去。

也就是说,一条消息从设计上就不是"一坨文字",而是一份内容清单,清单里的每一项都是 AIContent 的子类:

类型装什么类比
TextContent文字信纸
DataContent二进制数据:图片/音频/视频的字节把实物塞进箱子寄过去
UriContent一个 URL,指向网上的内容告诉对方取货地址,让他自己去拿
FunctionCallContent / FunctionResultContent工具调用的请求与结果第 2 篇见过它们

一条"用户发来两张商品图 + 一句话"的消息,解剖出来是这样:

ChatMessage (Role = User)
├─ TextContent:  "这两件是同一个商品吗?"
├─ DataContent:  product-a.jpg 的字节 (image/jpeg)
└─ DataContent:  product-b.jpg 的字节 (image/jpeg)

文字和图片在清单里平起平坐。构造这样一条消息,就是把 AIContent 们放进列表:

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:

/// <summary>视觉理解模型(多模态图片输入),百炼可用 qwen3-vl-plus</summary>
public string VisionModel { get; set; } = "qwen3-vl-plus";

ChatClientFactory.Create 顺手支持了指定模型创建客户端:

public static IChatClient Create(LlmSettings settings, string? model = null) =>
    CreateOpenAiClient(settings).GetChatClient(model ?? settings.Model).AsIChatClient();

Demo 里这样创建视觉客户端:

IChatClient chatClient = ChatClientFactory.Create(llm, llm.VisionModel);

现在 LlmSettings 里住着三个模型,各司其职:

配置项模型干什么
Modelqwen3.7-plus日常文字对话
VisionModelqwen3-vl-plus看图
EmbeddingModeltext-embedding-v3文本转向量(第 7 篇 RAG 用)

这是真实项目里非常常见的形态:不是一个大模型包打天下,而是按任务分工配多个模型。

3.2 场景 1:本地图片 → DataContent

Demo 里所有"发图"都收敛到一个辅助方法里:

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);
}

拆开看就三步:

  1. 读字节:File.ReadAllBytes 把图片文件读成 byte[];
  2. 转内容:new DataContent(bytes, mediaType)。第二个参数是 MIME 类型(image/jpeg、image/png……),告诉服务端这堆字节是什么格式——写错了有的服务会直接拒收;
  3. 进清单:contents.Add(...),和 TextContent 并排放进 ChatMessage。

场景 1 的调用与运行就这么点:

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,多传几个文件名就行:

ChatMessage message = CreateUserMessage(
    "用户问:图里这两件是同一个商品吗?请说明相同点和不同点。",
    "product-a.jpg", "product-b.jpg");

模型会按清单顺序看图。这也是 AIContent 列表设计的直观之处:想加一张图就加一项,消息结构不用变。

3.4 场景 4:网络图片 → UriContent

如果图片已经有个公网 URL,就别下载再传字节了,直接把地址发给模型:

var message = new ChatMessage(ChatRole.User,
[
    new TextContent("请描述这张网络图片的内容。"),
    new UriContent(SampleWebImageUrl, "image/jpeg"),
]);

UriContent 和 DataContent 只差构造方式:一个给地址,一个给字节。拉图发生在模型服务端——百炼服务器去访问这个 URL,所以它必须是公网可达的。Demo 里对这一步加了 try-catch,URL 失效时打印可读的提示,而不是让一串英文异常刷屏:

catch (Exception ex)
{
    Console.WriteLine($"  [失败] 网络图片不可用或模型拉取失败:{ex.Message}");
    Console.WriteLine("         请把 AgentVisionDemo.SampleWebImageUrl 换成可公网访问的图片链接再试。");
}

3.5 场景 3:带图消息进会话记忆

这是本篇最值得体会的场景。第 3 篇讲过 AgentSession 会自动维护对话历史——而历史里存的本来就是 ChatMessage 列表,带图的消息照样存得进去。

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 列表,所以文字、图片、工具调用结果才能在一条消息里和平共处,也才能无缝流过你前几篇学过的中间件、编排和会话记忆。


附录