MAF入门(3 下):多轮对话进阶——清除历史、注入 System、截断策略

0 阅读7分钟

MAF 入门(3 下):多轮对话进阶——清除历史、注入 System、截断策略


写在前面

(3 上)我们让 Agent 会记住——多轮里能答出「你叫小明」「你喜欢 C#」。

但真实产品里,光有记忆还不够,还要会 、会 改规矩、会 省 Token

需求MAF 能力
清除历史SetMessages / SetInMemoryChatHistory
注入 SystemMessageInjectingChatClient.EnqueueMessages
截断策略IChatReducer + MessageCountingChatReducer

这三件事,就是本篇的全部内容。


一、清除会话历史——「一键新开聊天」

1.1 为什么需要清除?

多轮记忆是双刃剑。用户点了 「新对话」,你还把上一轮「记住数字 42」带进上下文,既浪费 Token,也可能答非所问。

清除历史 ≠ 销毁 AgentSession
Session 还在(同一会话 ID、同一块 StateBag),只是 消息列表被清空——像微信里「清空聊天记录」,窗口没关。

1.2 实现步骤

步骤 1:照旧创建带 InMemoryChatHistoryProvider 的 Agent 和 Session。

步骤 2:先聊两轮,验证「记得住」:

await agent.RunAsync("记住这个数字:42。", session);
await agent.RunAsync("我刚才让你记住的数字是多少?", session);
// 预期:42

步骤 3:清空历史(两种写法等价):

// 写法 A:通过 Provider
historyProvider.SetMessages(session, []);

// 写法 B:通过 Session 扩展方法
session.SetInMemoryChatHistory([]);

步骤 4:再问同一个问题:

await agent.RunAsync("我刚才让你记住的数字是多少?", session);
// 预期:不知道 / 没有相关信息

1.3 Demo 关键代码

Console.WriteLine("--- 执行清除历史 ---");
historyProvider.SetMessages(session, []);
PrintHistory("清除后", historyProvider, session);  // 应为 0 条

await RunTurnAsync(agent, session, "我刚才让你记住的数字是多少?", cancellationToken);

1.4 注意点

  • 清的是 ChatHistory 消息,不是 Instructions(创建 Agent 时的系统角色仍在)。
  • 若只清 Session 却换了一个没挂同一 Provider 的 Agent,行为可能不一致——同一 Agent + 同一 Provider 实例 最稳妥。
  • 生产环境还可 新建 SessionCreateSessionAsync())代替清空,效果类似「全新对话窗口」。

二、运行时注入 System Message——「对话中途改规矩」

2.1 和 Instructions 有什么不同?

(3 上)讲过:ChatOptions.Instructions创建 Agent 时 写好,相当于入职手册。

有时要在 聊了一半 才改规则,例如:

  • 用户点击「切换英文」
  • 运营活动临时加一条「今日禁止讨论价格」
  • 工具执行完后插入 hidden system 提示

这时不适合重建 Agent,而是 往当前 Session 里再塞一条 System 消息

Instructions(静态)运行时注入(动态)
时机AsAIAgent / ChatClientAgentOptions任意一轮 RunAsync 之前
改法换配置或换 AgentEnqueueMessages
历史每轮都有从注入时刻起影响后续轮次

2.2 机制:MessageInjectingChatClient

MAF 在管道里加一层 MessageInjectingChatClient

RunAsync 触发
    → 从 Session.StateBag 取出「待注入消息队列」
    → 合并进本次发给模型的 messages
    → 调用大模型

要启用它,创建 Agent 时必须:

var options = new ChatClientAgentOptions
{
    Name = "InjectSystemAgent",
    ChatOptions = new ChatOptions { Instructions = BaseInstructions },
    ChatHistoryProvider = historyProvider,
    EnableMessageInjection = true,   // 关键开关
};

2.3 实现步骤

步骤 1enableMessageInjection: true 创建 Agent,并 CreateSessionAsync()

步骤 2:第一轮正常聊(中文):

await agent.RunAsync("用一句话介绍你自己。", session);

步骤 3:拿到注入器并排队 System 消息:

MessageInjectingChatClient? injector = agent.GetService<MessageInjectingChatClient>();
if (injector is null)
{
    // 说明 EnableMessageInjection 未生效
    return;
}

injector.EnqueueMessages(session,
[
    new ChatMessage(ChatRole.System, "From now on, reply only in brief English.")
]);

步骤 4:第二轮提问,观察是否变英文:

await agent.RunAsync("用一句话介绍 MAF。", session);

2.4 形象理解

把对话想成开会:

  • Instructions:会议开始前发的议程(一直有效)
  • EnqueueMessages(System):会中主席突然补充:「接下来请用英文发言」

之前的发言记录还在(History 没清),但 后续 模型会多看到一条 System,从而改变风格。

2.5 注意点

  • 必须 EnableMessageInjection = true,否则 GetService<MessageInjectingChatClient>() 为 null。
  • 注入的是 下一轮(或同轮 pipeline 内下一次模型调用)才生效,不是改已经发出去的历史。
  • 模型不一定 100% 遵守新 System,和写静态 Instructions 一样要靠 prompt 与评测。

三、截断策略——「聊天记录太长就裁剪」

3.1 为什么需要截断?

(3 上)历史会一直 append。聊 50 轮后:

  • Token 爆掉 —— 超 context window,API 报错或截断
  • 变慢变贵 —— 每次带全长历史
  • 干扰答案 —— 早期无关内容稀释注意力

所以要在 发给模型之前,对历史做 Reduce(缩减)。MAF 通过 IChatReducer 挂在 InMemoryChatHistoryProvider 上实现。

3.2 存储 vs 发给模型:两个数量

Demo 【5】里有一个容易混淆的点:

概念含义
存储条数GetMessages(session).Count —— StateBag 里完整保存的轮次
发给模型的条数ChatReducer 裁剪 之后 再拼进 API 的 messages

截断默认在 BeforeMessagesRetrieval(取历史给模型 之前)触发:

new InMemoryChatHistoryProviderOptions
{
    ChatReducer = new MessageCountingChatReducer(maxMessages),
    ReducerTriggerEvent = InMemoryChatHistoryProviderOptions
        .ChatReducerTriggerEvent.BeforeMessagesRetrieval,
}

因此可能出现:存储 12 条,实际只把最近 4 条非 System 消息发给模型

3.3 MessageCountingChatReducer 做什么?

ChatReducer = new MessageCountingChatReducer(4)  // 最多保留 4 条「非 System」消息

行为(简化理解):

  • 保留 第一条 System(若有)
  • 保留 最近 4 条 user / assistant 消息
  • 丢掉 更早的 user / assistant
  • 工具调用 的消息通常 不参与 计数/会被排除(避免 tool 链断裂)

3.4 Demo 设计:水果游戏

连续 6 轮让用户只说水果名,第 6 轮问「按顺序列出你记得的水果」:

string[] prompts =
[
    "第1轮:说「苹果」。",
    "第2轮:说「香蕉」。",
    "第3轮:说「橙子」。",
    "第4轮:说「葡萄」。",
    "第5轮:说「西瓜」。",
    "第6轮:请按顺序列出你记得我说过哪些水果(只列水果名)。",
];

不截断,模型可能列出 6 个;
只保留 4 条,模型往往只能稳定记住 后 4 个(香蕉、橙子、葡萄、西瓜),苹果 可能被裁掉。

每轮打印存储条数,你会看到存储持续增长,但模型「记忆」受 reducer 限制——这就是截断策略的直观实验。

3.5 方法代码

AgentFactory.CreateWithTruncation 把配置收成一行:

public static AIAgent CreateWithTruncation(
    IChatClient chatClient,
    string instructions,
    string name,
    int maxNonSystemMessages)
{
    var historyProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
    {
        ChatReducer = new MessageCountingChatReducer(maxNonSystemMessages),
        ReducerTriggerEvent = InMemoryChatHistoryProviderOptions
            .ChatReducerTriggerEvent.BeforeMessagesRetrieval,
    });

    return CreateWithSessionHistory(chatClient, instructions, name, historyProvider);
}

3.6 注意点

  • maxMessages 过小会「失忆」过早内容;过大则失去截断意义,需按模型 context 与业务调参。
  • Function Tool 的多轮对话要谨慎截断,避免裁断 tool call / tool result 配对。
  • 还有 SummarizingChatReducer(把旧对话摘要成一条)等,适合要「保留语义」而不是「硬砍条数」的场景——可后续单独开一篇。
  • ReducerTriggerEvent.AfterMessageAdded 会在 写入后 就缩减存储;BeforeMessagesRetrieval 只影响 读出,存储仍完整——Demo 用的是后者,便于观察「存得多、读得少」。

四、三种能力一张表

能力核心 API是否清空 Session典型场景
清除历史SetMessages(session, [])否,只清消息新对话、隐私、换话题
注入 SystemEnqueueMessages(session, [System...])否,追加规则切换语言、临时策略
截断ChatReducer on Provider否,裁剪读出长对话、控 Token
         AgentSession(会话身份不变)
              │
              ├── 清除历史     → 消息列表 = []
              ├── 注入 System  → 队列里多一条 System,下轮生效
              └── 截断         → 存储可很长,读出时变短

五、拓展知识

5.1 清除 vs 新建 Session

做法优点缺点
SetMessages([], …)同一 sessionId,前端不用换StateBag 里其它状态还在
CreateSessionAsync() 新的彻底隔离要管理更多 session 对象

按产品需求选;很多 App 的「新对话」其实是 新 Session

5.2 注入消息还能干什么?

EnqueueMessages 不限 System,也可注入 User / Assistant(例如模拟用户确认、插入 RAG 检索结果)。
System 注入最常见,因为 改行为而不冒充用户原话

5.3 Reducer 生态(Microsoft.Extensions.AI)

Reducer策略
MessageCountingChatReducer按条数保留最近 N 条
SummarizingChatReducer旧消息用大模型摘要成一条

MAF 的 Compaction 命名空间还有更复杂的压缩管线,适合超长 Agent 任务。

5.4 和(3 上)手动 History 的关系

手动 List<ChatMessage> 时:

  • 清除history.Clear()
  • 注入history.Insert(0, new ChatMessage(System, …)) 自己控制位置
  • 截断history = (await reducer.ReduceAsync(history)).ToList()

MAF Provider + Reducer 是把这套 标准化、可插拔;理解手动版有助于 debug。

5.5 生产 checklist

  1. 长会话必须配 截断或摘要,并监控 Token。
  2. 「新对话」要 清历史或新 Session,避免串话。
  3. 动态规则用 注入,静态角色用 Instructions,不要混为一谈。
  4. 预览 API(MessageInjectingChatClient 等)关注 MAF 版本升级说明。

六、系列小结(3 上 + 3 下)

(3 上)Agent 会「记住」
    AgentSession + InMemoryChatHistoryProvider
    手动 List<ChatMessage>

(3 下)Agent 会「管记忆」
    清除历史  → SetMessages / SetInMemoryChatHistory
    注入 System → EnableMessageInjection + EnqueueMessages
    截断策略  → MessageCountingChatReducer + BeforeMessagesRetrieval

配合系列前两篇:

(1)会「说」  → RunAsync / RunStreamingAsync
(2)会「做」  → AIFunctionFactory + tools
(3)会「记」  → Session + ChatHistory
(3 下)会「管」→ 清除 / 注入 / 截断