在众多预定义的IChatClient中间件中,FunctionInvokingChatClient无疑是最重要的一个,以至于没有它整个Agent就无法工作了。原因在于驱动Agent执行的核心机制的ReAct循环就是通过FunctionInvokingChatClient实现的,我们注册的工具函数最终由它来调用。对于相对敏感的工具函数,我们还需要通过人机交互引入审批流程,这也是通过FunctionInvokingChatClient来实现的。
1. 利用FunctionInvokingChatClient实现ReAct循环
ReAct(Reasoning and Acting,推理与行动)是一种结合了推理 与工具使用 的LLM工作流模式。 它通过交替进行推理(Reasoning)和行动(Acting),让AI能够像人类一样,一边分析问题一边寻找外部信息,从而解决复杂的、需要实时数据的任务。ReAct 的核心循环机制ReAct循环通常由三个核心步骤组成,不断重复直到得出最终答案:
- Thought(思考):模型分析当前状态,决定下一步该做什么;
- Action(行动):模型选择并调用外部工具(如搜索引擎、数据库、计算器);
- Observation(观察):模型读取工具返回的结果,并将其作为新的上下文;
比如我最常用的"根据某地天气提供着装建议"的场景,ReAct循环的执行流程如下。这是一个简单的只涉及单次迭代的ReAct循环,实际的ReAct循环可能会涉及多次迭代,模型在每次迭代中都会根据新的上下文来分析下一步该做什么。
- Thought:模型分析当前状态,发现缺少天气信息,决定需要调用工具来获取天气信息;
- Action:模型调用工具(如天气API)来获取天气信息;
- Observation:模型读取工具返回的天气信息,并将其作为新的上下文来分析天气情况,最终得出着装建议;
下面这个演示程序直接利用FunctionInvokingChatClient将上述的ReAct循环落地。如代码所示,我们创建了一个基于OpenAIClient的IChatClient对象,并在调用AsBuilder扩展方法将ChatClientBuilder构建出来后,通过调用UseFunctionInvocation方法来注册FunctionInvokingChatClient中间件。由于我们在调用GetResponseAsync方法的时候传入了一个工具函数,所以在执行过程中会触发ReAct循环,模型会先分析当前状态,发现缺少天气信息,然后调用工具函数来获取天气信息,最后根据获取到的天气信息来分析天气情况并得出着装建议。
csharp
using Azure;
using dotenv.net;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ComponentModel;
DotEnv.Load();
var model = Environment.GetEnvironmentVariable("MODEL")!;
var apiKey = Environment.GetEnvironmentVariable("API_KEY")!;
var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!;
var tool = AIFunctionFactory.Create(method: GetWeather);
var client = new OpenAIClient(
credential: new AzureKeyCredential(apiKey),
options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model:model)
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
var response = await client.GetResponseAsync(
messages: [new ChatMessage(ChatRole.User,content: "根据苏州当前天气情况,给出一些穿衣建议")],
options: new ChatOptions { Tools = [tool] });
Console.WriteLine(response.Text);
[Description("获取指定城市的天气信息")]
static string GetWeather(string city)=> $"{city}今天的天气是晴天,气温是25°C。";
输出:
markdown
苏州今天**晴天,气温25°C**,体感整体比较舒适,稍微偏暖一些。给你一些穿衣建议:
### 👕 上衣
- 短袖T恤、薄衬衫都很合适
- 如果长时间在空调房,可以带一件**薄外套或防晒衫**
### 👖 下装
- 牛仔裤、休闲裤、薄款长裤
- 女生也可以选择半身裙、连衣裙
### 👟 鞋子
- 运动鞋、休闲鞋、帆布鞋
- 不建议穿太厚重或闷脚的鞋子
### ☀️ 其他建议
- 晴天紫外线较强,外出可以做好**防晒(帽子、墨镜、防晒霜)**
- 白天气温较暖,但早晚可能稍微凉一点,怕冷的话可带薄外套
如果你是要通勤、旅游或者运动,我也可以帮你细化搭配 😊
2. 利用FunctionInvokingChatClient实现人机交互的审批流程
在某些场景下,工具函数可能会涉及一些敏感操作,比如访问用户的个人信息、执行一些可能产生副作用的操作等。对于这些敏感的工具函数,我们可能需要引入一个人机交互的审批流程,在模型调用工具函数之前先征求用户的同意。在如下的演示程序中,我们创建了一个工具函数Transfer,它模拟了一个银行转账的操作。由于这个操作比较敏感,所以我们在调用UseFunctionInvocation方法注册FunctionInvokingChatClient中间件的时候,并没有直接将这个工具函数传入,而是通过一个包装类ApprovalRequiredAIFunction来包装这个工具函数。ApprovalRequiredAIFunction会在模型调用工具函数之前先生成一个审批请求,并将其作为响应的一部分返回给用户。用户可以根据这个审批请求来决定是否批准执行这个工具函数。如果用户批准了,那么模型就会继续执行这个工具函数;如果用户拒绝了,那么模型就会放弃执行这个工具函数。
csharp
using Azure;
using dotenv.net;
using Microsoft.Extensions.AI;
using OpenAI;
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(method: Transfer, "Transfer");
AIFunction logTool = AIFunctionFactory.Create(method: Log, "Log");
transfer = new ApprovalRequiredAIFunction(transfer);
AITool[] tools = [transfer, logTool];
var client = new OpenAIClient(
credential: new AzureKeyCredential(apiKey),
options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model:model)
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
var prompt = new ChatMessage(
role: ChatRole.User,
content: "从账号`4242 4242 4242 4242` 转账100块到账号 `5555 5555 5555 4444`");
var options = new ChatOptions { Tools = tools };
var response = await client.GetResponseAsync(
messages: [prompt],
options: options);
while (response is not null)
{
var lastMessage = response.Messages.Last();
var approvalRequestContents = lastMessage.Contents.OfType<ToolApprovalRequestContent>();
if (!approvalRequestContents.Any())
{
Console.WriteLine(lastMessage.Text);
break;
}
Console.WriteLine("如下待执行工具需要你的审批");
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("是否批准执行 [Y/N]: ");
var input = Console.ReadLine();
bool isApproved = input?.Trim().ToUpper() == "Y";
var approvalResponses = approvalRequestContents.Select(it=>it.CreateResponse(isApproved)).ToArray();
var messages = response.Messages.ToList();
messages.Add(new ChatMessage(ChatRole.User, approvalResponses));
response = await client.GetResponseAsync(messages, options);
}
[Description("执行银行转账操作")]
static string Transfer(
[Description("转出银行账号")] string from,
[Description("转入银行账号")] string to,
[Description("转账金额")] decimal amount)
=> $"从账号 {from} 转账 {amount} 元到账号 {to} 已完成。";
[Description("跟踪记录执行银行转账操作")]
static void Log(string message) => Console.WriteLine(message);
如下的两端输出分别对应批准执行和拒绝执行的情况:
markdown
如下待执行工具需要你的审批
工具 `Transfer` 正在请求执行,参数如下:
- from: 4242 4242 4242 4242
- to: 5555 5555 5555 4444
- amount: 100
工具 `Log` 正在请求执行,参数如下:
- message: 从账号 4242 4242 4242 4242 转账 100 元到账号 5555 5555 5555 4444
是否批准执行 [Y/N]: Y
从账号 4242 4242 4242 4242 转账 100 元到账号 5555 5555 5555 4444
✅ 转账已完成!
- **转出账户**:4242 4242 4242 4242
- **转入账户**:5555 5555 5555 4444
- **转账金额**:100 元
📄 交易记录已成功保存。如需继续操作,请告诉我。
markdown
如下待执行工具需要你的审批
工具 `Transfer` 正在请求执行,参数如下:
- from: 4242 4242 4242 4242
- to: 5555 5555 5555 4444
- amount: 100
工具 `Log` 正在请求执行,参数如下:
- message: 从账号 4242 4242 4242 4242 转账 100 元到账号 5555 5555 5555 4444
是否批准执行 [Y/N]: N
❌ 转账失败。
原因:无法确定目标账户的有效性,因此银行转账操作被拒绝执行。
请核对以下信息后重新提交:
- 转出账户是否正确
- 转入账户是否正确
- 账户是否为有效银行账号格式
- 是否需要提供更多身份验证信息
如需重新发起转账,请提供正确的账户信息。
从这里例子可以看出,FunctionInvokingChatClient会将LLM返回的所有工具调用视为一个类似于事务的整体,如果所有工具都不需要审批,那么它会采用直接调用这些工具。如果其中有任何一个工具需要审批,它会任务所有工具调用都需要审批。这也很好理解,因为所有的工具都是为了同一个任务服务的,如果其中一个工具需要审批,那么整个任务就需要审批。以本例来说,虽然Log工具本身并不敏感,但由于它和敏感的Transfer工具是同一个任务的一部分,所以它也需要审批。如果Transfer工具被拒绝,而Log工具无条件执行,那么就会出现Log工具记录了一个实际上并没有发生的转账操作的情况,这显然是我们不希望看到的。
3. FunctionInvokingChatClient
FunctionInvokingChatClient实现ReAct循环和人机交互的审批流程的逻辑相对复杂一些。我们先来了解一下定义在这个类型中用于控制ReAct循环行为的几个重要属性。
csharp
public class FunctionInvokingChatClient : DelegatingChatClient
{
public bool IncludeDetailedErrors { get; set; }
public bool AllowConcurrentInvocation { get; set; }
public int MaximumIterationsPerRequest{ get; set; } = 40;
public int MaximumConsecutiveErrorsPerRequest{ get; set; } = 3;
public IList<AITool>? AdditionalTools { get; set; }
public bool TerminateOnUnknownCalls { get; set; }
public Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>>? FunctionInvoker { get; set; }
}
属性说明如下:
IncludeDetailedErrors:这个属性控制在ReAct循环过程中是否包含详细的错误信息。如果设置为true,当模型调用工具函数时发生错误,响应中将包含详细的错误信息;如果设置为false,则只会包含一个简单的错误提示。AllowConcurrentInvocation:这个属性控制是否允许并发调用工具函数。如果设置为true,模型在ReAct循环过程中可以同时调用多个工具函数;如果设置为false,则模型在调用一个工具函数时必须等待其完成后才能调用下一个工具函数。MaximumIterationsPerRequest:这个属性控制每个请求中ReAct循环的最大迭代次数。如果模型在一个请求中执行的ReAct循环迭代次数超过这个值,循环将被强制终止,以防止无限循环的情况发生。MaximumConsecutiveErrorsPerRequest:这个属性控制每个请求中允许的连续错误的最大次数。如果模型在一个请求中连续发生的错误次数超过这个值,循环将被强制终止,以防止模型在遇到错误时不断重试的情况发生。AdditionalTools:这个属性允许我们为模型提供一些额外的工具函数,模型在ReAct循环过程中可以调用这些工具函数来获取更多的信息或执行更多的操作。TerminateOnUnknownCalls:这个属性控制当模型调用了一个未知的工具函数时是否终止ReAct循环。如果设置为true,当模型调用了一个未知的工具函数时,循环将被强制终止;如果设置为false,则模型在调用一个未知的工具函数时会收到一个错误提示,但循环不会被终止。FunctionInvoker:这个属性允许我们自定义工具函数的调用逻辑。如果我们不设置这个属性,FunctionInvokingChatClient将使用默认的工具函数调用逻辑来调用工具函数;如果我们设置了这个属性,FunctionInvokingChatClient将使用我们提供的自定义逻辑来调用工具函数。
3.1 ReAct循环的实现逻辑
ReAct循环在FunctionInvokingChatClient的GetResponseAsync和GetStreamingResponseAsync方法中实现相对繁琐,所以我不打算详细解说FunctionInvokingChatClient中ReAct循环的实现逻辑,而是利用如下这个FunctionInvokingChatClientSimulator类型的GetResponseAsync来模拟ReAct循环的实现。如下面的代码片段所示,在重写的GetResponseAsync方法中,我们首先调用InnerClient.GetResponseAsync方法来获取LLM的响应,然后利用一个while循环来模拟ReAct循环。
csharp
class FunctionInvokingChatClientSimulator(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var response = await InnerClient.GetResponseAsync(messages, options, cancellationToken);
IEnumerable<AIFunction>? tools = options?.Tools?.OfType<AIFunction>();
if (!(tools?.Any() ?? false))
{
return response;
}
var messages4ChatClient = messages.ToList();
messages4ChatClient.AddRange(response.Messages);
while (true)
{
var functionCalls = response.Messages.Last().Contents.OfType<FunctionCallContent>();
if (!functionCalls.Any())
{
return response;
}
var pairs = from functionCall in functionCalls
let tool = tools!.FirstOrDefault(t => string.Equals(t.Name, functionCall.Name, StringComparison.Ordinal))
where tool is not null
select new { Tool = tool, FunctionCall= functionCall };
var results = await Task.WhenAll(pairs.Select(it=> InvokeFunctionAsync(it.Tool, it.FunctionCall, cancellationToken)));
var toolMessages = results.Select(content => new ChatMessage(ChatRole.Tool, [content]));
messages4ChatClient.AddRange(toolMessages);
response = await InnerClient.GetResponseAsync(messages4ChatClient, options, cancellationToken);
}
}
private static async Task<FunctionResultContent> InvokeFunctionAsync(AIFunction tool, FunctionCallContent functionCall, CancellationToken cancellationToken)
{
var arguments = new AIFunctionArguments(functionCall.Arguments);
var result = await tool.InvokeAsync(arguments, cancellationToken);
return new FunctionResultContent(functionCall.CallId, result);
}
}
对于每次迭代:
- 首先检查LLM的响应中是否包含工具调用的意图,如果没有,就直接返回这个响应,结束ReAct循环;
- 如果包含工具调用的意图,就提取出工具调用的相关信息,并找到对应的工具对象,然后并发调用这些工具来获取工具调用的结果;我们将这些结果封装成角色为
Tool的ChatMessage对象,并将它们添加到当前的消息列表中,作为下一轮LLM调用的输入。为了保证对话历史的完整性,之前响应的消息也被添加到了这个输入消息列表中;
3.2 人机交互的审批流程的实现逻辑
上面我们说过,FunctionInvokingChatClient会将LLM返回的所有工具调用视为一个整体,如果所有工具都不需要审批,那么它会采用直接调用这些工具。如果其中有任何一个工具需要审批,它会任务所有工具调用都需要审批。此时它会为每个代表工具调用的FunctionCallContent生成一个对应的ToolApprovalRequestContent,然后根据它们创建一个ChatMessage对象,代替LLM返回的原始响应返回给用户。
用户接收到这个包含审批请求的响应后,需要对每个ToolApprovalRequestContent生成一个对应的ToolApprovalResponseContent,其Approved属性表示用户是批准还是拒绝了这个工具调用。在根据ToolApprovalResponseContent创建ChatMessage对象并将它们添加到消息列表中后,用户需要再次调用GetResponseAsync方法。
请求被FunctionInvokingChatClient拦截后,它会使用上次LLM返回的携带FunctionCallContent消息替换掉消息列表中自己创建的那一条(这条消息是为了向用户展示审批请求而创建的占位消息,LLM不需要它)。然后遍历消息列表中的每个ToolApprovalResponseContent对象:
- 如果
Approved为true,那么FunctionInvokingChatClient就会执行对应的工具调用,将根据工具调用的结果来生成一个FunctionResultContent对象; - 如果
Approved为false,那么FunctionInvokingChatClient同样会生成一个表示拒绝执行的FunctionResultContent对象;
这些FunctionResultContent对象同样会被封装成角色为Tool的ChatMessage对象,并添加到消息列表中,作为下一轮LLM调用的输入。
4. UseFunctionInvocation扩展方法
针对FunctionInvokingChatClient中间件的注册可以通过ChatClientBuilder的UseFunctionInvocation扩展方法来完成。FunctionInvokingChatClient在内部也会输出相应的日志,我们可以传入用来创建ILogger对象的ILoggerFactory来控制这些日志的输出。如果没有指定,这个ILoggerFactory会从宿主程序的DI容器中提取。和其他用于注册中间件的扩展方法一样,我们也利用提供一个委托对注册的中间件进行相应的设置。
csharp
public static class FunctionInvokingChatClientBuilderExtensions
{
public static ChatClientBuilder UseFunctionInvocation(
this ChatClientBuilder builder,
ILoggerFactory? loggerFactory = null,
Action<FunctionInvokingChatClient>? configure = null);
}