统一消息结构:隔开模型协议与 Agent 运行时
前一章讨论了 Agent 循环由谁推进,这一章继续追问循环里的另一个基础问题:模型、工具、会话和界面之间传递的究竟是什么。
OpenAI Responses API 会发送 response.output_text.delta,Anthropic Messages API 会发送 content_block_delta,不同接口对工具调用、推理内容和用量统计也有各自的字段。如果 Agent 运行时直接依赖这些原始结构,那么更换模型接口时,工具执行、历史记录和前端渲染都可能跟着修改。
Microsoft Agent Framework 的做法不是重新定义一套底层聊天协议,也没有设计一份能容纳所有字段的巨大 JSON。它把消息放在两层之上:ChatMessage、ChatResponse、ChatResponseUpdate 和 AIContent 来自 Microsoft.Extensions.AI,MAF 在其上增加 AgentResponse、AgentResponseUpdate 和会话抽象,负责 Agent 标识、会话和运行语义。
统一的不是传输格式
统一消息结构容易被误解成一套跨厂商通用的 HTTP 协议。实际上,HTTP、SSE 和 WebSocket 只负责传输,真正需要稳定的是进入运行时之后的语义。
以一次工具调用为例,运行时至少要识别下面几件事:
- 这段内容是普通文本还是工具参数。
- 工具叫什么,参数属于哪一次调用。
- 当前收到的是参数片段,还是已经可以执行的完整调用。
- 工具结果应该关联到哪个调用。
- 本轮是正常结束、达到长度限制,还是要求继续执行工具。
因此,统一消息层一般位于两次转换之间:请求发送前,把内部消息降级成 provider 所需的请求;响应返回后,把 provider 事件提升成内部内容。
图中的内部结构也不必只有一种。模型接口关心角色、内容和工具结果,前端关心开始、增量和结束,持久化层关心最终状态。强行让三者共用完全相同的对象,反而会让一个类型承担过多职责。MAF 的取向是让运行时始终面向 ChatMessage 与 AIContent,而在进入 provider 或输出到界面时才发生转换。
流式响应还带来一个额外要求:每个增量必须有归属。文本片段需要知道自己属于哪个内容块,工具参数需要知道自己属于哪个调用,完整输出需要知道何时收口。没有稳定的 ID,多个并行工具调用或文本与推理交错输出时就无法正确归并。
内容即多态:AIContent
MAF 没有为“内容”设计一个不断膨胀的 type 枚举,而是用多态类型表达不同内容。AIContent 是抽象基类,文本、推理、工具调用、工具结果、图片、文件和用量都由它的子类表示。
public abstract class AIContent
{
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
public object? RawRepresentation { get; set; }
}
各具体子类只在原有内容之外增加自己需要的字段:
| 类型 | 关键字段 | 用途 |
|---|---|---|
TextContent | Text | 普通文本 |
FunctionCallContent | CallId、Name、Arguments | 模型请求执行一次工具 |
FunctionResultContent | CallId、Result、Exception | 一次工具调用的结果 |
TextReasoningContent | Text、ProtectedData | 模型推理过程,不一定需要展示 |
UsageContent | Details(UsageDetails) | Token 用量等计量信息 |
DataContent | Uri、MediaType、Name | 图片、音频、文件等二进制对象 |
UriContent | Uri、MediaType | 以 URI 引用的数据 |
HostedFileContent | FileId | 由服务端托管、只带 ID 的文件 |
ErrorContent | Message | 一次内容生成失败或拒绝的说明 |
ToolApprovalRequestContent | 请求信息 | 需要用户审批的工具调用 |
ToolApprovalResponseContent | 审批结果 | 用户对审批请求的回应 |
消息不需要一个类型枚举来区分内容,只要内容的消费者能够识别具体子类即可。比如 ChatClientAgent 只关心哪段是可执行的工具调用,Hosting.OpenAI 的转换器会根据子类选择对应的出站事件。AdditionalProperties 与 RawRepresentation 保留未统一字段与原始对象,但只是逃生口,不是主路径。
消息、响应与流式更新
ChatMessage 是消息粒度的单位,Contents 是一组有顺序的 AIContent。文本、推理、工具调用和工具结果按输出先后排列,这使“先输出文字、再调用工具、之后继续输出文字”的真实顺序得以保留。
public class ChatMessage
{
public ChatRole? Role { get; set; }
public string? AuthorName { get; set; }
public IList<AIContent> Contents { get; set; }
public string? MessageId { get; set; }
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
public object? RawRepresentation { get; set; }
}
AgentResponse 是一次运行的完整结果,AgentResponseUpdate 是流式输出的一个切片。AgentResponseUpdate 概念上合并了 AgentResponse 与 ChatMessage 在流式阶段各自承担的角色:既有整轮响应的 ResponseId、FinishReason,也有单条消息的 Role、MessageId、AuthorName 和 Contents。
public class AgentResponseUpdate
{
public ChatRole? Role { get; set; }
public IList<AIContent> Contents { get; set; }
public string? AgentId { get; set; }
public string? ResponseId { get; set; }
public string? MessageId { get; set; }
public ChatFinishReason? FinishReason { get; set; }
}
流式和非流式共享同一套内容模型,依靠 AgentResponseExtensions 双向转换。一次响应可能包含多条逻辑消息,一条逻辑消息又可能拆成多个 AgentResponseUpdate。ToAgentResponseAsync 会按 MessageId 恢复消息边界,并合并相邻的 TextContent;反方向上的 AgentResponse.ToAgentResponseUpdates 则把完整响应拆成更新流。
AgentResponse response = await updates
.ToAgentResponseAsync(cancellationToken);
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
UsageContent 作为单独的更新附着在流的末尾,AgentResponse 的 Text 属性则把消息中的文本拼接起来。据此,调用方既可以把更新实时推给界面,也可以把累积后的结果写入历史。
会话与历史
AgentResponse 与 AgentResponseUpdate 描述一次运行,AgentSession 描述一轮运行之间需要保留的状态。会话中可以存放历史、记忆或任意自定义状态,并由 StateBag 汇总这些随会话持久化的数据。每次运行结束后,ChatClientAgent 会调用 ChatHistoryProvider 通知新消息,由它决定把 ChatMessage 保存在内存还是外部存储。
ChatClientAgent
-> ChatHistoryProvider 保存 ChatMessage 历史
-> AIContextProvider 提供指令、消息和工具
这里的消息、响应、更新和会话各自承担不同职责:ChatMessage 面向模型请求与历史,AgentResponse / AgentResponseUpdate 面向一次运行与流式消费,AgentSession 与 ChatHistoryProvider 面向跨运行持久化。它们共享同一套内容类型,但不必共用同一个对象。
与模型协议和界面的绑定
统一消息的上游是 provider,下游是界面。ChatClientAgent 只调用统一的 IChatClient.GetResponseAsync 或 GetStreamingResponseAsync,自身不再处理各家协议的对外字段。
ChatResponse chatResponse = await chatClient.GetResponseAsync(
inputMessagesForChatClient,
chatOptions,
cancellationToken);
var responseUpdates = chatClient.GetStreamingResponseAsync(...);
OpenAI 原生对象到 ChatResponse 的主要转换由 Microsoft.Extensions.AI.OpenAI 完成,MAF 仓库中的 Microsoft.Agents.AI.OpenAI 负责把 OpenAI 客户端接入 IChatClient。不能把 AIContent、ChatMessage 与全部 OpenAI 协议转换误写成 MAF 自己实现的类型。
统一结构也可以再次向外转换。Microsoft.Agents.AI.Hosting.OpenAI 中的 AgentResponseUpdateExtensions.ToStreamingResponseAsync 会把更新流输出为 OpenAI Responses 风格的事件,并按 Content 子类选择对应的 StreamingEventGenerator,同时维护 SequenceNumber、OutputIndex、ItemId 与 ContentIndex。这说明统一消息不是终点,它还可以成为另一种传输协议的输入。外发事件的类型大致如下:
internal abstract class StreamingResponseEvent { int SequenceNumber; }
response.created
response.in_progress
response.completed
response.incomplete
response.failed
response.cancelled
response.output_item.added / done
response.content_part.added / done
response.output_text.delta / done
response.function_call_arguments.delta / done
response.reasoning_summary_text.delta / done
response.workflow_event.completed
response.function_approval.requested / responded
设计自己的消息层
如果要为 Agent 运行时设计消息结构,可以先确定边界,而不是先罗列字段。
第一,模型消息和界面事件要分开。模型需要角色、内容和工具结果,界面需要工具状态、审批请求和增量更新。ChatMessage 负责模型与历史,AgentResponseUpdate 负责流式增量,AgentSession 负责持久化状态,三者可以共享 AIContent,但不必共用同一个联合类型。
第二,文本、推理、媒体和工具调用应当是有顺序的内容项。不要只在消息上放一个 text 字段,再把工具调用挂到另一个数组,否则无法表达交错输出的顺序。MAF 靠 Contents 中的 AIContent 顺序解决这一点。
第三,调用 ID 必须贯穿参数流、执行和结果。工具名只描述能力,不能标识一次调用。FunctionCallContent 与 FunctionResultContent 通过 CallId 关联,支持并行工具后,这一点会直接决定结果能否正确写回模型历史。
第四,原始 provider 数据只能作为逃生口,不能成为主路径。AIContent.RawRepresentation 允许保留未统一字段,但普通业务逻辑仍应只读取内部类型。否则看似完成了统一,实际只是把厂商对象藏进了另一个字段。
第五,明确哪些数据需要持久化。文本增量适合实时显示,完整文本适合保存;工具参数可以临时累积,最终调用和结果必须进入历史;Token 用量与结束状态通常属于整轮响应。AgentResponseUpdate 与 AgentResponse 之间可以互相转换,但没有必要逐字段完全相同。
真正稳定的统一消息层,不是包含所有厂商字段的最大集合,而是让模型协议、Agent 控制流和界面各自停留在自己的边界内。Provider 变化时只修改适配器,前端变化时只修改投影,工具和会话逻辑继续处理同一套 AIContent、ChatMessage 与 AgentResponse,这才是统一结构带来的工程价值。