从零手写 ReAct Agent:最小闭环架构与工程踩坑指南
1. 为什么我要从零手写一个 Agent 而不是直接套框架市面上关于 Agent 的教程和框架已经多到让人眼花缭乱LangChain、AutoGPT、CrewAI、AutoGen随便挑一个都能在半小时内跑出一个能对话、能调工具的 Demo。但我还是决定从零开始手写一个原因很简单框架帮你省掉的每一行代码都会在你需要排查问题时变成一堵墙。我见过太多人用 LangChain 搭了个 Agent跑起来效果不错结果一遇到工具调用失败、上下文丢失、循环停不下来这类问题就完全不知道从哪下手。因为整个执行链路被框架封装成了黑盒你看到的只是agent.run()返回了一个结果中间发生了什么、Prompt 是怎么拼的、工具调用的返回值是怎么塞回上下文的一概不知。所以这个学习记录系列的第一篇我想先把最核心的东西讲清楚一个 Agent 的最小可用架构到底由哪几部分组成每一部分为什么必须存在以及手写的时候最容易在哪些地方翻车。这不是一篇框架使用教程而是一篇拆开看里面到底有什么的拆解记录。适合的读者是已经用过大模型 API、写过基本的 Prompt但对 Agent 的内部运转机制还比较模糊想自己动手搭一个但不知道从哪开始的人。如果你已经能熟练手写 ReAct 循环这篇可能对你偏基础但里面关于工具设计和上下文管理的踩坑经验应该还是有参考价值。2. Agent 的最小闭环四个不可省略的组成部分2.1 大模型本身不是 Agent它只是决策器很多人第一次接触 Agent 的时候会有一个误解觉得用了 GPT-4 就是 Agent 了。不是的。大模型在 Agent 里扮演的角色非常明确它是一个决策器负责根据当前上下文决定下一步做什么。它不负责执行不负责存储状态也不负责判断什么时候该停。这个认知非常重要因为它决定了你写代码时的分工。大模型只做一件事输入一段文本包含系统提示、历史对话、工具描述、当前观察结果输出一段文本通常是一个动作指令或者一个最终答案。剩下的所有事情——解析输出、执行工具、管理上下文、判断终止条件——都是你的代码要做的。我一开始犯的错误就是把太多东西塞给模型去自己判断。比如让模型自己决定要不要继续循环、自己记住之前调用过哪些工具。结果就是模型经常忘记之前做过什么或者在一个死胡同里反复打转。后来我把这些逻辑全部收到代码层面模型只负责当前这一步该干什么整个系统立刻稳定了很多。2.2 工具层Agent 的手和脚工具就是 Agent 能调用的外部函数。没有工具Agent 就只是一个聊天机器人。工具层的设计直接决定了 Agent 的能力边界。一个工具通常包含三个部分名称、描述、参数定义。名称是模型调用时用的标识符描述是给模型看的这个工具是干什么的参数定义告诉模型调用时需要传什么。这三样东西都会被拼进 Prompt 里所以它们的写法直接影响模型的调用准确率。我踩过的一个坑是工具描述写得太模糊。比如我写了一个叫search的工具描述是搜索信息。结果模型经常在不该搜索的时候调用它或者在需要搜索的时候传了完全无关的参数。后来我把描述改成根据关键词在本地知识库中检索相关文档片段返回最匹配的前三条结果适用于需要查找具体事实或数据时使用调用准确率立刻上了一个台阶。工具描述不是写给人看的注释是写给模型看的说明书。你写得多清楚模型就用得多准确。2.3 上下文管理最容易被低估的核心模块上下文管理是 Agent 里最不显眼但最关键的模块。它负责维护一个消息列表每次循环时把系统提示、历史消息、工具调用记录、工具返回结果按顺序拼起来发给模型。这里有几个容易翻车的点。第一是上下文长度爆炸。每次工具调用都会往上下文里追加内容几轮下来 token 数就爆了。我的做法是给工具返回结果设一个长度上限超出部分截断并加省略标记。第二是消息顺序错乱。工具调用的返回结果必须紧跟在对应的调用请求后面顺序错了模型就会困惑。第三是系统提示被淹没。当上下文很长时开头的系统提示对模型的影响力会下降我通常会在每几轮之后重新强调一次核心指令。2.4 循环控制什么时候停比什么时候继续更重要Agent 的主循环逻辑其实很简单调用模型 → 解析输出 → 如果是工具调用就执行工具并把结果追加到上下文 → 再次调用模型 → 直到模型输出最终答案或者达到最大轮数。但什么时候停这个问题比想象中复杂。模型有时候会陷入循环反复调用同一个工具有时候会在应该继续的时候提前给出答案有时候会因为工具报错而卡住。我的做法是设三重保险最大轮数限制、重复调用检测、错误重试上限。任何一个触发就强制终止并返回当前状态。3. ReAct 模式的手写实现从 Prompt 到解析的完整链路3.1 为什么选 ReAct 作为起点Agent 的推理模式有很多种ReActReasoning Acting是最经典也最适合入门的一种。它的核心思想是让模型在每一步都先输出一段思考Thought然后输出一个动作Action代码执行动作后把观察结果Observation返回给模型模型再继续下一轮思考。选它作为起点的原因很实际结构清晰、容易调试、对模型能力要求相对低。每一步的输入输出都是可读的文本出了问题直接看日志就知道是哪一环断了。相比之下一些更复杂的规划模式比如先制定完整计划再执行虽然在某些任务上效果更好但调试难度高很多不适合作为第一个手写项目。3.2 Prompt 模板的设计细节ReAct 的 Prompt 模板通常长这样你可以使用以下工具 {tool_descriptions} 请按照以下格式回答 Question: 需要回答的问题 Thought: 你的思考过程 Action: 要使用的工具名称必须是 [{tool_names}] 中的一个 Action Input: 工具的输入参数 Observation: 工具返回的结果 ...Thought/Action/Action Input/Observation 可以重复多次 Thought: 我现在知道最终答案了 Final Answer: 对问题的最终回答 开始 Question: {input} Thought: {agent_scratchpad}这里有几个设计细节值得展开说。agent_scratchpad是存放历史 Thought/Action/Observation 的地方每次循环都会把新的步骤追加进去。tool_names是所有可用工具名称的列表用来约束模型的输出范围。格式说明里的必须是其中之一这句话很重要没有它模型可能会编造不存在的工具名。我实测下来发现在格式说明后面加一句如果你不确定该用什么工具请直接输出 Final Answer 说明你无法完成能显著减少模型胡乱调用工具的情况。这个技巧在官方文档里通常不会写但是实际用起来效果很好。3.3 输出解析正则表达式是最稳的方案模型输出的文本需要被解析成结构化的动作指令。我试过三种方案让模型输出 JSON、用正则表达式提取、用另一个模型来解析。JSON 方案的问题是模型经常输出不合法的 JSON比如多一个逗号、少一个引号解析直接报错。用另一个模型解析成本太高而且引入了额外的不确定性。正则表达式是最稳的因为 ReAct 的格式是固定的Thought:、Action:、Action Input:、Final Answer:这些标记词很明确用正则提取对应的内容就行。import re def parse_output(text): # 先检查是否有最终答案 final_match re.search(rFinal Answer:\s*(.), text, re.DOTALL) if final_match: return {type: final, content: final_match.group(1).strip()} # 提取动作和参数 action_match re.search(rAction:\s*(.), text) input_match re.search(rAction Input:\s*(.), text, re.DOTALL) if action_match and input_match: return { type: action, action: action_match.group(1).strip(), input: input_match.group(1).strip() } return {type: unknown, raw: text}注意re.DOTALL这个标志没有它的话.不匹配换行符多行输入会解析失败。这个坑我踩过调试了半小时才发现是正则标志的问题。3.4 工具执行与结果回填解析出动作之后代码需要找到对应的工具函数并执行。这里的关键是异常处理必须做足。工具执行可能因为各种原因失败参数格式不对、外部服务超时、返回结果为空。任何一种失败都不应该让整个 Agent 崩溃而应该把错误信息作为 Observation 返回给模型让模型决定下一步怎么办。def execute_tool(tool_name, tool_input, tools): if tool_name not in tools: return f错误不存在名为 {tool_name} 的工具 try: result tools[tool_name](tool_input) # 截断过长的结果 if len(str(result)) 2000: result str(result)[:2000] ...结果已截断 return str(result) except Exception as e: return f工具执行出错{str(e)}把错误信息返回给模型而不是直接抛异常这个设计的好处是模型有机会自我修正。比如它传错了参数格式看到错误信息后下一轮可能会用正确的格式重试。当然这也不是万能的所以还需要配合最大轮数限制。4. 工具设计里那些文档不会告诉你的坑4.1 工具粒度太细和太粗都会出问题工具设计的第一个决策是粒度。一个工具是应该做一件小事比如读取文件还是做一件大事比如分析文件并生成报告我的经验是工具应该做原子操作但原子操作的边界要按模型能理解的最小单元来划分。举个例子读取文件和写入文件应该是两个工具因为它们对应两个不同的意图。但打开文件句柄和读取文件内容就不应该分开因为模型不需要理解文件句柄这个概念。太细的工具会让模型在多个工具之间反复横跳增加出错概率。太粗的工具则会让模型无法灵活组合而且一个工具内部逻辑太复杂的话出错时很难定位问题。4.2 参数设计能用字符串就别用复杂结构工具的输入参数尽量设计成简单的字符串或数字。我一开始设计了一个工具参数是一个嵌套的 JSON 对象结果模型十次有八次传不对格式。后来改成用简单的分隔符拼接字符串比如文件名|关键词|最大条数模型反而每次都能传对。原因不难理解模型生成嵌套 JSON 的准确率远低于生成简单字符串。而且简单字符串在 Prompt 里描述起来也更清晰模型更容易理解每个位置该填什么。4.3 返回值格式结构化但别太复杂工具返回给模型的结果应该是人类可读的文本而不是 JSON 或 XML。模型对自然语言的理解能力远强于对结构化数据的解析能力。如果你的工具返回的是一个列表把它格式化成每行一条的文本比返回 JSON 数组效果好得多。另外返回值里最好包含一些上下文信息。比如搜索工具返回结果时除了内容本身还应该带上来源、时间等元信息这样模型在后续推理时能利用这些信息做判断。5. 上下文窗口管理Agent 跑着跑着就失忆怎么办5.1 上下文膨胀的三种典型表现Agent 跑了几轮之后上下文会越来越长然后出现三种典型症状。第一种是模型开始忽略早期指令比如系统提示里说了不要调用不存在的工具但模型还是编了一个工具名。第二种是响应变慢因为每次请求的 token 数在增加。第三种是成本飙升这个不用解释。根本原因是注意力机制的特性上下文越长模型对每个位置的注意力越分散早期内容的影响力会被稀释。这不是模型记性不好而是机制决定的。5.2 我的截断策略保留头尾压缩中间处理上下文膨胀最直接的方法是截断。但不能简单地从头截或从尾截因为头部的系统提示和尾部的最近交互都很重要。我的策略是系统提示永远保留最近 N 轮完整保留中间的旧轮次压缩成摘要。摘要可以用一个便宜的小模型来生成把多轮工具调用压缩成一两句话。比如调用了搜索工具查找 X得到了 Y 的结果这样一句话就能替代原来几百 token 的完整记录。如果不想引入额外的模型调用也可以做简单的规则压缩只保留每轮的工具名和结果的前 100 个字符去掉 Thought 部分。实测下来这样能省掉大约 60% 的 token而且对效果的影响可以接受。5.3 系统提示的锚定技巧除了截断还有一个技巧是在上下文中定期重复关键指令。我通常会在每 5 轮之后在上下文末尾追加一条系统消息重申核心约束比如记住只使用已定义的工具输出格式必须严格遵循模板。这个做法相当于给模型重新锚定一下能明显减少长对话中的格式漂移问题。6. 循环终止与异常兜底让 Agent 该停的时候真的能停6.1 最大轮数不是万能的但没有它万万不能最大轮数限制是最基本的兜底机制。我一般设 10 到 15 轮具体取决于任务复杂度。设太少了任务做不完设太多了浪费资源。但光有最大轮数不够因为模型可能在 3 轮内就陷入死循环等到 15 轮才停已经浪费了很多调用。6.2 重复调用检测识别鬼打墙重复调用检测的逻辑很简单记录最近几轮的工具名和参数如果连续两轮完全一样就判定为循环。这时候可以采取两种策略一是直接终止并返回当前结果二是在上下文中插入一条提示你已经调用过这个工具并得到了相同的结果请尝试其他方法或给出最终答案。我倾向于第二种因为它给了模型自我纠正的机会。实测下来大约有一半的情况模型看到提示后会改变策略另一半情况会继续重复这时候再触发强制终止。6.3 工具报错的降级处理工具报错是常态不是异常。网络超时、API 限流、参数错误这些都会发生。关键是要有降级策略。我的做法是给每个工具设一个重试次数上限通常 2 次超过之后不再重试把错误信息返回给模型。如果同一个工具连续报错 3 次就在上下文中插入提示建议模型换一个工具或直接给出答案。7. 调试 Agent 的笨办法打印一切7.1 每一步的输入输出都要落盘调试 Agent 最有效的方法就是把每一步的完整输入和输出都写到日志文件里。包括发给模型的完整 Prompt、模型返回的原始文本、解析后的动作、工具执行的结果、当前上下文的消息数量。这些信息在出问题的时候就是你的黑匣子。我习惯用 JSON Lines 格式记录每行一个 JSON 对象包含时间戳、步骤编号、事件类型和具体内容。这样后续可以用脚本分析比如统计平均轮数、工具调用分布、错误率等。7.2 用固定输入做回归测试Agent 的行为有随机性同样的输入可能得到不同的输出。但这不意味着不能做测试。我的做法是准备一组固定的测试用例输入 期望的工具调用序列每次修改 Prompt 或工具之后跑一遍看通过率有没有下降。虽然不能保证 100% 通过但如果通过率从 80% 掉到 50%那肯定是什么地方改坏了。7.3 把 Prompt 单独拿出来调很多时候问题出在 Prompt 上而不是代码上。我的习惯是把 Prompt 模板单独拿出来手动填入不同的上下文直接调模型 API 看输出。这样能快速定位是 Prompt 的问题还是解析代码的问题。如果手动调模型输出正常但 Agent 跑起来不对那问题就在解析或上下文拼接上。8. 写在跑通第一个 Demo 之后从零手写一个 ReAct Agent代码量其实不大核心逻辑加起来可能就两三百行。但这两三百行里包含的决策点非常多Prompt 怎么设计、工具怎么定义、上下文怎么管理、循环怎么控制、错误怎么处理。每一个决策点都有多种方案每种方案都有取舍。我最大的体会是Agent 的稳定性不来自于模型有多强而来自于工程层面的兜底有多完善。模型一定会犯错一定会跑偏一定会陷入循环。好的 Agent 系统不是让模型不犯错而是让模型犯错之后系统还能正常运转。下一步我打算在这个最小闭环的基础上加几个东西一是多工具的组合调用看模型能不能自己规划出先搜索再计算这样的流程二是加一个简单的记忆模块让 Agent 能跨会话记住一些信息三是做一个简单的评测集量化不同 Prompt 策略的效果差异。这些都会在后续的学习记录里展开。如果你也在手写 Agent我的建议是先把最小闭环跑通别急着加功能。把 ReAct 的每一步都打印出来看一遍理解模型在每个位置看到了什么、输出了什么、为什么这么输出。这个笨功夫花下去后面加任何复杂功能都会顺利很多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →