MAF 入门(6):人工审核(HITL)------敏感工具调用前等待确认
写在前面
书接上回,Agent 能查订单了,但退款这种操作,你敢让它全自动吗?错了没法撤销。
HITL(Human-in-the-Loop) 就是在敏感工具执行前加一道人工确认:Agent 该查的自己查,该退钱的时候先问你,你同意了再动账。
这篇 Demo 的场景很日常------客户说包装坏了要退款。用户用口语提诉求,Agent 调工具查单,再根据金额决定走小额直退还是大额审批。一条对话线,一个 Agent,三个工具。
一、场景
1.1 用户需要退款
客户:
「你好,我要退订单 ORD-8842,收到的货包装坏了,请帮我查一下并办理退款。」
这句话原样交给 Agent,后面的查单、判金额、选退款工具,由大模型 + Function Tool 完成。
1.2 两笔订单,两种结果
| 订单 | 金额 | Agent 会调用的工具 | 人工审批 |
|---|---|---|---|
ORD-001 |
¥99 | ExecuteSmallRefund |
不需要 |
ORD-8842 |
¥299 | ExecuteLargeRefund |
控制台输入 y/n |
金额分界 ¥200 ,写在工具 Description 和 System Instructions 里。由大模型查单后判断该走哪条退款路径。
1.3 三个工具
text
LookupOrder 查单,只读
ExecuteSmallRefund 小额退款,直接执行
ExecuteLargeRefund 大额退款,需 ApprovalRequiredAIFunction 包装
1.4 流程
text
用户口语诉求
→ Agent 调 LookupOrder
→ Agent 根据金额选退款工具
├─ 小额 → ExecuteSmallRefund → [系统] 退款已实际执行
└─ 大额 → ExecuteLargeRefund
→ ToolApprovalRequestContent
→ 控制台 y/n
→ 批准后才执行 DoRefund
二、完整代码
2.1 Hitl/RefundTools.cs
csharp
using System.ComponentModel;
namespace MafDemo.Hitl;
/// <summary>退款 Demo:查单 + 小额直退 + 大额审批退。</summary>
public static class RefundTools
{
public const decimal LargeRefundThreshold = 200m;
/// <summary>退款工具是否真正执行过(区分 Agent 口头承诺)。</summary>
public static bool RefundActuallyExecuted { get; private set; }
public static void ResetExecutionFlag() => RefundActuallyExecuted = false;
[Description("根据订单号查询金额、签收状态、商品信息")]
public static string LookupOrder(
[Description("订单号,如 ORD-8842")] string orderId)
{
return orderId.Trim().ToUpperInvariant() switch
{
"ORD-8842" => "ORD-8842 | 金额=299 | 状态=已签收 | 商品=.NET AI 课程礼盒 | 备注=外盒压损",
"ORD-001" => "ORD-001 | 金额=99 | 状态=已签收 | 商品=贴纸 | 备注=包装破损",
_ => $"未找到订单 {orderId}(模拟数据)",
};
}
[Description("小额退款(金额严格小于 200 元),直接办理,无需人工审批")]
public static string ExecuteSmallRefund(
[Description("订单号")] string orderId,
[Description("退款金额,须与查单结果一致")] decimal amount)
=> DoRefund(orderId, amount);
[Description("大额退款(金额大于等于 200 元),需人工审批通过后才执行")]
public static string ExecuteLargeRefund(
[Description("订单号")] string orderId,
[Description("退款金额,须与查单结果一致")] decimal amount)
=> DoRefund(orderId, amount);
private static string DoRefund(string orderId, decimal amount)
{
RefundActuallyExecuted = true;
string id = orderId.Trim().ToUpperInvariant();
Console.WriteLine($"[系统] 退款已实际执行 → {id} ¥{amount:0.##}");
return $"[已退款] {id} 退回 ¥{amount:0.##},预计 1-3 个工作日到账。";
}
}
2.2 AgentHitlDemo.cs
csharp
using MafDemo.Configuration;
using MafDemo.Hitl;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
namespace MafDemo;
/// <summary>
/// 第⑥篇 Demo:用户口语申请退款 → Agent 查单 → Agent 判大小额 → 小额直退 / 大额 HITL。
/// </summary>
public static class AgentHitlDemo
{
private const string SystemInstructions =
"""
你是电商客服退款助手。
处理流程:
1. 必须先调用 LookupOrder 查询订单,不要编造数据。
2. 根据查单金额选择工具(禁止只用文字说「已提交审批」而不调工具):
- 金额小于 200 元 → 必须调用 ExecuteSmallRefund
- 金额大于等于 200 元 → 必须调用 ExecuteLargeRefund
3. 退款金额须与查单结果一致。
4. 若大额退款审批被拒绝或未执行,必须明确告知「退款未办理」。
用简洁中文回复客户。
""";
public static async Task RunAsync(
IConfiguration configuration,
string? orderArg = null,
CancellationToken cancellationToken = default)
{
var llm = configuration.GetSection(LlmSettings.SectionName).Get<LlmSettings>()
?? throw new InvalidOperationException($"缺少配置节 {LlmSettings.SectionName}");
if (string.IsNullOrWhiteSpace(llm.ApiKey))
{
Console.WriteLine("请在 appsettings.Development.json 或环境变量 Llm__ApiKey 中配置 ApiKey。");
return;
}
string orderId = orderArg?.Trim().ToUpperInvariant() switch
{
"small" or "ord-001" => "ORD-001",
"large" or "ord-8842" => "ORD-8842",
null or "" => "ORD-8842",
_ => orderArg!.Trim().ToUpperInvariant(),
};
string userMessage =
$"你好,我要退订单 {orderId},收到的货包装坏了,请帮我查一下并办理退款。";
IChatClient chatClient = ChatClientFactory.Create(llm);
RefundTools.ResetExecutionFlag();
AIAgent agent = chatClient.AsAIAgent(
instructions: SystemInstructions,
name: "RefundAssistant",
tools:
[
AIFunctionFactory.Create(RefundTools.LookupOrder),
AIFunctionFactory.Create(RefundTools.ExecuteSmallRefund),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(RefundTools.ExecuteLargeRefund)),
]);
Console.WriteLine("=== MAF HITL Demo:包装损坏退款 ===");
Console.WriteLine($"Provider: {llm.Provider} | Model: {llm.Model}");
Console.WriteLine($"大额阈值: ¥{RefundTools.LargeRefundThreshold}(Agent 查单后自行判断)");
Console.WriteLine("用法: dotnet run -- hitl [ORD-8842|ORD-001|small|large]");
Console.WriteLine();
Console.WriteLine($"用户: {userMessage}");
AgentSession session = await agent.CreateSessionAsync(cancellationToken);
await RunWithApprovalLoopAsync(agent, userMessage, session, cancellationToken);
if (!RefundTools.RefundActuallyExecuted)
{
Console.WriteLine("[系统] 本次未实际执行退款工具。");
}
Console.WriteLine();
}
private static async Task RunWithApprovalLoopAsync(
AIAgent agent,
string userMessage,
AgentSession session,
CancellationToken cancellationToken)
{
IEnumerable<ChatMessage> input = [new ChatMessage(ChatRole.User, userMessage)];
bool? lastApproved = null;
while (true)
{
Console.Write("助手: ");
List<AgentResponseUpdate> updates = await CollectStreamingUpdatesAsync(
agent, input, session, cancellationToken);
Console.WriteLine();
List<ToolApprovalRequestContent> approvals = ExtractApprovalRequests(updates);
if (approvals.Count == 0)
{
break;
}
var approvalMessages = new List<ChatMessage>();
foreach (ToolApprovalRequestContent req in approvals)
{
lastApproved = PromptApproval(req);
approvalMessages.Add(new ChatMessage(ChatRole.User, [req.CreateResponse(approved: lastApproved.Value)]));
}
input = approvalMessages;
}
PrintExecutionSummary(lastApproved);
}
private static async Task<List<AgentResponseUpdate>> CollectStreamingUpdatesAsync(
AIAgent agent,
IEnumerable<ChatMessage> input,
AgentSession session,
CancellationToken cancellationToken)
{
var updates = new List<AgentResponseUpdate>();
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
input, session, cancellationToken: cancellationToken))
{
updates.Add(update);
if (update.Text is { Length: > 0 } text)
{
Console.Write(text);
}
}
return updates;
}
private static List<ToolApprovalRequestContent> ExtractApprovalRequests(
IEnumerable<AgentResponseUpdate> updates) =>
updates
.SelectMany(u => u.Contents)
.OfType<ToolApprovalRequestContent>()
.DistinctBy(r => r.ToolCall.CallId)
.ToList();
private static bool PromptApproval(ToolApprovalRequestContent req)
{
var call = req.ToolCall as FunctionCallContent;
Console.WriteLine();
Console.WriteLine("⚠ 大额退款待审批");
Console.WriteLine($" 工具: {call?.Name ?? "(unknown)"}");
Console.WriteLine($" 参数: {call?.Arguments}");
Console.Write(" 允许执行? (y/n): ");
bool approved = Console.ReadLine()?.Trim().ToLowerInvariant() is "y" or "yes";
Console.WriteLine(approved ? " → 已批准" : " → 已拒绝");
return approved;
}
private static void PrintExecutionSummary(bool? approved)
{
if (approved == false)
{
Console.WriteLine(RefundTools.RefundActuallyExecuted
? "[系统] ⚠ 异常:已拒绝但退款工具仍被执行"
: "[系统] ✓ 已拒绝,退款工具未执行(上方助手回复仅供参考)");
}
else if (RefundTools.RefundActuallyExecuted)
{
Console.WriteLine("[系统] ✓ 退款工具已实际执行");
}
}
}
三、代码解析
3.1 注册 Agent 与工具
用户消息是自然语言:
csharp
string userMessage =
$"你好,我要退订单 {orderId},收到的货包装坏了,请帮我查一下并办理退款。";
三个工具里,只有大额退款包了审批:
csharp
tools:
[
AIFunctionFactory.Create(RefundTools.LookupOrder),
AIFunctionFactory.Create(RefundTools.ExecuteSmallRefund),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(RefundTools.ExecuteLargeRefund)),
]
ApprovalRequiredAIFunction 是 HITL 的开关:模型想调 ExecuteLargeRefund 时,框架不直接执行 C# 方法,而是抛出 ToolApprovalRequestContent,等你回复 CreateResponse(approved: true/false)。
System Instructions 规定流程:先 LookupOrder,再按金额选 ExecuteSmallRefund 或 ExecuteLargeRefund。
3.2 为什么拆两个退款工具
HITL 绑定在单个工具 上。小额要直退、大额要审批,所以拆成两个入口,底层共用 DoRefund:
ExecuteSmallRefund--- 普通AIFunction,直接执行ExecuteLargeRefund--- 包ApprovalRequiredAIFunction,等人批
大模型通过工具 Description(「小于 200」「大于等于 200」)和 Instructions 学会该调哪个。
RefundActuallyExecuted 和日志 [系统] 退款已实际执行 标记工具是否真的跑过,用来和 Agent 的自然语言回复区分。
3.3 审批循环 RunWithApprovalLoopAsync
text
① RunStreamingAsync(用户消息 或 审批回复)
② 从 update.Contents 提取 ToolApprovalRequestContent
③ 无审批 → 结束
④ 有审批 → 问 y/n → CreateResponse 作为下一轮 input → 回到 ①
一轮对话里 Agent 可能连续调多个工具(先查单、再退款),审批请求出现在流式 update.Contents 中,因此用 RunStreamingAsync 收集 updates,再用 DistinctBy(CallId) 去重。
审批回复按 MAF 推荐方式,每个请求单独一条 User 消息:
csharp
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: lastApproved.Value)])
approved: true 时框架执行 DoRefund;false 时跳过。
3.4 框架内的 HITL 链路
text
模型发起 ExecuteLargeRefund 调用
→ ApprovalRequiredAIFunction 拦截
→ ToolApprovalRequestContent 写入流式 updates
→ 你的代码 PromptApproval → CreateResponse
→ Agent 继续 → 批准则 DoRefund 执行
Python 侧等价写法是 @tool(approval_mode="always_require");C# 侧用 ApprovalRequiredAIFunction 包装。
3.5 运行效果
小额 ORD-001 :Agent 查单后直接调 ExecuteSmallRefund,控制台出现 [系统] 退款已实际执行,无 y/n 提示。
大额 ORD-8842 :Agent 查单后调 ExecuteLargeRefund,弹出审批;输入 y 后执行退款,输入 n 则不出现 [系统] 退款已实际执行。
判断是否真的退款,以 [系统] 退款已实际执行 为准,不以 Agent 口头回复为准。
四、小结
| 概念 | 代码位置 |
|---|---|
| HITL | ApprovalRequiredAIFunction 包装 ExecuteLargeRefund |
| 大小额路由 | 大模型查单后选择两个退款工具之一 |
| 审批工具 | ToolApprovalRequestContent |
| 审批 | RunWithApprovalLoopAsync |
| 流式 | RunStreamingAsync + ExtractApprovalRequests |