与模型对话
Microsoft Agent Framework 框架将各家的 AI SDK 集成,将不同的协议统一封装集成到 AIAgent 类型里面,所以我们在学习 MAF 框架时,重点就是要通过 AIAgent 类型去掌握这些 API 使用和理解各类概念。
本文将通过各种姿势使用 AIAgent,帮助读者去理清楚统一接口,在 Agent 框架基础上实现与模型对话和基础 Agent 开发。
上一章我们把各家模型接进来,统一归一化成了一个 ChatClientAgent。本章从 “连得上” 走到 “能对话” ,学习怎么发起一次普通对话和流式对话、怎么把图片塞进对话、怎么注册函数让模型自己调用、以及多轮对话到底靠什么记住上下文。
本章涉及的所有能力都挂在 AIAgent 这棵树上:
AIAgent (Microsoft.Agents.AI.Abstractions)
│ 公共方法都定义在这里,ChatClientAgent 只是其中一种实现
│
├── 一次对话 → RunAsync("...")
├── 流式对话 → RunStreamingAsync("...") 返回增量片段
├── 多模态(图片) → RunAsync(ChatMessage) 消息里混排文本+图片
├── 工具 调用 → tools: [...] 模型自动调 C# 方法
├── 手动多轮 → RunAsync(IEnumerable<…>) 自己拼历史记录
└── 自动多轮(会话) → AgentSession 框架/服务端帮你记上下文
非流式对话
非流式对话的入口是 RunAsync,它有四个重载,最常用的就是直接传一个字符串,框架会自动把它包成一条 role: user 的消息发出去。
RunAsync 都返回 Task<AgentResponse>,区别只在消息从哪来:
// 1) 不传消息:靠 session 里已有的历史继续(比如接着上一次没说完的话)
Task<AgentResponse> RunAsync(
AgentSession? session = null, AgentRunOptions? runOptions = null, CancellationToken ct = default);
// 2) 传字符串:最常用,框架自动包成 user 消息
Task<AgentResponse> RunAsync(string message,
AgentSession? session = null, AgentRunOptions? runOptions = null, CancellationToken ct = default);
// 3) 传单条 ChatMessage:多模态(图片)走这个
Task<AgentResponse> RunAsync(ChatMessage message,
AgentSession? session = null, AgentRunOptions? runOptions = null, CancellationToken ct = default);
// 4) 传一组消息:手动多轮,自己拼历史记录
Task<AgentResponse> RunAsync(IEnumerable<ChatMessage> messages,
AgentSession? session = null, AgentRunOptions? runOptions = null, CancellationToken ct = default);
使用示例:
using Microsoft.Agents.AI;
using OpenAI;
using System.ClientModel;
AIAgent agent = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个讲笑话高手。",
name: "Joker");
AgentResponse response = await agent.RunAsync("讲一个关于海盗的笑话。");
Console.WriteLine(response); // 直接打印回复文本,它的 ToString() 就是 response.Text
Console.WriteLine(response.Text); // 等价,显式取文本
Console.WriteLine(response.Usage); // Token 用量等元信息
这行代码背后,框架实际发给模型的是这样一串请求,instructions 被当成 system 消息发出去,你传的字符串被当成 user 消息:
实际请求内容:
{
"messages": [
{
"role": "system",
"content": "你是一个讲笑话高手。"
},
{
"role": "user",
"content": "讲一个关于海盗的笑话。"
}
],
"model": "qwen/qwen3.5-9b"
}
非流式对话返回的是 AgentResponse 对象,表示当前一轮的全部结果,它把 OpenAI、Anthropic、Gemini 等厂家接口的格式统一到框架里面,使得开发者可以通过一套代码处理不同的模型渠道。
AgentResponse 属性如下:
| 属性 | 含义 |
|---|---|
Text | 拼接所有 TextContent 得到的纯文本(ToString() 也返回它) |
Messages | 这一轮产生的完整消息列表(可能含多模态、工具调用等内容) |
Usage | UsageDetails,输入/输出 token 用量,用来算成本 |
FinishReason | 结束原因(Stop、Length、ToolCalls 等) |
ResponseId / AgentId | 响应 ID / 产生响应的 agent ID,排查问题时有用 |
ContinuationToken | 分页/续传 token(服务端支持时) |
由于非流式对话属于同步等待,用得应该比较少,所以关于函数调用和多轮对话这些,就不对其展开讲解了。
流式对话
普通对话要等模型把整段话全部生成完才一次性返回。遇到长回复时,用户会盯着空白屏幕干等好几秒,体验很差。流式对话(streaming)则是一边生成一边往外吐字,前端可以实时把字打出来。
使用方法很简单,把 RunAsync 换成 RunStreamingAsync 即可,它返回的是 IAsyncEnumerable<AgentResponseUpdate>,用 await foreach 逐个收:
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("讲一个关于海盗的笑话。"))
{
// update 是增量片段,直接拼到控制台就是逐字打印的效果
Console.Write(update);
}
Console.WriteLine();
笔者想起以前写 MoAI 项目时,使用 SemanticKernel 框架接入不同的模型,兼容性很差,只能自己定义一套消息模型,针对不同的渠道单独设计流式处理规则。
而 Microsoft Agent Framework 通过 AgentResponseUpdate 统一抽象增量片段,将不同模型渠道的接口格式增量内容统一到 AgentResponseUpdate ,开发者使用起来就很简单了。

AgentResponseUpdate 增量片段详解
RunStreamingAsync 流式吐出来的不是一个完整的 AgentResponse,而是一堆 AgentResponseUpdate(增量片段)。可以把它理解成一层一层叠加、最终凑成一句完整回复的碎片。官方对它的定位是:
Represents a single streaming response chunk from an
AIAgent. …… it represents updates that layer on each other to form a single agent response. Conceptually, this combines the roles ofAgentResponseandChatMessagein streaming output.
换句话说,一个 AgentResponseUpdate 同时扮演了两个角色:它既是响应片段(像 AgentResponse),又是消息片段(像 ChatMessage)。一串 update 叠加起来,才等于一次完整的回复。
掌握 AgentResponseUpdate 非常重要,它是后续流式、审批交互、工具调用等的基础,下面把它的字段逐个拆开看,按内容 / 元信息 / 终止信号三类分组,方便你抓住重点。
内容相关
AgentResponseUpdate 中的两个字段:
| 属性 | 类型 | 说明 |
|---|---|---|
Contents | IList<AIContent> | 这一片真正的内容,可空、可多条。文字、图片、工具调用片段都装在这里。访问时若为 null 会自动初始化为空列表(??= []),所以不会抛空引用。 |
Text | string | 只读便捷属性。把 Contents 里所有 TextContent 的文字拼起来的结果;没有文字时返回 string.Empty。ToString() 也是返回它,所以 Console.Write(update) 能直接打出字来。 |
注意
Text是「拼接所有TextContent」,而不是「取第一个」。如果这一片夹了多段文字,Text会把它们连起来。但工具调用、图片这些非TextContent的内容不会被算进Text,要拿它们得直接遍历Contents。
AIContent 是一个内容抽象,有多种实现,所以Contents 里可能包含了图片、函数调用或者深度思考的内容。
Contents: IList<AIContent>
├── TextContent 文字片段(最常见,流式逐字就是靠它)
├── FunctionCallContent 工具调用片段(模型决定调工具时出现)
├── FunctionResultContent 工具结果片段
├── DataContent 二进制内容(图片等)
└── ...
所以判断这一片是不是工具调用要看 AIContent 的具体类型。
string fullText = "";
ChatFinishReason? reason = null;
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("讲个笑话。"))
{
// 1) 边收边打字
Console.Write(update.Text);
fullText += update.Text;
// 2) 捕获终止原因(只有最后一片有)
reason ??= update.FinishReason;
// 3) 捕获工具调用
foreach (var call in update.Contents.OfType<FunctionCallContent>())
Console.WriteLine($"\n[调用工具] {call.Name}");
}
// 流式结束后,检查为什么停了
Console.WriteLine($"\n--- 结束原因:{reason}"); // Stop / Length / ToolCalls ...
角色 / 标识相关
| 属性 | 类型 | 说明 |
|---|---|---|
Role | ChatRole? | 这一片的作者角色,流式里几乎总是 assistant。 |
AuthorName | string? | 作者名字。设值时若是空白字符串会被自动置为 null(源码里 set 做了 IsNullOrWhiteSpace 判断)。 |
AgentId | string? | 产生这一片的 agent 的 ID,排查「是哪个 agent 回的」时有用。 |
ResponseId | string? | 这一片所属整次响应的 ID。同一次流式响应的所有片段,ResponseId 相同。 |
MessageId | string? | 这一片所属单条消息的 ID。一次流式响应可能由多条消息组成(比如先回一段、再调工具),MessageId 用来把片段归到各自的消息里。有些厂家把整次响应当成一条消息,此时 MessageId 可能等于 ResponseId。 |
CreatedAt | DateTimeOffset? | 这一片的时间戳。 |
ResponseId 和 MessageId 容易混,区别在粒度:
一次 RunStreamingAsync 调用
└─ ResponseId = "resp_123" ← 整次响应一个
├─ MessageId = "msg_a" ← 第一条消息(多个片段共享)
│ update, update, update
└─ MessageId = "msg_b" ← 第二条消息(如工具调用结果)
update, update
把流式片段重新组装回 AgentResponse 时,框架就是靠 MessageId 来分组的(见 AgentResponseExtensions.ToAgentResponseAsync)。
终止 / 续传信号
流式对话返回的每一片,要确定流式对话是否结束,需要判断 FinishReason 的字段内容。
| 属性 | 类型 | 说明 |
|---|---|---|
FinishReason | ChatFinishReason? | 告诉你当前轮对话状态,常见取值:Stop(正常说完)、Length(达到最大长度被截断)、ToolCalls(要调工具)、ContentFilter(被内容审核拦截)。如果是 null,说明还在流式处理中,还没有结束。 |
ContinuationToken | ResponseContinuationToken? | 流式续传用。支持后台响应的 agent 会在除最后一片之外的每个片段上带一个 token;最后一片为 null。把最新拿到的 token 传到下一次 RunStreamingAsync 的 AgentRunOptions.ContinuationToken,就能从被打断的地方接着流。 |
RawRepresentation | object? | 原始对象,通过这个属性可以获取 OpenAI、Anthropic 等厂家接口原始的接口数据。 |
AdditionalProperties | AdditionalPropertiesDictionary? | 厂家扩展字段。模型返回的、但标准抽象里没有覆盖的东西(比如 OpenAI 的 system_fingerprint),会塞在这里。 |
注意,FinishReason=stop 后,还会推送一条消息,标记当前对话消耗的 token 数量。
和 AgentResponse 的关系
流式和非流式的关系是:
非流式:RunAsync → 一个完整的 AgentResponse
流式: RunStreamingAsync → 一串 AgentResponseUpdate →(按 MessageId 拼起来)→ AgentResponse
这两者可以双向转换:
ToAgentResponse()/ToAgentResponseAsync()—— 把一串 update 收集成一个AgentResponse。ToAgentResponseUpdates()—— 反过来把AgentResponse拆成一串 update。
不过源码注释里提醒了,这个转换可能是有损的,比如多个 update 各自带着不同的 RawRepresentation,而 AgentResponse 只有一个槽位放它,转换后会丢一些。另外合并时框架会做两件事:用 MessageId 判断消息边界,以及把相邻的同类型 AIContent 合并(比如连续的多个 TextContent 会拼成一个)。
例如,后端流式返回到前端后,为了保证前端接收的数据完整,最后一次推送,后端可以通过把 AgentResponse 将当前对话所有内容一次性推送给前端,作为最终版本,在前端刷新渲染一次,或者后端以此为准,存储对话历史到数据库中,避免自己手动拼接 AgentResponseUpdate。
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("讲一个关于海盗的笑话。"))
{
Console.Write(update.Text); // 实时逐字打印
updates.Add(update); // 同时存下来
}
// 流结束后,把片段拼成完整的 AgentResponse
AgentResponse full = updates.ToAgentResponse();
Console.WriteLine($"\nToken 用量:{full.Usage}");
要真正的流式(边收边显示),就必须自己 await foreach 消费;消费完想要完整结果,就把片段收进 List<> 再调 ToAgentResponse()。两者不能靠一条链式调用同时拿到。
图片处理
前面传的都是纯文本字符串。要让附带图片给大模型,就得传一条 ChatMessage,在消息里混排文本和图片内容。
先认识一下类型来源:ChatMessage、TextContent、DataContent 这些都来自 Microsoft.Extensions.AI,不是 MAF 自己定义的,是更底层的统一抽象,所有厂家最终都归一化到这套类型。一条消息可以同时携带多个 AIContent,文本、图片、工具调用结果都是它的子类:
AIContent (基类)
├── TextContent 纯文本
├── DataContent 二进制数据(图片、音频等,以 data: URI 编码)
├── UriContent 一个外部 URL 引用
├── FunctionCallContent 工具调用请求
└── FunctionResultContent 工具调用结果
所以图文混排本质上就是往一条 ChatMessage 里塞多个 AIContent:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
AIAgent agent = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个讲笑话高手。",
name: "Joker");
ChatMessage message = new(ChatRole.User, [
new TextContent("这张图里有什么?"),
await DataContent.LoadFromAsync("34e7caa2-2852-458d-96cc-babff3c65c03.png"), // 从本地文件读图
]);
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(message))
{
Console.Write(update);
}
实际请求:
{
"messages": [
{
"role": "system",
"content": "你是一个讲笑话高手。"
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "这张图里有什么?"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcMA..."
}
}
]
}
],
"model": "qwen/qwen3.5-9b",
"stream": true,
"stream_options": {
"include_usage": true
}
}
图片内容有两种载体:
| 类型 | 适用场景 | 构造方式 | 怎么传给模型 |
|---|---|---|---|
DataContent | 图片在本地 / 内存 | new DataContent(bytes, "image/png") 或 await DataContent.LoadFromAsync("路径") | 编码成 base64 的 data: URI 随请求体一起发 |
UriContent | 图片在公网 URL | new UriContent(new Uri("https://.../x.png"), "image/png") | 只发 URL,由模型服务端自己去拉取 |
DataContent:图片会进请求体,体积大、耗 token,但模型一定能拿到(内网/本地图片走这个)。哪怕你传的是字节流,它内部也会合成一个 base64data:URI。UriContent:省流量,但要求模型服务能从公网访问到这个地址。图片在内网、或需要鉴权时,服务端拉不到就会失败。
对应到 OpenAI 协议的请求体,两者分别长这样:
// DataContent:base64 内联在请求里
{
"role": "user",
"content": [
{ "type": "text", "text": "这张图里有什么?" },
{ "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,/9j/4AAQ..." } }
]
}
// UriContent:只发一个公网链接
{
"role": "user",
"content": [
{ "type": "text", "text": "这张图里有什么?" },
{ "type": "image_url", "image_url": { "url": "https://example.com/walkway.jpg" } }
]
}
从本地文件读(最简单,LoadFromAsync 会自动探测媒体类型):
DataContent img = await DataContent.LoadFromAsync("34e7caa2-2852-458d-96cc-babff3c65c03.png");
从字节流构造(比如图片存在数据库里、或刚从 HTTP 下载下来):
byte[] imageBytes = File.ReadAllBytes("34e7caa2-2852-458d-96cc-babff3c65c03.png");
ChatMessage message = new(ChatRole.User, [
new TextContent("帮我描述这张图。"),
new DataContent(imageBytes, "image/jpeg"), // 第二个参数是 MIME 类型,必须给
]);
引用一个公网 URL(图片已上传到 OSS/CDN):
ChatMessage message = new(ChatRole.User, [
new TextContent("这张图里有什么?"),
new UriContent(new Uri("https://example.com/mountain.png"), "image/png"),
]);
函数调用
光会聊天还不够,真正的 Agent 要能动手,例如查天气、查数据库、调外部接口。MAF 通过**函数工具(function tool)**让模型在需要时主动调用你提供的 C# 方法。
AIAgent 类型会自动帮你执行多轮对话,完全不需要我们关注中间过程。所以 MAF 框架不只是连接大模型,已经内置了一系列行为实现了 Agent 模式,只是其能力需要我们自己扩展。
整套流程是:把一个 C# 方法用 AIFunctionFactory.Create(...) 包成 AIFunction,创建 agent 时通过 tools: 参数传进去。模型根据方法上的 [Description] 知道有这个工具、它干什么;对话中模型决定要调它时,框架自动执行你的方法,把结果回喂给模型,模型再接着生成回答。
[Description] 不是注释,是给模型看的。 方法上的描述会变成工具说明,参数上的描述会变成参数说明,模型靠这些文字判断该不该调、传什么参数。描述写得越清楚,模型调得越准。对比下面两种写法,模型面对第二种时基本是瞎猜:
// ❌ 模型不知道这个工具是干嘛的,参数 location 是啥也不知道
static string GetWeather(string location) => ...;
// ✅ 描述清晰,模型能准确判断什么时候调、传什么
[Description("获取指定地点的天气。")]
static string GetWeather([Description("地点名称")] string location) => ...;
AIFunctionFactory.Create 有多个重载。直接传委托,它会反射方法签名自动生成 JSON Schema 给模型,也可以显式传 name、description、参数元数据来覆盖自动推断的结果。
调用是自动的。 你不用自己解析模型的 tool_calls、不用手动执行、不用手动把结果拼回消息,框架内部挂了一个 FunctionInvokingChatClient 中间件,把模型要调工具 → 执行 → 回喂 → 再生成这个循环全自动跑完(这套机制下面讲多轮时会再点一下)。
循环有上限。 自动调用默认最多循环 MaximumIterationsPerRequest = 40 次。普通场景足够,但如果你的工具特别多、链路特别深,要留意别让模型陷在调工具 → 拿到结果 → 又调工具的死循环里出不来。
工具放在哪,有三种粒度:
// 1) 构造时传(最简写法,对所有调用生效)
.AsAIAgent(..., tools: [AIFunctionFactory.Create(GetWeather)]);
// 2) 会话级:通过 ChatClientAgentOptions.ChatOptions.Tools
var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
ChatOptions = new() { Tools = [AIFunctionFactory.Create(GetWeather)] }
});
// 3) 单次调用级:通过 ChatClientAgentRunOptions.ChatOptions.Tools(只在这一轮生效)
var runOptions = new ChatClientAgentRunOptions
{
ChatOptions = new() { Tools = [AIFunctionFactory.Create(GetWeather)] }
};
await agent.RunAsync("...", session, runOptions);
本章只讲「怎么让模型会调工具」。函数调用的进阶内容,人工审批(approval)、内置工具(文件搜索、代码解释器)、接入 MCP、多工具编排——放在后面 函数调用 一章展开。
完整案例:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.ComponentModel;
[Description("获取指定地点的天气。")]
static string GetWeather([Description("地点名称")] string location)
=> $"{location} 今天多云,最高 15°C。";
AIAgent agent = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个乐于助人的助手。",
name: "Helper",
tools: [AIFunctionFactory.Create(GetWeather)]); // 关键:注册工具
Console.WriteLine(await agent.RunAsync("阿姆斯特丹天气怎么样?"));
// 或者
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("阿姆斯特丹天气怎么样?"))
{
Console.Write(update);
}
当你问「阿姆斯特丹天气怎么样」,背后其实发生了好几步:
1. 用户: 阿姆斯特丹天气怎么样?
2. 模型: (判断需要调工具)→ tool_calls: GetWeather("阿姆斯特丹")
3. 框架: 自动执行 GetWeather("阿姆斯特丹") → "阿姆斯特丹 今天多云,最高 15°C。"
4. 框架: 把工具结果作为 tool 消息回喂给模型
5. 模型: 根据结果组织自然语言 → "阿姆斯特丹今天多云,最高 15°C。"
6. 返回: 你拿到最终的文字回复
第一步,程序提交问题和 tools 列表:
{
"messages": [
{
"role": "system",
"content": "你是一个乐于助人的助手。"
},
{
"role": "user",
"content": "阿姆斯特丹天气怎么样?"
}
],
"model": "qwen/qwen3.5-9b",
"tools": [
{
"type": "function",
"function": {
"description": "获取指定地点的天气。",
"name": "_Main_g_GetWeather_0_0",
"parameters": {
"type": "object",
"required": [
"location"
],
"properties": {
"location": {
"description": "地点名称",
"type": "string"
}
},
"additionalProperties": false
}
}
}
],
"tool_choice": "auto"
}
第二步,模型返回思考内容,和确定如何调用函数。所以 "finish_reason": "tool_calls",表示当前 Agent 还没有结束,需要等待客户端执行函数并把函数结果返回给 AI 模型。
{
"id": "chatcmpl-ga26tfzx1piorw5irve2cd",
"object": "chat.completion",
"created": 1784098078,
"model": "qwen/qwen3.5-9b",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "用户询问阿姆斯特丹的天气情况。我需要使用天气查询工具来获取这个信息。工具名称是 Main_g_GetWeather_0_0,需要传入地点参数 \"location\"。\n\n地点应该是\"阿姆斯特丹\",这是用户明确提到的城市名称。\n",
"tool_calls": [
{
"type": "function",
"id": "MG9aVOIpsvXaYHfk3FK5llhktftlMytI",
"function": {
"name": "_Main_g_GetWeather_0_0",
"arguments": "{\"location\":\"阿姆斯特丹\"}"
}
}
]
},
"logprobs": null,
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 296,
"completion_tokens": 88,
"total_tokens": 384,
"completion_tokens_details": {
"reasoning_tokens": 52
}
},
"stats": {},
"system_fingerprint": "qwen/qwen3.5-9b"
}
第三步,框架自动执行函数,然后第四步把函数执行结果返回给 AI 模型:
{
"messages": [
{
"role": "system",
"content": "你是一个乐于助人的助手。"
},
{
"role": "user",
"content": "阿姆斯特丹天气怎么样?"
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "MG9aVOIpsvXaYHfk3FK5llhktftlMytI",
"type": "function",
"function": {
"name": "_Main_g_GetWeather_0_0",
"arguments": "{\r\n \"location\": \"阿姆斯特丹\"\r\n}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "MG9aVOIpsvXaYHfk3FK5llhktftlMytI",
"content": "\"阿姆斯特丹 今天多云,最高 15°C。\""
}
],
"model": "qwen/qwen3.5-9b",
"tools": [
{
"type": "function",
"function": {
"description": "获取指定地点的天气。",
"name": "_Main_g_GetWeather_0_0",
"parameters": {
"type": "object",
"required": [
"location"
],
"properties": {
"location": {
"description": "地点名称",
"type": "string"
}
},
"additionalProperties": false
}
}
}
],
"tool_choice": "auto"
}
AI 模型最后一次返回结论,"finish_reason": "stop"。
{
"id": "chatcmpl-9lke8am2u1wu162lnk5g4b",
"object": "chat.completion",
"created": 1784098082,
"model": "qwen/qwen3.5-9b",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "阿姆斯特丹今天的天气是多云,最高气温为 15°C。",
"reasoning_content": "用户询问了阿姆斯特丹的天气情况,我已经通过工具获取到了相关信息:今天多云,最高15°C。现在我应该将这个信息清晰地回复给用户。\n",
"tool_calls": []
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 361,
"completion_tokens": 49,
"total_tokens": 410,
"completion_tokens_details": {
"reasoning_tokens": 32
}
},
"stats": {},
"system_fingerprint": "qwen/qwen3.5-9b"
}
自动的多轮对话
前面只定义了 GetWeather() 一个工具,AIAgent 自动进行第二轮对话,那么如果可能轮流调用多个函数,还能自动多轮对话吗?
结果笔者的测试,AIAgent 是可以自动帮助我们进行多轮对话的。
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.ComponentModel;
[Description("获取城市的编码。")]
static string GetCode([Description("地点名称")] string location)
=> $"{location} 编码是 AMSTD";
[Description("获取指定地点的天气。")]
static string GetWeather([Description("地点编码")] string code)
=> $"{code} 今天多云,最高 15°C。";
AIAgent agent = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个乐于助人的助手。",
name: "Helper",
tools: [AIFunctionFactory.Create(GetCode), AIFunctionFactory.Create(GetWeather)]); // 关键:注册工具
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("阿姆斯特丹的城市代码和天气怎么样?"))
{
Console.Write(update);
}
AI 模型第一次要求调用 GetCode()。

AI 模型第二次要求调用 GetWeather()。

AI 模型第三次最后返回结果:

所以实践证明,在 MAF 框架中,是不需要我们自己手写多轮对话能力的。
对话上下文 AgentSession
Microsoft Agent Framework 是支持使用 ChatMessage 构建对话历史的,这种方式属于手动维护消息列表。
using Microsoft.Extensions.AI;
List<ChatMessage> history = [];
while (true)
{
Console.Write("你:");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) break;
// 1) 把用户的话加进历史
history.Add(new ChatMessage(ChatRole.User, input));
// 2) 把完整历史发给模型
AgentResponse response = await agent.RunAsync(history);
// 3) 把模型的回复也加进历史,下一轮才能接得上
history.Add(new ChatMessage(ChatRole.Assistant, response.Text));
Console.WriteLine($"AI:{response.Text}\n");
}
但是对于流式对话来说,后续多轮对话(例如前面的函数调用),如果要自己维护 ChatMessage,就需要一直手动解析流式响应的结果,处理多轮对话里面的函数调用等数据,会非常麻烦。这种方式只适合简单对话,或者第一次对话发起时使用。
Microsoft Agent Framework 提供了 AgentSession,框架(或服务端)替你记历史,你只管每次传新消息。
AgentSession 来自 Microsoft.Agents.AI.Abstractions,是个抽象基类,代表一次会话的状态容器。官方对它的定义是:
AgentSessioncontains conversation history or reference to it, memories, and any other state. It is always constructed by anAIAgent, and may not be reusable across different agents.
翻译过来,有三个要点记死:
- 会话由 agent 创建:调
agent.CreateSessionAsync()拿到一个会话对象,别自己new。 - 会话跟 agent 绑定:不同 agent 的会话不能混用(一个
ChatClientAgent的 session 不能塞给另一个 agent)。 - 会话可序列化:
SerializeSessionAsync/DeserializeSessionAsync能把会话存成 JSON,重启或迁移时再恢复。序列化内容里含对话明文,有 PII 风险,存储要加密。
需要特别说明的是:AgentSession 本身不直接暴露 ChatHistory 属性。它是给框架和服务用的状态袋,具体历史记在哪、怎么记,跟它没关系。
这也是它能做到客户端和服务端两种模式对调用方透明的原因。
后面的章节会详细介绍 AgentSession。
用法非常简单,创建一次会话,之后每一轮都把同一个 session 传进去,上下文就自动接上了:
// 创建一个会话(上下文容器)
AgentSession session = await agent.CreateSessionAsync();
// 第 1 轮
Console.WriteLine(await agent.RunAsync("讲一个关于海盗的笑话。", session));
// 第 2 轮:模型还记得上一句的笑话,因为它在同一个 session 里
Console.WriteLine(await agent.RunAsync("给这个笑话加上 emoji,用海盗鹦鹉的口吻再讲一遍。", session));
流式版也一样,传同一个 session:
session = await agent.CreateSessionAsync();
await foreach (var update in agent.RunStreamingAsync("讲一个关于海盗的笑话。", session))
Console.Write(update);
Console.WriteLine();
await foreach (var update in agent.RunStreamingAsync("用海盗鹦鹉的口吻再讲一遍。", session))
Console.Write(update);
实践总结
本文学习了不少内容,把本章的东西揉到一起,做一个能看图、会调工具、还记得上下文的交互循环的 Agent。
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.ComponentModel;
[Description("获取指定地点的天气。")]
static string GetWeather([Description("地点名称")] string location)
=> $"{location} 今天多云,最高 15°C。";
AIAgent agent = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个乐于助人的助手,必要时调用工具。",
name: "Helper",
tools: [AIFunctionFactory.Create(GetWeather)]);
// 一个会话 = 一段连续对话的上下文容器
AgentSession session = await agent.CreateSessionAsync();
// 第 1 轮:纯文本
await foreach (var u in agent.RunStreamingAsync("你好!", session))
Console.Write(u);
// 第 2 轮:触发工具调用(模型会自动调 GetWeather),模型仍记得第 1 轮
Console.WriteLine();
await foreach (var u in agent.RunStreamingAsync("阿姆斯特丹天气怎么样?", session))
Console.Write(u);
// 第 3 轮:多模态,传一张图,上下文继续延续
Console.WriteLine();
Microsoft.Extensions.AI.ChatMessage imgMsg = new(ChatRole.User, [
new TextContent("这张图里是什么天气?和我刚才问的地方比呢?"),
await DataContent.LoadFromAsync("34e7caa2-2852-458d-96cc-babff3c65c03.png"),
]);
await foreach (var u in agent.RunStreamingAsync(imgMsg, session))
Console.Write(u);
注意三件事:
① 三个 RunStreamingAsync 都传同一个 session,所以模型记得前面聊过什么。
② 工具调用是自动的,你只是把 tools 注册进去。
③ 多模态消息和多轮会话可以叠加——图片走 ChatMessage,历史走 session,互不冲突。
