手写智能体ReAct循环:从工具注册到异常处理的完整实践 最近不少人在做《智能体框架深度实践》这一章的习题时卡在同一个地方——看答案感觉每一步都懂自己从头写一遍就开始到处报错。第六章这套题很有意思表面上是让实现几个工具调用实际上是在考一个完整的 ReAct 循环模型怎么决定调工具、工具返回值怎么喂回给模型、循环什么时候该停。只要把这套循环吃透所有关于智能体的面试题、项目题都不再是死记硬背而是真正能动手的东西。这篇内容适合三类人正在啃智能体章节的学生、自学 Agent 应用开发的初学者、以及准备 Agent 相关面试的开发者。我会把整道题的拆解、代码实现、测试过程和踩坑记录全部合在一起你可以直接照着复现也能快速理解每一个“为什么”。我不建议直接调现成的智能体框架来交差后面会解释原因但如果你已经用框架跑通过这篇文章能帮你把底层原理补得更扎实。1. 先把这道题拆明白了它到底在考什么1.1 从一个“天气查询助手”看智能体的本质第六章的习题通常会给你一个看起来很简单的小目标——实现一个助手能够根据用户提问自主决定调用哪些工具最后给出回答。比如用户说“某城市A现在天气怎么样今天日期是多少”一个合格的 Agent 应该先查看日期再查询城市天气然后把两段信息拼成一句完整回答。这看起来平平无奇但普通的大模型对话是做不到的因为模型本身不知道当前的实时天气也不会主动玩“先查日历再查天气”这套组合动作。智能体和大模型对话的本质区别就在这里Agent 不只是生成文本它通过“感知环境 - 做出决策 - 执行动作 - 观察结果”的闭环把大模型从一个聊天对象变成了一个能调用真实工具的“操作员”。在这个闭环里大模型仍然是大脑负责思考下一步该干什么工具是手脚负责实际获取数据或操作系统资源。习题要求你实现的正是这个闭环里最关键的两件事让模型“知道”有哪些工具可以用以及让模型“看到”工具执行的结果。1.2 习题考察的四个核心能力点做这道题之前我建议你先对照下面的表格明确自己的代码要覆盖哪些评分点。这是某门课程第六章作业最常见的评分框架你可以当作自查清单用。评分点具体要求常见丢分原因功能完整性能正确调用至少两个工具并组合结果回答复合问题只实现了一个工具或者工具调用顺序错误异常处理工具未找到、参数解析失败、模型输出格式错误时不会崩溃直接抛异常退出没有把错误转为模型可见的观察结果循环终止能区分“需要继续调工具”和“可以输出最终答案”两种状态模型一直自言自语不结束或过早结束导致答案残缺代码可读性工具定义、注册、调用逻辑分层清晰便于扩展新工具工具逻辑全部写死在 if-else 里加一个新工具要改主循环这四个评分点不是凭空来的它们恰好对应真实 Agent 框架中四个必须解决的工程问题工具注册、错误容错、流程控制、扩展性。你在自己动手实现时会特别强烈地感受到每一个评分点背后都有真实踩坑的价值。1.3 为什么我坚持让你手写一遍“微型 ReAct”而不是直接调现成框架现在市面上的智能体框架很多有重型的、有轻量的一两行代码就能把 Agent 跑起来。但第六章这道习题我的建议是至少先做一次不依赖现成框架的手写实现。为什么要“折磨”自己我告诉你三个实际理由。第一框架是黑盒。框架帮你封装了模型交互、工具调用、记忆管理出了问题你只能看日志猜很难真正理解是模型理解错了还是工具描述写得不够好。手写实现后每一行代码都是你控制的每条报错你都能定位到具体环节这种排查能力在外面做项目时非常值钱。第二面试时不会被问倒。现在 Agent 方向的技术面试基本都会追问“ReAct 循环的每一步发生了什么”“如果 Action Input 解析失败你会怎么处理”这些问题只调用框架但没看过源码的人很难答出实质内容。自己实现过一遍这些问题就是送分题。第三扩展思路更清晰。一道习题完成后你想加记忆、加多 Agent 协作、加流式输出如果底层是自己写的你会知道在哪里加、加了会影响哪些模块。如果底层是框架你只能查框架文档有没有现成接口。学习阶段不要把“会用框架”误当成“会做 Agent”。2. 环境准备与工具层设计2.1 最小依赖清单与模型接入方式先把环境说清楚。这道题只需要 Python 3.9 以上环境核心依赖其实是“调用大模型 API 的客户端”无论你用的是哪个服务商——这里统称某大模型服务商的接口——代码层面只需要一个支持多轮对话的 chat 函数输入消息列表、输出回复文本即可。我会给你留一个llm_chat()占位函数你换成自己的模型接入就行。一个很关键的参数是temperature建议调到 0 到 0.2 之间。Agent 场景下你希望模型尽量稳定地输出结构化动作而不是发挥创意编造工具名所以采样温度必须压低。我见过有人用默认 temperature 跑 Agent结果模型一会儿把动作格式写歪一会儿在“思考”环节长篇大论最后循环超时。把温度调低之后问题立刻少一大半。模型接口接入时我建议打印每次请求的消息数量。这不是为了炫技而是后面排查上下文超限时你一定会需要这行日志。代码如下def llm_chat(messages): 调用某大模型服务商的 chat 接口。 你现在只需要把下面的 TODO 换成本地可用的模型服务地址或 SDK 即可。 # TODO: 替换为实际的模型客户端代码 # 例如: resp your_sdk.chat.completions.create( # model..., messagesmessages, temperature0.1) # return resp.choices[0].message.content pass从调试角度来说你最好固定使用一个足够聪明的模型。什么算“足够聪明”就是能稳定理解复杂 JSON 和长工具的模型。不过与此同时你又要尽量让 prompt 写得不依赖模型玄学做到换一个普通模型也能跑这才是真正的工程能力。2.2 用“工具注册表”管理 Agent 的可用能力你可能会想不就是让模型调工具吗我用 if-else 判断模型输出了哪个工具名不就行了这样确实最简单的但每加一个工具就要改一次主循环而且工具参数说明完全没法让你和模型顺畅交流。更好的做法是用一张“工具注册表”把每个工具的名字、描述、参数结构、可执行函数绑在一起单独存成一个结构体。这里我用到一个小技巧工具的description不是写给程序员看的而是写给模型看的。同一个函数如果你把它描述成“获取指定城市的当前室外天气状况”模型就知道该在什么时候调用它如果你描述成“返回 weather_pool 中的一个字符串”模型很可能因为看不懂而不调用。这个“为模型写描述”的思维方式是从普通程序员切换到 Agent 开发者最重要的一步。工具注册层的代码如下class Tool: def __init__(self, name, description, func, parameters): self.name name self.description description self.func func self.parameters parameters # JSON Schema 风格参数说明 def get_current_time(): 获取当前系统时间精确到秒。 from datetime import datetime return datetime.now().strftime(%Y-%m-%d %H:%M:%S) def get_city_weather(city: str) - str: 查询指定城市的当前天气状况习题演示用模拟数据。 weather_pool { 城市A: (晴朗, 23), 城市B: (多云, 19), 城市C: (小雨, 15), } if city in weather_pool: cond, temp weather_pool[city] return f{city}当前天气{cond}气温{temp}℃。 return f未找到{city}的天气数据请确认城市名是否正确。 def days_between(date_a: str, date_b: str) - str: 计算两个 YYYY-MM-DD 格式日期之间相差的天数。 from datetime import date try: d1 date.fromisoformat(date_a) d2 date.fromisoformat(date_b) return f{date_a}与{date_b}之间相差{abs((d2 - d1).days)}天。 except ValueError: return 日期格式错误需要 YYYY-MM-DD 格式。 TOOL_REGISTRY { get_current_time: Tool( nameget_current_time, description获取当前系统时间包括年月日和时分秒。当用户问今天日期、现在几点时使用。, funcget_current_time, parameters{type: object, properties: {}, required: []}, ), get_city_weather: Tool( nameget_city_weather, description查询指定城市的当前天气状况和实时气温。参数 city 接收中文城市名。, funcget_city_weather, parameters{ type: object, properties: {city: {type: string, description: 城市名如 城市A}}, required: [city], }, ), days_between: Tool( namedays_between, description计算两个日期之间相差的天数。参数格式必须是 YYYY-MM-DD。, funcdays_between, parameters{ type: object, properties: { date_a: {type: string, description: 第一个日期格式 YYYY-MM-DD}, date_b: {type: string, description: 第二个日期格式 YYYY-MM-DD}, }, required: [date_a, date_b], }, ), }注意看TOOL_REGISTRY用字典而不是列表这能让主循环以最快速度根据工具名查到对应函数。另外days_between里我很早就做好了异常处理传入非法日期时返回的不是异常堆栈而是自然语言错误提示。这么做的原因会在下一节展开。2.3 工具实现时最容易踩的三个坑工具函数看着简单实际上坑不少。我按踩坑频率给你列三个最容易出问题的地方。第一返回值的可读性。工具返回给模型的字符串应当是一句人话而不是裸数字或裸字典。比如days_between我返回的就是“2025-01-18与2025-12-31之间相差347天。”而不是返回347。因为模型后续要根据这个字符串来组织最终回答如果只有个数字它可能会猜这是什么东西从而导致回答语焉不详。你在定义每个工具时都要问自己模型拿到这个字符串能不能直接理解其中的信息第二工具内部异常不能外抛。模型传参永远有可能出乎你的意料比如把一个空字符串传给days_between或者传一个根本不在支持列表中的城市名。处理方式是所有工具都自己捕获异常并返回用户/模型能看懂的错误提示。异常一旦抛到 Agent 主循环就意味着整个任务中断而把异常转成“观察结果”文字返回给模型模型反而可能意识到自己传错参数然后自动修正。第三注意参数名的一致性。我见过不少人把函数形参叫做d1、d2然后把需求描述里写着“YYYY-MM-DD 格式的日期”模型也确实按格式传了但传的参数名和实际 JSON Schema 里的不一致导致注册表构建时报错。工具的parameters结构一定要和函数签名严格对应这是程序员的严谨也是 Agent 稳定运行的基础。3. 核心 Agent 循环的完整实现3.1 ReAct 循环的“想、做、看”到底是什么现在进入最核心的 ReAct 循环。ReAct 这个名字是 Reasoning Acting 的缩写原理说起来非常朴素——让大模型在每一轮输出里先用THOUGHT表达自己对当前情况的判断再用ACTION给出下一步动作。系统拿到 ACTION 后执行对应工具把返回的OBSERVATION再粘回对话历史交给模型继续思考。如此反复直到模型认为自己掌握足够信息输出FINAL ANSWER。这个过程很像人类解决复杂问题的思路看题目 - 想一想需要什么信息 - 查资料 - 看了资料再想 - 直到能把问题完整答出来。习题要求你实现的关键就是把这个“想 - 做 - 看”的三步闭环用代码固定下来。难点不在写循环而在“模型不按你的要求输出”时的容错处理。比如它不输出 ACTION 而直接写OBSERVATION或者 ACTION 的 JSON 参数格式歪歪扭扭这些都要靠解析容错来解决。3.2 关键代码THOUGHT、ACTION、OBSERVATION 三步循环我直接给出一个可以运行的ReActAgent类。这段代码是按“自己写一遍”的标准来设计的逻辑全部显式展开。你完全可以在此基础上加功能。import json import re class ReActAgent: def __init__(self, system_prompt, max_iterations8): self.system_prompt system_prompt self.max_iterations max_iterations self.messages [{role: system, content: system_prompt}] def run(self, user_query: str) - str: self.messages.append({role: user, content: user_query}) for step in range(self.max_iterations): response llm_chat(self.messages) content response.strip() # 调试日志打印模型当前这个“想法” print(f\n[第{step 1}轮模型输出]\n{content}) self.messages.append({role: assistant, content: content}) if FINAL ANSWER: in content: return content.split(FINAL ANSWER:, 1)[-1].strip() action self._parse_action(content) if action is None: # 模型没有给出合法的 ACTION告诉它格式不对换个说法再试 hint ( OBSERVATION: 上一条消息中出现非法格式或缺少 ACTION 请严格按 ACTION: 工具名({\参数\: \值\}) 输出 或者直接输出 FINAL ANSWER: 结束对话。 ) self.messages.append({role: user, content: hint}) continue tool_name, params action tool TOOL_REGISTRY.get(tool_name) if tool is None: observation fOBSERVATION: 未找到名为 {tool_name} 的工具请检查工具名。 else: try: result tool.func(**params) observation fOBSERVATION: {result} except TypeError as e: observation fOBSERVATION: 工具 {tool_name} 参数错误请检查参数名和类型。错误信息{e} except Exception as e: observation fOBSERVATION: 工具 {tool_name} 执行异常{e} self.messages.append({role: user, content: observation}) return 达到最大迭代次数未能生成最终答案。 def _parse_action(self, content: str): 从模型输出中提取 ACTION: 工具名({JSON})。 如果找不到合法动作返回 None。 match re.search(rACTION:\s*(\w)\s*(\{.*\})?, content, re.S) if not match: return None tool_name match.group(1) params_text match.group(2) if params_text is None: return tool_name, {} try: params json.loads(params_text) except json.JSONDecodeError as e: # 常见情况模型把单引号当成了 JSON 引号这里做一层轻量修复 try: params json.loads(params_text.replace(, )) return tool_name, params except json.JSONDecodeError: return tool_name, {} return tool_name, params你要特别留意的有三处逻辑。第一_parse_action用正则去匹配ACTION:前缀而没有在 prompt 里实现那种严格的“只允许单次输出动作”限制。因为模型偶尔会在 THOUGHT 里夹杂解释文字正则能灵活把动作挑出来。第二每次工具执行结果都包装成OBSERVATION: xxx再放进消息列表这保证了系统提示词里承诺的循环格式始终成立。第三当解析失败时我们没有直接终止而是构造一条“OBSERVATION 提示”让模型自我修正这其实就是在实现知识点里的“容错重试”机制。3.3 边界情况处理JSON 解析容错、迭代上限、工具异常再往下走是区分“能把任务跑通”和“生产可用”的分水岭。即使模型能力很强你还是会遇到三类边界情况。JSON 解析容错是最常见的。模型输出的 ACTION 参数几乎没有人能保证 100% 是合法 JSON。我见过输出{city: 城市A}这种合法 JSON也见过输出{city: 城市A}单引号版本、输出{city: “城市A”}中文引号版本、甚至输出截断的{city: 城市A少了一个右括号。我的建议是先用标准json.loads失败后尝试替换中文引号和单引号再失败就放弃解析并把问题反馈给模型让它重新生成。绝对不推荐自己去写手工拼接参数的解析逻辑那是一条通往无底洞的路。迭代上限的重要性和 JSON 容错差不多。模型陷入“查了这个工具还想查那个工具”的循环时如果没有上限你的 API 费用和时间都会无限消耗。上界设多大习题场景 8 轮足够了但你要在代码里保留这个变量真实项目里再根据任务复杂度调整。要特别提示的是触发上限后的返回信息不要硬编码成含糊的“失败”最好把已经收集到的中间步骤存放在日志中方便回溯模型为什么会走到这一步。工具异常处理的关键是把异常“藏住”并回归循环。我在代码里写了TypeError和Exception两层捕获。为什么单独留一层TypeError因为模型传参的时候很容易少传一个参数此时 Python 抛出的就是TypeError而模型看到“参数错误”的提示后通常能立刻意识到要补全参数这是真实调试中反馈效率最高的一种错误。其余的异常类型归入Exception兜底至少保证程序不直接崩溃。4. 实测验题场景一次完整的任务执行4.1 构造一条覆盖多个工具的复合问题习题做完后你需要一个能证明代码真的跑通的测试场景。我建议不要只测单工具调用因为那样测不出 Agent 的决策能力。一个合格的测试输入应该满足三个条件需要多个工具协同、依赖实时信息、有明确的最终答案。下面的例子就很典型用户提问“今天是几号帮我查一下城市A现在的天气另外算一算距离2025年12月31日还有多少天把这三个结果一次性告诉我。”这个问题需要get_current_time获取当前日期然后用get_city_weather查天气最后用days_between计算两个日期之间的间隔。正好用一个复合问题把三个工具全部串起来同时检验 Agent 是否先获取当前日期再去计算天数——这是这道题最微妙的决策点先查日期再去算日期差顺序一旦反了模型就会因为缺少“今天几号”的信息而计算失败。4.2 逐步解析 Agent 的内部流转过程为了让你直观看到 Agent 内部发生了什么我基于某次运行截取了一段日志时间假设为 2025-01-18。你可以在自己的输出里看到和下面类似的过程[第1轮模型输出] THOUGHT: 用户需要我提供当前日期、城市A天气以及到2025年12月31日的天数。我需要先获取当前系统时间才能计算后续日期差。 ACTION: get_current_time() [第2轮模型输出] THOUGHT: 当前日期是2025-01-18。接下来查询城市A的天气。 ACTION: get_city_weather({city: 城市A}) [第3轮模型输出] THOUGHT: 已获得城市A天气晴朗23℃。现在利用当前日期2025-01-18和目标日期2025-12-31计算间隔天数。 ACTION: days_between({date_a: 2025-01-18, date_b: 2025-12-31}) [第4轮模型输出] THOUGHT: 所有信息都已取得我可以组织最终回答。 FINAL ANSWER: 今天是2025年1月18日。城市A目前晴朗气温23℃。距离2025年12月31日还有347天。你可以看到模型使用了严格的THOUGHT / ACTION / OBSERVATION格式这是 system prompt 设计成功的体现。从顺序上看模型把获取当前时间放在第一步说明它真的理解了“要计算天数必须先知道起算日期”这个逻辑。在更复杂的测试中如果模型没有先调用get_current_time就直接调用days_between观察结果会提醒它参数不足它通常会在下一轮自我修正。4.3 对照“习题评分点”逐条自查任务跑通后不要急着交作业拿前面那张评分表逐项自查一遍。我当时的自查记录可以给你参考功能完整性三个工具全部被自动调用且调用顺序正确复合问题组合回答成功。满足。异常处理我把get_city_weather的入参改成不存在的“某城市Z”Agent 收到工具返回的错误后在下一轮回复中主动向用户道歉并请求城市名修正没有崩溃。满足。循环终止模型在拿到全部信息后输出FINAL ANSWER且没有出现漫长自说自话。满足。代码可读性工具定义在注册表里主循环只有十几行新加一个工具只需要在注册表加一项。满足。自查时务必专门去破坏一个工具试试。比如故意让days_between接收非法日期看你的 Agent 能不能体面地把错误转化为观察信息。如果这一步没有处理好那么这道题在“异常处理”这个评分点上很可能是拿不到高分的。这也正是这道习题在真实 Agent 工程中的核心能力系统从不保证输入必然正确而 Agent 需要学会处理意外。5. 扩展与加分项从单 Agent 走向更灵活的架构5.1 用“规划 - 执行”双层结构升级原题你如果学有余力或者想拿加分项下面这个扩展方向性价比很高把单 Agent 拆成“规划器”和“执行器”。规划器的职责是和用户直接对话分析用户意图把一个大问题拆成若干小步骤然后调用执行器逐一处理。执行器则像一个“工具工人”每轮只负责调用一个具体工具把结果返回给规划器。这样做的好处是Agent 两次调用之间的职责更清晰规划器不会被工具参数的细节淹没执行器也不会因为反复决定下一步做什么而迷失。我维护过一个类似的个人项目把天气查询、日程提醒、待办事项三个能力接入同一个 Agent 时双层结构明显比单循环结构容易扩展。代价则是多了一轮模型调用的开销所以在小习题中不必强行使用作为一个加分设计体会思路即可。5.2 轻量记忆与上下文滑动窗口单次任务中Agent 要把之前所有步骤保留在消息列表里这也意味着每轮调用模型的 token 数都会不断增长。这个问题在长流程中尤其明显你可能会遇到上下文长度上限。一个轻量方案是只保留最近 N 条消息更早的消息压缩成一条总结摘要每次用户提问时把摘要放在最前面。这就是 Agent 里所谓的“滑动窗口记忆”。实现上并不复杂。当你的消息列表超过阈值时调一次模型让它把前面所有 tool 调用和结果的要点浓缩成三四行摘要然后清空被压缩的部分摘要作为新的 system 一部分继续保留。很多框架都有类似机制但你自己写下一次的收获非常大——你真正理解了为什么 Agent 会“忘事”、为什么压缩后仍然可能丢失细节而不是只会调用框架的memory参数。我建议把这个扩展放在习题主体完成之后再做因为滑动窗口很可能引入新的 BUG摘要丢失是常态模型压缩摘要时可能会丢掉一个关键观测进而影响最终答案。提前在简单题上验证这一步后面做真实项目时才敢放开用。6. 常见问题与排查技巧实录6.1 模型就是不调用工具满嘴跑火车这是新手最容易遇到的头号问题。模型在对话里直接给出“城市A今天天气是晴天”完全没调用工具答案全靠编。排查方向有三个第一系统提示词里是否清楚写明了“你无法直接知道实时数据必须调用工具获取”很多模型没有默认的“工具意识”你不强调它就会直接回答。第二温度是否太高已经说过Agent 场景调低到 0.1 左右。第三工具描述是否足够具体你要检查工具描述里有没有明确说明“当用户问天气时必须调用此工具”而不仅仅是“查询天气”。一个有效的强制手段是在系统提示词里加一句“在回答任何涉及实时数据的问题前必须先在 THOUGHT 中说明你缺少数据并计划调用工具否则直接输出 FINAL ANSWER 将被视为错误响应。”这类显式约束对大多数模型都管用。6.2 ACTION 解析失败的三种典型表现解析失败也是高频问题我总结过三种最常见的表现形态你在调试时可以对号入座。第一种模型输出了ACTION: get_city_weather(city城市A)。这是函数调用的 Python 风格不是 JSON。正则能匹配到工具名但 params_text 后半部分根本解析不了。我的处理是解析失败时将“参数需要标准 JSON 格式”作为观察结果返回给模型通常下一轮它就会修正成{city: 城市A}。第二种模型输出了ACTION: get_city_weather({city: 城市A}); ACTION: days_between(...)。一回合想调用多个工具这在当前循环结构里是不支持的。产生原因往往是任务确实需要两个工具协同。我的处理是解析正则只取第一个 ACTION后续内容当作观察结果的一部分丢回给模型模型看到“一次只能调用一个工具”的反馈后就会拆成多轮执行。第三种模型输出了ACTION: get_city_weather({city “城市A”})参数里混入了中文冒号和中文引号。这最贴近真实大模型的输出习惯。我的处理是在json.loads失败后先把中文标点替换成英文标点再解析。这个替换逻辑极其实用建议在解析层保留。6.3 上下文超限、死循环和其他运行期异常任务特别长时你可能会看到模型把早期观察结果忘掉或者干脆在某一轮之后就偏离主题。这是上下文窗口被占满后的典型表现。排查时先看两个指标消息列表里每个 message 的 token 数以及当前总 token 数。如果总 token 数接近模型限制优先实现 5.2 节的滑动窗口压缩而不是继续盲目加轮数。死循环问题表现为模型反复调用同一个工具。比如它已经查到天气下一轮还是继续查同一个城市的天气。这种情况通常是因为 observe 没有给足结构化信息或模型想确认但没意识到自己已经有了答案。解决方案有两个层面代码层限制最大迭代次数这是底线prompt 层在观察结果里补充“该工具最近已调用过一次除非用户发出新指令否则不要重复调用”。把这两层组合起来死循环基本能被防住。最后还要留一个调试技巧永远为每轮循环打印 token 数或消息列表长度。你不需要理解每一条日志但一旦任务异常回看日志能非常快地定位是模型决策出了问题还是工具执行出了问题。我当时排查一个 JSON 解析问题就是靠日志发现模型输出的 ACTION 里混入了不可见字符单看代码完全想不到原因。这道题做完最直接的收获是你对“什么是智能体”有了切身的体感它不再是一个模糊术语而是一段固定逻辑加一个灵活控制策略的工程结构。后面再去用现成框架你会比只学过文档的人多一层理解——你清楚框架的哪一层在做工具注册、哪一层在做循环控制、哪一层在做记忆管理出问题时也不会对着黑盒干瞪眼。我个人建议等这道题跑通之后再回头把框架源码里对应你手写部分的那段代码翻出来对比一下你会发现自己的代码已经具备了接近工业实现的雏形而这也是第六章想让你真正掌握的东西。