历史记录和上下文
本章涉及的核心对象就四个:Agent、AgentSession、StateBag、Provider,本系列教程后面所有内容都是在讲它们怎么协作。
在 MAF 框架中,可以这样定义一个 Agent:
AIAgent agent = new OpenAIClient(...)
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsAIAgent(
instructions: "你是一个讲笑话高手。",
name: "Joker");
前面我们提到,可以使用 AgentSession 实现多次对话,保证历史记录连贯,这是因为每个 AIAgent 都有一个 ChatHistoryProvider 和多个 AIContextProviders,MAF 框架默认使用一个内存型的 ChatHistoryProvider 处理上下文历史对话。
每个 Agent 都有以下两个属性:
// ChatClientAgent.cs
public ChatHistoryProvider? ChatHistoryProvider { get; private set; } // 单数
// 对比:AIContextProvider 是列表
public IReadOnlyList<AIContextProvider>? AIContextProviders { get; } // 复数,可以提供多个
如下图所示,要深入理解 MAF 的 Agent 架构设计,需要学习三个内容核心 Agent、AgentSession、Provider。
上面已经简单介绍过 Provider,Provider 就是 ChatHistoryProvider 和 AIContextProvider,这两个逻辑都在 Agent 里面,它们的生命周期跟 ChatClientAgent 一致。
而 AgentSession 是一个无状态的空壳子,里面有个 StateBag,生命周期是独立的,AgentSession 是空壳子,它本身不负责任何事情,本身相当于一个容器,被 ChatClientAgent 的 Provider 被当作数据容器,用于存储和读取数据。
可以看到,Agent 跟 AgentSession 是隔离的且是无状态的,所以 AgentSession 可以跨 Agent 使用。
笔者提示,记住这句话,后面笔者会详细介绍原理。
你可以定义多个 Agent,使用同一个 AgentSession 在这些 Agent 中共享上下文历史记录。
AIAgent agentA = ...
AIAgent agentB = ...
var session = await agentA.CreateSessionAsync();
await foreach (var u in agentA.RunStreamingAsync("阿姆斯特丹天气怎么样?", session))
Console.Write(u);
await foreach (var u in agentB.RunStreamingAsync("再说一次?", session))
Console.Write(u);
这样不同的 Agent 就可以共享同一份对话历史记录。
我们可以这样理解,Session 就是一个会话,Agent 是智能体执行器,一个 Session 理应能够被不同智能体读取并使用,多个 Agent 共同处理一个 Session。
让 AI 梳理 MAF 框架源码,整理了一下流程图,读者在需要的时候再回头看看这个流程图,加深理解。
AgentSession 会话状态容器
前面提到,AgentSession 支持在多个 Agent 之间共享上下文历史记录,因为 AgentSession 是一个状态容器,它跟 Agent 是隔离的,不会跟某个 Agent 绑定,所以支持在多个 Agent 之间共享状态。
本节继续来解释 AgentSession 为什么叫容器。
在多轮对话中,使用 AgentSession 可以自动维护上下文,之后每轮都传同一个 session,框架自动帮你记上下文历史记录:
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine(await agent.RunAsync("讲个关于海盗的笑话。", session));
Console.WriteLine(await agent.RunAsync("用海盗的口吻再讲一遍。", session)); // 还记得上一句
看上去就两行代码,但要真搞懂为什么传同一个 session 就能记住上下文,得拆开 AgentSession 的内部结构。这也是后面持久化、上下文注入的地基。
AgentSession(Microsoft.Agents.AI.Abstractions)是个抽象基类,它的结构非常简单,它是无状态的,本身没有 History、Messages 这类属性,也就是说它本身不存储历史数据。AgentSession 自己只是个壳,真正存历史/上下文的,是挂在 StateBag 里的各个 Provider。
public abstract class AgentSession
{
public AgentSessionStateBag StateBag { get; protected set; } = new();
}
官方 ChatClientAgent 用的具体实现是 ChatClientAgentSession,在 StateBag 之外只多了一个字段 ConversationId(仅服务端托管场景用),以及一对序列化方法。下面是简化版:
public sealed class ChatClientAgentSession : AgentSession
{
public AgentSessionStateBag StateBag { get; protected set; } = new();
// 仅用于「服务端托管历史」的场景(如 Azure Foundry),客户端托管时为 null
[JsonPropertyName("conversationId")]
public string? ConversationId { get; internal set; }
internal JsonElement Serialize(JsonSerializerOptions? jso = null) { /* 序列化整个 session */ }
internal static ChatClientAgentSession Deserialize(JsonElement state, ...) { /* 反序列化 */ }
}
AgentSessionStateBag
前面看到 AgentSession 就是一个壳子,其核心就是一句 public AgentSessionStateBag StateBag { get; protected set; } = new();,所以理解 AgentSession 在于理解它里面的 AgentSessionStateBag 属性。
需要把 AgentSession、AgentSessionStateBag、AgentSessionStateBagValue 三层定义啃下来。
public abstract class AgentSession
{
public AgentSessionStateBag StateBag { get; protected set; } = new();
}
public class AgentSessionStateBag
{
private readonly ConcurrentDictionary<string, AgentSessionStateBagValue> _state;
public bool TryGetValue<T>(string key, out T? value, JsonSerializerOptions? jso = null) where T : class;
public T? GetValue<T>(string key, JsonSerializerOptions? jso = null) where T : class;
public void SetValue<T>(string key, T? value, JsonSerializerOptions? jso = null) where T : class;
}
再看 AgentSessionStateBagValue 的定义:
internal class AgentSessionStateBagValue
{
private readonly object _lock = new();
private DeserializedCache? _cache; // ① 对象形态(强类型对象缓存)
private JsonElement _jsonValue; // ② JSON 形态(原始 JSON 字节)
private readonly struct DeserializedCache
{
public object? Value { get; } // 缓存的对象
public Type ValueType { get; } // 对象的类型
public JsonSerializerOptions Options { get; }
}
}
首先我们要理清楚四个对象的关系:Agent / Provider / State / StateBag。

AgentSessionStateBag 是存储数据的容器,但是其本身是无状态的,可以被任意 Agent 使用。
AgentSession.AgentSessionStateBag 被一个 Agent A 使用时,这个 Agent A 的 ChatHistoryProvider 会在 AgentSessionStateBag 里面写入历史上下文数据。那么另一个 Agent B 拿到相同的 AgentSession 时,Agent B 的 ChatHistoryProvider 会读取 AgentSessionStateBag 上下文历史记录,对话后会继续往里面写入新的对话记录。
你可以这样理解,AgentSession 是数据库,不同的 Agent 都往同一个数据库里面读写数据,那么这些 Agent 当然具有相同的上下文历史记录。
默认 Agent 使用 InMemoryChatHistoryProvider 做上下文历史记录存储,但是注意,它本身只是一个实例,没有状态,不能存储数据,首先看 InMemoryChatHistoryProvider 的定义:
// InMemoryChatHistoryProvider 的 State:装消息列表
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
public sealed class State
{
public List<ChatMessage> Messages { get; set; } = [];
}
}
也就是说,它定义了一个 State 的类型,但是实际上生产一条新的历史对话时,会把数据存储到 AgentSessionStateBag 的 StateBag 里面,InMemoryChatHistoryProvider 本身不存储数据。
如果你启动程序调试,就会发现默认 AgentSessionStateBag 的 _state 有个名为 InMemoryChatHistoryProvider 的 数据项。

所以一个 ChatClientAgentSession 序列化出来,结构大致是:
{
"conversationId": null,
"stateBag": {
"InMemoryChatHistoryProvider": { "messages": [...] }
}
}
注意:
StateBag是无状态的,InMemoryChatHistoryProvider 是在RunAsync()时注入的。另外 StateBag 跟AIContextProvider有关,后面会详细讲解。
每个 Provider 的 State 长得不一样,因为 AgentSessionStateBagValue 的 Value 定义是 Object,但它们都是被塞进 StateBag 的那一格内容。State 是数据,Provider 是行为,两者是不同的对象,别搞混。
这正是 MAF 框架的设计哲学,后面会以 Redis 存储历史记录的例子讲解为什么 MAF 会这样设计。
再总结一下,Provider 本身是行为,它不能存储数据,所以需要获取 AgentSession 的 StateBag 来存储数据,每个 Provider 都可以在 StateBag 里面存储数据,但是要根据 Key 隔离,所以一般 Key 就是 Provider 本身的类型名称 nameof(InMemoryChatHistoryProvider)。
所以,一个 AgentSession 可以被不同的 Agent 实例使用,不会出现冲突,并且因为 AgentSession 是线程安全的,所以被多个 Agent 同时对话也没事,在多个 Agent 并行工作时特别有用,这是一个非常好的设计。
细究 StateBag 如何存储上下文
StateBag 是 AgentSession 的字段,在 new AgentSession() 时就建好了,一开始是空字典:
agent.CreateSessionAsync()
→ new ChatClientAgentSession()
→ session.StateBag = {} (空字典,没有任何 State)
此刻 AgentSession.StateBag 里是空的,也就是 AgentSession 是一个空容器。
StateBag 本身就是个线程安全字典,定义非常简单:
public class AgentSessionStateBag
{
private readonly ConcurrentDictionary<string, AgentSessionStateBagValue> _state;
public bool TryGetValue<T>(string key, out T? value, JsonSerializerOptions? jso = null) where T : class;
public T? GetValue<T>(string key, JsonSerializerOptions? jso = null) where T : class;
public void SetValue<T>(string key, T? value, JsonSerializerOptions? jso = null) where T : class;
}
字典的 value 不是直接存你的对象,而是包了一层 AgentSessionStateBagValue,它同时持有对象和JSON两种形态:
internal class AgentSessionStateBagValue
{
private readonly object _lock = new();
private DeserializedCache? _cache; // ① 对象形态(强类型对象缓存)
private JsonElement _jsonValue; // ② JSON 形态(原始 JSON 字节)
// 从 JSON 反序列化时用这个构造函数:只存 JSON,对象缓存为空
public AgentSessionStateBagValue(JsonElement jsonValue) { this.JsonValue = jsonValue; }
// 从对象创建时用这个构造函数:只存对象,JSON 还没生成
public AgentSessionStateBagValue(object? deserializedValue, Type valueType, JsonSerializerOptions jso)
{
this._cache = new DeserializedCache(deserializedValue, valueType, jso);
}
public JsonElement JsonValue
{
get
{
lock (_lock)
{
// 读 JSON 时,如果对象缓存有值,就此刻序列化成 JSON(懒生成)
if (_cache is { } cache)
_jsonValue = JsonSerializer.SerializeToElement(cache.Value, cache.Options.GetTypeInfo(cache.ValueType));
return _jsonValue;
}
}
set { lock (_lock) { _jsonValue = value; _cache = null; } } // 设 JSON 时清空对象缓存
}
public bool TryReadDeserializedValue<T>(out T? value, ...) where T : class;
public T? ReadDeserializedValue<T>(...) where T : class;
public void SetDeserialized<T>(T? value, Type type, JsonSerializerOptions jso);
private readonly struct DeserializedCache
{
public object? Value { get; } // 缓存的对象
public Type ValueType { get; } // 对象的类型
public JsonSerializerOptions Options { get; }
}
}
为什么要两种形态?因为 Provider 读写时想用对象(强类型、方便操作),而 session 持久化时要存JSON(跨进程)。
StateBag 在这两种形态间自动转换,规则是:写入时只缓存对象(JSON 不生成);序列化时才把对象懒序列化成 JSON;反序列化回来时只存 JSON(对象缓存空);读取时按你指定的类型把 JSON 反序列化成对象并缓存。
Provider 完全不用碰序列化细节。
第 1 轮:Provider 怎么存储消息
Agent 里面有 Provider,主要有两种,一个是 ChatHistoryProvider 负责上下文历史记录的存储和读取,AIContextProvider 负责上下文注入。
注意,这里说的是默认的 MAF InMemoryChatHistoryProvider 行为,如果后续自己定制 ChatHistoryProvider ,那么逻辑就不是这样的了。
用户发起第 1 轮对话 RunAsync("讲个笑话", session)。Agent 进入 RunCoreAsync 后,第一步是 ChatHistoryProvider 从 AgentSession 读取历史,一开始字典没有 Key["InMemoryChatHistoryProvider"],就会初始化一个新的 InMemoryChatHistoryProvider,把 State 递进去。
Provider 在构造时建好的私有字段:
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
// Provider 的私有字段:一个「按 key 读写 StateBag」的帮手
// 泛型参数 TState = State(InMemoryChatHistoryProvider 的内嵌类)
private readonly ProviderSessionState<State> _sessionState;
public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = null)
{
this._sessionState = new ProviderSessionState<State>(
stateInitializer: _ => new State(), // 找不到 State 时,用这个函数建一个空的
stateKey: this.GetType().Name); // key = "InMemoryChatHistoryProvider"(类型名)
}
}
因为一开始 Session 的 StateBag 是空的,所以 GetOrInitializeState() 读取 State 发现是空的时候就会初始化一个 State。
// ProviderSessionState<State>.GetOrInitializeState(this = _sessionState 帮手)
public State GetOrInitializeState(AgentSession? session)
{
// ① 去这个 session 的 StateBag 里,按 key "InMemoryChatHistoryProvider" 找
if (session?.StateBag.TryGetValue<State>(this.StateKey, out var state, ...) is true && state is not null)
return state; // 找到 → 直接返回那个 State 引用
// ② 没找到 → 调初始化函数 new 一个空的 State
state = this._stateInitializer(session); // = new State { Messages = [] }
// ③ 把这个新 State 塞进 session 的 StateBag 里(key 还是 "InMemoryChatHistoryProvider")
session.StateBag.SetValue(this.StateKey, state, ...);
return state;
}
但是 Agent 层不关心这些细节,Agent 会调用 ChatHistoryProvider.ProvideChatHistoryAsync() 获取对话历史,然后读取 ChatMessage 列表。
protected virtual async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var output = await this.ProvideChatHistoryAsync(context, cancellationToken).ConfigureAwait(false);
if (this._provideOutputMessageFilter is not null)
{
output = this._provideOutputMessageFilter(output);
}
return output
.Select(message => message.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!))
.Concat(context.RequestMessages);
}
所以 InMemoryChatHistoryProvider 自己负责跟 AgentSession 交互,怎么从里面还原历史记录。
// InMemoryChatHistoryProvider.ProvideChatHistoryAsync(this = Provider 实例)
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
InvokingContext context, CancellationToken ct = default)
{
// this._sessionState → Provider 私有的那个帮手字段
// context.Session → Agent 递进来的 session
State state = this._sessionState.GetOrInitializeState(context.Session);
return new(state.Messages); // 直接返回 List<ChatMessage>
}
此刻 StateBag:
StateBag = {
"InMemoryChatHistoryProvider": State { Messages: [] } ← 空列表
}
历史读出来是空的,Agent 把「空历史 + 新消息」发给模型,拿到助手回复。然后 Agent 再次调 Provider 的 StoreChatHistoryAsync,写回历史:
// InMemoryChatHistoryProvider.StoreChatHistoryAsync(this = Provider 实例)
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken ct = default)
{
// 同样通过 _sessionState 帮手,从 StateBag 取出那个 State(这次走 ① 分支,已经有了)
State state = this._sessionState.GetOrInitializeState(context.Session);
// 把这一轮的「用户消息 + 助手回复」追加进 List
var allNew = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNew);
return default;
}
这里有个关键点:state 是从 StateBag 取出的对象引用,直接 AddRange 修改它就等于改了 StateBag 里的内容,不需要再写回一次。第 1 轮结束,StateBag 里那份 State.Messages 已经有两条消息了:
StateBag = {
"InMemoryChatHistoryProvider": State { Messages: [用户1, 助手1] }
}
第 2 轮:怎么读取消息、怎么存储第二轮消息
用户发起第 2 轮 RunAsync("再讲一个", session)。同一个 session,同一份 StateBag。这次 Provider 的 ProvideChatHistoryAsync 同样调 GetOrInitializeState但走 ① 分支了,因为上一轮已经把 State 塞进去了:
// 第 2 轮的 ProvideChatHistoryAsync(this = Provider 实例,和第 1 轮是同一个)
// ProviderSessionState<TState>..GetOrInitializeState(context.Session);
State state = this._sessionState..GetOrInitializeState(context.Session);
// ↑
// GetOrInitializeState 内部 TryGetValue 找到了
// 返回上一轮塞进去的那个 State(Messages 已经有 2 条)
return new(state.Messages); // → [用户1, 助手1]
于是历史被拼进请求:[system, 用户1, 助手1, 用户2]。模型记得上一句,因为上一轮写进去的历史,这一轮原封不动读出来了。
模型回复后,Agent 同样调 StoreChatHistoryAsync,走和第 1 轮一样的流程,取出同一个 State 引用,把这一轮的对话AddRange 进去:
StateBag = {
"InMemoryChatHistoryProvider": State { Messages: [用户1, 助手1, 用户2, 助手2] }
}
之后每一轮都是这个循环:读出历史 → 拼进请求发模型 → 把新消息追加回同一个 List。列表越来越长,这就是自动多轮的全部秘密。
InMemoryChatHistoryProvider
InMemoryChatHistoryProvider 是 Agent 默认塞给你的 ChatHistoryProvider(。它把历史消息存在 session 的 StateBag 里,核心就是一个 List<ChatMessage>。
核心数据结构:一个 List<ChatMessage>
先看它的状态类 State——这就是被存进 StateBag 的那一格的内容:
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
public sealed class State
{
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = [];
}
}
就这么简单:一个 List<ChatMessage>。对话历史本质就是「按时间顺序排好的消息列表」,所以用一个 List 装着,新消息往尾部 Add。每条 ChatMessage 是 Microsoft.Extensions.AI 里的统一消息抽象(含 Role、Contents 等),可以被 JSON 序列化。
这个 State 实例就存在 session.StateBag["InMemoryChatHistoryProvider"] 那一格(key 是 GetType().Name,即 "InMemoryChatHistoryProvider")。
InMemoryChatHistoryProvider 重写了基类的两个方法,逻辑都很短:
// 读:每次 RunAsync 开始时被调用,返回历史消息
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
InvokingContext context, CancellationToken ct = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
return new(state.Messages); // 直接返回 List 的引用
}
// 写:每次 RunAsync 结束时被调用,把这一轮的新消息追加进历史
protected override ValueTask StoreChatHistoryAsync(
InvokedContext context, CancellationToken ct = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages); // 追加到 List 尾部
return default;
}
两个方法都先通过 _sessionState.GetOrInitializeState(context.Session) 从 StateBag 拿到 State 引用(找不到就建空的,上一节讲过),然后:
- 读:直接返回
state.Messages(List 引用),框架把这个列表里的消息拼到请求前面发给模型。 - 写:把这一轮的「用户消息(
RequestMessages)+ 助手回复(ResponseMessages)」AddRange到 List 尾部。
注意写的时候是直接修改 List 对象——因为 state 是从 StateBag 取出的对象引用,改它就等于改了 StateBag 内容,不需要「写回」。
当你调 agent.SerializeSessionAsync(session) 把会话存下来时,整个 StateBag 会被序列化成 JSON。对 InMemoryChatHistoryProvider 这格来说,就是把它缓存里的 State 对象序列化。
得到的 JSON 大致长这样:
{
"conversationId": null,
"stateBag": {
"InMemoryChatHistoryProvider": {
"messages": [
{ "role": "user", "contents": [{ "type": "text", "text": "讲个笑话" }] },
{ "role": "assistant", "contents": [{ "type": "text", "text": "为什么..." }] },
{ "role": "user", "contents": [{ "type": "text", "text": "再讲一个" }] },
{ "role": "assistant", "contents": [{ "type": "text", "text": "好的..." }] }
]
}
}
}
会话存储与复原 AgentSessionStore
AgentSession 是数据容器,AgentSessionStore 解决的是如何持久化存储 AgentSession ,也就是能够存储和恢复一个 AgentSession 。
注意,如果是自己实现一个 ChatHistoryProvider,其实可以跳过 AgentSessionStore 和 AgentSession ,下一节的 Redis 持久化会话会讲解到。
其实,实现持久化会话有两种方案,第一种是实现一个 AgentSessionStore,但是它是以整个对话序列化和反序列化的,每次对话的成本都比较大,但是如果出现上下文太长进行压缩的情况,那么需要整个对话压缩后存储,AgentSessionStore 比较友好,另一种是自己替换 ChatHistoryProvider,自定义 ChatHistoryProvider 存储对话,AgentSession 只存对话 id,自行使用存储后端存储历史对话上下文,存储成本小,但是这样读取成本会比较大,并且发生上下文压缩的时候,怎么维护后端数据,也是个问题。需要权衡利弊,合理取舍。
下一章会介绍解决方案。
我们本身可以手动序列化和反序列化存储 AgentSession .
// 序列化当前会话(存到数据库/Redis/文件都行)
JsonElement sessionState = await agent.SerializeSessionAsync(session);
string json = sessionState.GetRawText(); // 拿到 JSON 字符串
// ……下次启动 ……
// 反序列化,继续对话
AgentSession restored = await agent.DeserializeSessionAsync(JsonDocument.Parse(json).RootElement);
Console.WriteLine(await agent.RunAsync("我上次说到哪了?", restored));
但是 MAF 框架提供了一套抽象,把 Session 的存储和读取标准化了,那就是 AgentSessionStore。
AgentSessionStore 是托管层抽象,来自 Microsoft.Agents.AI.Hosting 包,它跟前面讲的 AgentSession、ChatHistoryProvider 不在一个层级,前两个是 Agent 内核的东西,AgentSessionStore 则是在独立扩展包。
它的契约非常薄,就两个抽象方法 + 一个给装饰器链用的服务查找钩子:
// Microsoft.Agents.AI.Hosting
public abstract class AgentSessionStore
{
// 按 (agent, conversationId) 存一个 session
public abstract ValueTask SaveSessionAsync(
AIAgent agent, string conversationId, AgentSession session,
CancellationToken cancellationToken = default);
// 按 (agent, conversationId) 取一个 session;没有则由实现决定(通常新建一个空的)
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent, string conversationId,
CancellationToken cancellationToken = default);
// 装饰器链自省用:沿链查找某个类型的实现(例如查 IsolationKeyScopedAgentSessionStore 在不在)
public virtual object? GetService(Type serviceType, object? serviceKey = null);
public TService? GetService<TService>(object? serviceKey = null);
}
框架在 Microsoft.Agents.AI.Hosting 里提供了以下实现,它们的分工很重要:
| 实现 | 存储 | 适用场景 | 关键行为 |
|---|---|---|---|
NoopAgentSessionStore | 不存 | 默认值 | Save 空操作;Get 永远 new 一个全新 session。即"多轮不记忆"。 |
InMemoryAgentSessionStore | 进程内存 ConcurrentDictionary | 单机开发/调试 | 进程重启即丢;只有一个 (agent.Id, conversationId) 维度。 |
FileSystemAgentSessionStore | 文件系统 | 仅 Foundry 托管(Microsoft.Agents.AI.Foundry.Hosting) | 写 $HOME/.checkpoints 下的 JSON 文件,原子写(temp + Move)。Foundry 托管默认就是它。 |
IsolationKeyScopedAgentSessionStore | 不存数据,是装饰器 | 多用户主机 | 把 conversationId 改写成 {隔离key}::{conversationId} 再转发给内层 store。 |
DelegatingAgentSessionStore | —— | 写自定义装饰器的基类 | 持有一个 innerStore,默认全部转发。 |
重点:不显式注册任何 store 时,默认套的是
NoopAgentSessionStore,如果你什么都不配,每次请求都是全新会话,多轮对话完全不记忆。
注册入口在 IHostedAgentBuilder 上(Microsoft.Agents.AI.Hosting):
// 注册 InMemory store(最常见,开发调试用)
agentBuilder.WithInMemorySessionStore();
// 或:注册自定义 store(比如你自己写的 Redis store)
agentBuilder.WithSessionStore(new MyRedisSessionStore(...));
// 或:用工厂 + 指定 DI 生命周期
agentBuilder.WithSessionStore(
(sp, agentName) => new MyRedisSessionStore(sp.GetRequiredService<IConnectionMultiplexer>()),
ServiceLifetime.Singleton);
一个典型的 ASP.NET Core 启动配置长这样:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAIAgent("chat", sp =>
{
// ... 构建 AIAgent ...
})
.WithInMemorySessionStore(); // ← 关键:注册一个 session store
var app = builder.Build();
app.MapAGUI("chat", "/agents/chat"); // 挂一个 AG-UI 协议端点
app.Run();
感兴趣的读者自己研究一下自己实现 AgentSessionStore ,这里笔者就不展开讲解了。
使用 Redis 持久化对话
默认的 InMemoryChatHistoryProvider 进程一退出数据就没了,生产场景要用持久化存储,最常见的是 Redis 存储对话历史,我们自行实现一个 ChatHistoryProvider。
需要安装 StackExchange.Redis 包。
设计思路:
- StateBag 里只存一个
ConversationId(会话 ID,指针)。 - 真实的消息列表存到 Redis 的 List 里,key 格式
chat_history:{ConversationId}。 - 每条消息序列化成 JSON 存进 List,读取时反序列化回来。
所以 session 序列化出来体积很小(只有一个 ID),真正的对话内容都在 Redis 里。
完整实现:
public sealed class RedisChatHistoryProvider : ChatHistoryProvider
{
private readonly IConnectionMultiplexer _redis;
private readonly string _keyPrefix;
private readonly int _maxMessages; // 单会话最多保留多少条,超过自动裁剪
private readonly ProviderSessionState<State> _sessionState;
public RedisChatHistoryProvider(IConnectionMultiplexer redis, string keyPrefix = "chat_history", int maxMessages = 1000)
{
_redis = redis;
_keyPrefix = keyPrefix;
_maxMessages = maxMessages;
// 帮手类:负责按 key 读写 StateBag。
// stateInitializer:第一次用某个 session 时,给它分配一个新的会话 ID。
// stateKey:在 StateBag 里的字典 key,默认用本类的类型名。
_sessionState = new ProviderSessionState<State>(
stateInitializer: _ => new State($"conv-{Guid.NewGuid():N}"),
stateKey: this.GetType().Name);
}
// 声明本 provider 用到哪些 StateBag key(框架会校验不与其他 provider 冲突)
public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];
// 读取历史:从 Redis List 里把所有消息反序列化回来
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
InvokingContext context, CancellationToken ct = default)
{
State state = _sessionState.GetOrInitializeState(context.Session);
var db = _redis.GetDatabase();
var key = $"{_keyPrefix}:{state.ConversationId}";
// LRANGE 取整个 List(按时间顺序)
RedisValue[] values = await db.ListRangeAsync(key);
var messages = new List<ChatMessage>(values.Length);
foreach (var value in values)
{
if (value.IsNullOrEmpty) continue;
var msg = JsonSerializer.Deserialize<ChatMessage>(value.ToString());
if (msg is not null) messages.Add(msg);
}
return messages;
}
// 写入历史:把这一轮的新消息追加到 Redis List 尾部
protected override async ValueTask StoreChatHistoryAsync(
InvokedContext context, CancellationToken ct = default)
{
State state = _sessionState.GetOrInitializeState(context.Session);
List<ChatMessage> allNew = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []).ToList();
if (allNew.Count == 0) return;
IDatabase db = _redis.GetDatabase();
var key = $"{_keyPrefix}:{state.ConversationId}";
// 把每条消息序列化成 JSON,批量 RPUSH 进 List
RedisValue[] serialized = allNew.Select(m => (RedisValue)JsonSerializer.Serialize(m)).ToArray();
await db.ListRightPushAsync(key, serialized);
// 超过上限时,保留尾部最新的 N 条(裁掉老的)
await db.ListTrimAsync(key, -_maxMessages, -1);
}
// State:只存一个 ConversationId。
// 这个对象会被序列化进 StateBag,所以 session 存到哪,ID 就跟到哪。
public sealed class State(string conversationId)
{
public string ConversationId { get; } = conversationId;
}
}
就这么简单。核心逻辑就两个方法:ProvideChatHistoryAsync 用 LRANGE 读,StoreChatHistoryAsync 用 RPUSH 写 + LTRIM 裁剪。其余的 ProviderSessionState 帮手是框架提供的,负责按 key 读写 StateBag(前面章节讲过)。
挂到 Agent 上,用法和默认实现完全一样:
var redis = await ConnectionMultiplexer.ConnectAsync("192.168.50.199:6379");
// 2. 创建我们自己的 RedisChatHistoryProvider
var historyProvider = new RedisChatHistoryProvider(redis, keyPrefix: "chat_history", maxMessages: 1000);
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(new ChatClientAgentOptions
{
Name = "Helper",
Description = "一个示例 AI Agent,使用 OpenAI Chat Completion API。",
ChatOptions = new ChatOptions
{
Instructions = "你是一个乐于助人的助手,必要时调用工具",
Tools = [AIFunctionFactory.Create(GetWeather)]
},
ChatHistoryProvider = historyProvider
})
.AsBuilder()
.UseOpenTelemetry(sourceName: "AAA", configure: cfg =>
{
cfg.EnableSensitiveData = true;
})
.UseLogging(loggerFactory: loggerFactory)
.Build();

要注意,Redis 存储的是 List<ChatMessage> 而不是 Session。
如果要从 Redis 还原 Session,需要:
// 1. 建一个空 session
AgentSession restored = await agent.CreateSessionAsync();
// 2. 把已知的 ConversationId 塞进 StateBag(provider 那一格)
restored.StateBag.SetValue("RedisChatHistoryProvider", new RedisChatHistoryProvider.State("conv-3e9b751b124d41829a7c188730ace530"));
Console.WriteLine();
await foreach (var u in agent.RunStreamingAsync("我上个问题是什么?", restored))
Console.Write(u);

但是因为获取 ConversationId 是太麻烦了,因为套了好几层,Session 并不能直接存储数据, Session 的 State 是各个 Provider 自己存储的 Object 数据,实际上我们并不知道有没有 ConversationId 这个东西,所以动态取出来比较曲折,除非我们知道 State 一定有某个值。
第一次对话时,我们手动指定 Session 的 ConversationId ,然后把 ConversationId 跟 Session 关联起来,后续对话都可以通过一个 ConversationId 存储和读取历史消息,还可以实现多级缓存。
// 自定义 Id
string conversationId = Guid.CreateVersion7().ToString("N");
// 建一个空 session
AgentSession session = await agent.CreateSessionAsync();
// 提前把 ConversationId 塞进 StateBag(provider 那一格)
session.StateBag.SetValue("RedisChatHistoryProvider", new RedisChatHistoryProvider.State(conversationId));
await foreach (var u in agent.RunStreamingAsync("阿姆斯特丹天气怎么样?", session))
Console.Write(u);
AgentSession restored = await agent.CreateSessionAsync();
// 把已知的 ConversationId 塞进 StateBag(provider 那一格)
restored.StateBag.SetValue("RedisChatHistoryProvider", new RedisChatHistoryProvider.State(conversationId));
await foreach (var u in agent.RunStreamingAsync("我上个问题是什么?", restored))
Console.Write(u);
Console.WriteLine();

到这里,已经介绍了两种持久化 Session 的方法,各有优缺点,主要矛盾点在于发送上下文压缩时,原本的后端对话历史应该怎么处理。这里读者可以思考一下。
AIContextProvider:上下文提供器
AIContextProvider上下文提供程序在每次 Agent 运行时、在执行前添加上下文并在执行后处理数据。
一个 ChatClientAgent 可以有多个 AIContextProvider,Agent 会按顺序调用它们。
就类似 ASP.NET Core 的中间件管道,可以用于注入动态指令/消息/工具或在运行后提取状态时,或者做敏感信息拦截。
抽象基类核心两个钩子,在一个对话运行前执行,一个在对话结束时执行。
public abstract class AIContextProvider
{
// RunAsync 开始:返回额外的 AIContext(消息/工具/指令)
protected virtual ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context, CancellationToken ct = default)
=> new(new AIContext());
// RunAsync 结束:处理这次的结果
protected virtual ValueTask StoreAIContextAsync(
InvokedContext context, CancellationToken ct = default) => default;
}
AIContext 是个小容器,三个字段都是可选的:
public sealed class AIContext
{
public string? Instructions { get; set; } // 追加到 system 指令
public IList<ChatMessage>? Messages { get; set; } // 注入的消息
public IList<AITool>? Tools { get; set; } // 注入的工具
}
AIContextProvider有两种注入方式,第一种是在 ChatClientAgentOptions.AIContextProviders 里配(agent 级别)。这种方式 Provider 在 RunCoreAsync 里被调用一次,不会在函数调用循环的每一轮里重新触发:
var agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
AIContextProviders = [new TodoListProvider(), new CalendarProvider()],
});
第二种是在 builder 上用 UseAIContextProviders**(chat client 管道级别)。这种方式 Provider 挂在 AIContextProviderChatClient 装饰器里,**会在函数调用循环的每次 LLM 调用前都触发,适合需要每轮都刷新上下文的场景(如上下文压缩、敏感信息处理,见第六章):
var agent = chatClient
.AsBuilder()
.UseAIContextProviders(new CompactionProvider(...))
.BuildAIAgent(new ChatClientAgentOptions { ... });
两种方式可以混用:builder 上的 Provider 每轮触发,options 里的 Provider 每次运行触发。
源码里这段链式调用很直接:
// 摘自 ChatClientAgent.RunCoreAsync
AIContext aiContext = new() { Instructions = ..., Messages = inputMessages, Tools = ... };
foreach (var provider in aiContextProviders)
{
var invokingContext = new AIContextProvider.InvokingContext(this, session, aiContext);
aiContext = await provider.InvokingAsync(invokingContext, ct); // 上一个的输出是下一个的输入
}
InvokedAsync(结束时回调)则按相反顺序——从最后一个 Provider 往前回调,但每个都只调一次。
动手写一个 AIContextProvider
前面讲了 AIContextProvider 的概念和挂法,这一节看怎么自己写一个。先理解它要解决什么问题。
ChatHistoryProvider 只能拼历史消息——固定的一段对话记录。但很多场景下,模型每轮需要的上下文是动态的:
- 用户问「我下一个日程是什么」——你要从日历系统实时拉数据喂给模型。
- 每轮都把「当前用户的待办列表」给模型看——让它知道还有什么没做。
- 根据当前用户权限,动态开放不同的工具(VIP 用户能用高级功能)。
这些需求有个共同点:每次 RunAsync 时,要往请求里临时加点东西(消息、工具、指令都可能)。AIContextProvider 就是干这个的,它的 ProvideAIContextAsync 返回一个 AIContext,里面可以装消息、工具、指令三类内容:
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, ...)
{
return new(new AIContext
{
Instructions = "...", // 追加到 system 指令
Messages = [...], // 注入的消息
Tools = [...] // 注入的工具
});
}
只注入消息的话,继承 MessageAIContextProvider 实现会比较方便,SDK 里面有个 MessageAIContextProvider 抽象,封装了一些东西,比直接继承 AIContextProvider 方便一些。
最常见的场景是每轮往请求里塞几条消息,不需要动态工具或指令,这时继承 MessageAIContextProvider 最省事,它把 AIContext 那套简化成只返回消息,基类自动帮你打包:
// 示范:每轮把用户的日程安排注入给模型
internal sealed class CalendarProvider(Func<Task<string[]>> loadEvents) : MessageAIContextProvider
{
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(
InvokingContext context, CancellationToken ct = default)
{
var events = await loadEvents(); // 从日历系统拉数据
return [new ChatMessage(ChatRole.User, $"你接下来有这些安排:\n{string.Join("\n", events)}")];
}
}
如果需要注入工具 + 消息,则需要基于 AIContextProvider 继承改造。
典型例子是待办列表——既把当前列表读给模型看(消息),又给模型两个工具增删待办(工具):
// 注意:这是示范「怎么自己写」的样例,不是框架内置类
internal sealed class TodoListProvider : AIContextProvider
{
// 待办列表存在 session.StateBag 里(跟着 session 一起持久化)
private static List<string> GetItems(AgentSession? s)
=> s?.StateBag.GetValue<List<string>>(nameof(TodoListProvider)) ?? [];
private static void SetItems(AgentSession? s, List<string> items)
=> s?.StateBag.SetValue(nameof(TodoListProvider), items);
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context, CancellationToken ct = default)
{
var items = GetItems(context.Session);
var sb = new StringBuilder("你当前的待办:\n");
for (int i = 0; i < items.Count; i++)
sb.AppendLine($"{i}. {items[i]}");
return new(new AIContext
{
// 注入两个工具:模型可以调用它们增删待办
Tools =
[
AIFunctionFactory.Create((string item) => AddItem(context.Session, item), "AddTodo", "加一条待办"),
AIFunctionFactory.Create((int idx) => RemoveItem(context.Session, idx), "RemoveTodo", "删一条待办"),
],
// 注入一条消息:把当前列表读给模型看
Messages = [new ChatMessage(ChatRole.User, sb.ToString())]
});
}
private static void AddItem(AgentSession? s, string item) { /* ... */ }
private static void RemoveItem(AgentSession? s, int idx) { /* ... */ }
}
挂上去后,模型每轮都「看得见」待办列表,还能自己调 AddTodo/RemoveTodo。这种「动态工具 + 上下文」是 ChatHistoryProvider 做不到的,是 context Provider 的核心价值。
如果消息是在 AIContextProvider 被注入的,其属性会标识 SourceType 来源,开发者可以通过属性筛选这些消息到底是 AI、用户,还是 AIContextProvider 注入的。

写回历史时能识别如果不想把 AIContextProvider 注入的内容也存储,如果你不想让注入的内容污染历史,可以配置过滤器,把 AIContextProvider 来源的消息也排除掉:
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
StorageInputRequestMessageFilter = msgs => msgs.Where(m =>
m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider &&
m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
});