第一次看到 Microsoft Agent Framework 的时候,很容易产生一种错觉:它好像只是把各种 Agent 能力整理成了一套比较完整的 API。
于是我们看到 AIAgent,就去看 Agent 怎么创建;看到 AgentSession,就去看 Session 怎么保存;看到 ChatHistoryProvider,就去看历史怎么加载;再往后又遇到 AIContextProvider、Middleware、Compaction、Harness、Routing......每一个概念单独拿出来都不难,可一旦它们同时出现在一个真实项目里,问题反而变成了:"这些东西为什么都存在?它们到底应该放在哪里?"
更麻烦的是,很多问题并不是 API 不会用,而是概念之间的边界没有建立起来。比如,为什么我已经有了 Session,下一轮对话还是会失忆?为什么加了 Memory 以后,同一条记忆会被重复保存?为什么明明配置了 Compaction,发送给模型的上下文还是越来越长?为什么在 AIAgent 上加了 Middleware,却拿不到最终 Prompt?为什么 Terminate = true 之后,下一轮模型开始拒绝历史?为什么只是在运行时换了一下模型,会话就突然接不上了?
这些问题看起来分别属于 Session、Memory、Middleware、Compaction 和 Routing,实际上它们都在追问同一件事:
这一段逻辑,到底发生在 Agent Runtime 的哪一层?
所以,理解 Agent Framework 最好的方式,可能不是从 API 名字开始,而是先把它想成一条运行时管道。
一次 RunAsync,究竟发生了什么?
从调用方的角度,Agent Framework 故意把事情做得很简单。你通常只需要:
csharp
AIAgent agent = ...;
AgentSession session =
await agent.CreateSessionAsync();
AgentResponse response =
await agent.RunAsync(
"帮我订下周二去悉尼的机票",
session);
外部代码看到的只有 AIAgent、AgentSession 和一次 Run。这三个东西足以把不同实现统一到一个编排模型里:今天你的 Agent 可能是 ChatClientAgent,明天可能换成自定义 AIAgent,后天甚至可能接到 A2A、GitHub Copilot 或其他远程 Agent 服务。上层编排代码不需要因为底层实现换了,就重写一遍。
真正复杂的是 Agent 内部。
对于一个典型的本地 ChatClientAgent,一次调用可以抽象成这样:
text
调用方
│
▼
RunAsync / RunStreamingAsync
│
├─ Agent Middleware
│
├─ ChatHistoryProvider
│
├─ AIContextProviders
│
├─ IChatClient Middleware / Client Context
│
├─ Function Invocation Loop
│
└─ LLM
│
▼
响应返回
│
├─ History Provider
└─ Context Providers
这里面每一层都在回答一个不同的问题。
Agent Middleware 管的是"这一整次运行要不要发生、最后要返回什么";ChatHistoryProvider 管的是"这一通对话以前发生过什么";AIContextProviders 管的是"除了历史之外,这次还需要额外知道什么";IChatClient 层管的是"真正发送给模型的请求是什么";函数调用循环则负责处理"模型决定调用工具之后应该怎么继续"。
远程 Agent 的情况又不一样。像 A2AAgent、GitHubCopilotAgent、CopilotStudioAgent 虽然同样是 AIAgent,因此依然可以被 Agent Middleware 包住,也可以使用适用于 Agent 层的上下文提供程序,但它们没有本地 IChatClient 这一层,也没有你可以直接控制的本地函数调用循环。因此,一旦看到 AIAgent,不要下意识地认为"下面一定藏着一个 IChatClient"。这是后面理解 Middleware、Prompt 审计和工具控制时非常重要的一条边界。
更容易让人犯错的,还有一个经常被忽略的事实:一次 RunAsync 不一定只调用一次模型。
比如"小旅"收到这样一个请求:
"下周二从墨尔本去悉尼,帮我订最合适的航班。"
它完全可能经历:
text
用户
↓
LLM
↓
SearchFlights
↓
LLM
↓
GetWeather
↓
LLM
↓
CreateBooking
↓
LLM
↓
最终回答
所以 Agent Runtime 实际上同时存在两个时间尺度。
第一个是外层时钟 :一次 RunAsync 从开始到结束,调用方最后拿到一个 AgentResponse。第二个是内层时钟:同一个 Run 为了完成任务,模型和工具可能循环很多次。
这两个时钟非常重要,因为后面几乎所有"我明明配置了,怎么没生效"的问题,都和它有关。Guardrail、异常处理、最终回复改写,天然属于外层 Run;而"每一次真正调用模型之前做压缩"、"每次模型调用之后保存历史"则明显属于内层工具循环。原稿里把这件事称为"两个时钟",我认为这是理解整个框架非常值得保留的核心隐喻。
还有一条规则要从一开始就记住:
Agent 上挂的是共享组件,Session 里放的是某一次对话的状态。
Provider、Middleware 等实例通常挂在 Agent 上,因此会被多个 Session 共用。反过来,某一个用户的 UserId、这一通会话的数据库主键、当前预订号之类的数据,必须属于 Session。否则用户 A 的数据和用户 B 的数据很容易在一个共享对象里串起来。
理解了这几条,下面"小旅"的所有能力,其实都只是继续往这条管道上增加东西。
小旅为什么是一个很好的例子?
假设我们正在开发一个公司的内部差旅助手,叫"小旅"。
一开始,它只需要能回答问题和调用几个工具;接着,它要记得当前对话;再往后,它要记住用户跨会话的偏好;聊天一长,又需要解决上下文越来越大的问题;最后,还要让它遵守权限、审批、审计和安全规则。
于是,一个看似简单的产品会自然地演化成:
text
第一阶段:Agent + Tools
第二阶段:Session + History
第三阶段:Memory
第四阶段:Compaction
第五阶段:Middleware
第六阶段:Model Routing
第七阶段:Custom Agent
最后:Security
这几期并不是随意排列的。它们恰好对应了 Runtime 中越来越深的一层:先让 Agent 能运行,再让它拥有状态,再让它拥有更长的记忆,再解决上下文规模,再控制执行路径,最后才进入更复杂的路由和自定义 Agent。
第一阶段:先让 Agent 能真正工作
ChatClientAgent 可以理解成一种非常直接的 Agent:应用自己拥有这个 Agent,底层连接一个实现了 IChatClient 的聊天客户端。
所以它适合这样的系统:模型调用掌握在你手里,工具、指令、历史、中间件都希望自己控制,同时又希望未来可以把它替换成另一个 AIAgent 实现,而不影响上层编排。
例如小旅最开始可以简单到只有两个工具:
csharp
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
static string GetWeather(string city)
=> city.Contains(
"悉尼",
StringComparison.OrdinalIgnoreCase)
? "悉尼多云,22°C,适合出行"
: $"暂无 {city} 的实时天气";
static string SearchFlights(
string from,
string to,
string date)
=> $"找到 {date} {from}->{to}:8:10 QF431、14:20 VA823";
IChatClient chatClient =
/* Azure OpenAI / OpenAI / Foundry 的 IChatClient */;
AIAgent agent =
new ChatClientAgent(
chatClient,
new ChatClientAgentOptions
{
Name = "XiaoLv",
ChatOptions = new ChatOptions
{
Instructions = """
你是公司差旅助手小旅。
先确认出发地、目的地、日期,
再查天气和航班。
不要编造不存在的航班号。
""",
Tools =
[
AIFunctionFactory.Create(GetWeather),
AIFunctionFactory.Create(SearchFlights)
]
}
// 如果 chatClient 自己已经完成工具循环:
// UseProvidedChatClientAsIs = true
});
Console.WriteLine(
await agent.RunAsync(
"下周二从墨尔本去悉尼,帮我看看。"));
这里有一个非常容易忽略的地方:ChatClientAgent 默认会在底层客户端之上再提供函数调用循环。也就是说,模型产生 FunctionCallContent 后,框架会执行工具,再把 FunctionResultContent 送回模型。
这本身没有问题,直到你使用的底层 IChatClient 已经自己实现了完整的 Tool Loop。这时候,如果 Framework 再包一层,就会出现"双循环":框架跑一遍,Client 自己又跑一遍。于是才有 UseProvidedChatClientAsIs = true 这样的开关。
这个配置看上去只是一个小细节,但它其实告诉我们一件很重要的事:Agent Framework 并不假设底层 Client 是一个毫无能力的"裸模型接口"。 你的 Client 可能已经拥有一部分 Agent 行为,Framework 允许你选择由谁接管这些能力。
AgentResponse 不是"模型最后那句话"
第一次写 Agent,最自然的代码就是:
csharp
AgentResponse response =
await agent.RunAsync(
"墨尔本天气怎么样?");
Console.WriteLine(response.Text);
这没错,但不要因此把 AgentResponse 理解成一个普通字符串容器。
一次 Agent 执行里可能同时存在文本、工具调用、工具结果、推理内容或者其他状态信息。因此,response.Text 只是一个方便的文本聚合;如果你真正要做审计、调试或自定义编排,就应该关注 Messages 和 Contents。
Streaming 也是同一个执行模型,只是返回方式不同:
csharp
await foreach (
var update in agent.RunStreamingAsync("再看看悉尼"))
{
if (!string.IsNullOrEmpty(update.Text))
{
Console.Write(update.Text);
}
}
非流式是等全部执行结束后,把结果作为一个 AgentResponse 给你;流式则把执行过程切成多个 AgentResponseUpdate。但语义本身没有变。
真正要注意的是 Middleware。如果你的 Middleware 只实现了非流式版本,框架为了让它工作,可能不得不先把流式结果收完再交给你的中间件处理。于是代码看起来仍然使用 RunStreamingAsync,用户实际看到的却已经不是即时输出了。也就是说,Streaming 并不是 UI 层的一个小优化,它是 Runtime Contract 的一部分。
消息也不是一个字符串
另外一个必须建立起来的概念,是消息的结构。
ChatMessage 只是消息容器,真正的内容由一组 AIContent 组成。常见内容包括:
| 类型 | 含义 | 为什么会影响后面的能力 |
|---|---|---|
TextContent |
普通文本 | 几乎所有模型都能处理 |
DataContent |
图片、音频、视频等数据 | 新模型必须支持对应模态 |
UriContent |
文件 URL | URL 是否仍然有效、目标模型是否能访问 |
FunctionCallContent |
模型要求调用工具 | 必须和结果形成正确关系 |
FunctionResultContent |
工具执行结果 | 不能随便和对应调用拆开 |
这直接带出一个以后非常重要的结论:
"我自己保存历史,所以我以后就可以随便换模型。"这句话并不完整。
完整条件是:你不仅要拥有原始历史,还必须保证下一跳模型理解这些消息的角色、内容类型和工具关系。
举个非常现实的例子:小旅如果允许用户上传登机牌照片,那么历史里可能存在 DataContent。今天我们使用一个支持视觉的模型,一切正常;明天为了省成本,把这一轮切给一个只接受文本的模型,此时问题就不是简单地把 model name 换掉,而是必须先决定图片内容怎么处理。
所以,可替换模型的基础不是 Router,而是可重放且兼容的消息历史。
第二阶段:多轮对话的本质不是 List<ChatMessage>,而是 Session
现在的小旅遇到一个自然的问题。
用户第一句话:
"我叫刘振,座位喜欢靠窗。"
下一句话:
"那帮我订下周二墨尔本到悉尼的机票。"
如果 Agent 想知道"我"是谁,就必须让第二次调用看到第一次发生过的事情。
很多人第一反应是自己保存:
csharp
List<ChatMessage> history;
这当然可以,但 Agent Framework 想让你拥有的并不只是一个消息列表,而是:
csharp
AgentSession
AgentSession 更适合被理解成:
这一通对话的运行时状态容器。
消息历史只是其中的一部分。Session 还可以包含 StateBag、Provider 的会话状态,以及某些服务端会话 ID。不同 Agent 的具体 Session 实现还可能携带远程服务的上下文标识。
所以:
text
Agent
├─ Session A
├─ Session B
├─ Session C
└─ Session D
而 Session A 里面又可能是:
text
Session A
├─ History
├─ StateBag
├─ Provider State
└─ Remote Conversation ID
这也解释了为什么不能简单地把"当前用户的记忆 ID"塞进 Provider 实例。Provider 是 Agent 级共享对象,而用户数据属于 Session。
但真正关键的问题其实是:历史到底归谁?
一旦讨论 Session,马上会遇到一个非常重要的架构分叉。
方案一:历史由调用方管理
消息存在:
- 应用内存
- 数据库
- 自定义
ChatHistoryProvider
你每次调用模型的时候,自己决定哪些历史应该重新发出去。
方案二:历史由推理服务管理
应用只保存:
text
conversation ID
每次请求把:
text
conversation ID + 当前消息
交给服务。
两种方式都可以,但它们其实决定了后面很多能力。
| 调用方管理 | 服务方管理 | |
|---|---|---|
| 历史在哪里 | 应用自己掌握 | 服务端 |
| 可以自己压缩吗 | 可以 | 通常不行 |
| 可以跨厂商换模型吗 | 可以重新构建消息 | 基本不行 |
| 可以自己审计历史吗 | 可以 | 取决于服务接口 |
| 多租户隔离 | 应用自己负责 | 仍然需要业务层映射 |
所以,如果你的产品要求:
"同一个会话里今天使用模型 A,明天可以切到模型 B;上下文还要自己控制;还要自己做压缩和审计。"
那么调用方管理历史,往往更有价值。
反过来,如果你使用的 Agent Service 已经替你管理了会话,而且你接受它的模型和上下文边界,那么让服务托管历史反而更简单。
Session 持久化、History 持久化,不是一回事
这里特别容易把三个概念混成一个"持久化"。
第一件事是:
csharp
agent.SerializeSession(session);
agent.DeserializeSessionAsync(...);
它解决的是:进程重启以后,怎么把整颗 Session 状态恢复回来。
第二件事是 ChatHistoryProvider。它解决的是:每次运行时,到底加载哪些历史消息,以及这次运行结束后保存哪些新消息。
第三件事是 AgentSessionStore。它解决的是:HTTP 请求到来时,怎么根据 continuation ID 把完整 Session 找回来。
它们分别对应:
text
Session serialization
≠
Message history persistence
≠
Session lookup/store
不要把其中任何一个当成另外两个的替代品。
服务端 conversation ID 也绝对不是授权凭证
如果你使用某些服务托管的会话,Session 中可能保存:
text
resp_*
conv_*
context_id
task_id
这些 ID 只是服务端理解对话的标识,不应该自动被当成:
"这个用户拥有这段对话。"
尤其是在一个 Azure/OpenAI/Foundry project 需要服务多个最终用户的情况下,如果你把服务端 conversation ID 原样返回浏览器,再让浏览器以后拿回来直接调用,很容易把"知道某个 ID"和"有权访问这个会话"混为一谈。
更安全的结构应该是:
text
你的 sessionId
↓
你的数据库
↓
userId / tenantId
↓
service conversation ID
恢复时先验证当前用户或租户,再决定是否允许使用这个远程 ID。服务端的 previous_response_id、conversation_id、service_session_id 都不应该直接充当业务授权边界。
History Provider 为什么要给消息"盖章"?
这是一个非常漂亮的设计,也很值得理解。
假设数据库里已经保存:
text
Hello
你好
我喜欢靠窗
好的
下一次请求时,History Provider 把这些消息重新加载进当前请求:
text
历史
+
本轮新输入
如果运行结束后,把整个列表再次保存,那么下一次就可能变成:
text
Hello
你好
我喜欢靠窗
好的
Hello
你好
我喜欢靠窗
好的
我现在想订机票
再下一轮继续重复。
所以 Framework 给历史消息标记:
text
AgentRequestMessageSourceType.ChatHistory
它表达的意思其实非常直白:
"这条消息是旧账,不是这次新发生的事情。"
于是存储阶段就可以过滤掉那些已经来自历史的消息,只保存本轮真正产生的新内容。否则历史会随着每轮调用重复膨胀。
这里又有一个容易混淆的点:InMemoryChatHistoryProvider 上的 ChatReducer 是存储侧的消息数量限制,而后面要讲的 Compaction 是上下文层面的语义压缩,两者解决的问题不同。前者更像"最多保存多少条",后者则是在问"为了让模型继续工作,哪些历史可以被缩短"。
第三阶段:"记住"其实至少有三种意思
当产品经理说:
"我要让小旅记住用户。"
作为工程师,第一反应不应该是"上一个向量数据库"。
因为首先应该问:
记住什么?
如果用户说:
"我刚刚说要周二出发。"
下一句还能理解"周二",这是当前对话历史的问题,不需要向量数据库。
如果用户在当前流程里已经确认:
text
selectedFlight = QF431
waitingApproval = true
bookingId = xxx
这是结构化的 Session 工作状态,也不应该塞进 Vector DB。
只有当用户上个月说:
"我每次坐飞机都喜欢靠窗。"
而这次打开的是一个全新的 Session,你仍然希望 Agent 能想起来,这才是跨会话语义记忆。
所以可以把"记忆"拆成三层:
| 层次 | 机制 | 记住什么 |
|---|---|---|
| 当前对话窗口 | ChatHistoryProvider |
原始对话历史 |
| Session 工作记忆 | StateBag / Harness state |
当前任务状态 |
| 跨会话语义记忆 | ChatHistoryMemoryProvider / AIContextProvider |
长期有价值的事实和偏好 |
这个划分一旦理解,很多设计决定会变得非常自然。
AIContextProvider 是干什么的?
它可以理解成一个扩展点:
在调用模型之前,我还要不要补充一些上下文?调用结束之后,我又要不要从这次运行里保存一些东西?
最简单的接口思路就是:
text
ProvideAIContextAsync
↓
把额外上下文加入请求
↓
LLM / Tools
↓
StoreAIContextAsync
所以它非常适合:
- 用户画像
- RAG
- 动态业务政策
- 跨会话记忆
- 外部知识
但 Provider 又会遇到一个与 History Provider 非常相似的问题:自己注入的内容不能无意中成为下一次自己检索的数据。
假设 Memory Provider 注入:
"用户喜欢靠窗。"
如果下一次保存的时候,它又把这条 AIContextProvider 内容当成 External 新消息保存进去,就会变成某种"记忆套娃"。
所以高级实现需要区分消息来源:
text
External
ChatHistory
AIContextProvider
原始用户输入是 External;历史是 ChatHistory;Provider 自己注入的内容则应该保持为 AIContextProvider。检索和保存通常只应该针对合适的来源,否则记忆系统会开始"记住自己记得什么"。
跨会话 Memory:真正值得注意的是"写入范围"和"检索范围"可以不同
假设用户第一次对话说:
"我坐飞机一定要靠窗。"
我们可以把它写入:
text
storageScope:
UserId = user-123
SessionId = session-abc
这样它在存储层可以被精确地追踪和清理。
但下一次搜索时,我们只需要:
text
searchScope:
UserId = user-123
于是:
text
写入:User + Session
检索:User
同一位用户开启新 Session,依然可以找到旧偏好。
这就是 storageScope 与 searchScope 最值得理解的地方:它们并不要求保持完全一致。前者描述"这条记忆属于谁以及从哪里来",后者描述"这次搜索应该在哪个范围里找"。还可以进一步加入 AgentId、ApplicationId 等维度,实现更严格的隔离。
例如:
csharp
VectorStore vectorStore =
new InMemoryVectorStore(
new InMemoryVectorStoreOptions
{
EmbeddingGenerator = embeddingGenerator
});
AIAgent agent =
chatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "MemoryAgent",
ChatOptions = new()
{
Instructions =
"你是差旅助手。优先尊重已知的座位和航司偏好。"
},
AIContextProviders =
[
new ChatHistoryMemoryProvider(
vectorStore,
collectionName: "chathistory",
vectorDimensions: 3072,
session =>
new ChatHistoryMemoryProvider.State(
storageScope: new()
{
UserId = "user-123",
SessionId =
Guid.NewGuid().ToString()
},
searchScope: new()
{
UserId = "user-123"
}))
]
});
AgentSession s1 =
await agent.CreateSessionAsync();
await agent.RunAsync(
"我坐飞机一定要靠窗。",
s1);
AgentSession s2 =
await agent.CreateSessionAsync();
Console.WriteLine(
await agent.RunAsync(
"帮我订墨尔本到悉尼的机票。",
s2));
第一次对话把偏好写进去,第二次完全不同的 Session 仍然可以通过 User scope 找回这条记忆。
自动搜索,还是让模型自己决定搜索?
Memory 还有一个设计选择:搜索到底什么时候发生。
一种方式是 BeforeAIInvoke。每轮模型调用之前自动检索,适合用户偏好、政策这类"几乎每次都有可能用到"的内容。
另一种方式是 OnDemandFunctionCalling。把 Memory Search 暴露成工具,让模型自己决定这次是否需要查。
后者看起来更加聪明,但同时引入了一个很重要的安全边界:
模型生成的搜索词本身就是不可信输入。
也就是说,SearchMemory("...") 和 GetWeather("...") 在安全模型上其实没有本质区别。它们都是模型提出的参数,数据库和工具层都应该对其做约束。
还有一个非常值得留意的问题:默认情况下,助手回复本身也可能被写入 Memory。
这意味着模型一次错误地说:
"已经帮你订好了。"
如果这句话后来进入向量库,那么下一次新会话里,Memory Search 可能把它找出来,而模型会把这次幻觉重新当成事实。
所以长期 Memory 真正的问题从来不是:
"怎么把东西都记住?"
而是:
"什么东西值得被当成事实记住?"
第四阶段:当聊天开始变长,问题变成"模型究竟还要看多少?"
假设小旅和用户聊了 40 轮。
系统越来越慢,Token 越来越贵,最终甚至超过模型上下文窗口。
这就是 Compaction 出场的时候。
但这里先要强调一个非常重要的边界:
Compaction 只对你自己拥有的历史有意义。
如果对话历史完全由 Foundry Agent、Copilot Studio 或某些服务端 Conversation 管理,那么真正发送给模型的是服务端决定的内容。此时你在本地配置一个 Compaction Provider,并不会神奇地改变服务器内部维护的历史。
所以 Compaction 的前提依然是:
text
历史在我手里
而不是:
text
历史在别人手里
原稿里这一点其实非常关键,而且应该放在介绍策略之前,因为它直接决定读者到底有没有必要继续看后面的 Compaction API。
Compaction 为什么不是简单地 RemoveAt()?
因为 Agent 消息不是一个纯粹的字符串列表。
框架会先建立 MessageIndex,再把消息划分成原子性的 MessageGroup。每个 Group 会跟踪消息数量、字节数和估算 Token。
为什么要这么麻烦?
因为很多消息不能单独删除。
例如:
text
FunctionCallContent
FunctionResultContent
它们构成了一个完整的工具交互。如果只删其中一个,下一轮模型就可能看到:
"我调用过某个工具。"
却看不到:
"工具到底返回了什么。"
于是整个消息序列可能变得非法。
所以 Compaction 的基本思想不是:
"删几条消息。"
而是:
"按语义边界删消息组。"
常见的 Group 可以理解为:
text
System
User
AssistantText
ToolCall
Summary
其中 ToolCall 本身就代表一个需要保持结构完整的工具交互。
Trigger 和 Target:什么时候开始压,压到哪里停?
每个 Compaction 策略都可以拆成两个问题。
第一个是 Trigger:
"现在有没有必要开始压缩?"
例如:
csharp
CompactionTriggers.TokensExceed(2000)
或者:
csharp
CompactionTriggers.HasToolCalls()
它们还可以组合:
csharp
var trigger =
CompactionTriggers.All(
CompactionTriggers.HasToolCalls(),
CompactionTriggers.TokensExceed(2000));
表达的是:
只有"存在工具调用"并且"Token 超过 2000"时,才开始折叠。
第二个是 Target:
"压到什么时候算够?"
而 MinimumPreserved 则是一个保护措施,用来避免把最近的内容一下子全部卷走。
五种策略,其实是一把把越来越重的刀
最温和的是 ToolResultCompactionStrategy。它只针对旧工具结果,把很长的工具输出压成类似:
text
[Tool calls: SearchFlights, GetWeather]
而用户原话和普通助手文本仍然保留。它不需要额外 LLM,是最适合"先把工具输出收拾干净"的第一刀。
再往前一步是 SummarizationCompactionStrategy。它会额外调用一个 IChatClient,把较老的对话压成一个 Summary Group。好处是可以压得非常狠,代价则很明确:需要额外的模型调用,而且摘要天然存在信息丢失和幻觉风险。因此很常见的做法是让主模型负责真正的工作,让一个便宜的小模型负责摘要。
SlidingWindowCompactionStrategy 更简单,它不关心消息条数,而是保留最近 N 个用户轮次。一个轮次包括一条 User 消息以及后面所有属于这一轮的 Assistant / Tool Group,直到遇到下一条 User 消息。这样可以避免从语义中间拦腰切断。
TruncationCompactionStrategy 则是最后的保险丝:从最老的非系统 Group 开始往前删,直到满足目标。它可靠,但很粗暴,所以更适合当熔断器,而不是日常策略。
最后才是 PipelineCompactionStrategy,把前面几种策略串起来。例如:
csharp
var pipeline =
new PipelineCompactionStrategy(
new ToolResultCompactionStrategy(
CompactionTriggers.TokensExceed(512)),
new SummarizationCompactionStrategy(
summarizerClient,
CompactionTriggers.TokensExceed(1280)),
new SlidingWindowCompactionStrategy(
CompactionTriggers.TurnsExceed(8)),
new TruncationCompactionStrategy(
CompactionTriggers.TokensExceed(32_000),
minimumPreserved: 10));
这个顺序很有意义:先做便宜、低损失的处理,再逐渐升级到昂贵和激进的手段,最后才使用硬截断。
一个经常被低估的决定:Compaction 到底应该改 Prompt,还是改历史?
这是整个 Compaction 设计里最重要的一道分水岭。
如果你把它挂在 ChatClientBuilder.UseAIContextProviders,那么它位于真正调用模型之前。它压缩的是:
本次请求准备发给模型的副本。
因此可以形成:
text
原始历史 ──────────→ 持久化
│
└→ Compaction → Prompt → LLM
这是大多数生产系统更愿意采用的方式:数据库里继续保留完整历史,而每次调用模型时再根据 Token 预算做压缩。
但如果你把 Compaction 放在 ChatClientAgentOptions.AIContextProviders,它发生得更靠前,生成出来的 Summary 可能会被 History Provider 当成一条新的上下文写回历史。
于是:
text
原始历史
↓
压缩
↓
Summary
↓
History
这已经不是"Prompt 优化",而是在修改持久化的事实本身。
所以:
你到底是在压缩模型看到的东西,还是在压缩你保存的历史?这是两个完全不同的设计。
第五阶段:Middleware------因为有些事根本不能交给模型决定
到这里,小旅已经会聊天、有历史、有长期记忆,也知道怎么控制上下文长度。
但是它离一个真正的生产 Agent 还差很远。
因为有些事情,根本不应该让模型决定。
例如:
- 用户把护照号、密码发进来了,应该直接拒绝;
- 模型选中了禁飞航班,不能真正调用
CreateBooking; - 当前登录用户是谁,不能让模型自己猜;
- 工具执行超时,不能把内部异常直接扔给用户;
- 最终对外回复必须统一加免责声明;
- 真实发送给模型的 Prompt 必须可以审计。
这些事情看起来完全不同,但它们其实都属于一个概念:
横切逻辑。
Agent Framework 用 Middleware 把这些能力插入 Runtime。
三层 Middleware,三个不同深度
最外层是 Agent Middleware,它围住的是整个 RunAsync。因此任何 AIAgent 都能使用,包括远程 Agent。它最适合做整次运行的门禁、异常处理、最终结果改写。
中间是 Function Middleware,它发生在每一次工具真正执行之前和之后,适合做工具参数修改、权限判断、审批和工具循环终止。
最里面是 IChatClient Middleware,它发生在每一次真正访问推理服务之前和之后。到了这个位置,历史、Memory、Compaction 等通常都已经完成,所以它最适合做最终 Prompt 审计、模型调用日志和 Token/Latency 统计。
可以把它画成:
text
Agent Run
│
├─ Agent Middleware
│
├─ History / Memory / Compaction
│
├─ IChatClient Middleware
│
├─ LLM
│
└─ Tool Loop
│
├─ Function Middleware
│
└─ Tool
这个图本身就足以解释很多 API 应该放在哪里。
为什么"审计最终 Prompt"必须靠近 IChatClient?
假设你的需求是:
"我需要把真正发给模型的 Prompt 打进审计日志。"
如果你在 RunAsync 入口做:
csharp
Console.WriteLine(messages);
你看到的其实还不是最终 Prompt,因为这时候历史和 Memory 可能都还没注入。
相反,如果在 IChatClient Middleware:
csharp
async Task<ChatResponse> CustomChatClientMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
CancellationToken cancellationToken)
{
Console.WriteLine(
$"[ChatLog] Sending {messages.Count()} messages.");
foreach (var msg in messages)
{
var preview =
msg.Text is null
? ""
: msg.Text[..Math.Min(
msg.Text.Length,
80)];
Console.WriteLine(
$"[ChatLog] {msg.Role}: {preview}");
}
var response =
await innerChatClient.GetResponseAsync(
messages,
options,
cancellationToken);
Console.WriteLine(
$"[ChatLog] Received " +
$"{response.Messages.Count} messages.");
return response;
}
这里拿到的 messages 已经是上下文加工之后的结果了,所以它才真正接近:
"模型这一刻究竟看到了什么?"
这也是为什么 IChatClient Middleware 不应该被理解成"另一个 Agent Middleware"。它们虽然都叫 Middleware,但它们站在完全不同的运行时深度。
Guardrail:不是让模型遵守规则,而是让代码决定规则
例如,小旅收到:
"我的 password 是什么?"
不应该让模型先被调用,再告诉它"不要回答"。
更合理的路径是:
text
用户输入
↓
Guardrail
↓
直接拒绝
核心代码类似:
csharp
async Task<AgentResponse> GuardrailMiddleware(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
var lastMessage =
messages.LastOrDefault()?.Text?
.ToLowerInvariant()
?? "";
string[] blockedWords =
[
"password",
"secret",
"credentials",
"护照号",
"身份证"
];
foreach (var word in blockedWords)
{
if (lastMessage.Contains(word))
{
return new AgentResponse(
[
new ChatMessage(
ChatRole.Assistant,
$"抱歉,我不能处理包含"{word}"的请求。")
]);
}
}
return await innerAgent.RunAsync(
messages,
session,
options,
cancellationToken);
}
关键不是关键词列表有多聪明,而是:
csharp
innerAgent.RunAsync(...)
在拒绝路径上根本不会被调用。
这意味着这次 Run 连模型都没有进入。
所以一个非常值得长期记住的区别是:
text
Prompt
→ 告诉模型"应该怎么做"
Middleware
→ 决定程序"允许做到哪里"
二者不是替代关系。
Terminate:停止工具循环,而不是回到"什么都没发生"
另一个特别容易理解错的 API 是:
csharp
context.Terminate = true;
它不是 Guardrail 的另一个写法。
Guardrail 是外层的:
text
整个 Run
而 Terminate 发生在:
text
模型已经决定调用 Tool
↓
Function Middleware
↓
发现这一跳不允许继续
比如模型选择:
text
CreateBooking("VA823")
而公司规定 VA823 不在协议范围。
那么可以:
csharp
if (context.Function.Name
== nameof(CreateBooking))
{
var flightNo =
context.Arguments.TryGetValue(
"flightNo",
out var raw)
? raw?.ToString()
: "";
if (string.Equals(
flightNo,
"VA823",
StringComparison.OrdinalIgnoreCase))
{
context.Terminate = true;
return
"该航班不在公司协议范围内," +
"已中止本轮工具循环。";
}
}
return await next(
context,
cancellationToken);
这里发生的事情是:模型已经被调用了,也已经产生了工具调用,只是 Runtime 在真正继续工具循环之前把它掐掉了。
因此 Terminate 和 Guardrail 的区别可以概括成:
text
Guardrail
→ 不让整个 Run 继续
Terminate
→ 不让当前 Tool Loop 继续
这个区别特别重要,因为设置 Terminate 后,框架不会再自动发一轮模型请求,让模型根据你刚刚返回的字符串重新组织自然语言。如果还有其他排队中的工具调用,也可能直接被跳过。
更棘手的是历史。
如果某次执行已经产生了:
text
FunctionCallContent
却因为终止没有留下合法的:
text
FunctionResultContent
那么下一次继续复用这个 Session 时,某些模型可能会直接拒绝这份不完整历史。
所以 Terminate 是一个执行控制机制 ,不是一个历史修复机制。使用它时必须同时考虑如何保证 Tool Call / Tool Result 的历史结构仍然完整。
Middleware 之间怎么传状态?
当 Middleware 越来越多,就会自然产生一个问题:
外层知道
request_id,内层工具怎么知道?
可以使用的状态载体主要有几种。
| 载体 | 生命周期 | 典型用途 |
|---|---|---|
闭包 Dictionary |
进程级 | Demo、简单计数 |
AdditionalProperties |
一次 Run | user_id、tenant、request_id |
StateBag |
一个 Session | bookingId、已确认航段 |
FunctionInvocationContext.Arguments |
一个工具调用 | 给 Tool 注入可信参数 |
其中最容易犯错的是闭包。
Demo 中:
csharp
var sharedState =
new Dictionary<string, object>
{
["callCount"] = 0
};
完全没问题。
但是如果你开始往里面放:
text
currentBooking
currentUser
currentTenant
那就不行了,因为它已经变成:
text
整个进程共享
而不是:
text
当前用户 / 当前 Session
所以原稿里关于这个问题的判断非常值得保留:闭包可以用来演示 Middleware 链;业务状态不要放进去。
AdditionalProperties 与 StateBag 的边界
假设 HTTP 层刚刚认证出:
text
user_id
tenant
request_id
这些信息只需要活这一轮,那么把它们放进:
csharp
AgentRunOptions.AdditionalProperties
很自然:
csharp
var options = new AgentRunOptions
{
AdditionalProperties =
new AdditionalPropertiesDictionary
{
["user_id"] = currentUserId,
["tenant"] = "contoso",
["request_id"] =
Guid.NewGuid().ToString()
}
};
但如果某个值下一轮还需要,例如:
text
selectedFlight
bookingId
waitingApproval
那么它应该进入:
csharp
session.StateBag
因为它的生命周期已经从"一次 Run"变成了"一通 Session"。
这个判断可以简单地理解成:
状态应该活多久,就放到对应生命周期的容器里。
身份不要让模型猜,要从应用层注入
这是 Agent 系统和普通 LLM Demo 的一个关键分界。
假设工具定义:
csharp
CreateBooking(
string flightNo,
string userId,
string tenant)
最危险的方案是让模型自己生成:
json
{
"flightNo": "QF431",
"userId": "user-123",
"tenant": "contoso"
}
因为 userId 和 tenant 根本不是模型推理得到的东西,它们来自:
text
HTTP Authentication
Application Context
Tenant Context
所以正确的路径应该是:
text
HTTP / Auth
↓
AdditionalProperties
↓
Function Middleware
↓
context.Arguments
↓
Tool
例如:
csharp
async ValueTask<object?> InjectRunContext(
AIAgent agent,
FunctionInvocationContext context,
Func<
FunctionInvocationContext,
CancellationToken,
ValueTask<object?>> next,
CancellationToken cancellationToken)
{
var runOptions =
AIAgent.CurrentRunContext?.RunOptions;
if (runOptions?.AdditionalProperties
is { } props)
{
if (props.TryGetValue(
"user_id",
out var userId))
{
context.Arguments["userId"] =
userId;
}
if (props.TryGetValue(
"tenant",
out var tenant))
{
context.Arguments["tenant"] =
tenant;
}
}
return await next(
context,
cancellationToken);
}
因此:
模型可以决定订哪一班,但不能决定自己是谁。
这是一个非常值得从 Agent 开发延伸到整个 AI Application Architecture 的原则。
第六阶段:运行时换模型,真正难的是"历史能不能跟过去"
现在假设产品经理提出一个很合理的需求:
闲聊用便宜模型,复杂订票用强模型。
听起来就是一个 Routing 问题。
但真正困难的第二问是:
新模型能不能看到旧对话?
如果历史由调用方管理,那么路由可以很自然:
text
History
↓
Router
├─ Cheap Client
├─ Strong Client
└─ Another Client
因为每个模型都收到同一份历史。
如果历史由服务端管理,那么你手里可能只有:
text
OpenAI conversation ID
你不能简单地把它交给另一家模型服务。这个 ID 对另一家服务没有意义。
所以跨厂商模型切换真正的必要条件不是:
text
"我有 Router。"
而是:
text
"我能重新得到可兼容的原始消息。"
之后才是:
text
"我把消息交给哪个模型。"
因此 Routing 实际上由两个问题组成:
下一跳打哪个客户端?
以及:
下一跳能否理解截至目前的上下文?
前者是路由;后者是历史所有权和消息兼容性。
这也是为什么"调用方管理历史"具有非常大的架构价值。
第七阶段:不是所有 Agent 都必须是聊天模型
到这里,如果你已经把 ChatClientAgent 理解成:
text
Agent
=
上下文
+
聊天模型
+
Tool Loop
那下一步就会发现,这个定义其实太窄了。
有些 Agent 根本不是聊天模型:
text
规则引擎
工作流
RPC 服务
固定逻辑
状态机
另一个 Agent
但上层仍然希望它符合:
csharp
AIAgent
这样才能继续被统一地编排、持有 Session、使用 Middleware。
这就是 Custom Agent 的价值。
你可以继承:
csharp
AIAgent
自己实现 Session 创建、Session 恢复,以及 Run / RunStreaming 的核心行为。
最重要的并不是把某个类写出来,而是:
自定义 Agent 也必须尊重 Agent Runtime 的生命周期。
也就是说,运行之前要给 History/Context Provider 机会参与;运行之后也要让它们得到本轮结果;Session 必须能够创建与恢复;Streaming 也必须有对应语义。
否则你的类虽然"长得像 AIAgent",实际上却没有真正融入 Agent Framework 的运行时。
这也是抽象层真正的价值:上层只知道:
csharp
AgentSession session =
await agent.CreateSessionAsync();
await agent.RunAsync(
"...",
session);
至于下面到底是 LLM、Workflow、Remote Agent 还是你自己的实现,都可以隐藏起来。
最后一个阶段:安全不是"再加一层 Guardrail"那么简单
当 Agent 已经具备:
text
History
Memory
Tools
Compaction
Middleware
Routing
安全问题其实才真正开始暴露。
因为数据进入模型的路径可能是:
text
用户输入
↓
你的 API
↓
认证 / 限流 / 长度限制
↓
Agent Middleware
↓
History
↓
Memory / RAG
↓
System Instruction
↓
LLM
↓
Tool
↓
外部系统
这里任何一层都可能带入不可信数据。
因此,用户输入不可信,Tool Result 不可信,RAG 文档不可信,Vector Memory 不可信,Assistant Output 也不可信。
角色本身就可以看成一种权限边界:
| Role | 默认信任程度 |
|---|---|
system |
最高 |
user |
不可信 |
assistant |
不可信 |
tool |
不可信 |
最容易被误解的是 assistant 和 tool。
assistant 并不代表"这是你的程序写出来的可信数据",它可能来自外部 LLM;tool 也不代表"这是系统事实",它可能包含第三方 API 数据、用户可控内容或者被污染的数据。RAG 文档与 Vector Memory 同样可能成为间接提示注入通道。
所以工具参数应该按照普通 Web API 的外部输入来处理:限制长度、范围和允许值;文件路径限制在指定目录;SQL 和 Shell 使用参数化方式;涉及发信、下单、删除、修改数据等副作用时,考虑显式审批。
而模型输出也不能直接当成事实,更不能不经检查地直接:
text
渲染成 HTML
执行成代码
拼接 SQL
执行 Shell
写入高可信数据库
换句话说:
模型只是一个"不可信但非常有用的推理组件"。
它能帮你决定,但不能自动获得系统权限。
现在,再把所有东西放回同一条管道
如果一路跟着小旅走到这里,你会发现原本那些看起来互不相关的 API,其实都可以放回同一个 Runtime:
text
AIAgent
│
┌────────┴────────┐
│ │
Middleware Session
│ │
│ ┌───────┼────────┐
│ │ │ │
│ History State Memory
│ │ │ │
└─────────┴───────┴────────┘
│
Context Build
│
Compaction
│
IChatClient Layer
│
LLM
│
Tool Loop
│
┌───────────┼───────────┐
│ │ │
Function MW Approval Terminate
│
Tool
到这个时候,Agent、Session、Memory、Compaction、Middleware、Tool、Routing 已经不再是一堆零散 API 了。
它们是在回答不同的问题。
AIAgent 回答的是:"这个组件怎么以 Agent 的形式运行?"
AgentSession 回答的是:"这一通对话的状态是什么?"
ChatHistoryProvider 回答的是:"这一通以前发生过什么?"
AIContextProvider 回答的是:"这一次还需要额外知道什么?"
Compaction 回答的是:"信息太多时,模型还需要看多少?"
Middleware 回答的是:"哪些事情不能完全交给模型?"
Function Middleware 回答的是:"模型想调用工具时,我到底允许它做到哪一步?"
Routing 回答的是:"这一轮应该把请求交给哪个模型?"
Custom Agent 则回答:"如果底层根本不是聊天模型,能不能仍然遵守同一个 Agent Contract?"
最后真正需要记住的,不是 API,而是三条边界
如果把整篇文章压缩成几个工程上真正有用的判断,我反而不建议去背几十个类名,而是记住下面三件事。
第一件事是:
问自己:我要操作的是哪一个时钟?
如果我要控制整次 RunAsync,就在外层;如果我要控制每一次模型调用,就要深入工具循环和 IChatClient;如果我要控制某一次工具执行,就继续往 Function Middleware 里走。
第二件事是:
问自己:这份数据应该活多久?
只活一个工具调用的,就放 Tool Context;只活一次 Run 的,就放 AdditionalProperties;整个 Session 都需要,就放 StateBag;多个 Session 共用的,才应该进入 Agent 级 Provider 或服务。
第三件事是:
问自己:我到底是在改变"历史",还是改变"这一次模型看到的请求"?
这是很多设计决定真正的分叉点。
Memory 可以改变当前请求,但未必应该改变历史。
Compaction 可以缩短 Prompt,但未必应该破坏持久化历史。
Routing 可以更换模型,但它是否成立,取决于历史是不是掌握在调用方手里。
Middleware 可以拒绝一次 Run,也可以阻止一次 Tool,但它们发生的位置不同,行为也完全不同。
结语:真正的 Agent Framework,其实是在定义一个"运行时"
如果只把 Agent Framework 看成一个"调用 LLM 的 SDK",那么你会不断遇到:
这个功能应该用哪个 API?
但如果把它看成一个运行时,问题就会变成另一种形式:
这份数据属于哪个生命周期?
这段逻辑发生在 Run 的哪一层?
我正在修改的是历史、当前请求,还是整个 Agent 的执行行为?
这也是为什么一旦理解了 Runtime,前面的所有概念会突然变得很简单。
AgentSession 不是"一个更高级的消息列表",它是会话状态边界。
ChatHistoryProvider 不是"一个存消息的类",它决定了模型下一次能看到怎样的历史。
AIContextProvider 不是"另一个 Memory API",它决定了一次调用还要额外引入哪些上下文。
Compaction 不是"删除旧消息",它是在一个结构化的消息模型上减少模型看到的上下文。
Middleware 也不是"拦截请求的工具",而是 Runtime 在不同深度暴露出来的控制点。
而 Agent Framework 真正想统一的,也许从来不是某一种 Agent。
它统一的是:
text
Agent
+
Session
+
Context
+
Execution
+
Tools
+
State
这一整套生命周期。
所以,当你下一次面对 Agent Framework 的某个新 API 时,与其先问:
"这个类是干什么的?"
不如先问:
"它插在这条运行时管道的哪里?"
再问:
"它操作的是哪个时钟?"
最后问:
"它管理的是谁的状态?"
一旦这三个问题能回答出来,很多 API 其实就不需要死记了。因为你已经不是在学习一堆 Agent Framework 的组件,而是在理解它背后的运行时。
而这,才是从"会用 Agent Framework",走到"真正理解 Agent Framework"的分界线。