ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Go语言实现50行核心代码的轻量级ReAct循环引擎

2026/8/11 9:07:10 拓冰建站 浏览量
Go语言实现50行核心代码的轻量级ReAct循环引擎 1. 从概念到代码为什么我们需要一个轻量级的 ReAct 循环如果你最近在关注大语言模型LLM的应用开发尤其是智能体Agent方向那么“ReAct”这个词你一定不陌生。它不是什么新的前端框架而是驱动智能体进行“思考”和“行动”的核心范式。简单来说ReAct 就是让 LLM 像人一样在面对复杂问题时能够推理Reason出下一步该做什么然后行动Act去执行比如调用一个工具或搜索最后根据行动的观察Observe结果进行下一轮的思考。这个“推理-行动-观察”的循环就是 ReAct 循环。听起来很酷对吧但当你真正想把它用起来时往往会发现一些“甜蜜的负担”。现有的框架比如 LangChain 或 LlamaIndex功能强大但体系庞大对于想快速验证一个想法、或者构建一个轻量级、高性能后端服务的开发者来说学习成本和集成复杂度都不低。这就好比你想在自家后院搭个小木屋结果别人给了你一套摩天大楼的施工蓝图和重型机械。这正是我动手用 Go 实现一个仅 50 行核心代码的 ReAct 循环的原因。Go 语言以其简洁的语法、卓越的并发性能和高效的编译部署非常适合构建需要快速响应、稳定运行的 Agent 服务端。这个实现的目标不是替代那些全功能框架而是提供一个极度透明、可掌控、易于集成的“发动机”核心。通过逐行拆解这 50 行代码你不仅能彻底理解 ReAct 的工作机制更能获得一个可以随手嵌入任何 Go 项目的、功能完整的智能体内核。无论你是想学习 Agent 原理还是急需一个高性能的推理执行引擎这篇文章都将为你提供一条清晰的路径。2. 核心设计拆解一个最小可运行的 ReAct 引擎在开始写代码之前我们必须想清楚这个轻量级引擎需要哪些最核心的部件。一个完整的 ReAct 循环其本质是一个与 LLM 交互的状态机。我们不需要一开始就支持所有类型的工具和复杂的记忆模块但以下几个部分是不可或缺的LLM 客户端负责发送提示词Prompt并接收模型的回复。这是智能体的“大脑”。工具系统定义智能体可以执行哪些操作。每个工具都有名称、描述和具体的执行函数。提示词模板告诉 LLM 它现在扮演什么角色它可以做什么以及它应该如何格式化它的“思考”和“行动”。这是引导模型行为的关键。解析器LLM 的回复是自由文本我们需要从中精准地提取出“推理内容”、“要调用的工具名称”和“工具输入参数”。这通常通过要求模型遵循特定格式如 JSON、特定标记来实现。循环控制器负责管理“推理-行动-观察”这个循环的流程判断何时应该继续何时应该结束比如当模型给出最终答案时。我们的设计原则是高内聚低耦合。每个部分职责清晰通过定义良好的接口进行交互。这样未来如果你想更换 LLM 提供商比如从 OpenAI 换到 Anthropic或者增加新的工具类型只需要修改对应的模块而不会牵一发而动全身。2.1 定义核心数据结构工具与循环状态首先我们来定义两个最基础的结构体。它们构成了整个引擎的骨架。// Tool 定义了智能体可以调用的一个工具 type Tool struct { Name string // 工具名称如 search_web, calculate Description string // 工具描述用于告诉LLM这个工具是干什么的 Execute func(input string) (string, error) // 工具的执行函数 } // ReActState 代表了ReAct循环在某一时刻的状态 type ReActState struct { Question string // 用户提出的原始问题 History []string // 历史记录格式为 [Thought: ..., Action: ..., Observation: ...] FinalAnswer string // 最终答案如果循环结束这里会有值 MaxIterations int // 最大循环次数防止无限循环 }为什么这么设计Tool结构体将工具的元信息名称、描述和执行逻辑绑定在一起。Execute函数使用string作为输入和输出简化了接口。在实际复杂场景中输入可能是结构化数据但字符串格式具有最好的通用性我们可以通过 JSON 序列化/反序列化来处理复杂参数。ReActState是循环的“记忆体”。History字段至关重要它记录了每一轮的 Thought、Action 和 Observation并在下一轮被拼接到提示词中让 LLM 拥有“上下文记忆”。MaxIterations是一个安全阀任何与 LLM 交互的系统都必须有防止死循环的机制。2.2 构建提示词模板引导模型的“思维链”提示词是控制 LLM 行为的方向盘。一个有效的 ReAct 提示词需要明确以下几点角色你是一个擅长分步解决问题的助手。能力你可以使用一系列工具。这里需要动态插入可用工具的名称和描述。格式你必须严格按照“Thought:”、“Action:”、“Action Input:”的格式来回复。目标当你足够确信时用“Final Answer:”给出最终回复。我们将提示词模板化以便动态插入工具列表和历史记录。func buildPrompt(question string, tools []Tool, history []string) string { toolsDesc : for _, tool : range tools { toolsDesc fmt.Sprintf(- %s: %s\n, tool.Name, tool.Description) } historyText : if len(history) 0 { historyText \n strings.Join(history, \n) } prompt : fmt.Sprintf(你是一个智能助手需要通过思考和行动来回答问题。 你可以使用以下工具 %s 请遵循以下格式 Thought: 你需要思考当前情况决定下一步是使用工具还是直接回答。 Action: 需要调用的工具名称必须是以下之一[%s]。如果无需工具则此项为空。 Action Input: 工具的输入参数。如果无需工具则此项为空。 Observation: 工具返回的结果。这部分将由系统填充。 开始问题%s%s Thought:, toolsDesc, getToolNames(tools), question, historyText) return prompt } // 辅助函数获取所有工具名称用于提示词中的列表 func getToolNames(tools []Tool) string { names : make([]string, len(tools)) for i, t : range tools { names[i] t.Name } return strings.Join(names, , ) }关键点解析提示词清晰地规定了输出格式这是我们后续能够自动解析模型回复的前提。许多 ReAct 实现失败的原因就是提示词对格式的约束不够强导致解析失败。我们将history直接拼接在问题后面这是一种简单而有效的短期记忆实现方式。对于超长对话你可能需要引入摘要或向量存储但对于大多数有限步骤的任务这足够了。工具描述toolsDesc的生成是动态的这意味着你可以在运行时灵活地给智能体配置不同的工具集。注意提示词工程是 Agent 稳定性的关键。在实际应用中你可能需要根据使用的具体模型如 GPT-4、Claude、GLM微调提示词的措辞和格式要求以达到最佳效果。例如某些模型对 JSON 格式的响应解析更友好。3. 实现核心循环逐行解读那50行精华代码现在让我们进入最核心的部分——驱动整个循环的Run函数。我将它分成几个逻辑块并逐行解释。// Run 启动并执行ReAct循环直到获得最终答案或达到最大迭代次数 func Run(question string, tools []Tool, maxIterations int, llmClient func(string) (string, error)) (string, error) { // 初始化状态 state : ReActState{ Question: question, History: []string{}, MaxIterations: maxIterations, } // 主循环 for i : 0; i maxIterations; i { // 1. 构建当前轮次的提示词 prompt : buildPrompt(state.Question, tools, state.History) // 2. 调用LLM获取回复 llmResponse, err : llmClient(prompt) if err ! nil { return , fmt.Errorf(LLM调用失败: %w, err) } // 3. 解析LLM的回复 thought, action, actionInput, finalAnswer : parseResponse(llmResponse) // 4. 判断是否已获得最终答案 if finalAnswer ! { state.FinalAnswer finalAnswer return finalAnswer, nil // 成功退出循环 } // 5. 记录“思考”过程 state.History append(state.History, Thought: thought) // 6. 验证并执行工具调用 if action { // 模型没有指定行动这可能是一个错误或它试图直接回答但格式不对 state.History append(state.History, Observation: 未指定行动请根据Thought重新决定Action。) continue } // 查找工具 var targetTool *Tool for idx : range tools { if tools[idx].Name action { targetTool tools[idx] break } } if targetTool nil { // 工具不存在将错误信息作为Observation反馈给模型 obs : fmt.Sprintf(工具 %s 不存在。可用工具%s, action, getToolNames(tools)) state.History append(state.History, Action: action, Action Input: actionInput, Observation: obs) continue } // 执行工具 observation, err : targetTool.Execute(actionInput) if err ! nil { observation fmt.Sprintf(执行工具 %s 时出错: %v, action, err) } // 7. 记录“行动”和“观察”结果进入下一轮 state.History append(state.History, Action: action, Action Input: actionInput, Observation: observation) } // 循环结束仍未得到答案 return , fmt.Errorf(达到最大迭代次数(%d)仍未获得最终答案, maxIterations) }逐行拆解与设计逻辑初始化与循环控制 (for i : 0; i maxIterations; i): 这是最基本的安全保障。LLM 的推理可能陷入死胡同我们必须设置一个明确的退出条件。构建提示词 (buildPrompt): 每一轮我们都基于最新的History重新构建完整的提示词。这确保了模型始终拥有完整的上下文。调用 LLM (llmClient): 这里通过一个函数参数llmClient抽象了 LLM 调用。这是一个非常巧妙的设计它将 ReAct 引擎的核心逻辑与具体的 LLM API 调用解耦。你可以传入一个调用 OpenAI GPT 的函数也可以传入调用本地 Llama 模型的函数引擎本身不关心这些细节。解析回复 (parseResponse): 这是连接 LLM “自由思考”和程序“结构化处理”的桥梁。一个健壮的解析器至关重要。判断终止 (if finalAnswer ! ): 如果解析器成功提取到了Final Answer:后面的内容说明模型认为问题已解决我们立即返回结果循环结束。工具查找与执行: 这是“行动”阶段。首先检查action是否为空处理模型可能出现的格式错误。通过遍历tools切片查找匹配的工具。这里使用指针*Tool是为了避免在 append History 时复制整个 Execute 函数虽然函数在 Go 中也是引用类型但保持习惯。如果工具未找到我们将错误信息格式化为Observation反馈给模型。这是一个关键技巧不要因为模型犯了一个错误调用了不存在的工具就终止循环而是让它从错误中学习这符合 ReAct 的“观察”精神。执行工具并妥善处理可能出现的错误同样将错误信息作为 Observation。更新历史并继续: 将本轮完整的 Thought, Action, Observation 三元组记录到History中循环进入下一轮。这个Run函数完整地演绎了 ReAct 的核心理念并且代码清晰没有一丝冗余。3.1 关键辅助函数解析器的实现上面提到的parseResponse函数是另一个核心。它的任务是从模型可能不那么规整的回复中准确地提取出四个部分。这里我们采用基于正则表达式的简单而实用的方法。import regexp func parseResponse(response string) (thought, action, actionInput, finalAnswer string) { // 尝试匹配最终答案 finalAnswerRegex : regexp.MustCompile(Final Answer:\s*(.)) if match : finalAnswerRegex.FindStringSubmatch(response); match ! nil { finalAnswer strings.TrimSpace(match[1]) // 如果找到最终答案可以提前返回其他字段不重要了 return } // 匹配 Thought thoughtRegex : regexp.MustCompile(Thought:\s*(.)) if match : thoughtRegex.FindStringSubmatch(response); match ! nil { thought strings.TrimSpace(match[1]) } // 匹配 Action (可能跨行直到遇到 Action Input 或结尾) // 先找到 Action: 的行然后捕获直到下一个标签或结尾的内容 actionRegex : regexp.MustCompile(Action:\s*(.)) // 这是一个简化版本实际中可能需要更复杂的多行匹配 lines : strings.Split(response, \n) capturingAction : false actionLines : []string{} for _, line : range lines { if strings.HasPrefix(strings.TrimSpace(line), Action:) { capturingAction true parts : strings.SplitN(line, :, 2) if len(parts) 1 { actionLines append(actionLines, strings.TrimSpace(parts[1])) } continue } if capturingAction { if strings.HasPrefix(strings.TrimSpace(line), Action Input:) || strings.HasPrefix(strings.TrimSpace(line), Observation:) || strings.HasPrefix(strings.TrimSpace(line), Thought:) { capturingAction false break } actionLines append(actionLines, strings.TrimSpace(line)) } } action strings.Join(actionLines, ) // 匹配 Action Input (同样可能需要处理多行) capturingInput : false inputLines : []string{} for _, line : range lines { if strings.HasPrefix(strings.TrimSpace(line), Action Input:) { capturingInput true parts : strings.SplitN(line, :, 2) if len(parts) 1 { inputLines append(inputLines, strings.TrimSpace(parts[1])) } continue } if capturingInput { if strings.HasPrefix(strings.TrimSpace(line), Observation:) || strings.HasPrefix(strings.TrimSpace(line), Thought:) || strings.HasPrefix(strings.TrimSpace(line), Action:) { capturingInput false break } inputLines append(inputLines, strings.TrimSpace(line)) } } actionInput strings.Join(inputLines, ) return thought, action, actionInput, finalAnswer }解析器的挑战与策略模型输出的不确定性LLM 可能会在关键词后添加多余的空格、换行甚至偶尔拼写错误。我们的解析器需要有一定的容错性。多行内容Thought或Action Input的内容可能很长包含多行文本。上面的实现通过一个简单的状态机capturingAction,capturingInput来捕获直到下一个关键标签为止的所有行。优先级我们首先检查Final Answer:因为一旦出现它循环就应该终止其他内容无需再解析。更健壮的方案对于生产环境更推荐的做法是在提示词中强制要求模型输出 JSON 格式例如{thought: ..., action: ..., action_input: ...}。这样可以直接使用json.Unmarshal进行解析鲁棒性远高于文本匹配。本示例为了清晰展示原理采用了文本匹配的方式。4. 实战演练组装一个能查询天气的智能体理论说得再多不如跑个例子。让我们用上面的引擎创建一个能回答“北京和上海天气对比”的智能体。我们需要一个模拟的“天气查询工具”。package main import ( fmt strings ) // 模拟天气查询工具 func mockWeatherTool(city string) (string, error) { weatherDB : map[string]string{ 北京: 晴天气温 22-30°C, 上海: 多云气温 25-32°C, 广州: 雷阵雨气温 26-34°C, } if weather, ok : weatherDB[city]; ok { return fmt.Sprintf(%s的天气是%s, city, weather), nil } return , fmt.Errorf(未找到城市 %s 的天气信息, city) } // 模拟LLM调用实际中替换为真实的API调用 func mockLLMClient(prompt string) (string, error) { // 这是一个极度简化的模拟仅用于演示。 // 实际应用中这里会是 http.Post 请求调用 OpenAI, Claude 等API。 fmt.Println( 发送给LLM的提示词 ) fmt.Println(prompt) fmt.Println( LLM 模拟回复 ) // 根据提示词内容模拟一个合理的多轮回复 if strings.Contains(prompt, 北京和上海) { // 第一轮模型决定查询天气 return Thought: 用户想比较北京和上海的天气。我需要先获取这两个城市的当前天气信息。 Action: get_weather Action Input: 北京, nil } else if strings.Contains(prompt, Observation: 北京的天气是晴天) { // 第二轮根据第一次观察决定查询上海 return Thought: 我已经知道了北京的天气是晴天。现在我需要获取上海的天气信息来完成比较。 Action: get_weather Action Input: 上海, nil } else if strings.Contains(prompt, Observation: 上海的天气是多云) { // 第三轮获得足够信息给出最终答案 return Thought: 我已经掌握了北京和上海的天气信息。北京是晴天22-30°C上海是多云25-32°C。现在可以进行比较并给出最终答案。 Final Answer: 北京当前为晴天气温22-30°C上海为多云气温25-32°C。上海比北京气温稍高且天气为多云。, nil } return Thought: 我无法理解这个问题。Final Answer: 我暂时无法回答这个问题。, nil } func main() { // 1. 定义工具集 tools : []Tool{ { Name: get_weather, Description: 根据城市名称查询该城市的当前天气情况。, Execute: func(input string) (string, error) { return mockWeatherTool(input) }, }, } // 2. 定义问题 question : 对比一下北京和上海的天气。 // 3. 运行ReAct引擎 answer, err : Run(question, tools, 5, mockLLMClient) // 最多循环5次 if err ! nil { fmt.Printf(运行失败: %v\n, err) return } // 4. 输出结果 fmt.Printf(\n 智能体最终答案 \n%s\n, answer) }运行过程推演第一轮引擎将问题和空历史传给mockLLMClient。模拟 LLM 回复思考后决定调用get_weather工具查询“北京”。引擎找到工具并执行得到观察结果“北京的天气是晴天气温 22-30°C”并记录到历史。第二轮引擎构建新的提示词包含了第一轮的历史。LLM 看到历史后回复思考后决定调用get_weather查询“上海”。引擎执行得到观察结果“上海的天气是多云气温 25-32°C”。第三轮引擎再次构建提示词包含前两轮完整历史。LLM 看到所有信息后回复思考并给出最终答案。引擎解析到Final Answer循环终止返回答案。通过这个例子你可以清晰地看到 ReAct 循环是如何一步步推进并利用历史信息进行连贯推理的。将mockLLMClient替换为真实的 API 调用函数这个程序就能处理真实的复杂任务。5. 避坑指南与性能优化实战当你把这个基础引擎用于实际项目时肯定会遇到各种问题。下面是我在实践过程中总结的几个关键点和优化思路。5.1 常见问题与排查技巧模型不遵循输出格式现象解析器频繁失败无法提取Thought/Action。排查首先打印出模型返回的原始响应检查是否与提示词要求的格式完全一致。常见的偏差包括使用中文冒号、关键词拼写错误、在Thought:前添加了无关字符等。解决强化提示词在提示词中使用更强烈的措辞如“你必须严格、精确地使用以下格式”、“你的回复只能包含以下部分”。使用 JSON 模式如前所述要求模型输出 JSON。大多数现代 LLM API 都支持response_format参数来约束输出为 JSON 对象这是最可靠的方案。后处理清洗在解析前对回复文本进行简单的清洗如去除首尾空行、将全角冒号替换为半角等。循环陷入僵局或重复动作现象智能体在几个相同的Thought-Action之间来回循环无法推进。排查检查History。通常是因为工具的Observation结果没有提供新的、有价值的信息或者模型基于当前信息无法做出新的推理。解决优化工具设计确保工具返回的信息是具体、明确且对下一步推理有用的。避免返回过于模糊或错误的信息。在提示词中加入反思指令例如在提示词末尾加上“请避免重复之前的操作。如果之前的行动无效请尝试新的思路。”实现循环检测在Run函数中可以维护一个最近 N 轮(Action, Action Input)的队列。如果检测到完全相同的操作组合再次出现可以主动向History插入一个强制的Observation如“检测到重复操作请尝试不同的方法。”工具执行错误导致循环中断现象工具抛出一个错误智能体不知道如何处理后续循环变得混乱。解决我们的设计已经处理了这一点。将工具执行的错误信息err.Error()作为Observation返回给模型让模型去“消化”这个错误并调整策略这比直接让引擎崩溃要健壮得多。5.2 进阶优化与扩展思路这个 50 行的实现是一个完美的起点你可以根据需求对它进行扩展并发工具执行如果智能体的Thought表明可以并行调用多个独立工具你可以修改引擎在解析出多个Action后使用 Go 的 goroutine 并发执行它们最后汇总Observation。这能显著提升处理效率。状态持久化将ReActState序列化如 JSON后存储到数据库或缓存中。这允许你将一个长耗时任务暂停、恢复或者实现异步的 Agent 任务队列。更复杂的工具输入当前Action Input是字符串。你可以修改Tool.Execute的签名使其接收map[string]interface{}并在解析器中将模型返回的Action Input解析为 JSON 对象。这样工具可以接收更结构化的参数。集成流式输出当前的llmClient是阻塞的。你可以将其改造为支持 Server-Sent Events (SSE) 的流式接口让Thought过程能够实时展示给用户体验更佳。引入验证器在工具执行前对Action Input进行验证例如查询天气的城市名是否有效。这可以减少无效的工具调用和 LLM 的困惑。// 扩展支持结构化参数的工具示例 type StructuredTool struct { Name string Description string Execute func(params map[string]interface{}) (string, error) Schema map[string]interface{} // JSON Schema用于描述参数结构也可用于提示词生成 } // 在解析器中需要将 Action Input 的字符串解析为 JSON // 提示词也需要相应调整指导模型输出 JSON 格式的 Action Input。这个轻量级 Go 实现的 ReAct 循环就像一副清晰的骨架。它揭示了智能体最核心的运转机制去除了所有不必要的装饰。你可以基于这副骨架填充上肌肉更强大的工具、神经更精准的提示词和皮肤更友好的交互构建出适合自己业务场景的、独一无二的智能体应用。