[对比学习LangChain和MAF-08]ReAct循环的实现方式
对于Agent的每一次调用,Agent内部都采用ReAct循环调用LLM和工具,直到达到某个终止条件才会停止循环,具体的流程如下:
- Thought (思考):模型分析当前现状,决定下一步该做什么,以及为什么要这么做;
- Action (行动):模型从工具箱中选择一个工具(如:搜索、计算器、API调用)并执行;
- Observation (观察):模型读取工具返回的结果(即外部世界的反馈);
- Repeat (迭代):模型根据新的观察结果,开始下一轮 Thought,直到找到最终答案;
由于Agent处理管道在LangChain和MAF中具有截然不同的涉及,所以针对ReAct循环的实现也有很大的差异,下面我们来看看它们是如何实现的。
1. LangChain
对于通过create_agent创建的Agent来说,对应的状态图model和tools两个核心节点(如下所示),前者用来调用LLM,后者根据LLM返回的结果来调用对应的工具。在这个简单的图上实现ReAct循环不仅简单还很直观,因为我们在两个节点之间定义相依的边就可以了。
对于每一次针对Agent的调用,总是从针对LLM的调用开始,而且最终的答案也总是由LLM生成,所以model节点既是整个状态图的入口节点(Entry Node),还是完成节点(Finish Node)。工具调用返回的结果总是作为LLM下一轮调用的输入,所以从tools到model之间需要一条静态边,确保只要有工具被真正地被调用,就一定会触发下一轮LLM的调用。但是LLM调用之后却有两个选择:
- 如果LLM返回的结果是一个工具调用的意图,那么就会触发工具的调用;
- 如果LLM返回的结果是一个最终答案,那么就会直接完成(Finish)这个状态图。
所以从model到tools之间需要一条条件边,条件就是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被设计成了一个线性的管道,就显得没那个直观了。不过在线性管道的某个环境开启一个循环也没有那么麻烦。
再来看看如上图所示的ChatClientAgent管道,它分成从右到左的三个部分:
- ChatClient管道:包括与LLM交互的
IChatClient对象,以及一系列装饰在它上面的DelegatingChatClient对象,被称为IChatClient中间件; - ChatClientAgent对象:它具有一个用来持久化对话历史的
ChatHistoryProvider,以及一个用来增强输入输出的AIContextProvider; - Agent管道:一系列装饰在
ChatClientAgent对象上的DelegatingAIAgent对象;
2.1 FunctionInvokingChatClient
实现ReAct循环的是一个名为FunctionInvokingChatClient的IChatClient中间件,它同时也是引用人机交互(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上的工具集将会作为FunctionInvokingChatClient的AdditionalTools属性。
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循环;
- 如果包含工具调用的意图,就提取出工具调用的相关信息,并找到对应的工具对象,然后并发调用这些工具来获取工具调用的结果;我们将这些结果封装成角色为
Tool的ChatMessage对象,并将它们添加到当前的消息列表中,作为下一轮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在重写的CreateSessionCoreAsync、SerializeSessionCoreAsync和DeserializeSessionCoreAsync方法中利用反射来调用这些内部方法。ChatClientAgentSimulator使用一个InMemoryChatHistoryProvider来持久化对话历史。
在重写的RunCoreAsync方法中,我们利用ChatHistoryProvider根据指定的Session提取对话历史,并与传入的消息列表进行合并;我们将这个合并的消息列表和ChatClientAgentOptions提供的ChatOptions(其实还应该与方法传入的ChatOptions进行合并)作为参数调用IChatClient的GetResponseAsync方法来获取LLM的响应;最后我们将这个响应封装成一个AgentResponse对象并返回。RunCoreStreamingAsync也应该作类似的处理,这里并没有实现。
2.4 测试
为了验证FunctionInvokingChatClientSimulator和ChatClientAgentSimulator能够正确地模拟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循环和工具审批实现者提供了针对该中间件的详细介绍,感兴趣的读者可以点击链接查看。
更多推荐

所有评论(0)