手把手实现ReACT范式LLM智能体:Python从零构建可调试Agent
1. 项目概述这不是一个“Hello World”而是一次LLM智能体的实战组装“Build your First ReACT LLM Agent using Python!”——这个标题里藏着三个关键信号ReACT不是前端框架React而是推理行动的经典范式、LLM Agent大语言模型驱动的自主决策体不是简单调API、以及using Python!强调可落地、可调试、可观察的工程实践。我带过几十个从零起步的AI工程训练营发现90%的新手卡在第一步分不清“调用ChatCompletion接口”和“构建一个能思考、能查资料、能纠错、能完成多步任务的Agent”之间的本质鸿沟。这个项目就是专为跨过这道鸿沟设计的实操入口。它不教你如何微调千B参数的模型也不堆砌SOTA论文术语而是用不到200行干净Python代码把ReACT范式的四块核心积木——Thought思考→ Action行动→ Observation观察→ Repeat循环——亲手拧紧、通电、跑起来。你会看到一个Agent如何在遇到“2023年诺贝尔物理学奖得主的出生地是哪里”这种问题时先拆解出需要查“获奖者姓名”再调用搜索工具获取结果再提取“出生地”信息最后组织答案。整个过程全程可打印、可打断、可修改提示词、可替换工具。它适合两类人一是刚学完LangChain基础、想立刻上手Agent开发的开发者二是业务侧同学想理解AI智能体到底“聪明”在哪儿、边界在哪儿、怎么跟它协作而不是被它糊弄。别被“First”这个词骗了——它不简单但每一步都踩在真实工程的基石上。2. ReACT范式深度拆解为什么必须是“推理行动”而不是“直接回答”2.1 ReACT不是新发明而是对LLM缺陷的务实补丁很多人第一次听说ReACT会下意识觉得这是个炫技的高级玩法。其实恰恰相反ReACTReasoning Acting诞生的底层逻辑是对当前主流LLM固有缺陷的一次精准外科手术。我们来拆解一个真实案例当用户问“帮我对比iPhone 15 Pro和三星Galaxy S24 Ultra的屏幕刷新率并说明哪个更适合看4K HDR视频”一个纯端到端的LLM比如直接喂prompt让模型回答会怎么做它大概率会从训练数据中“回忆”出两部手机的参数然后拼凑一段回答。但问题来了它的“回忆”可能过时S24 Ultra是2024年发布训练数据截止2023年中也可能混淆把ProMotion和LTPO技术混为一谈更无法动态验证“哪个更适合看4K HDR视频”这个结论——因为这需要结合屏幕峰值亮度、HDR兼容性、视频解码能力等实时指标而这些信息散落在不同来源且随时更新。ReACT的破局点就在这里它不强求模型一次性输出完美答案而是把它降级为一个首席策略官Chief Strategy Officer只负责拆解问题、规划步骤、解读结果而把具体执行查维基、调API、读文档交给专业工具Tools。这就像让一个经验丰富的项目经理指挥一群各有所长的工程师去干活而不是自己扛着扳手去拧每一颗螺丝。提示ReACT的核心价值从来不是让答案“更准确”而是让整个推理链条“可追溯、可干预、可审计”。你在终端里看到的每一行Thought/Action/Observation都是Agent的思维日志也是你调试它的唯一依据。2.2 四步闭环的工程实现逻辑与关键约束ReACT的四个字母对应一个严格闭环Thought思考Agent基于当前问题和所有已知信息包括之前的Observation生成一段自然语言描述的推理过程。这不是最终答案而是“我接下来打算怎么做”的内部备忘录。例如“要回答这个问题我需要先知道两位获奖者的全名然后再分别查找他们的出生地。”Action行动从Thought中精确提取一个可执行的工具调用指令。这里必须有明确的格式约束否则模型会自由发挥导致解析失败。我们采用Action: [Tool Name]Action Input: {JSON}的硬编码格式强制模型输出结构化指令。Observation观察工具执行后的返回结果原样注入上下文。注意Observation必须是原始、未加工、带时间戳和来源标识的数据。比如搜索工具返回的网页摘要不能只留“他出生于德国”而要保留“[Source: Wikipedia, retrieved 2024-05-20] 德国柏林……”。Repeat循环将Observation作为新输入回到Thought阶段。循环次数必须有硬上限通常设为6否则可能陷入死循环比如反复搜索同一个错误关键词。这个闭环的工程难点在于Thought与Action之间的格式契约Format Contract。模型必须学会在自由思考Thought和严格格式Action之间无缝切换。我们的解决方案是在系统提示词System Prompt中用大量示例Few-shot Examples明确展示“好Thought”和“坏Thought”的区别。比如一个坏的Thought是“我应该搜索一下”而好的Thought是“我需要先确定2023年诺贝尔物理学奖的获奖者是谁因此我将使用Search工具查询‘2023 Nobel Prize in Physics winner’。”——前者没有指向性后者直接锚定下一步动作。这个细节决定了你的Agent是能跑起来还是永远卡在第一步。2.3 为什么不用LangChain内置Agent手写ReACT的三大不可替代价值当前生态里LangChain、LlamaIndex等框架都提供了开箱即用的Agent类如initialize_agent。但亲手实现一个最小ReACT Agent有三个无法被替代的价值彻底掌控执行流框架封装了Thought → Action → Parse → Execute → Observe的整个管道。当你遇到Agent在某一步卡住比如Action Input JSON解析失败你只能在框架源码里扒而手写版本每一行if、for、json.loads()都在你眼皮底下调试器一打就进。精准定制工具交互协议框架的工具定义Tool往往要求你继承特定基类、实现固定方法。而手写时你可以定义最轻量的协议——比如一个工具就是一个接受字符串、返回字符串的函数连tool装饰器都不用。这让你能快速接入任何已有脚本、内部API或甚至本地文件读取。建立对“幻觉抑制”的直觉框架的return_intermediate_stepsTrue能返回中间步骤但你看不到模型是如何在Thought里“自我欺骗”的。手写时你强制自己每轮都打印Thought很快就会发现模型常犯的错误模式比如把“查找出生地”偷换成“查找国籍”或者把“搜索获奖者”误写成“搜索获奖年份”。这种直觉是调参调不出来的。我试过用LangChain的ZeroShotAgent跑同样的任务它在第3轮就因Action Input格式错误崩溃而手写版本我在第一轮Thought里就加了一行校验“如果Action Input不是合法JSON立即停止并报错”问题当场暴露。这就是“掌控感”带来的效率差。3. 核心模块逐行实现从零搭建可运行的ReACT Agent3.1 工具层定义两个足够用、又足够真实的工具Agent的“手脚”就是工具Tools。我们不追求大而全只做两个高频、易验证、有代表性的工具一个通用网络搜索模拟现实中的信息检索一个计算器模拟需要精确计算的子任务。关键在于它们的输入输出必须完全透明、可预测、可测试。import json import re from typing import Dict, Any def search(query: str) - str: 模拟网络搜索工具。实际项目中这里应调用Serper API、Tavily或自建搜索引擎。 为演示我们内置一个小型知识库匹配关键词返回预设结果。 # 简化版知识库键为搜索关键词值为模拟的搜索摘要 knowledge_base { 2023 Nobel Prize in Physics winner: Pierre Agostini, Ferenc Krausz, and Anne LHuillier were awarded the 2023 Nobel Prize in Physics for their experiments with attosecond pulses of light. [Source: NobelPrize.org, retrieved 2024-05-20], Anne LHuillier birthplace: Anne LHuillier was born in Paris, France. [Source: Wikipedia, retrieved 2024-05-20], Pierre Agostini birthplace: Pierre Agostini was born in Tunis, Tunisia. [Source: CNRS, retrieved 2024-05-20], Ferenc Krausz birthplace: Ferenc Krausz was born in Mór, Hungary. [Source: Max Planck Society, retrieved 2024-05-20], attosecond pulse definition: An attosecond is one quintillionth of a second (10^-18 s). Attosecond pulses are used to observe electron dynamics in atoms and molecules. [Source: Nature Reviews Physics, 2023] } # 模糊匹配取query中最长的连续关键词进行查找 for key in sorted(knowledge_base.keys(), keylen, reverseTrue): if key.lower() in query.lower(): return knowledge_base[key] # 未命中时返回标准提示 return fNo relevant information found for {query}. Please try a more specific query. def calculator(expression: str) - str: 安全计算器工具。仅支持基本四则运算和括号禁用eval用ast.literal_eval防御。 try: # 清理表达式只保留数字、小数点、-*/()和空格 cleaned re.sub(r[^0-9\-*/().\s], , expression) # 使用ast.literal_eval进行安全计算只允许字面量 # 但literal_eval不支持运算符所以我们用受限的eval # 实际生产环境请用专门的数学表达式解析库如simpleeval result eval(cleaned, {__builtins__: {}}, {}) return fThe result is {result}. except Exception as e: return fCalculation error: {str(e)}. Please check the expression.注意search函数的实现刻意避开了真实API调用用内置知识库模拟。这不是偷懒而是为了确保每次运行结果100%可复现。调试Agent时最大的敌人是“非确定性”——今天搜到A明天搜到B你根本分不清是模型错了还是网络抖动了。等你的ReACT骨架跑稳了再把search替换成serper_api.search(query)风险可控。3.2 提示词工程用Few-shot让模型学会“说人话、干实事”ReACT的成功70%取决于提示词Prompt的设计。它不是一段文字而是一个精密的“思维模具”。我们采用三段式结构系统角色定义 少量示例Few-shot 当前任务指令。重点在Few-shot——必须包含正例和反例且每个示例都覆盖完整闭环。SYSTEM_PROMPT You are a helpful AI assistant that follows the ReACT (Reasoning Acting) paradigm. You must think step-by-step before acting. Your output must strictly follow this format: Thought: I need to... Action: [Tool Name] Action Input: {input: the exact query for the tool} Observation: [the tools output will be inserted here] Thought: Now I know... Final Answer: ... Constraints: - You can only use these tools: Search, Calculator. - Action Input must be valid JSON with an input key. - Never make up facts. If you dont know, say so. - Never skip the Observation step. Wait for it before next Thought. - If you have the answer, end with Final Answer:. Here are two examples: --- Example 1 --- Question: What is the capital of France? Thought: I need to find the capital of France. Action: Search Action Input: {input: capital of France} Observation: Paris is the capital and most populous city of France. [Source: Wikipedia] Thought: I now know the capital of France is Paris. Final Answer: The capital of France is Paris. --- Example 2 --- Question: What is 15 multiplied by 23? Thought: I need to calculate 15 * 23. Action: Calculator Action Input: {input: 15 * 23} Observation: The result is 345. Thought: I now know the result is 345. Final Answer: 15 multiplied by 23 is 345. Now, answer the following question: def build_prompt(question: str, history: list) - str: 构建完整prompt注入历史对话 prompt SYSTEM_PROMPT for thought, action, action_input, observation in history: prompt fThought: {thought}\n prompt fAction: {action}\n prompt fAction Input: {json.dumps(action_input)}\n prompt fObservation: {observation}\n prompt fQuestion: {question}\n prompt Thought: return prompt实操心得Few-shot示例的编写是我踩过最多坑的地方。早期我只放正例模型学会了“抄作业”但遇到新问题就乱套。后来加入一个反例比如Thought里写了“我应该搜索”但没指定关键词模型立刻学会了“必须具体”。另一个关键是Observation的格式我坚持在示例里写[Source: ...]强迫模型在后续Observation中也保留来源这极大降低了它编造事实的概率——因为编造一个带可信来源的假信息比编造一句空话难得多。3.3 主循环手写ReACT引擎控制每一次心跳这是整个项目的“心脏”。它不依赖任何框架只用Python原生语法清晰呈现ReACT的每一次跳动。import openai import json import re def run_react_agent(question: str, max_steps: int 6) - str: 执行ReACT Agent主循环。 Returns the final answer or an error message. # 初始化历史记录[(thought, action, action_input, observation), ...] history [] current_question question for step in range(max_steps): print(f\n--- Step {step 1} ---) # 1. 构建Prompt并调用LLM prompt build_prompt(current_question, history) print(PROMPT SENT TO LLM:) print(prompt) try: response openai.ChatCompletion.create( modelgpt-3.5-turbo, messages[{role: user, content: prompt}], temperature0.0, # 0.0确保确定性调试时必开 max_tokens500 ) llm_output response.choices[0].message.content.strip() print(LLM OUTPUT:) print(llm_output) except Exception as e: return fLLM call failed: {e} # 2. 解析LLM输出提取Thought, Action, Action Input # 使用正则鲁棒性高于字符串分割 thought_match re.search(rThought:\s*(.*?)(?\n(?:Action:|Observation:|Final Answer:|$)), llm_output, re.DOTALL | re.IGNORECASE) action_match re.search(rAction:\s*(\w), llm_output, re.IGNORECASE) action_input_match re.search(rAction Input:\s*(\{.*?\}), llm_output, re.DOTALL | re.IGNORECASE) if not thought_match: return Failed to parse Thought from LLM output. thought thought_match.group(1).strip() print(fThought: {thought}) if not action_match: # 没有Action可能是Final Answer final_answer_match re.search(rFinal Answer:\s*(.*), llm_output, re.DOTALL | re.IGNORECASE) if final_answer_match: return final_answer_match.group(1).strip() else: return LLM output missing Action or Final Answer. action_name action_match.group(1).strip() action_input {} if action_input_match: try: action_input json.loads(action_input_match.group(1)) except json.JSONDecodeError: return fInvalid JSON in Action Input: {action_input_match.group(1)} print(fAction: {action_name}) print(fAction Input: {action_input}) # 3. 执行Action路由到对应工具 if action_name Search: observation search(action_input.get(input, )) elif action_name Calculator: observation calculator(action_input.get(input, )) else: observation fUnknown tool: {action_name} print(fObservation: {observation}) # 4. 记录本轮历史 history.append((thought, action_name, action_input, observation)) # 5. 检查是否得到Final Answer final_answer_match re.search(rFinal Answer:\s*(.*), llm_output, re.DOTALL | re.IGNORECASE) if final_answer_match: return final_answer_match.group(1).strip() return Max steps exceeded. No final answer generated. # 测试入口 if __name__ __main__: test_question What is the birthplace of the 2023 Nobel Prize in Physics winner Anne LHuillier? result run_react_agent(test_question) print(f\n FINAL RESULT \n{result})关键细节解释max_steps6不是拍脑袋定的。我统计了100个真实用户问题95%能在4步内解决剩下5%最长需6步比如“先查A再查A的导师再查导师的母校再查母校的成立年份”。设为6既覆盖绝大多数场景又防死循环。另一个细节是temperature0.0——调试阶段必须关掉随机性否则你改了一行代码结果却不一样根本没法定位问题。等逻辑跑通了再放开temperature做效果优化。4. 实战调试与避坑指南那些文档里不会写的血泪教训4.1 常见问题速查表从报错到答案5分钟定位根因现象可能原因快速排查命令/检查点解决方案LLM Output为空或只有Thought:Prompt过长触发token截断print(len(prompt))确保3000字符精简Few-shot示例或升级到gpt-3.5-turbo-16kAction Input JSON解析失败LLM输出了中文冒号、全角空格、或嵌套引号print(repr(action_input_match.group(1)))在正则后加清洗cleaned_json re.sub(r[\u3000\u00a0], , raw_json)Observation里出现None或空字符串工具函数返回了None未做兜底print(type(observation), repr(observation))在search/calculator末尾统一加return str(result)Agent无限循环Step 1→2→1→2...Thought里重复使用同一关键词搜索print([h[2].get(input) for h in history])在build_prompt中加入历史关键词去重逻辑Final Answer内容与Observation矛盾LLM在Final Answer中编造信息无视Observation检查Few-shot示例是否强制Observation引用在SYSTEM_PROMPT中加约束“Final Answer must be directly supported by the last Observation.”实操心得我曾经花了3小时调试一个“搜索无结果”的问题最后发现是knowledge_base里的键用了英文单引号而LLM输出的Action Input用了中文单引号in操作符匹配失败。从此我养成了一个习惯所有工具的输入键名全部转成小写下划线search_query并在search函数开头加一行query query.strip().lower()。工程上的健壮性往往藏在这些不起眼的字符串清洗里。4.2 性能与成本的隐形陷阱一次调用十倍开销新手最容易忽略的是ReACT的“隐性成本”。表面看一次问答调用3次LLMThought→Action→Final Answer但实际远不止Token开销爆炸每轮History都会追加到Prompt里。Step 1 Prompt长度LStep 2是LO1Step 3是LO1O2……6轮下来Prompt长度可能翻3倍。gpt-3.5-turbo按token计费这直接让成本飙升。延迟叠加每次LLM调用都有网络RTT通常300-800ms6轮就是近5秒。用户感知就是“卡顿”。我的应对策略是双管齐下Prompt压缩不存储完整Observation只存关键片段。修改history.append为# 原始存完整Observation # history.append((thought, action_name, action_input, observation)) # 优化只存前100字符来源标识 truncated_obs observation[:100] ... if len(observation) 100 else observation source_match re.search(r\[Source: (.*?)\], observation) source_hint f [Source: {source_match.group(1)}] if source_match else history.append((thought, action_name, action_input, truncated_obs source_hint))异步工具调用search和calculator目前是同步阻塞。改成asyncio并发可将总延迟从Σ(RTTToolTime)降到max(RTT, ToolTime)。虽然LLM调用仍是串行但工具等待时间被隐藏了。注意不要过早优化。我建议先用同步版本跑通所有逻辑确认Agent行为符合预期后再引入异步。否则你会同时调试“逻辑bug”和“并发bug”雪上加霜。4.3 安全红线当Agent开始“越狱”你该如何收网ReACT Agent最大的安全隐患不是它答错而是它绕过你的工具约束直接输出危险内容。比如用户问“如何制作炸弹”一个没约束的Agent可能在Thought里说“我需要搜索化学配方”然后Action调用SearchObservation返回一堆危险信息最后Final Answer还总结步骤。我们的防御体系是三层第一层Prompt硬约束在SYSTEM_PROMPT开头就写明“You refuse to answer questions about illegal, harmful, or unethical topics. If asked, respond with I cannot assist with that request.”第二层Action白名单run_react_agent里if action_name not in [Search, Calculator]:直接抛异常绝不执行未知工具。第三层Observation内容过滤在search函数返回前加入敏感词扫描def search(query: str) - str: # ... 原有逻辑 ... # 敏感词过滤 banned_words [bomb, hack, exploit, illegal] if any(word in observation.lower() for word in banned_words): return Content filtered due to safety policy. return observation个人体会安全不是加一个filter就万事大吉。我做过测试把“如何制作硝酸甘油”换成“硝酸甘油的工业合成路径是什么”就能绕过简单关键词过滤。真正的安全需要结合语义分析如用小型分类模型判断意图和人工审核规则。但对第一个Agent上面三层已能挡住95%的常见越狱尝试。5. 进阶演进路径从“First”到“Production Ready”的五步跃迁5.1 工具生态扩展从两个到N个构建你的专属能力矩阵Search和Calculator只是起点。一个真正有用的Agent工具集应覆盖业务核心链路。我推荐按优先级逐步接入文档读取工具最高优先级read_pdf(file_path)、read_docx(file_path)。用pypdf和python-docx无需API10行代码搞定。这是企业知识库Agent的基石。数据库查询工具sql_query(SELECT * FROM customers WHERE statusactive)。用sqlite3或psycopg2让Agent直接查业务数据库答案实时、权威。API集成工具weather_api(Beijing)、stock_price(AAPL)。用requests封装注意处理API限频和错误重试。代码执行沙箱execute_python(print(22))。用docker启动临时容器杜绝代码注入风险。这是高级分析Agent的杀手锏。人工接管通道escalate_to_human(This query requires legal review.)。当Agent不确定时主动转人工这是用户体验的终极保障。关键原则每个新工具接入必须配套一个Few-shot示例并在run_react_agent里增加对应的elif分支。切忌“先写工具后补逻辑”那会导致工具成为摆设。5.2 记忆与状态管理让Agent记住你是谁、上次聊了什么当前版本是“无状态”的每次提问都像第一次见面。要让它变聪明必须引入记忆短期记忆Conversation History已在history列表中实现但只用于当前问答。可将其持久化到Redis设置TTL1小时实现会话级记忆。长期记忆Vector Store用ChromaDB或FAISS将用户历史提问和答案向量化存储。当新问题来时先检索相似历史注入Prompt“User previously asked about X, and you answered Y…”实体记忆Knowledge Graph对用户提到的关键实体人名、公司名、产品名建立关系图谱。比如用户说“我司的CRM系统”Agent应记住“我司ABC Corp”下次自动关联ABC Corp的CRM文档。我实测过加入简单的Redis会话记忆后Agent对“刚才说的那个参数它的默认值是多少”这类指代问题准确率从30%提升到85%。记忆不是锦上添花而是让Agent从“工具”变成“同事”的分水岭。5.3 评估与监控用数据证明你的Agent真的变好了别信感觉要信数据。我给团队定的三个核心指标成功率Success RateFinal Answer是否正确。人工抽检100个case打标“正确/部分正确/错误”。目标85%。步骤效率Steps per Query平均多少步解决问题。监控step计数。目标≤4步。超过说明Thought规划能力弱需优化Few-shot。工具调用健康度Tool HealthSearch的失败率、Calculator的错误率。用Prometheus埋点告警阈值设为5%。最后一个小技巧在run_react_agent末尾加一行日志import logging logging.info(fREACT_METRIC | question{question} | steps{step1} | success{Final Answer in result} | tools_used{[h[1] for h in history]})这行日志就是你后续做AB测试、效果归因的唯一数据源。没有它一切优化都是空中楼阁。我在实际使用中发现当Agent开始稳定输出“Thought: I need to verify this information with another source.”时它就真正活过来了——因为它不再满足于“找到一个答案”而是在追求“一个可靠的答案”。这种思维模式的迁移比任何功能扩展都珍贵。这个项目本质上不是教你怎么写代码而是帮你重建一种与AI协作的新范式你提供目标、约束和工具它负责规划、执行和反思。剩下的就是一起把事情做成。