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)中,我们需要将可用工具的列表和描述提供给LLM,LLM根据这些描述来决定在特定情境下该调用哪个工具。因此,清晰、准确的描述是工具能被正确使用的关键。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,calculate),Action Input必须是JSON格式,可以极大降低解析复杂度。
关键点:parseResponse函数解析LLM的响应字符串,将其转换为Step结构体,并判断是否是最终答案。
- 通常使用正则表达式或简单的字符串分割来提取
Thought、Action、Action Input和Final Answer等部分。 - 如果解析到
Final Answer,则返回done=true,并将答案内容放入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调用和工具执行都可能很慢或挂起。必须为每一次
think(LLM调用)和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循环时,我踩过不少坑,这里分享几个最常见的教训。
坑1:LLM不按格式输出,导致解析失败。这是最头疼的问题。你的提示词明明写了“请按以下格式回应”,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反复查询一个不存在的城市天气时,可以返回:“未找到该城市信息。请注意,本工具仅支持查询北京、上海、广州的模拟天气。请基于已有信息进行推理或给出最终答案。”
- 强制最终答案指令:在提示词中明确要求,当LLM认为信息足够时,必须使用
调试技巧:
- 完整日志:在
Agent的每个关键步骤(调用LLM前、解析响应后、执行工具前、执行工具后)都打印详细的日志,包括完整的提示词、LLM原始响应、工具输入输出。这是定位问题最快的方法。 - 单元测试:为
parseResponse函数编写详尽的单元测试,覆盖各种正常和边缘情况的LLM响应字符串。为每个工具的Execute方法编写测试,确保它们能正确处理各种格式的输入。 - 可视化状态:在开发调试时,可以临时添加一个功能,在每轮循环后打印出当前的
State结构体(JSON格式)。这让你能一目了然地看到智能体的“思考轨迹”。
用Go实现ReAct循环,更像是在构建一个可靠的基础设施,而不是在探索AI的前沿。它的价值在于将不稳定的LLM输出,通过严谨的程序逻辑,规整为一个可控、可观测、可扩展的工作流。这50行代码只是一个起点,你可以在此基础上,结合Go生态中强大的并发库、网络库和中间件,构建出能处理复杂任务、稳定运行的企业级智能体服务。