MAF 入门(6):人工审核(HITL)

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,再按金额选 ExecuteSmallRefundExecuteLargeRefund

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 时框架执行 DoRefundfalse 时跳过。

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

相关推荐
leeyi2 小时前
中间件系统:在 Agent 执行流中插入自定义逻辑
aigc·agent·ai编程
i晟4 小时前
Claude对话机制深度解析:为什么 Claude Code 和你越聊越懂你?每句对话都要读一整个上下文吗?
agent·claude
火锅小王子5 小时前
从 0 到 1:我用 AI Coding 撸了一套带「智能客服」的全栈电商系统
agent·vibecoding
Awu12275 小时前
💡一个 \r引发的重试循环:AI Agent CLI 在 Windows 上踩的 CRLF 匹配病
agent
小白鼠幻想家5 小时前
你的 Agent 正在被 Prompt 注入:MCP 协议 RCE 漏洞深度拆解
agent
doiito6 小时前
【Agent Harness】Gliding Horse 设计细节 -- 不跟风开发自己的AI Agent
架构·rust·agent
小白鼠幻想家6 小时前
AI Coding Agent 在老代码面前集体翻车
agent
武子康7 小时前
调查研究-203 SpaceX IPO 总览:先别急着讲故事,先把发行事实和信息边界立住
人工智能·openai·agent
葫芦和十三16 小时前
图解 MongoDB 19|Oplog:复制的真正载体,不是文档是操作
后端·mongodb·agent