与模型对话

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这一轮产生的完整消息列表(可能含多模态、工具调用等内容)
UsageUsageDetails,输入/输出 token 用量,用来算成本
FinishReason结束原因(StopLengthToolCalls 等)
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 ,开发者使用起来就很简单了。

image-20260715142106877


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 of AgentResponse and ChatMessage in streaming output.


换句话说,一个 AgentResponseUpdate 同时扮演了两个角色:它既是响应片段(像 AgentResponse),又是消息片段(像 ChatMessage)。一串 update 叠加起来,才等于一次完整的回复。

掌握 AgentResponseUpdate 非常重要,它是后续流式、审批交互、工具调用等的基础,下面把它的字段逐个拆开看,按内容 / 元信息 / 终止信号三类分组,方便你抓住重点。


内容相关

AgentResponseUpdate 中的两个字段:

属性类型说明
ContentsIList<AIContent>这一片真正的内容,可空、可多条。文字、图片、工具调用片段都装在这里。访问时若为 null 会自动初始化为空列表(??= []),所以不会抛空引用。
Textstring只读便捷属性。把 Contents 里所有 TextContent 的文字拼起来的结果;没有文字时返回 string.EmptyToString() 也是返回它,所以 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 ...

角色 / 标识相关

属性类型说明
RoleChatRole?这一片的作者角色,流式里几乎总是 assistant
AuthorNamestring?作者名字。设值时若是空白字符串会被自动置为 null(源码里 set 做了 IsNullOrWhiteSpace 判断)。
AgentIdstring?产生这一片的 agent 的 ID,排查「是哪个 agent 回的」时有用。
ResponseIdstring?这一片所属整次响应的 ID。同一次流式响应的所有片段,ResponseId 相同。
MessageIdstring?这一片所属单条消息的 ID。一次流式响应可能由多条消息组成(比如先回一段、再调工具),MessageId 用来把片段归到各自的消息里。有些厂家把整次响应当成一条消息,此时 MessageId 可能等于 ResponseId
CreatedAtDateTimeOffset?这一片的时间戳。


ResponseIdMessageId 容易混,区别在粒度:

一次 RunStreamingAsync 调用
   └─ ResponseId = "resp_123"        ← 整次响应一个
        ├─ MessageId = "msg_a"        ← 第一条消息(多个片段共享)
        │     update, update, update
        └─ MessageId = "msg_b"        ← 第二条消息(如工具调用结果)
              update, update

把流式片段重新组装回 AgentResponse 时,框架就是靠 MessageId 来分组的(见 AgentResponseExtensions.ToAgentResponseAsync)。


终止 / 续传信号

流式对话返回的每一片,要确定流式对话是否结束,需要判断 FinishReason 的字段内容。

属性类型说明
FinishReasonChatFinishReason?告诉你当前轮对话状态,常见取值:Stop(正常说完)、Length(达到最大长度被截断)、ToolCalls(要调工具)、ContentFilter(被内容审核拦截)。如果是 null,说明还在流式处理中,还没有结束。
ContinuationTokenResponseContinuationToken?流式续传用。支持后台响应的 agent 会在除最后一片之外的每个片段上带一个 token;最后一片为 null。把最新拿到的 token 传到下一次 RunStreamingAsyncAgentRunOptions.ContinuationToken,就能从被打断的地方接着流。
RawRepresentationobject?原始对象,通过这个属性可以获取 OpenAI、Anthropic 等厂家接口原始的接口数据。
AdditionalPropertiesAdditionalPropertiesDictionary?厂家扩展字段。模型返回的、但标准抽象里没有覆盖的东西(比如 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,在消息里混排文本和图片内容。

先认识一下类型来源:ChatMessageTextContentDataContent 这些都来自 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图片在公网 URLnew UriContent(new Uri("https://.../x.png"), "image/png")只发 URL,由模型服务端自己去拉取
  • DataContent:图片会进请求体,体积大、耗 token,但模型一定能拿到(内网/本地图片走这个)。哪怕你传的是字节流,它内部也会合成一个 base64 data: 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 给模型,也可以显式传 namedescription、参数元数据来覆盖自动推断的结果。

调用是自动的。 你不用自己解析模型的 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()

image-20260715150419966


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

image-20260715150502615


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

image-20260715150508406


所以实践证明,在 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,是个抽象基类,代表一次会话的状态容器。官方对它的定义是:

AgentSession contains conversation history or reference to it, memories, and any other state. It is always constructed by an AIAgent, and may not be reusable across different agents.


翻译过来,有三个要点记死:

  1. 会话由 agent 创建:调 agent.CreateSessionAsync() 拿到一个会话对象,别自己 new
  2. 会话跟 agent 绑定:不同 agent 的会话不能混用(一个 ChatClientAgent 的 session 不能塞给另一个 agent)。
  3. 会话可序列化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,互不冲突。


image-20260715153004697