MAF 入门(4):使用 MAF Middleware 中间件
一、为什么需要 Middleware?
前面几篇,我们学会了:
- 让 Agent 对话 (
RunAsync) - 让 Agent 调工具 (
tools:) - 让 Agent 记历史 (
AgentSession)
一旦要上生产,你还会反复遇到这些需求:
| 需求 | 若写在业务里 | 问题 |
|---|---|---|
| 每次 Run 打日志、记耗时 | 每个 RunAsync 前后 copy 一遍 |
重复、易漏 |
| 敏感词拦截 | 在 Controller 里 if 判断 | 和 Agent 逻辑缠在一起 |
| 统一异常转友好文案 | try/catch 包每个调用点 | 维护成本高 |
这些叫 横切关注点(Cross-Cutting Concerns) :跟「这一轮对话的业务」无关,但每一轮 Run 都要做。
Middleware(中间件) 是 MAF 提供的标准插槽:在 Agent 执行前后(或更底层的模型 HTTP 调用前后)插入逻辑,不改核心 Agent 代码,就能叠加日志、治理、鉴权等能力。
二、MAF 的三层 Middleware
Microsoft Agent Framework 把扩展点分成三层(本文实现第 1、2 层,顺带介绍第 3 层):
| 层级 | 拦截什么 | 注册方式 |
|---|---|---|
| Agent Run | 每次 AIAgent.RunAsync / RunStreamingAsync |
agent.AsBuilder().Use(runFunc:, runStreamingFunc:) |
| Function 调用 | GetWeather / GetProductInfo 等工具执行前后 |
agent.AsBuilder().Use(functionCallback) |
| IChatClient | 发往推理服务的请求 | chatClient.AsBuilder().Use(getResponseFunc:, ...) |
调用链示意:
text
你的代码: agent.RunAsync("你好")
│
▼ ┌─────────────────────────────────────┐
│ Agent Run Middleware(业务层) │ 日志、敏感词、异常包装
└─────────────────┬───────────────────┘
▼
┌─────────────────────────────────────┐
│ ChatClientAgent / 工具循环等 │
└─────────────────┬───────────────────┘
▼
┌─────────────────────────────────────┐
│ IChatClient Middleware(传输层) │ HTTP 耗时、原始请求
└─────────────────┬───────────────────┘
▼
百炼 / OpenAI API
Agent Run 层 适合业务语义:审计、限流、敏感词、统一异常文案。
IChatClient 层适合传输语义:HTTP 耗时、重试、链路 ID、请求体大小。
两层日志可以同时开:Agent 层看「一次 Run 业务」;IChatClient 层看「一次 GetResponseAsync 网络调用」。
三、Agent Run Middleware:实现
3.1 注册入口:AIAgentBuilder.Use
在已有 AIAgent 上通过 Builder 挂中间件,再 Build() 得到带管道的 Agent:
csharp
var agent = coreAgent
.AsBuilder()
.Use(runFunc: YourMiddleware, runStreamingFunc: null)
.Build();
runFunc 是一个委托方法:
csharp
static async Task<AgentResponse> YourMiddleware(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
var response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
return response;
}
| 参数 | 含义 |
|---|---|
messages |
本轮传入的消息(含历史,取决于你怎么调 RunAsync) |
session |
会话对象;有 AgentSession 时可从 session 区分多用户 |
options |
本轮 AgentRunOptions |
innerAgent |
管道下一环:下一个 Middleware,或最内层的核心 Agent |
| 返回值 | 可原样返回,也可替换为新的 AgentResponse |
执行模型可以记成两句话:
- 继续管道 → 调用
await innerAgent.RunAsync(...) - 短路 (不调大模型)→ 直接
return new AgentResponse(...),不要 调innerAgent
3.2 链式多个 .Use()
多个中间件通过连续 .Use() 组成管道:
csharp
return core.AsBuilder()
.Use(runFunc: AgentRunMiddleware.FaultInjection, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.ContentGovernance, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.ExceptionHandling, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.RequestLogging, runStreamingFunc: null)
.Build();
顺序(与 ASP.NET Core 中间件类似):
- 先
.Use()→ 内层(离核心 Agent 近) - 后
.Use()→ 外层 (你的RunAsync先进入这一层)
ini
请求从外往里,响应从内往外返回。
### 3.3 流式说明
`Use` 还可传入 `runStreamingFunc` 处理 `RunStreamingAsync`。Demo 为聚焦非流式场景,统一写 `runStreamingFunc: null`。
生产若要用流式,应为流式单独实现一版,或使用文档中的 `Use(sharedFunc:)`(适合只改输入、不改输出的场景)。
---
## 四、四个 Middleware 实现详解
文件:`Middleware/AgentRunMiddleware.cs`。四个 **静态方法**,方法名即职责,签名与上一节相同。
### 4.1 RequestLogging --- 请求/响应可观测
在调用 `innerAgent` 前后打日志:会话标识、消息条数、粗算输入 Token、用户预览、耗时、`Usage`。
```csharp
public static async Task<AgentResponse> RequestLogging(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
string sessionHint = session?.GetHashCode().ToString("X") ?? "none";
int estimatedTokens = AgentMiddlewareHelpers.EstimateInputTokens(messages);
string userPreview = GetLastUserText(messages);
Console.WriteLine(
$"[MAF.RequestLog] ▶ 请求 | session={sessionHint} | msgs={messages.Count()} " +
$"| estTokens≈{estimatedTokens} | user=\"{AgentMiddlewareHelpers.Truncate(userPreview, 40)}\"");
AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
sw.Stop();
Console.WriteLine(
$"[MAF.RequestLog] ◀ 响应 | {sw.ElapsedMilliseconds}ms | replyLen={response.Text?.Length ?? 0} " +
$"| usage={AgentMiddlewareHelpers.FormatUsage(response.Usage)}");
return response;
}
AgentMiddlewareHelpers.EstimateInputTokens 用「字符数 / 2」粗算 Token,适合 Demo;生产可换成 tiktoken 等库。
FormatUsage 输出 InputTokenCount / OutputTokenCount,便于和账单对照。
4.2 ContentGovernance --- 敏感词短路
默认拦截关键词:密码、身份证号、apikey、sk-。命中后不调用 innerAgent,直接返回助手消息,不产生模型 Token 费用。
csharp
public static async Task<AgentResponse> ContentGovernance(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
string? hit = FindBlockedKeyword(messages);
if (hit is not null)
{
Console.WriteLine($"[MAF.Governance] ⛔ 拦截 | keyword=\"{hit}\"");
return new AgentResponse(new ChatMessage(
ChatRole.Assistant,
$"【治理拦截】检测到敏感内容({hit}),请求未发送到大模型。"));
}
return await innerAgent.RunAsync(messages, session, options, cancellationToken);
}
只扫描 ChatRole.User 的消息文本。扩展关键词改 BlockedKeywords 数组即可。
4.3 ExceptionHandling --- 异常包装
内层 Middleware 或核心 Agent 抛错时,转为 AgentResponse,调用方仍走正常的 await agent.RunAsync(...) 流程。OperationCanceledException 继续向上抛,不吞掉取消。
csharp
public static async Task<AgentResponse> ExceptionHandling(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
try
{
return await innerAgent.RunAsync(messages, session, options, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
Console.WriteLine($"[MAF.Exception] ✖ {ex.GetType().Name}: {ex.Message}");
return new AgentResponse(new ChatMessage(
ChatRole.Assistant,
$"【系统异常】请求处理失败,请稍后重试。({ex.GetType().Name})"));
}
}
4.4 FaultInjection --- 异常场景演示
用户输入包含「模拟异常」时抛出 InvalidOperationException,用于验证 ExceptionHandling 是否生效。仅 Demo 使用,生产环境不要保留。
csharp
public static async Task<AgentResponse> FaultInjection(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
if (GetLastUserText(messages).Contains("模拟异常", StringComparison.Ordinal))
{
throw new InvalidOperationException("FaultInjection:模拟 Middleware 管道内异常");
}
return await innerAgent.RunAsync(messages, session, options, cancellationToken);
}
五、Function 调用 Middleware
5.1 何时生效?
Agent 通过 AsAIAgent(..., tools: tools) 注册工具后,MAF 会在内部使用 FunctionInvokingChatClient 跑 Tool Calling 循环。此时可对每一次工具执行再挂 Middleware。
注册方式(与 Agent Run 的 runFunc 不同,是另一个 Use 重载):
csharp
core.AsBuilder()
.Use(runFunc: AgentRunMiddleware.RequestLogging, runStreamingFunc: null)
// ... 其他 Agent Run 中间件 ...
.Use(FunctionInvocationMiddleware.UnknownProductGuard)
.Use(FunctionInvocationMiddleware.InvocationLogging)
.Build();
方法签名:
csharp
static async ValueTask<object?> YourFunctionMiddleware(
AIAgent agent,
FunctionInvocationContext context,
Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
CancellationToken cancellationToken)
{
object? result = await next(context, cancellationToken);
return result;
}
| 参数 | 含义 |
|---|---|
context.Function |
当前被调用的 AIFunction(如 GetWeather) |
context.Arguments |
模型填写的参数 |
context.Iteration |
第几轮工具调用循环 |
next |
继续执行下一环(最终落到你的 C# 方法) |
| 返回值 | 可原样返回,也可短路 直接 return "..." 而不调 next |
5.2 例子:InvocationLogging --- 工具调用日志
Middleware/FunctionInvocationMiddleware.cs:
csharp
public static async ValueTask<object?> InvocationLogging(
AIAgent agent,
FunctionInvocationContext context,
Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
CancellationToken cancellationToken)
{
Console.WriteLine($"[MAF.Function] ▶ {context.Function.Name}(...) | iter={context.Iteration}");
object? result = await next(context, cancellationToken);
Console.WriteLine($"[MAF.Function] ◀ {context.Function.Name} => ...");
return result;
}
5.3 例子:UnknownProductGuard 不在调用模型
对 GetProductInfo,若 productId 以 X 开头,不调用 next,直接返回拦截文案(模型会基于该结果组织回复):
csharp
if (IsGetProductInfo(context.Function.Name)
&& TryGetArgument(context.Arguments, "productId", out string? productId)
&& productId.StartsWith('X'))
{
return $"【工具拦截】产品编号 {productId} 不允许查询,请使用 P001 / P002 / P003。";
}
return await next(context, cancellationToken);
5.4 与 Agent Run 层的关系
text
agent.RunAsync("查 P002")
→ Agent Run Middleware(RequestLog / Governance ...)
→ 核心 Agent + Tool 循环
→ 模型返回 tool_call: GetProductInfo
→ Function Middleware(InvocationLogging → UnknownProductGuard → AgentTools.GetProductInfo)
→ 模型根据工具结果生成最终回复
← AgentResponse
两层正交:Agent Run 包「整次对话」;Function 包「每一次工具执行」。
六、中间件链式注册、调用
CreateWithMiddlewarePipeline 根据是否传入 tools 决定是否注册 Function 层:
csharp
public static IList<AITool> CreateDemoTools() =>
[
AIFunctionFactory.Create(AgentTools.GetWeather),
AIFunctionFactory.Create(AgentTools.GetProductInfo),
];
AIAgent core = tools is null
? client.AsAIAgent(instructions: instructions, name: name)
: client.AsAIAgent(instructions: instructions, name: name, tools: tools);
AIAgentBuilder builder = core.AsBuilder()
.Use(runFunc: AgentRunMiddleware.FaultInjection, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.ContentGovernance, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.ExceptionHandling, runStreamingFunc: null)
.Use(runFunc: AgentRunMiddleware.RequestLogging, runStreamingFunc: null);
if (tools is not null)
{
builder = builder
.Use(FunctionInvocationMiddleware.UnknownProductGuard)
.Use(FunctionInvocationMiddleware.InvocationLogging);
}
return builder.Build();
无工具时只走 Agent Run 管道;有工具时两层一起启用。
七、IChatClient 层 Middleware(可选)
Middleware/ChatClientLoggingMiddleware.cs 在 模型 HTTP 调用 前后记日志:
csharp
public static ChatClientBuilder UseRequestLogging(this ChatClientBuilder builder, string tag = "MAF.ChatClient")
{
return builder.Use(
getResponseFunc: async (messages, options, innerClient, cancellationToken) =>
{
var sw = Stopwatch.StartNew();
Console.WriteLine($"[{tag}] ▶ GetResponseAsync | msgs={messages.Count()}");
ChatResponse response = await innerClient.GetResponseAsync(messages, options, cancellationToken);
sw.Stop();
Console.WriteLine($"[{tag}] ◀ GetResponseAsync {sw.ElapsedMilliseconds}ms");
return response;
},
getStreamingResponseFunc: null);
}
与 Agent Run 层的对比:
Agent Run(RequestLogging) |
IChatClient(UseRequestLogging) |
|
|---|---|---|
| 拦截点 | 一次 agent.RunAsync |
一次 GetResponseAsync |
| 典型用途 | 业务审计、敏感词、异常文案 | HTTP 耗时、重试、链路追踪 |
| 工具调用时 | 一次 Run 可能触发多轮模型调用 | 每一轮 HTTP 都会经过 |
八、小结
前几篇让 Agent 能说、能做、能记 ;本篇 AsBuilder().Use(...) 在 对话入口 和 工具入口 两处叠加横切逻辑,让 Agent 更易观测、更易管控,更适合往生产环境推进。