[MAF预定义Agent中间件-05]ToolApprovalAgent-摆脱重复审批的烦恼

涉及敏感操作的工具执行都需要引入基于人机交互(HITL, Human-In-The-Loop)的审批机制来确保安全。按照安全级别由高到低,我们可以采用如下三种常见的审批模式:

  • 只要工具调用被装饰了ApprovalRequiredAIFunction中间件,就需要人工介入审批;
  • 如果当前工具调用审批通过,后者针对同一工具针对相同参数列表的调用可以免审批直接执行;
  • 如果当前工具调用审批通过,后者针对同一工具的调用可以免审批直接执行(不管参数列表是否相同)。

其中第一种为默认的审批模式,虽然安全级别最高,但在实际使用中会带来大量重复审批的烦恼,尤其是当Agent需要频繁调用同一个工具时。第二种和第三种模式则通过引入记忆机制来减少重复审批的次数,从而提升Agent的效率和用户体验。这种don't ask again 的审批需要借助ToolApprovalAgent中间件来实现。

1. 基于"don't ask again"的审批

如下的程序演示了如何利用ToolApprovalAgent来实现基于"don't ask again"的审批:我们定义了一个转账工具TransferMoney,它被ApprovalRequiredAIFunction装饰,表示它需要人工审批。然后我们创建了一个OpenAIClient,并通过AsAIAgent方法将其转换为一个Agent实例,在这个过程中我们把转账工具注册到了Agent上。接着我们在Agent的构建器中调用了UseToolApproval方法来启用基于don't ask again的审批机制。

csharp 复制代码
using Azure;
using dotenv.net;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using System.ComponentModel;

DotEnv.Load();

var model = Environment.GetEnvironmentVariable("MODEL")!;
var apiKey = Environment.GetEnvironmentVariable("API_KEY")!;
var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!;
AIFunction transfer = AIFunctionFactory.Create(TransferMoney, nameof(TransferMoney));
transfer = new ApprovalRequiredAIFunction(transfer);
var agent = new OpenAIClient(
        credential: new AzureKeyCredential(apiKey),
        options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
    .GetChatClient(model: model)
    .AsIChatClient()    
    .AsAIAgent(tools: [transfer])
    .AsBuilder()
    .UseToolApproval()
    .Build();
string? promptTemplate = null;
var session = await agent.CreateSessionAsync();

while (true)
{ 
    Console.Write("请输入转账金额:");
    string prompt = default!;
    if (promptTemplate is null)
    {
        promptTemplate = "再次请将{0}元从账户`4242 4242 4242 4242`转账到账户`5555 5555 5555 4444`, 请将转账金额均分为两部分,执行两次转账操作!";
        prompt = $"请将{Console.ReadLine()}元从账户`4242 4242 4242 4242`转账到账户`5555 5555 5555 4444`, 请将转账金额均分为两部分,执行两次转账操作!";
    }
    else
    {
        prompt = string.Format(promptTemplate, Console.ReadLine());
    }
    var response = await agent.RunAsync(prompt, session);
    var message = response.Messages.Last();
    var approvalRequestContents = message.Contents.OfType<ToolApprovalRequestContent>();
    if (!approvalRequestContents.Any())
    {
        Console.WriteLine($"\n{response}\n\n");
        continue;
    }

    foreach (var content in approvalRequestContents)
    {
        var toolCall = (FunctionCallContent)content.ToolCall;
        Console.WriteLine($"待执行操作`{toolCall.Name}`需要你的审批 :");
        foreach (var (k, v) in toolCall.Arguments!)
        {
            Console.WriteLine($"    - {k}: {v}");
        }
        Console.WriteLine();
    }

    Console.Write("""
        - 批准针对工具的本次调用,并且在未来自动批准同样参数的调用,请输入:1
        - 批准针对工具的本次调用,并且在未来自动批准同类调用(不论参数)请输入:2
        - 拒绝针对工具的本次调用,请输入:3
        你的审批决定:
        """);
    var decision = Console.ReadLine()?.Trim();
    var approvalResponses = approvalRequestContents.Select(it => decision switch
        {
            "1" => (AIContent)it.CreateAlwaysApproveToolWithArgumentsResponse(),
            "2" => it.CreateAlwaysApproveToolResponse(),
            "3" => it.CreateResponse(false),
            _ => throw new InvalidOperationException("无效的输入")
        }
    ).ToArray();

    response = await agent.RunAsync(new ChatMessage(ChatRole.User, approvalResponses), session);
    Console.WriteLine($"\n{response}\n\n");
}


[Description("银行转账")]
static string TransferMoney(
    [Description("转出账户")]
    string fromAccount, 
    [Description("转入账户")]
    string toAccount, 
    [Description("转账金额")]
    decimal amount)
=> $"已成功将{amount}元从{fromAccount}转账到{toAccount}";

我们在一个循环中,通过调用Agent多次在两个账户之间转账,但是转账的金额由用户在控制台输入。我们通过提示词引导Agent将用户输入的金额均分为两部分来执行两次转账操作。我们通过响应消息是否携带ToolApprovalRequestContent来判断当前工具调用是否需要审批,并根据用户的输入来生成不同的审批响应。并给用户提供三种审批选项:

  • 批准针对工具的本次调用,并且在未来自动批准同样参数 的调用,此时我们会调用扩展方法CreateAlwaysApproveToolWithArgumentsResponse方法来生成审批响应,这会告诉Agent对于同样参数的调用都自动批准;
  • 批准针对工具的本次调用,并且在未来自动批准同类调用(不论参数) ,此时我们会调用扩展方法CreateAlwaysApproveToolResponse方法来生成审批响应,这会告诉Agent对于同类调用都自动批准,无论参数如何变化;
  • 拒绝针对工具的本次调用,此时我们会调用CreateResponse(false)方法来生成审批响应,这会告诉Agent拒绝本次调用。

我们来看看选择不同的审批模式时的效果。如下为选择第一种审批模式时的交互内容:我们第一次输入转账金额100,Agent会将其均分为两笔50元的转账来执行。但是ToolApprovalAgent只会提交给用户第一个审批请求。我们选择了第一个选项后,两笔相同金额的转账会成功执行。

markdown 复制代码
请输入转账金额:100
待执行操作`TransferMoney`需要你的审批 :
    - fromAccount: 4242 4242 4242 4242
    - toAccount: 5555 5555 5555 4444
    - amount: 50

- 批准针对工具的本次调用,并且在未来自动批准同样参数的调用,请输入:1
- 批准针对工具的本次调用,并且在未来自动批准同类调用(不论参数)请输入:2
- 拒绝针对工具的本次调用,请输入:3
你的审批决定:1

转账已完成!以下是转账详情:

| 项目 | 详情 |
|------|------|
| **转出账户** | 4242 4242 4242 4242 |
| **转入账户** | 5555 5555 5555 4444 |
| **总金额** | 100 元 |
| **第一次转账** | 50 元 ✅ |
| **第二次转账** | 50 元 ✅ |

两笔转账均已成功执行,共计100元已从您的账户转入目标账户。


请输入转账金额:100

转账再次完成!以下是转账详情:

| 项目 | 详情 |
|------|------|
| **转出账户** | 4242 4242 4242 4242 |
| **转入账户** | 5555 5555 5555 4444 |
| **总金额** | 100 元 |
| **第一次转账** | 50 元 ✅ |
| **第二次转账** | 50 元 ✅ |

两笔转账均已成功执行,共计再次转账100元至目标账户。


请输入转账金额:200
如下待执行工具需要你的审批
待执行操作`TransferMoney`需要你的审批 :
    - fromAccount: 4242 4242 4242 4242
    - toAccount: 5555 5555 5555 4444
    - amount: 100

- 批准针对工具的本次调用,并且在未来自动批准同样参数的调用,请输入:1
- 批准针对工具的本次调用,并且在未来自动批准同类调用(不论参数)请输入:2
- 拒绝针对工具的本次调用,请输入:3
你的审批决定:1

转账完成!以下是本次转账详情:

| 项目 | 详情 |
|------|------|
| **转出账户** | 4242 4242 4242 4242 |
| **转入账户** | 5555 5555 5555 4444 |
| **总金额** | 200 元 |
| **第一次转账** | 100 元 ✅ |
| **第二次转账** | 100 元 ✅ |

两笔各100元的转账均已成功执行,共计200元已转入目标账户。

接下来我们再次提交相同金额(100)的转账请求后,Agent不会再提交审批请求,而是直接执行转账操作了。由于我们选择的审批模式是针对相同参数的调用自动批准,所以只有当我们提交完全相同参数的转账请求时才会免审批直接执行。如果我们提交了不同金额(比如200)的转账请求,Agent还是会提交审批请求的。但是如何我们选择了第二种审批模式,那么无论我们提交什么金额的转账请求,Agent都会免审批直接执行了,具体交互如下所示:

markdown 复制代码
请输入转账金额:100
待执行操作`TransferMoney`需要你的审批 :
    - fromAccount: 4242 4242 4242 4242
    - toAccount: 5555 5555 5555 4444
    - amount: 50

- 批准针对工具的本次调用,并且在未来自动批准同样参数的调用,请输入:1
- 批准针对工具的本次调用,并且在未来自动批准同类调用(不论参数)请输入:2
- 拒绝针对工具的本次调用,请输入:3
你的审批决定:2

转账已全部完成!以下是转账摘要:

| 项目 | 详情 |
|------|------|
| **转出账户** | 4242 4242 4242 4242 |
| **转入账户** | 5555 5555 5555 4444 |
| **转账总金额** | 100 元 |

**两次转账明细:**

| 次数 | 金额 | 状态 |
|------|------|------|
| 第一次 | 50 元 | ✅ 成功 |
| 第二次 | 50 元 | ✅ 成功 |

两次转账均已成功执行,总共从账户 `4242 4242 4242 4242` 转出 100 元到账户 `5555 5555 5555 4444`。


请输入转账金额:200

转账已全部完成!以下是转账摘要:

| 项目 | 详情 |
|------|------|
| **转出账户** | 4242 4242 4242 4242 |
| **转入账户** | 5555 5555 5555 4444 |
| **转账总金额** | 200 元 |

**两次转账明细:**

| 次数 | 金额 | 状态 |
|------|------|------|
| 第一次 | 100 元 | ✅ 成功 |
| 第二次 | 100 元 | ✅ 成功 |

两次转账均已成功执行,总共从账户 `4242 4242 4242 4242` 转出 200 元到账户 `5555 5555 5555 4444`。

2. AlwaysApproveToolApprovalResponseContent

在完成了针对ToolApprovalAgent中间件的注册前提下,两种基于don't ask again 的审批模式由用户根据审批请求内容创建的审批响应内容来决定。这个特殊的AIContent类型为AlwaysApproveToolApprovalResponseContent,它包含了一个ToolApprovalResponseContent类型的属性InnerResponse,以及两个布尔属性AlwaysApproveToolAlwaysApproveToolWithArguments来分别表示两种审批模式。Agent会根据这两个属性的值来决定是否启用基于don't ask again的审批机制。

csharp 复制代码
public sealed class AlwaysApproveToolApprovalResponseContent : AIContent
{
	public ToolApprovalResponseContent InnerResponse { get; }
	public bool AlwaysApproveTool { get; }
	public bool AlwaysApproveToolWithArguments { get; }
	internal AlwaysApproveToolApprovalResponseContent(
        ToolApprovalResponseContent innerResponse, 
        bool alwaysApproveTool, 
        bool alwaysApproveToolWithArguments);
}

AlwaysApproveToolApprovalResponseContent是对另一个ToolApprovalResponseContent的包装,后者体现在它的InnerResponse属性上。该对象通过调用ToolApprovalRequestContentCreateResponse方法来创建。由于真正实现工具审批机制的FunctionInvokingChatClient只认识ToolApprovalResponseContent类型的审批响应内容,所以在ToolApprovalAgent向后传递的消息里,表达审评响应的内容是被AlwaysApproveToolApprovalResponseContent包装的ToolApprovalResponseContent对象。由于它只包含一个internal构造函数,所以我们需要按照演示实例那样,通过调用ToolApprovalRequestContent类型如下两个扩展方法来创建针对不同审批模式的AlwaysApproveToolApprovalResponseContent对象。

csharp 复制代码
public static class ToolApprovalRequestContentExtensions
{
    public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolResponse(
        this ToolApprovalRequestContent request,
        string? reason = null)
        => new AlwaysApproveToolApprovalResponseContent(
            request.CreateResponse(approved: true, reason),
            alwaysApproveTool: true,
            alwaysApproveToolWithArguments: false);
    public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolWithArgumentsResponse(
        this ToolApprovalRequestContent request,
        string? reason = null)
         => new AlwaysApproveToolApprovalResponseContent(
            request.CreateResponse(approved: true, reason),
            alwaysApproveTool: false,
            alwaysApproveToolWithArguments: true);
}

3. 审批状态的维持

ToolApprovalAgent中间件为了一个由ToolApprovalState对象承载的状态。ToolApprovalAgent利用_sessionState字段返回的ProviderSessionState<ToolApprovalState>来维护这ToolApprovalState这个状态对象,它在Session中对应的键名为"toolApprovalState"。我们可以在构造函数中提供一个JsonSerializerOptions对象来指定ToolApprovalState对象来控制该状态的序列化。

csharp 复制代码
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
    private readonly ProviderSessionState<ToolApprovalState> _sessionState;
    private readonly JsonSerializerOptions _jsonSerializerOptions;

    public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
        : base(innerAgent)
    {
        _jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
        _sessionState = new ProviderSessionState<ToolApprovalState>(
            _ => new ToolApprovalState(),
            "toolApprovalState",
            _jsonSerializerOptions);
    }
}

ToolApprovalState类型定义如下,它包含三个集合类型的状态成员:

csharp 复制代码
internal sealed class ToolApprovalState
{    
	public List<ToolApprovalRequestContent> QueuedApprovalRequests { get; set; } = new List<ToolApprovalRequestContent>();
	public List<ToolApprovalRule> Rules { get; set; } = new List<ToolApprovalRule>();
	public List<ToolApprovalResponseContent> CollectedApprovalResponses { get; set; } = new List<ToolApprovalResponseContent>();
}

internal sealed class ToolApprovalRule
{
	public string ToolName { get; set; } = string.Empty;
	public IDictionary<string, string>? Arguments { get; set; }
}

三个属性成员说明如下:

  • QueuedApprovalRequests : 对于ToolApprovalAgent中间件为了收集审批响应与用户的每次交互,它只会提交一个审批请求给用户来审批,其余的审批请求会存在这个ToolApprovalRequestContent集合表示的队列中;
  • Rules : 用来记录基于"don't ask again"的审批规则,每当用户创建一个AlwaysApproveToolApprovalResponseContent类型的审批响应时,ToolApprovalAgent都会从中提取工具名称和参数信息来创建一个ToolApprovalRule对象并添加到Rules列表中以记录这个审批规则。队列中的审批请求和后续的审批请求都会与Rules列表中的规则进行比较来判断是否满足免审批条件;
  • CollectedApprovalResponses : 由于FunctionInvokingChatClient只认可ToolApprovalResponseContent,所以任何类型的审批响应(用户手工创建的或者ToolApprovalState自动创建的)都需要被转换成ToolApprovalResponseContent类型的内容来提交给内层Agent继续执行。它的成员包含如下三个类别:
    • 用于没有采用基于"don't ask again"的审批模式,直接调用扩展方法CreateAlwaysApproveToolResponse创建的ToolApprovalResponseContent对象;
    • 用户针对提交的请求创建了了一个AlwaysApproveToolApprovalResponseContent,其InnerResponse属性返回的ToolApprovalResponseContent对象;
    • 审批请求满足Rules列表里记录的某条规则的免审批条件,ToolApprovalAgent自动创建的ToolApprovalResponseContent对象;

4. ToolApprovalAgent

从上面的给出的定义可以看出,除了用来控制状态序列化的JsonSerializerOptions对象之外,ToolApprovalAgent并不需要额外的配置选项。它针对don't ask again 审批模式实现在重写的RunCoreAsyncRunCoreStreamingAsync方法中。

csharp 复制代码
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
    protected override async Task<AgentResponse> RunCoreAsync(
        IEnumerable<ChatMessage> messages,
        AgentSession? session = null,
        AgentRunOptions? options = null,
        CancellationToken cancellationToken = default);
    protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
        IEnumerable<ChatMessage> messages,
        AgentSession? session = null,
        AgentRunOptions? options = null,
        CancellationToken cancellationToken = default)
}

RunCoreAsync/RunCoreStreamingAsync方法执行流程大致如下:

  • 从请求消息中提取表达审批回复的内容:
    • 如果类型为ToolApprovalResponseContent,直接将其添加到ToolApprovalStateCollectedApprovalResponses列表中;
    • 如果类型为AlwaysApproveToolApprovalResponseContent
      • 提取其工具名称和参数列表来创建ToolApprovalRule对象,并添加到ToolApprovalStateRules列表中;
      • 将其InnerResponse添加到ToolApprovalStateCollectedApprovalResponses列表中;
      • ToolApprovalStateQueuedApprovalRequests队列中找到满足免审批条件的请求,并创建对应的ToolApprovalResponseContent添加到CollectedApprovalResponses列表中;
      • 如果ToolApprovalStateQueuedApprovalRequests队列里没有满足免审批条件的请求了,则提取第一个ToolApprovalRequestContent封装成响应消息提交给用户继续审批。如此循环,直至所有审批请求都具有对应的审批响应。
  • 提供ToolApprovalStateCollectedApprovalResponses,封装成请求消息提交给内层Agent继续执行,等待内层Agent的响应。
    • 如果响应消息中不包含ToolApprovalRequestContent类型的内容,则直接将响应返回给上层调用者;
    • 如果响应消息中包含ToolApprovalRequestContent类型的内容,则继续按照上面的步骤处理这些审批请求,形成一个循环,直到所有审批请求都得到处理为止。
相关推荐
码农阿强1 小时前
Claude-Fable-5 技术详解 + 基于 startapi.top 接口实战调用(附多语言代码示例)
人工智能·gpt·ai·aigc·ai编程
asdzx671 小时前
使用 C# 轻松为 Word 文档添加数字签名
c#·word
咸鱼翻身小阿橙1 小时前
VS2008 C# WinForm 简易计算器
开发语言·c#
奋飛1 小时前
反向拆解 skill-creator:一个好 skill 是怎么写出来的
agent·skill·anthropic·agent skill·skill-creator
todoitbo1 小时前
把 GitNexus 接进 Codex:安装、索引、Web UI 和项目分析实操
人工智能·ai·codex·claude code·gitnexus
学废了wuwu1 小时前
minGPT学习路径
ai
花伤情犹在1 小时前
Hermes 清理飞书会话操作指南
linux·sqlite·飞书·agent·hermes
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年6月9日
人工智能·python·ai·信息可视化·自然语言处理·ai编程·灵砚智能
李燚2 小时前
Chain 编排:线性流、并行、Passthrough
agent·chain·workflow·graph·ai-agent