对于Agent的每一次调用,Agent内部都采用ReAct循环调用LLM和工具,直到达到某个终止条件才会停止循环,具体的流程如下:

  • Thought (思考):模型分析当前现状,决定下一步该做什么,以及为什么要这么做;
  • Action (行动):模型从工具箱中选择一个工具(如:搜索、计算器、API调用)并执行;
  • Observation (观察):模型读取工具返回的结果(即外部世界的反馈);
  • Repeat (迭代):模型根据新的观察结果,开始下一轮 Thought,直到找到最终答案;

由于Agent处理管道在LangChain和MAF中具有截然不同的涉及,所以针对ReAct循环的实现也有很大的差异,下面我们来看看它们是如何实现的。

1. LangChain

对于通过create_agent创建的Agent来说,对应的状态图modeltools两个核心节点(如下所示),前者用来调用LLM,后者根据LLM返回的结果来调用对应的工具。在这个简单的图上实现ReAct循环不仅简单还很直观,因为我们在两个节点之间定义相依的边就可以了。

Alternative Text

对于每一次针对Agent的调用,总是从针对LLM的调用开始,而且最终的答案也总是由LLM生成,所以model节点既是整个状态图的入口节点(Entry Node),还是完成节点(Finish Node)。工具调用返回的结果总是作为LLM下一轮调用的输入,所以从toolsmodel之间需要一条静态边,确保只要有工具被真正地被调用,就一定会触发下一轮LLM的调用。但是LLM调用之后却有两个选择:

  • 如果LLM返回的结果是一个工具调用的意图,那么就会触发工具的调用;
  • 如果LLM返回的结果是一个最终答案,那么就会直接完成(Finish)这个状态图。

所以从modeltools之间需要一条条件边,条件就是LLM最后的输出是否包含工具调用,LangChain直接定义这个条边的条件写入了如下这个预定义函数中。

def tools_condition(
    state: list[AnyMessage] | dict[str, Any] | BaseModel,
    messages_key: str = "messages",
) -> Literal["tools", "__end__"]:
   
    if isinstance(state, list):
        ai_message = state[-1]
    elif (isinstance(state, dict) and (messages := state.get(messages_key, []))) or (
        messages := getattr(state, messages_key, [])
    ):
        ai_message = messages[-1]
    else:
        msg = f"No messages found in input state to tool_edge: {state}"
        raise ValueError(msg)
    if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
        return "tools"
    return "__end__"

2. MAF

与LangChain的Agent相对应的是MAF的ChatClientAgent,接下来我们来分析一下ReAct循环它上面是如何实现的。由于图是表达循环的天然结构,所以ReAct在LangChain中的设计和实现显得非常自然和直观,而ChatClientAgent被设计成了一个线性的管道,就显得没那个直观了。不过在线性管道的某个环境开启一个循环也没有那么麻烦。

Alternative Text

再来看看如上图所示的ChatClientAgent管道,它分成从右到左的三个部分:

  • ChatClient管道:包括与LLM交互的IChatClient对象,以及一系列装饰在它上面的DelegatingChatClient对象,被称为IChatClient中间件;
  • ChatClientAgent对象:它具有一个用来持久化对话历史的ChatHistoryProvider,以及一个用来增强输入输出的AIContextProvider
  • Agent管道:一系列装饰在ChatClientAgent对象上的DelegatingAIAgent对象;

2.1 FunctionInvokingChatClient

实现ReAct循环的是一个名为FunctionInvokingChatClientIChatClient中间件,它同时也是引用人机交互(Human-In-The-Loop, HITL)的头号功臣。我们不需要手工注册这个中间件,因为ChatClientAgent在初始化的时候会自动将它注册到IChatClient管道中。FunctionInvokingChatClient定义了如下的可读写属性用来控制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<AITool>? AdditionalTools { get; set; }
    public bool TerminateOnUnknownCalls { get; set; }
}

属性成员说明如下:

  • IncludeDetailedErrors:一个布尔值,表示是否在工具调用过程中包含详细的错误信息。如果为true,当工具调用发生错误时,返回的错误信息将包含更多的细节,有助于调试和诊断问题,默认值为false;
  • AllowConcurrentInvocation:一个布尔值,表示是否允许并发调用工具。如果为true,FunctionInvokingChatClient将允许多个工具调用同时进行,这对于某些需要并行处理的场景可能是有用的,默认值为false;
  • MaximumIterationsPerRequest:一个整数,表示每次请求允许的最大迭代次数。在ReAct循环中,每一次工具调用都会触发下一轮LLM的调用,这个属性用来限制这种循环的最大迭代次数,以防止无限循环或者过多的迭代导致性能问题,默认值为40;
  • MaximumConsecutiveErrorsPerRequest:一个整数,表示每次请求允许的最大连续错误次数。如果在工具调用过程中连续发生错误达到这个次数,FunctionInvokingChatClient将停止继续调用工具,并返回一个错误响应,默认值为3;
  • AdditionalTools:一个AITool对象的列表,用于在工具调用过程中提供一些额外的工具。这些工具可以在LLM生成工具调用意图时提供给LLM,帮助LLM更好地理解可用的工具,或者在工具调用过程中提供一些额外的上下文信息等,默认值为null;
  • TerminateOnUnknownCalls:一个布尔值,表示是否在遇到未知的工具调用时终止ReAct循环。如果为true,当LLM生成的工具调用意图中包含一个未注册的工具时,FunctionInvokingChatClient将停止ReAct循环,并返回一个错误响应;如果为false,FunctionInvokingChatClient将忽略这个未知的工具调用,并继续ReAct循环的执行,默认值为false;

如果我们利用提供的ChatClientAgentOptions来初始化ChatClientAgent对象,注册在ChatOptions上的工具集将会作为FunctionInvokingChatClientAdditionalTools属性。

public sealed class ChatClientAgentOptions
{
    public ChatOptions? ChatOptions { get; set; }
}
public class ChatOptions
{
    public IList<AITool>? Tools { get; set; }
}

2.2 模拟ReAct循环在FunctionInvokingChatClient中的实现

接下来我们利用一个自定义的IChatClient来模拟FunctionInvokingChatClient是如何实现ReAct循环的。如下面的代码片段所示,在重写的GetResponseAsync方法中,我们首先调用InnerClient.GetResponseAsync方法来获取LLM的响应,然后利用一个while循环来模拟ReAct循环。

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循环;
  • 如果包含工具调用的意图,就提取出工具调用的相关信息,并找到对应的工具对象,然后并发调用这些工具来获取工具调用的结果;我们将这些结果封装成角色为ToolChatMessage对象,并将它们添加到当前的消息列表中,作为下一轮LLM调用的输入。为了保证对话历史的完整性,之前响应的消息也被添加到了这个输入消息列表中;

2.3 模拟ChatClientAgent的实现

由于ChatClientAgent在初始化的时候会自动将FunctionInvokingChatClient注册到IChatClient管道中,为了验证FunctionInvokingChatClientSimulator能够起作用,我们通过继承AIAgent定义了如下这个ChatClientAgentSimulator类型来模拟ChatClientAgent。如下面的代码片段所示,我们在初始化ChatClientAgentSimulator时需要指定一个IChatClient对象和承载配置选项的ChatClientAgentOptions对象。

class ChatClientAgentSimulator : AIAgent
{
    private readonly Func<JsonElement, JsonSerializerOptions?, AgentSession> _deserializer;
    private readonly Func<AgentSession, JsonSerializerOptions?, JsonElement> _serializer;
    private readonly Func<AgentSession> _sessionFactory;
    private readonly Func<ResponseContinuationToken?, IEnumerable<ChatMessage>?, IList<ChatResponseUpdate>?, ResponseContinuationToken> _continuationTokenWrapper;
    private readonly IChatClient _chatClient;
    private readonly ChatHistoryProvider  _chatHistoryProvider = new InMemoryChatHistoryProvider();
    private readonly ChatClientAgentOptions _options;

    public ChatClientAgentSimulator(IChatClient chatClient, ChatClientAgentOptions options)
    {
        var constructor = typeof(ChatClientAgentSession).GetConstructor([typeof(string), typeof(AgentSessionStateBag)]);
        _sessionFactory = () => (AgentSession)constructor!.Invoke([Guid.NewGuid().ToString(), null]);

        var serializeMethod = typeof(ChatClientAgentSession).GetMethod("Serialize", BindingFlags.NonPublic | BindingFlags.Instance);
        _serializer = (session, options) => (JsonElement)serializeMethod!.Invoke(session, [options])!;

        var deserializeMethod = typeof(ChatClientAgentSession).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static);
        _deserializer = (json, options) => (AgentSession)deserializeMethod!.Invoke(null, [json, options])!;

        var wrapContinuationTokenMethod = typeof(ChatClientAgent).GetMethod("WrapContinuationToken", BindingFlags.NonPublic | BindingFlags.Static);
        _continuationTokenWrapper = (token, messages, updates) => (ResponseContinuationToken)wrapContinuationTokenMethod!.Invoke(null, [token, messages, updates])!;

        _chatClient = chatClient;
        _options = options;
    }
    protected override ValueTask<AgentSession> CreateSessionCoreAsync(
        CancellationToken cancellationToken = default)
    => ValueTask.FromResult(_sessionFactory());

    protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
        JsonElement serializedState, 
        JsonSerializerOptions? jsonSerializerOptions = null, 
        CancellationToken cancellationToken = default)
    => ValueTask.FromResult( _deserializer(serializedState, jsonSerializerOptions));

    protected override async  Task<AgentResponse> RunCoreAsync(
        IEnumerable<ChatMessage> messages, AgentSession? session = null, 
        AgentRunOptions? options = null, 
        CancellationToken cancellationToken = default)
    {
        var invokingContext = new ChatHistoryProvider.InvokingContext(this,session,messages);
        messages = await _chatHistoryProvider.InvokingAsync(invokingContext, cancellationToken) ?? messages;
        var response = await _chatClient.GetResponseAsync(messages, _options.ChatOptions, cancellationToken);
        var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages, response.Messages);
        await _chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken);
        return new AgentResponse(response)
        {
            AgentId = Id,
            ContinuationToken = _continuationTokenWrapper(response.ContinuationToken, messages, response.ToChatResponseUpdates().ToList())
        };
    }

    protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
        IEnumerable<ChatMessage> messages, 
        AgentSession? session = null, 
        AgentRunOptions? options = null, 
        CancellationToken cancellationToken = default)
    => throw new NotImplementedException();

    protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
        AgentSession session, 
        JsonSerializerOptions? jsonSerializerOptions = null, 
        CancellationToken cancellationToken = default)
    => ValueTask.FromResult(_serializer(session, jsonSerializerOptions));
}

ChatClientAgent对应的Session类型是ChatClientAgentSession,但是基于它的创建、序列化和反序列化操作均定义成了内部方法,所以ChatClientAgentSimulator在重写的CreateSessionCoreAsyncSerializeSessionCoreAsyncDeserializeSessionCoreAsync方法中利用反射来调用这些内部方法。ChatClientAgentSimulator使用一个InMemoryChatHistoryProvider来持久化对话历史。

在重写的RunCoreAsync方法中,我们利用ChatHistoryProvider根据指定的Session提取对话历史,并与传入的消息列表进行合并;我们将这个合并的消息列表和ChatClientAgentOptions提供的ChatOptions(其实还应该与方法传入的ChatOptions进行合并)作为参数调用IChatClientGetResponseAsync方法来获取LLM的响应;最后我们将这个响应封装成一个AgentResponse对象并返回。RunCoreStreamingAsync也应该作类似的处理,这里并没有实现。

2.4 测试

为了验证FunctionInvokingChatClientSimulatorChatClientAgentSimulator能够正确地模拟ReAct循环,我们可以利用如下的代码片段来进行测试。我们定义了一个GetWeather方法来模拟获取天气信息的操作,并利用AIFunctionFactory.Create方法将其转换成一个AIFunction对象。在利用OpenAIClient将对应的IChatClient构建出来的时候,我们将FunctionInvokingChatClientSimulator装饰在这个对象上,然后将它注册到ChatClientAgentSimulator中。最后我们调用ChatClientAgentSimulator来请求获取天气信息的穿衣建议,并输出最终的结果。

using dotenv.net;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;

DotEnv.Load();

var model = Environment.GetEnvironmentVariable("MODEL")!;
var apiKey = Environment.GetEnvironmentVariable("API_KEY")!;
var openAIUrl = Environment.GetEnvironmentVariable("OPENAI_URL")!;

var tool = AIFunctionFactory.Create(method: GetWeather, name: "GetWeather");
var chatClient = new OpenAIClient(
        credential: new ApiKeyCredential(key: apiKey),
        options: new OpenAIClientOptions{Endpoint = new Uri(openAIUrl)})
    .GetChatClient(model: model)
    .AsIChatClient()
    .AsBuilder()
    .Use(it=> new FunctionInvokingChatClientSimulator(it))
    .Build();

var agent = new ChatClientAgentSimulator(chatClient, new ChatClientAgentOptions { ChatOptions = new ChatOptions { Tools = [tool] } });
var result = await agent.RunAsync(message: "根据当前苏州的天气给我一些穿衣建议。");
Console.WriteLine(result);

[Description("获取指定位置的天气信息")]
static string GetWeather([Description("天气查询所在的位置")] string location)
    => $" {location}当前添加晴天,气温25°C。";

输出:

根据当前苏州的天气情况(**晴天,约 25°C**),给你一些穿衣建议:

### ✅ 日常穿搭
- **上衣**:短袖T恤、薄衬衫都很合适,透气面料更舒适
- **下装**:牛仔裤、休闲裤、薄款长裙或半裙
- **鞋子**:运动鞋、帆布鞋、乐福鞋都可以,通勤或出行都很舒服

### 🌞 防晒与舒适度
- 晴天紫外线可能偏强,建议:
  - 带一顶**遮阳帽**或**太阳镜**
  - 涂抹**防晒霜**
- 室内空调较凉的话,可备一件**薄外套/开衫**

### 🌤 早晚提示
- 如果早晚外出,体感可能稍凉,**薄外套**依然有用

如果你是要**上班通勤、约会、运动或出游**,我也可以帮你搭配更具体的穿搭方案。

FunctionInvokingChatClient不仅实现了ReAct,还将F针对人机交互审批流程引入MAF,我的文章FunctionInvokingChatClient:ReAct循环和工具审批实现者提供了针对该中间件的详细介绍,感兴趣的读者可以点击链接查看。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐