MAF多轮对话进阶:清除历史、注入System与截断策略
MAF 入门(3 下):多轮对话进阶——清除历史、注入 System、截断策略
前一篇我们让Agent学会了“记住”——在多轮对话里能准确回答“你叫小明”或者“你喜欢C#”。但到了真正的产品环境,光有“记忆”这个能力还不够,还得学会“忘记”,学会“改规矩”,以及学会“省Token”。
具体来说,这三件事构成了本篇的核心:
- 清除历史:用
SetMessages或SetInMemoryChatHistory实现“一键清空”。 - 注入System消息:靠
MessageInjectingChatClient.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实例。
- 生产环境中也可以新建Session(
CreateSessionAsync())来代替清空,效果类似于“全新的对话窗口”。
二、运行时注入 System Message——「对话中途改规矩」
2.1 和 Instructions 有什么不同?
前面讲过,ChatOptions.Instructions 是在创建Agent时写好的,相当于入职手册。但有些场景需要在聊了一半时修改规则,比如:
- 用户点击“切换英文”
- 运营活动临时加一条“今天禁止讨论价格”
- 工具执行完后插入一条隐式的System提示
这时候不适合重建Agent,而是应该往当前Session里再塞一条System消息。
| Instructions(静态) | 运行时注入(动态) | |
|---|---|---|
| 时机 | AsAIAgent / ChatClientAgentOptions | 任意一轮 RunAsync 之前 |
| 改法 | 换配置或换Agent | EnqueueMessages |
| 历史 | 每轮都有 | 从注入时刻起影响后续轮次 |
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 实现步骤
步骤1:enableMessageInjection: true 创建Agent,并 CreateSessionAsync()。
步骤2:正常聊一轮(用中文):
await agent.RunAsync("用一句话介绍你自己。", session);
步骤3:拿到注入器并排队System消息:
MessageInjectingChatClient? injector = agent.GetService();
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会返回null。() - 注入的消息在下一轮(或同轮pipeline内下一次模型调用)才生效,不是修改已经发出去的历史。
- 模型不一定100%遵守新System规则,这和写静态Instructions一样,需要靠prompt与评测来保证效果。
三、截断策略——「聊天记录太长就裁剪」
3.1 为什么需要截断?
历史消息会一直append。聊到50轮以后,问题就来了:
- Token爆掉:超出context window,API报错或自动截断。
- 变慢变贵:每次请求都要携带全长历史。
- 干扰答案:早期无关内容会稀释模型的注意力。
所以,在发给模型之前,必须对历史进行Reduce(缩减)。MAF通过 IChatReducer 挂在 InMemoryChatHistoryProvider 上实现这个机制。
3.2 存储 vs 发给模型:两个数量
Demo里有一个容易混淆的点:
| 概念 | 含义 |
|---|---|
| 存储条数 | 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, []) | 否,只清消息 | 新对话、隐私、换话题 |
| 注入 System | EnqueueMessages(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 和手动 History 的关系
如果你手动维护 List:
- 清除:
history.Clear() - 注入:
history.Insert(0, new ChatMessage(System, …)),自己控制位置 - 截断:
history = (await reducer.ReduceAsync(history)).ToList()
MAF的Provider + Reducer本质上就是把这套流程标准化、可插拔。理解手动版有助于遇到问题时debug。
5.5 生产 checklist
- 长会话必须配置截断或摘要,并持续监控Token消耗。
- “新对话”要清历史或新建Session,避免串话。
- 动态规则用注入,静态角色用Instructions,不要混为一谈。
- 预览API(如
MessageInjectingChatClient)要关注MAF版本的升级说明。
六、系列小结(3 上 + 3 下)
(3 上)Agent 会「记住」
AgentSession + InMemoryChatHistoryProvider
手动 List
(3 下)Agent 会「管记忆」
清除历史 → SetMessages / SetInMemoryChatHistory
注入 System → EnableMessageInjection + EnqueueMessages
截断策略 → MessageCountingChatReducer + BeforeMessagesRetrieval
配合系列前两篇:
(1)会「说」→ RunAsync / RunStreamingAsync
(2)会「做」→ AIFunctionFactory + tools
(3)会「记」→ Session + ChatHistory
(3 下)会「管」→ 清除 / 注入 / 截断