历史记录和上下文

本章涉及的核心对象就四个: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 被当作数据容器,用于存储和读取数据。


正在渲染 Mermaid 图表...

可以看到,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 框架源码,整理了一下流程图,读者在需要的时候再回头看看这个流程图,加深理解。

正在渲染 Mermaid 图表...

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 的内部结构。这也是后面持久化、上下文注入的地基。


AgentSessionMicrosoft.Agents.AI.Abstractions)是个抽象基类,它的结构非常简单,它是无状态的,本身没有 HistoryMessages 这类属性,也就是说它本身不存储历史数据。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。

image-20260720092048771


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 的 数据项。

image-20260719162534914


所以一个 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。每条 ChatMessageMicrosoft.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 包,它跟前面讲的 AgentSessionChatHistoryProvider 不在一个层级,前两个是 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;
    }
}


就这么简单。核心逻辑就两个方法:ProvideChatHistoryAsyncLRANGE 读,StoreChatHistoryAsyncRPUSH 写 + 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();

image-20260720110502529


要注意,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);

image-20260720112643389


但是因为获取 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();

image-20260720114055978


到这里,已经介绍了两种持久化 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 注入的。

image-20260720144437882


写回历史时能识别如果不想把 AIContextProvider 注入的内容也存储,如果你不想让注入的内容污染历史,可以配置过滤器,把 AIContextProvider 来源的消息也排除掉:

ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
    StorageInputRequestMessageFilter = msgs => msgs.Where(m =>
        m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider &&
        m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory)
});