统一消息结构:隔开模型协议与 Agent 运行时

前一章讨论了 Agent 循环由谁推进,这一章继续追问循环里的另一个基础问题:模型、工具、会话和界面之间传递的究竟是什么。

OpenAI Responses API 会发送 response.output_text.delta,Anthropic Messages API 会发送 content_block_delta,不同接口对工具调用、推理内容和用量统计也有各自的字段。如果 Agent 运行时直接依赖这些原始结构,那么更换模型接口时,工具执行、历史记录和前端渲染都可能跟着修改。

Microsoft Agent Framework 的做法不是重新定义一套底层聊天协议,也没有设计一份能容纳所有字段的巨大 JSON。它把消息放在两层之上:ChatMessageChatResponseChatResponseUpdateAIContent 来自 Microsoft.Extensions.AI,MAF 在其上增加 AgentResponseAgentResponseUpdate 和会话抽象,负责 Agent 标识、会话和运行语义。

统一的不是传输格式

统一消息结构容易被误解成一套跨厂商通用的 HTTP 协议。实际上,HTTP、SSE 和 WebSocket 只负责传输,真正需要稳定的是进入运行时之后的语义。

以一次工具调用为例,运行时至少要识别下面几件事:

  1. 这段内容是普通文本还是工具参数。
  2. 工具叫什么,参数属于哪一次调用。
  3. 当前收到的是参数片段,还是已经可以执行的完整调用。
  4. 工具结果应该关联到哪个调用。
  5. 本轮是正常结束、达到长度限制,还是要求继续执行工具。

因此,统一消息层一般位于两次转换之间:请求发送前,把内部消息降级成 provider 所需的请求;响应返回后,把 provider 事件提升成内部内容。

正在渲染 Mermaid 图表...

图中的内部结构也不必只有一种。模型接口关心角色、内容和工具结果,前端关心开始、增量和结束,持久化层关心最终状态。强行让三者共用完全相同的对象,反而会让一个类型承担过多职责。MAF 的取向是让运行时始终面向 ChatMessageAIContent,而在进入 provider 或输出到界面时才发生转换。

流式响应还带来一个额外要求:每个增量必须有归属。文本片段需要知道自己属于哪个内容块,工具参数需要知道自己属于哪个调用,完整输出需要知道何时收口。没有稳定的 ID,多个并行工具调用或文本与推理交错输出时就无法正确归并。

内容即多态:AIContent

MAF 没有为“内容”设计一个不断膨胀的 type 枚举,而是用多态类型表达不同内容。AIContent 是抽象基类,文本、推理、工具调用、工具结果、图片、文件和用量都由它的子类表示。

public abstract class AIContent
{
    public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
    public object? RawRepresentation { get; set; }
}

各具体子类只在原有内容之外增加自己需要的字段:

类型关键字段用途
TextContentText普通文本
FunctionCallContentCallIdNameArguments模型请求执行一次工具
FunctionResultContentCallIdResultException一次工具调用的结果
TextReasoningContentTextProtectedData模型推理过程,不一定需要展示
UsageContentDetailsUsageDetailsToken 用量等计量信息
DataContentUriMediaTypeName图片、音频、文件等二进制对象
UriContentUriMediaType以 URI 引用的数据
HostedFileContentFileId由服务端托管、只带 ID 的文件
ErrorContentMessage一次内容生成失败或拒绝的说明
ToolApprovalRequestContent请求信息需要用户审批的工具调用
ToolApprovalResponseContent审批结果用户对审批请求的回应

消息不需要一个类型枚举来区分内容,只要内容的消费者能够识别具体子类即可。比如 ChatClientAgent 只关心哪段是可执行的工具调用,Hosting.OpenAI 的转换器会根据子类选择对应的出站事件。AdditionalPropertiesRawRepresentation 保留未统一字段与原始对象,但只是逃生口,不是主路径。

消息、响应与流式更新

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 概念上合并了 AgentResponseChatMessage 在流式阶段各自承担的角色:既有整轮响应的 ResponseIdFinishReason,也有单条消息的 RoleMessageIdAuthorNameContents

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 双向转换。一次响应可能包含多条逻辑消息,一条逻辑消息又可能拆成多个 AgentResponseUpdateToAgentResponseAsync 会按 MessageId 恢复消息边界,并合并相邻的 TextContent;反方向上的 AgentResponse.ToAgentResponseUpdates 则把完整响应拆成更新流。

AgentResponse response = await updates
    .ToAgentResponseAsync(cancellationToken);

AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();

UsageContent 作为单独的更新附着在流的末尾,AgentResponseText 属性则把消息中的文本拼接起来。据此,调用方既可以把更新实时推给界面,也可以把累积后的结果写入历史。

会话与历史

AgentResponseAgentResponseUpdate 描述一次运行,AgentSession 描述一轮运行之间需要保留的状态。会话中可以存放历史、记忆或任意自定义状态,并由 StateBag 汇总这些随会话持久化的数据。每次运行结束后,ChatClientAgent 会调用 ChatHistoryProvider 通知新消息,由它决定把 ChatMessage 保存在内存还是外部存储。

ChatClientAgent
    -> ChatHistoryProvider       保存 ChatMessage 历史
    -> AIContextProvider         提供指令、消息和工具

这里的消息、响应、更新和会话各自承担不同职责:ChatMessage 面向模型请求与历史,AgentResponse / AgentResponseUpdate 面向一次运行与流式消费,AgentSessionChatHistoryProvider 面向跨运行持久化。它们共享同一套内容类型,但不必共用同一个对象。

与模型协议和界面的绑定

统一消息的上游是 provider,下游是界面。ChatClientAgent 只调用统一的 IChatClient.GetResponseAsyncGetStreamingResponseAsync,自身不再处理各家协议的对外字段。

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。不能把 AIContentChatMessage 与全部 OpenAI 协议转换误写成 MAF 自己实现的类型。

统一结构也可以再次向外转换。Microsoft.Agents.AI.Hosting.OpenAI 中的 AgentResponseUpdateExtensions.ToStreamingResponseAsync 会把更新流输出为 OpenAI Responses 风格的事件,并按 Content 子类选择对应的 StreamingEventGenerator,同时维护 SequenceNumberOutputIndexItemIdContentIndex。这说明统一消息不是终点,它还可以成为另一种传输协议的输入。外发事件的类型大致如下:

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 必须贯穿参数流、执行和结果。工具名只描述能力,不能标识一次调用。FunctionCallContentFunctionResultContent 通过 CallId 关联,支持并行工具后,这一点会直接决定结果能否正确写回模型历史。

第四,原始 provider 数据只能作为逃生口,不能成为主路径。AIContent.RawRepresentation 允许保留未统一字段,但普通业务逻辑仍应只读取内部类型。否则看似完成了统一,实际只是把厂商对象藏进了另一个字段。

第五,明确哪些数据需要持久化。文本增量适合实时显示,完整文本适合保存;工具参数可以临时累积,最终调用和结果必须进入历史;Token 用量与结束状态通常属于整轮响应。AgentResponseUpdateAgentResponse 之间可以互相转换,但没有必要逐字段完全相同。

真正稳定的统一消息层,不是包含所有厂商字段的最大集合,而是让模型协议、Agent 控制流和界面各自停留在自己的边界内。Provider 变化时只修改适配器,前端变化时只修改投影,工具和会话逻辑继续处理同一套 AIContentChatMessageAgentResponse,这才是统一结构带来的工程价值。