涉及敏感操作的工具执行都需要引入基于人机交互(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,以及两个布尔属性AlwaysApproveTool和AlwaysApproveToolWithArguments来分别表示两种审批模式。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属性上。该对象通过调用ToolApprovalRequestContent的CreateResponse方法来创建。由于真正实现工具审批机制的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对象;
- 用于没有采用基于"don't ask again"的审批模式,直接调用扩展方法
4. ToolApprovalAgent
从上面的给出的定义可以看出,除了用来控制状态序列化的JsonSerializerOptions对象之外,ToolApprovalAgent并不需要额外的配置选项。它针对don't ask again 审批模式实现在重写的RunCoreAsync和RunCoreStreamingAsync方法中。
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,直接将其添加到ToolApprovalState的CollectedApprovalResponses列表中; - 如果类型为
AlwaysApproveToolApprovalResponseContent:- 提取其工具名称和参数列表来创建
ToolApprovalRule对象,并添加到ToolApprovalState的Rules列表中; - 将其
InnerResponse添加到ToolApprovalState的CollectedApprovalResponses列表中; - 从
ToolApprovalState的QueuedApprovalRequests队列中找到满足免审批条件的请求,并创建对应的ToolApprovalResponseContent添加到CollectedApprovalResponses列表中; - 如果
ToolApprovalState的QueuedApprovalRequests队列里没有满足免审批条件的请求了,则提取第一个ToolApprovalRequestContent封装成响应消息提交给用户继续审批。如此循环,直至所有审批请求都具有对应的审批响应。
- 提取其工具名称和参数列表来创建
- 如果类型为
- 提供
ToolApprovalState的CollectedApprovalResponses,封装成请求消息提交给内层Agent继续执行,等待内层Agent的响应。- 如果响应消息中不包含
ToolApprovalRequestContent类型的内容,则直接将响应返回给上层调用者; - 如果响应消息中包含
ToolApprovalRequestContent类型的内容,则继续按照上面的步骤处理这些审批请求,形成一个循环,直到所有审批请求都得到处理为止。
- 如果响应消息中不包含