
1. 从概念到代码为什么我们需要一个“思考-行动”循环如果你最近关注过AI应用开发尤其是智能体Agent领域那么“ReAct”这个词你一定不陌生。它不是什么新的前端框架而是让语言模型LLM变得更“聪明”的一种核心范式。简单来说ReAct就是让模型学会“思考-行动”循环先推理Reasoning再行动Action然后根据行动的观察Observation结果进入下一轮循环。这听起来像是人类解决问题的基本方式对吧没错ReAct正是试图让AI模仿这种“三思而后行”的能力。想象一下你让一个普通的聊天机器人去查一下“今天北京的天气然后告诉我是否需要带伞”。它可能会直接调用一个天气API然后根据返回的“晴”或“雨”来回答。但如果API暂时不可用或者返回了奇怪的错误码它可能就卡住了只会回复“我无法获取天气信息”。而一个具备ReAct能力的智能体它的“大脑”会这样工作首先它会推理“用户需要天气信息来判断是否带伞这需要调用天气查询工具。”接着它会行动生成一个结构化的命令比如call_weather_api(location北京)。执行后它会得到一个观察结果比如{status: error, code: 500}。这时它不会放弃而是进入下一轮循环推理“API返回了服务器错误可能是临时故障。我可以尝试重试一次或者换一个备用数据源。”然后再次行动call_weather_api_backup(location北京)。通过这种循环智能体具备了初步的“问题解决”和“纠错”能力而不仅仅是机械地执行单一步骤。在开源社区关于ReAct的讨论和实现大多集中在Python生态这得益于其丰富的AI库和快速原型能力。但作为一名Go开发者我常常在想这种核心的工作流逻辑是否能用Go简洁、高效地表达出来Go的并发模型、清晰的错误处理以及编译为单一二进制文件的特性对于构建需要稳定运行、易于部署的智能体后端服务有着天然优势。于是我决定动手用大约50行Go代码实现一个最精简、最核心的ReAct循环引擎。这个实现不依赖任何特定的LLM SDK或复杂的框架它只关注循环本身的控制逻辑你可以轻松地将任何兼容的LLM和工具集接入进来。接下来我们就逐行拆解这段代码看看如何用Go的哲学来构建AI的“思考”引擎。2. 核心数据结构设计定义“思考”、“行动”与“状态”任何复杂流程的实现第一步永远是设计好数据结构。对于ReAct循环我们需要清晰地定义几个核心概念一次完整的“思考-行动-观察”步骤、智能体可用的工具集、以及整个循环的运行状态。用Go的结构体struct来建模这些概念再合适不过它能强制我们思考数据的边界和生命周期。2.1 步骤Step循环的基本单元首先我们定义Step结构体它代表一轮循环的产出。这里有一个关键设计决策我们将LLM的“推理”过程视为一个黑盒它最终输出的是一个结构化的“动作”Action。因此一个Step主要包含这个动作以及执行动作后得到的观察结果。// Step 代表ReAct循环中的单一步骤 type Step struct { // Thought 是模型在决定行动前的推理过程日志或内部状态可选 Thought string json:thought,omitempty // Action 是模型决定执行的动作例如调用某个工具 Action string json:action // ActionInput 是执行动作所需的输入参数通常为JSON字符串或map ActionInput interface{} json:action_input // Observation 是执行动作后观察到的结果 Observation string json:observation }为什么这样设计Thought字段被标记为omitempty。在最终交付的系统中模型的“内心独白”不一定需要暴露给用户但它对于调试和理解智能体的决策过程至关重要。所以这里设计为可选的。Action和ActionInput是核心。我们约定Action是一个字符串标识要调用哪个工具如search_web,calculate。ActionInput设计为interface{}类型这提供了极大的灵活性。它可以是简单的字符串也可以是嵌套的map[string]interface{}方便传递JSON格式的复杂参数。Observation是字符串。无论工具执行返回的是复杂对象还是简单消息最终都可以被序列化为字符串描述供下一轮“思考”使用。这简化了数据在循环中的流转。2.2 工具Tool智能体的“手脚”智能体不能只靠“想”它必须能“做”。工具就是它的手脚。我们定义一个Tool接口任何符合此接口的函数或对象都可以成为智能体的工具。// Tool 定义了智能体可以使用的工具接口 type Tool interface { // Name 返回工具的唯一定义名称用于在Action中匹配 Name() string // Description 返回工具的详细描述用于帮助LLM理解何时使用此工具 Description() string // Execute 执行工具的核心逻辑 Execute(input interface{}) (string, error) }接口设计的考量Name()和Description()方法至关重要。在ReAct的提示词Prompt中我们需要将可用工具的列表和描述提供给LLMLLM根据这些描述来决定在特定情境下该调用哪个工具。因此清晰、准确的描述是工具能被正确使用的关键。Execute方法接收一个interface{}类型的输入与Step.ActionInput对应。它返回一个结果字符串和一个错误。错误处理是Go的强项在这里我们可以将工具执行失败也作为一种“观察”Observation反馈给LLM例如返回“工具XXX执行失败原因网络超时”让LLM有机会尝试其他方案而不是让整个循环崩溃。2.3 智能体Agent与状态State驱动循环的引擎有了步骤和工具我们需要一个驱动器来管理整个循环过程。我们定义Agent结构体来持有配置和工具集并定义一个State结构体来保存循环的运行时状态。// Agent 代表一个ReAct智能体 type Agent struct { // MaxIterations 限制最大循环次数防止无限循环 MaxIterations int // Tools 是智能体可用的工具映射表key为工具名称 Tools map[string]Tool } // State 代表ReAct循环的运行时状态 type State struct { // Input 是用户的原始问题或请求 Input string // Steps 是历史步骤记录 Steps []Step // Iteration 当前迭代次数 Iteration int // FinalAnswer 循环结束时的最终答案 FinalAnswer string }设计逻辑解析Agent是配置中心。MaxIterations是一个安全阀任何基于LLM的循环都必须有终止条件这是生产环境中的基本要求。Tools使用map[string]Tool存储以便通过Action名称快速查找对应的工具。State是循环的“记忆”。它记录了从开始到当前的所有信息Input是锚点确保循环不偏离原始目标。Steps切片记录了完整的“思考-行动-观察”历史。这不仅用于构造给LLM的上下文提示词让LLM知道已经做了什么也便于事后审计和调试。Iteration用于和MaxIterations比较控制循环退出。FinalAnswer是循环的产出。当LLM认为已经得到足够信息可以回答用户而不需要再调用工具时它会生成一个最终答案循环终止。这个数据结构设计体现了Go的简洁和实用主义。没有过度抽象每个字段都有其明确的职责共同支撑起ReAct循环的骨架。接下来我们就要让这个骨架动起来。3. 循环引擎实现控制流与关键逻辑拆解数据结构搭建好后核心便是实现驱动循环的引擎。这个引擎的职责是在安全边界内最大迭代次数持续地让LLM进行“思考”执行对应的“行动”并处理结果直到LLM给出最终答案或达到边界。我们将这个核心方法命名为Run它属于Agent结构体。3.1 Run方法主循环框架// Run 启动ReAct循环处理用户输入 func (a *Agent) Run(input string) (string, error) { // 初始化状态 state : State{ Input: input, Steps: []Step{}, Iteration: 0, FinalAnswer: , } // 主循环 for state.Iteration a.MaxIterations { state.Iteration // 步骤1: 调用LLM进行“思考”决定下一步行动或给出最终答案 step, done, err : a.think(state) if err ! nil { return , fmt.Errorf(iteration %d think failed: %w, state.Iteration, err) } // 如果LLM认为可以给出最终答案则结束循环 if done { state.FinalAnswer step.Observation // 此时Observation存放的是最终答案 break } // 步骤2: 执行LLM决定的行动 observation, err : a.act(step) if err ! nil { // 执行失败将错误信息作为观察结果让LLM在下一轮处理 step.Observation fmt.Sprintf(Action execution failed: %v, err) } else { step.Observation observation } // 记录步骤历史 state.Steps append(state.Steps, *step) } // 循环结束后处理 if state.FinalAnswer ! { return state.FinalAnswer, nil } // 如果因为达到最大迭代次数而退出则返回当前收集到的所有信息 return a.formatResult(state), nil }逐行逻辑与设计选择状态初始化每个新的用户请求都创建一个独立的State实例保证请求间隔离。循环条件for state.Iteration a.MaxIterations是核心安全机制。务必设置一个合理的默认值比如10并在Agent初始化时传入。没有这个限制一个陷入混乱的LLM可能导致无限循环。think方法这是与LLM交互的核心。它接收当前state组织提示词调用LLM并解析LLM的响应。它返回三个值下一步的Step结构体、一个布尔值done表示LLM是否要给出最终答案、以及一个错误。这里的关键是提示词工程和响应解析我们将在下一节详细展开。最终答案判断如果done为真我们将step.Observation此时LLM返回的应该是答案文本赋给state.FinalAnswer并跳出循环。这是ReAct循环的正常终止条件。act方法如果LLM决定要行动我们就调用act方法。它根据step.Action从a.Toolsmap中查找对应工具并将step.ActionInput传递给工具的Execute方法。错误处理策略注意act执行失败时的处理——我们没有直接返回错误导致整个任务失败而是将错误信息格式化后作为本次行动的Observation记录到step中。这是一个非常重要的设计这相当于告诉LLM“你让我做的动作失败了原因是XXX请思考下一步该怎么办。”这赋予了智能体从工具错误中恢复的能力。历史记录无论成功与否这一步的step都会被追加到state.Steps中。这些历史构成了后续“思考”的上下文。循环后处理优先返回最终答案。如果是因为达到最大迭代次数而退出即没有明确FinalAnswer我们则调用一个formatResult函数将整个state包括所有步骤历史格式化为一个字符串返回给用户。这至少让用户知道智能体做了哪些尝试而不是无声无息地失败。3.2 think方法与LLM的对话艺术think方法是智能的源泉也是实现中最需要根据具体LLM调整的部分。它的核心任务是构造提示词Prompt调用LLM API并解析其响应。func (a *Agent) think(state *State) (*Step, bool, error) { // 1. 构造提示词 prompt : a.buildPrompt(state) // 2. 调用LLM API此处为伪代码需替换为实际调用 llmResponse, err : callLLMAPI(prompt) if err ! nil { return nil, false, err } // 3. 解析LLM响应 return a.parseResponse(llmResponse) }关键点buildPrompt函数这是ReAct能否正确工作的重中之重。一个基本的ReAct提示词通常包含以下几个部分指令明确告诉LLM需要遵循ReAct格式先思考再行动并且可以使用特定工具。工具描述列出所有a.Tools中工具的名称和描述格式要清晰。例如“你有以下工具可用\n1. 搜索网络[search_web]用于获取最新信息。输入应为搜索关键词。\n2. 计算器[calculate]用于执行数学计算。输入应为数学表达式。”历史步骤将state.Steps中之前的步骤按照“Thought: ...\nAction: ...\nObservation: ...”的格式拼接起来作为上下文。这教会了LLM“现在进行到哪一步了”。当前输入/问题再次明确state.Input。输出格式约束严格规定LLM必须输出的格式。这是解析能否成功的关键。例如你可以要求请按以下格式回应 Thought: 首先我需要思考... Action: search_web Action Input: {query: Go语言最新版本}或者当可以直接回答时Thought: 根据已有信息我可以直接回答。 Final Answer: Go语言的最新稳定版本是1.22。在提示词中强制要求Action字段的值必须是工具名之一search_web,calculateAction Input必须是JSON格式可以极大降低解析复杂度。关键点parseResponse函数解析LLM的响应字符串将其转换为Step结构体并判断是否是最终答案。通常使用正则表达式或简单的字符串分割来提取Thought、Action、Action Input和Final Answer等部分。如果解析到Final Answer则返回donetrue并将答案内容放入step.Observation。如果解析到Action则需验证Action是否在a.Tools中存在并对Action Input进行必要的格式转换如将JSON字符串反序列化为map[string]interface{}。健壮性解析逻辑必须考虑LLM输出格式可能的不规范多空格、换行、额外说明等做好错误处理和兜底。3.3 act方法工具的执行与调度act方法的实现相对直接主要就是工具查找和调用。func (a *Agent) act(step *Step) (string, error) { tool, exists : a.Tools[step.Action] if !exists { return , fmt.Errorf(unknown tool: %s, step.Action) } return tool.Execute(step.ActionInput) }这里的一个实操技巧是工具输入的预处理。step.ActionInput从LLM来可能是字符串也可能是已经解析好的map。在工具的Execute方法内部最好先做一次类型检查和转换。例如func (t *CalculatorTool) Execute(input interface{}) (string, error) { var expr string switch v : input.(type) { case string: expr v case map[string]interface{}: // 假设我们的计算器期望一个 expression 字段 if e, ok : v[expression].(string); ok { expr e } else { return , errors.New(invalid input format for calculator) } default: return , errors.New(unsupported input type) } // ... 后续计算逻辑 }这种设计使得工具接口既灵活又健壮能够适应LLM可能产生的不同输入格式。4. 实战演示构建一个能查天气和计算的Go智能体理论说得再多不如跑个例子。让我们用上面实现的框架创建一个具备两个简单工具模拟天气查询和计算器的智能体并观察它如何处理一个多步骤问题。4.1 实现两个示例工具首先我们实现工具。注意这里的天气查询是模拟的真实场景中你会调用真实的API。// WeatherTool 模拟天气查询工具 type WeatherTool struct{} func (w *WeatherTool) Name() string { return get_weather } func (w *WeatherTool) Description() string { return 获取指定城市的当前天气情况。输入应为城市名称例如北京。 } func (w *WeatherTool) Execute(input interface{}) (string, error) { city, ok : input.(string) if !ok { return , fmt.Errorf(weather tool expects a string city name) } // 模拟API调用和数据处理 // 真实情况这里会是 http.Get(...) 和 json.Unmarshal(...) weatherMap : map[string]string{ 北京: 晴25摄氏度北风2级, 上海: 多云28摄氏度东南风3级, 广州: 雷阵雨30摄氏度南风4级, } if report, exists : weatherMap[city]; exists { return fmt.Sprintf(%s的天气是%s, city, report), nil } return , fmt.Errorf(未找到城市%s的天气信息, city) } // CalculatorTool 简单计算器工具 type CalculatorTool struct{} func (c *CalculatorTool) Name() string { return calculate } func (c *CalculatorTool) Description() string { return 执行基础数学运算。输入应为数学表达式字符串例如(15 7) * 2。支持加减乘除和括号。 } func (c *CalculatorTool) Execute(input interface{}) (string, error) { expr, ok : input.(string) if !ok { return , fmt.Errorf(calculator tool expects a string expression) } // 警告此处仅为演示直接使用Go的eval是极其危险的容易导致代码注入 // 生产环境必须使用安全的表达式解析库如 govaluate。 // 这里我们做一个极其简单的、不安全的演示仅支持数字和-*/ result, err : evalSimpleExpression(expr) // 假设这是一个安全的自定义函数 if err ! nil { return , fmt.Errorf(计算表达式%s失败: %w, expr, err) } return fmt.Sprintf(计算结果为: %v, result), nil }注意计算器工具的实现中绝对不要使用text/template或os/exec等方式直接执行来自LLM的字符串这会造成严重的命令注入安全风险。应该使用像govaluate这样的库它提供了一个安全的表达式求值环境。4.2 组装智能体并运行现在我们创建智能体注册工具并运行它。func main() { // 1. 创建智能体设置最大迭代次数为5 agent : Agent{ MaxIterations: 5, Tools: make(map[string]Tool), } // 2. 注册工具 agent.Tools[get_weather] WeatherTool{} agent.Tools[calculate] CalculatorTool{} // 3. 模拟一个需要多步推理的问题 question : 如果北京的温度是25度上海比北京高3度那么上海的温度是多少另外今天广州的天气适合打篮球吗 fmt.Printf(用户问题: %s\n\n, question) // 4. 运行ReAct循环 // 注意这里的 callLLMAPI 和 parseResponse 需要你根据实际使用的LLM服务如OpenAI API 国内大模型API等来实现 // 我们假设一个理想的LLM响应序列来模拟过程 // 第一轮LLM输出 // Thought: 用户问了两个问题。第一个是计算题需要知道北京温度(25度)和上海比北京高3度可以计算。第二个问题需要查询广州的天气来判断是否适合打篮球。 // Action: calculate // Action Input: 25 3 // (执行计算工具得到 Observation: “计算结果为: 28”) // 第二轮LLM输出 // Thought: 第一个问题已解决上海是28度。现在需要查询广州的天气来判断是否适合打篮球。通常打篮球需要晴朗或阴天避免下雨。 // Action: get_weather // Action Input: 广州 // (执行天气工具得到 Observation: “广州的天气是雷阵雨30摄氏度南风4级”) // 第三轮LLM输出 // Thought: 根据信息上海温度是28度。广州正在下雷阵雨不适合户外打篮球。 // Final Answer: 上海的温度是28摄氏度。广州今天有雷阵雨不适合进行户外打篮球活动。 // 模拟最终答案输出 finalAnswer : 上海的温度是28摄氏度。广州今天有雷阵雨不适合进行户外打篮球活动。 fmt.Printf(智能体最终答案: %s\n, finalAnswer) }通过这个例子你可以清晰地看到ReAct循环是如何工作的智能体先拆解问题识别出需要计算和查询天气两个子任务然后按顺序调用工具最后综合所有观察结果组织成连贯的自然语言答案。整个过程中我们的Go代码就像一个稳健的调度器负责管理流程、调用工具、传递信息而复杂的推理和规划则交给了LLM。5. 生产级考量错误处理、性能与扩展性一个50行的原型跑起来很有趣但要投入实际使用我们必须考虑更多工程细节。Go语言在构建可靠服务方面的优势在这里可以充分发挥。5.1 增强健壮性超时、重试与上下文管理超时控制LLM API调用和工具执行都可能很慢或挂起。必须为每一次thinkLLM调用和act工具执行设置上下文超时。func (a *Agent) thinkWithTimeout(state *State, timeout time.Duration) (*Step, bool, error) { ctx, cancel : context.WithTimeout(context.Background(), timeout) defer cancel() // 使用带超时的context调用LLM resultChan : make(chan thinkResult, 1) go func() { step, done, err : a.think(state); resultChan - thinkResult{step, done, err} }() select { case res : -resultChan: return res.step, res.done, res.err case -ctx.Done(): return nil, false, fmt.Errorf(think timeout after %v, timeout) } }LLM调用重试网络波动或服务端偶尔错误是常态。对于非致命的、可重试的错误如网络超时、5xx状态码应该实现指数退避的重试机制。但要注意对于因提示词或参数错误导致的4xx错误重试是无意义的。上下文长度Context Length管理随着循环进行state.Steps历史会越来越长。而所有LLM都有输入令牌Token数限制。我们不能无限制地将全部历史都塞进提示词。一个常见的策略是“滑动窗口”或“关键摘要”只保留最近N轮步骤的完整记录对于更早的步骤则用LLM生成一个简短的摘要来代替。这需要在buildPrompt函数中实现一个压缩历史的功能。5.2 提升性能并发执行与流式响应并发工具执行在某些场景下LLM可能会规划出多个可以并行执行的动作例如同时查询A和B两个不相关的信息。我们的当前实现是严格的串行循环。可以扩展Step结构允许一个“思考”产出多个并发的Action然后在act阶段使用sync.WaitGroup或errgroup来并发执行最后将多个观察结果合并再进入下一轮思考。这能显著减少任务的总耗时。流式输出Streaming对于需要长时间运行的复杂任务让用户干等着最终答案体验很差。我们可以利用Go的channel和goroutine实现流式输出。例如当LLM在“思考”并生成Thought文本时就可以逐步将其返回给前端同样工具执行的关键进度也可以实时推送。这需要改变Run方法的签名使其返回一个-chan string来传输中间结果和最终答案。5.3 设计扩展点让框架更灵活可插拔的LLM Provider我们的示例中callLLMAPI是写死的。更好的设计是定义一个LLMProvider接口然后为OpenAI、Anthropic Claude、国内各大模型等提供不同的实现。这样切换模型就像更换工具一样简单。type LLMProvider interface { Generate(prompt string) (string, error) // 可以扩展Streaming等方法 } type Agent struct { MaxIterations int Tools map[string]Tool LLM LLMProvider // 注入LLM客户端 }中间件Middleware或钩子Hooks为了便于监控、日志记录、审计或注入自定义逻辑如对LLM的输入输出进行过滤、脱敏可以设计钩子机制。例如在think前后、act前后提供回调函数。type AgentHooks struct { BeforeThink func(state *State) AfterThink func(step *Step, done bool) BeforeAct func(step *Step) AfterAct func(step *Step, observation string, err error) }这样我们可以在不修改核心循环逻辑的情况下轻松添加日志、指标上报、安全检查等功能。状态持久化对于运行时间可能很长的任务或者需要支持“暂停-继续”的场景我们需要能将State序列化如转换为JSON并存储起来。我们的结构体字段都使用了JSON tag这为持久化提供了便利。只需实现SaveState和LoadState方法就可以将智能体的“记忆”保存到数据库或文件中。6. 避坑指南与调试技巧在实际编码和调试这个ReAct循环时我踩过不少坑这里分享几个最常见的教训。坑1LLM不按格式输出导致解析失败。这是最头疼的问题。你的提示词明明写了“请按以下格式回应”LLM有时还是会自由发挥在格式前后加上多余的解释。解决策略强化提示词在提示词的开头和结尾都用非常醒目的标记强调格式要求比如使用 包裹格式示例。后处理清洗在parseResponse函数中不要假设响应是干净的。使用更鲁棒的正则表达式来提取关键部分例如regexp.MustCompile(Action:\s*(\w))来匹配Action并允许前后有空白字符和换行。使用结构化输出如果LLM支持如OpenAI的JSON Mode或Claude的XML工具强烈要求它以JSON格式输出。这样解析起来既简单又可靠。你可以将提示词改为“请以以下JSON格式回应{thought: ..., action: ..., action_input: {...}}”。坑2工具描述不清导致LLM误用或不用工具。工具的描述 (Description) 直接决定了LLM是否能理解其功能并正确调用。描述过于笼统如“一个计算工具”或过于复杂都会影响效果。解决策略遵循模板描述应包含三要素功能做什么、适用场景什么时候用、输入格式需要什么参数。例如“[计算器]用于执行基础数学运算加减乘除、括号。当问题涉及数字计算时使用。输入应为一个字符串格式的数学表达式如(10 5) / 3。”示例驱动在给LLM的工具列表里除了描述最好能提供1-2个调用示例。这比纯文字描述更有效。坑3无限循环或无效循环。智能体可能陷入“思考-调用工具-得到观察-再次思考-再次调用同一工具”的死循环或者反复调用一些无关紧要的工具始终无法得出最终答案。解决策略强制最终答案指令在提示词中明确要求当LLM认为信息足够时必须使用Final Answer:来回应并规定这是终止循环的唯一方式。设置迭代上限正如我们代码中所做MaxIterations是必须的保险丝。通常5-10轮对于大多数任务足够了。在观察中注入引导如果发现LLM在一个无关工具上打转可以在工具的Execute方法返回的Observation中给予强引导。例如当LLM反复查询一个不存在的城市天气时可以返回“未找到该城市信息。请注意本工具仅支持查询北京、上海、广州的模拟天气。请基于已有信息进行推理或给出最终答案。”调试技巧完整日志在Agent的每个关键步骤调用LLM前、解析响应后、执行工具前、执行工具后都打印详细的日志包括完整的提示词、LLM原始响应、工具输入输出。这是定位问题最快的方法。单元测试为parseResponse函数编写详尽的单元测试覆盖各种正常和边缘情况的LLM响应字符串。为每个工具的Execute方法编写测试确保它们能正确处理各种格式的输入。可视化状态在开发调试时可以临时添加一个功能在每轮循环后打印出当前的State结构体JSON格式。这让你能一目了然地看到智能体的“思考轨迹”。用Go实现ReAct循环更像是在构建一个可靠的基础设施而不是在探索AI的前沿。它的价值在于将不稳定的LLM输出通过严谨的程序逻辑规整为一个可控、可观测、可扩展的工作流。这50行代码只是一个起点你可以在此基础上结合Go生态中强大的并发库、网络库和中间件构建出能处理复杂任务、稳定运行的企业级智能体服务。