利用FunctionInvokingChatClient实现ReAct循环ReActReasoning 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循环模型会先分析当前状态发现缺少天气信息然后调用工具函数来获取天气信息最后根据获取到的天气信息来分析天气情况并得出着装建议。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。”;输出:苏州今天晴天气温25°C体感整体比较舒适稍微偏暖一些。给你一些穿衣建议 上衣短袖T恤、薄衬衫都很合适如果长时间在空调房可以带一件薄外套或防晒衫 下装牛仔裤、休闲裤、薄款长裤女生也可以选择半身裙、连衣裙 鞋子运动鞋、休闲鞋、帆布鞋不建议穿太厚重或闷脚的鞋子☀️ 其他建议晴天紫外线较强外出可以做好防晒帽子、墨镜、防晒霜白天气温较暖但早晚可能稍微凉一点怕冷的话可带薄外套如果你是要通勤、旅游或者运动我也可以帮你细化搭配 2. 利用FunctionInvokingChatClient实现人机交互的审批流程在某些场景下工具函数可能会涉及一些敏感操作比如访问用户的个人信息、执行一些可能产生副作用的操作等。对于这些敏感的工具函数我们可能需要引入一个人机交互的审批流程在模型调用工具函数之前先征求用户的同意。在如下的演示程序中我们创建了一个工具函数Transfer它模拟了一个银行转账的操作。由于这个操作比较敏感所以我们在调用UseFunctionInvocation方法注册FunctionInvokingChatClient中间件的时候并没有直接将这个工具函数传入而是通过一个包装类ApprovalRequiredAIFunction来包装这个工具函数。ApprovalRequiredAIFunction会在模型调用工具函数之前先生成一个审批请求并将其作为响应的一部分返回给用户。用户可以根据这个审批请求来决定是否批准执行这个工具函数。如果用户批准了那么模型就会继续执行这个工具函数如果用户拒绝了那么模型就会放弃执行这个工具函数。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();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(itit.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);如下的两端输出分别对应批准执行和拒绝执行的情况如下待执行工具需要你的审批工具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 元 交易记录已成功保存。如需继续操作请告诉我。如下待执行工具需要你的审批工具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工具记录了一个实际上并没有发生的转账操作的情况这显然是我们不希望看到的。FunctionInvokingChatClientFunctionInvokingChatClient实现ReAct循环和人机交互的审批流程的逻辑相对复杂一些。我们先来了解一下定义在这个类型中用于控制ReAct循环行为的几个重要属性。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? AdditionalTools { get; set; }public bool TerminateOnUnknownCalls { get; set; }public FuncFunctionInvocationContext, CancellationToken, ValueTaskobject?? 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循环。class FunctionInvokingChatClientSimulator(IChatClient innerClient) : DelegatingChatClient(innerClient){public override async Task GetResponseAsync(IEnumerable messages,ChatOptions? options null,CancellationToken cancellationToken default){var response await InnerClient.GetResponseAsync(messages, options, cancellationToken);IEnumerable? tools options?.Tools?.OfType();if (!(tools?.Any() ?? false)){return response;}var messages4ChatClient messages.ToList(); messages4ChatClient.AddRange(response.Messages); while (true) { var functionCalls response.Messages.Last().Contents.OfTypeFunctionCallContent(); 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 TaskFunctionResultContent 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调用的输入。UseFunctionInvocation扩展方法针对FunctionInvokingChatClient中间件的注册可以通过ChatClientBuilder的UseFunctionInvocation扩展方法来完成。FunctionInvokingChatClient在内部也会输出相应的日志我们可以传入用来创建ILogger对象的ILoggerFactory来控制这些日志的输出。如果没有指定这个ILoggerFactory会从宿主程序的DI容器中提取。和其他用于注册中间件的扩展方法一样我们也利用提供一个委托对注册的中间件进行相应的设置。public static class FunctionInvokingChatClientBuilderExtensions{public static ChatClientBuilder UseFunctionInvocation(this ChatClientBuilder builder,ILoggerFactory? loggerFactory null,Action? configure null);