news 2026/10/18 4:02:08

手把手实现ReAct Agent:学习计划生成实战与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手把手实现ReAct Agent:学习计划生成实战与避坑指南

从零开始搭一个像样的Agent,难点往往不在某个框架怎么用,而在“循环”这个东西到底怎么写。我连续记录了十几天AI Agent学习笔记,今天第14天,终于把一个完整的ReAct Agent跑通了。这个Agent的目标很具体:让AI根据用户输入的一句话,自动生成一份可执行的学习计划。它会自己拆解目标、调用工具查日期和课程库、按周排期、最后输出一份Markdown计划文档。整个过程走的是ReAct路线,也就是Reasoning与Acting交替进行。这篇文章把完整实现的思路、代码和实测轨迹都记录下来,尤其会讲清楚那些文档里不会写的细节和坑,适合正在学Agent、想动手写完一个能跑通的任务型Agent的开发者参考。

1. 为什么非要ReAct:把推理和行动塞进同一个循环

1.1 一个Prompt做不到的事

在学Agent之前,我对LLM的用法还停留在“一次调用、输出结果”。但当你让AI真正去做成一件事,比如生成学习计划,你很快会发现,单次Prompt有硬伤:模型不知道今天几号,就没法按日期排期;模型不知道课程库里有什么课,就只能凭记忆编课程名;模型生成完计划,也没能力把文件保存下来。

这些事单靠Prompt词写的再花哨也没用,因为模型缺少对外部世界的影响能力。ReAct的方式是把整个过程拆成一个循环:模型先思考下一步该干什么,然后执行一个具体动作,看到动作的结果,再继续思考。这个循环一直持续到模型认为任务已经完成。

这套思想其实不复杂。想想人是怎么做菜的:先看菜谱想清楚要做红烧肉(Thought),去冰箱拿五花肉(Action),发现肉没解冻(Observation),于是决定先放微波炉解冻(Thought),再执行解冻(Action)。ReAct就是把这种“想一步、做一步、看一步”的过程模型化,让LLM在其中承担“大脑”的角色,各种工具承担“手脚”的角色。

1.2 三角色循环:Thought、Action、Observation

ReAct里的三个核心角色,每个都有明确分工:

  • Thought:模型的推理过程,解释“我为什么这么做”,这一部分通常不展示给用户,但对调试很有用。
  • Action:模型决定要执行的动作,例如调用某个工具、访问某个函数,需要带参数。
  • Observation:工具执行之后返回的结果,作为新的输入重新喂给模型。

整个循环用伪代码看就是:

while not finished: response = llm(conversation_history, tools) if response.contains_final_answer(): return response.final_answer else: tool_name = response.action tool_args = response.arguments result = execute_tool(tool_name, tool_args) conversation_history.append(observation=result)

这个循环之所以有效,是因为每一步的观察结果都会改变模型下一步的推理方向。模型第一次可能决定查日期,看到日期后决定查询课程库,看到课程库结果后再决定怎么排计划。没有这个循环,模型只能一次性输出一个看似合理但没有任何外部信息支撑的计划。

1.3 两种落地方式怎么选

我这次实现参考了两种ReAct落地方式。

第一种是走LLM原生Function Calling能力,模型返回结构化的工具调用请求,代码里直接判断tool_calls字段是否为空。这种方式解析稳定,基本不会出现格式错乱,适合生产环境。

第二种是纯文本协议方式,也就是让模型输出固定的Thought/Action/Action Input文本,再用正则或字符串解析。这种方式对模型要求更高,但兼容任何模型,不需要特别训练支持Function Calling。

我这次选了Function Calling方向,原因是稳定性更好。文本协议我前期试过,效果其实也OK,但模型偶尔会在Action后的解释里混入不该出现的文字,解析器一崩就是连锁反应。两者我也做了个简单对比:

对比维度Function Calling方式纯文本协议方式
解析稳定性高,模型返回结构化字段中,依赖格式约束和解析器
模型兼容性需要支持工具调用任意模型均可
调试直观度工具调用和普通回复区分清楚需要看原始文本
实现复杂度略高略低,适合学习原理
项目建议生产项目首选学习原理或对接老模型时用

如果你的目标是把ReAct机制彻底搞懂,我建议两种方式都写一遍,尤其是纯文本协议那版,会让你对交互格式的理解深入很多。如果只想要一个能跑的结果,直接上Function Calling就行。

2. 整体设计:给学习计划生成Agent搭骨架

2.1 需求拆解

在动手写代码之前,我先花时间把“生成学习计划”这个需求拆清楚了。用户可能输入这样一句话:我零基础,想用3个月系统地学习机器学习,帮我安排一个学习计划。

如果直接让LLM输出计划,它大概率会给出一个看起来很专业、但完全没有考虑现实约束的文档。我期望这个Agent产出的是一份包含阶段划分、每周具体任务、参考资料、自检方式的可执行计划。为了达到这个目标,Agent至少要回答这几个问题:

  • 今天是几号?3个月后是哪天?几号开始执行?
  • 课程库里有哪些适合零基础用户的课程?每个课程多长时间?
  • 学习目标如何分解到周?每周任务怎么做?
  • 最终计划如何落地成一份可保存、可阅读的文档?

这四类问题分别对应了“获取日期信息”“查询课程库”“推理规划”“调用输出工具”四个能力需求。这也是ReAct Agent和普通对话机器人的核心区别:它不是想起来什么说什么,而是为了完成任务主动调用工具、获取信息,再基于信息做决策。

2.2 三个核心模块的职责边界

整个Agent我拆成了三个模块,职责边界一开始就别扯到一起:

  • 工具注册表:维护所有可用工具的名称、描述、参数Schema、执行函数。模型不允许调用未注册的工具。
  • 循环控制器:负责维护对话历史,调用LLM,判断模型输出是工具调用还是最终回答,执行工具并把结果写回历史。
  • 任务记忆:也就是对话上下文。包含System Prompt里的角色设定、用户目标、每轮工具调用的输入和输出。

这里最需要注意的是任务记忆的维护。工具调用结果如果不回写到对话历史里,模型就相当于失忆了,下一步没法基于观察结果推理。很多新手Agent写出来总是一顿操作猛如虎,最后回答却完全没用到工具查询结果,基本都是这个环节漏了。

2.3 为什么选循环而非单次多步调用

这个选型的核心动机是可观测性和可控性。如果采用单次调用让模型一次性输出完整计划,那中间任何一步出错,你只能看到最终结果,根本不知道是日期算错了还是课程信息编造了。而用循环结构,每一轮Thought和Observation都留在对话历史上,我作为开发者可以完整追踪Agent的每一步决策。

另一个原因是容错。循环结构天然支持重试:如果工具返回异常,可以把异常信息作为Observation回传给模型,让模型决定换个参数重试,或者改用别的方案。这种自适应能力是单次调用完全不具备的。

3. 完整实现:手写一个学习计划ReAct Agent

3.1 工具层设计与注册

工具层是Agent的“手脚”。我在这个Demo里注册了三个工具,不多不少,刚好覆盖一个完整ReAct流程需要的信息获取和输出落地能力:

  • get_current_date:获取当前日期,用来推算学习周期的起止时间。
  • search_courses:按主题、难度关键词查询课程库,返回匹配的课程列表。
  • save_plan:将Markdown格式的最终计划保存到本地文件,返回文件路径。

每个工具都按Function Calling的标准格式定义,重点是把description写得足够精确。这里有个实操经验:模型的工具选择能力很大程度上依赖description质量,描述太含糊,模型就不知道该在什么时候调哪个工具。

TOOLS = [ { "type": "function", "function": { "name": "get_current_date", "description": "获取当前日期。当需要推算学习周期、截止日期或排期时,调用此工具。", "parameters": { "type": "object", "properties": {}, "required": [] } } }, { "type": "function", "function": { "name": "search_courses", "description": "按主题和难度查询课程库中的可用课程。返回课程名称、时长和难度。", "parameters": { "type": "object", "properties": { "topic": { "type": "string", "description": "学习主题,如机器学习、Python基础" }, "level": { "type": "string", "enum": ["beginner", "intermediate", "advanced"] } }, "required": ["topic"] } } }, { "type": "function", "function": { "name": "save_plan", "description": "将生成的学习计划以Markdown格式保存到本地文件。", "parameters": { "type": "object", "properties": { "content": { "type": "string", "description": "完整的Markdown格式学习计划内容" }, "filename": { "type": "string", "description": "文件名,需以.md结尾" } }, "required": ["content", "filename"] } } } ]

search_courses的真实实现就是一个字典加过滤逻辑,但为了真实感我给它模拟了一个课程库:

COURSE_DB = [ {"name": "机器学习导论", "topic": "machine_learning", "level": "beginner", "weeks": 6}, {"name": "Python快速入门", "topic": "python", "level": "beginner", "weeks": 4}, {"name": "线性代数与概率统计基础", "topic": "math", "level": "beginner", "weeks": 5}, {"name": "机器学习实战项目", "topic": "machine_learning", "level": "intermediate", "weeks": 8}, {"name": "深度学习进阶", "topic": "deep_learning", "level": "advanced", "weeks": 10} ] def search_courses(topic, level=None): result = [c for c in COURSE_DB if topic.lower() in c["topic"]] if level: result = [c for c in result if c["level"] == level] return result

如果你在自己项目里做工具层,我建议把工具执行函数做成统一的接口签名,比如都是params字典进、字符串出。不要搞五花八门的返回类型,因为Observation要拼进对话历史,统一成字符串大大简化处理逻辑。

3.2 Agent循环核心代码

循环控制器是Agent的心脏。逻辑写出来其实就十几行,但每行的位置都很有讲究。我先把完整代码贴出来,再逐行拆。

import json def run_agent(user_input, max_steps=8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for step in range(max_steps): response = call_llm(messages, tools=TOOLS) msg = response.choices[0].message if msg.tool_calls: messages.append({ "role": "assistant", "content": None, "tool_calls": msg.tool_calls }) for tool_call in msg.tool_calls: tool_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) result = execute_tool(tool_name, arguments) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "name": tool_name, "content": str(result) }) else: return msg.content return "已达最大步数限制,任务未在预期内完成" def execute_tool(name, args): if name == "get_current_date": return get_current_date() if name == "search_courses": return search_courses(**args) if name == "save_plan": return save_plan(args["content"], args["filename"]) raise ValueError(f"未知工具: {name}")

有几个细节我要特别指出。

第一个是max_steps。循环必须有个上限,否则模型一旦陷入反复调用工具的循环,你的token账单会非常难看。我这次设8步,对一个学习计划任务来说完全够用。

第二个是tool_call的messages回写格式。assistant这条消息必须原样带上tool_calls字段,然后在后续消息里用tool_role类型一一对应工具结果。这里有个常见坑:忘记回写assistant的tool_calls消息,直接写tool消息,很多接口会报错或者模型无法正确关联。

第三个是执行工具时要包一层异常处理。工具执行抛异常的情况很常见,比如参数缺字段、文件路径不存在。我的做法是捕获异常后把异常信息作为Observation回传,让模型自己决定怎么处理。这样就少了很多因为工具崩溃导致整个Agent挂掉的场景。

try: result = execute_tool(tool_name, arguments) except Exception as e: result = f"工具执行失败: {str(e)}"

3.3 学习计划任务的提示词设计

System Prompt在这个Agent里承担双重角色,既定义行为边界,又定义任务目标。我写的是:

SYSTEM_PROMPT = """ 你是一个学习规划助手。你的目标是根据用户的学习目标,生成一份可执行的学习计划。 在生成计划之前,你必须: 1. 先调用 get_current_date 获取当前日期,明确学习周期的起止时间。 2. 调用 search_courses 查询课程库,只使用课程库中真实存在的课程。 3. 将完整计划保存为 Markdown 格式文档。 计划中必须包含: - 学习目标拆解:至少拆成3个阶段 - 时间安排:按周划分,注明起止日期 - 每周学习内容与产出物 - 自检清单:每个阶段结束后如何验证学习成果 只有当你确认计划生成完毕并保存成功后,才输出最终的总结回答。 你的原始回复中不要出现Thought/Action等调试字段,只输出给用户的最终计划说明。 """

这段Prompt我调过好几轮。第一版没有强调“只使用课程库中真实存在的课程”,结果模型编出了两个根本不存在的课程名放进计划里,这属于工具诈骗式的幻觉。加了这条约束后,模型老实多了。第二版加了“只有保存成功后才输出最终回答”,这个也重要,否则模型会在调用save_plan之前就抢先输出总结,导致计划根本没存下来。

3.4 一次真实运行的轨迹复盘

我在测试环境跑了一个案例,输入是:我零基础,想用3个月系统地学习机器学习,帮我安排一个学习计划。

Agent的完整轨迹记录如下:

第一轮:

  • Thought:用户是零基础,目标是机器学习,周期3个月。我需要先确认起始日期,才能把3个月折算成具体的周数和截止时间。
  • Action:get_current_date
  • Observation:2025-06-10

第二轮:

  • Thought:今天是6月10日,3个月后是9月10日左右。接下来查询课程库中适合初学者的机器学习课程,看看课程库里有哪些可用资源。
  • Action:search_courses(topic="machine_learning", level="beginner")
  • Observation:[{"name": "机器学习导论", "level": "beginner", "weeks": 6}]

第三轮:

  • Thought:课程库里零基础机器学习课程只有机器学习导论,但用户要学3个月,只学一个6周课程明显不够。我需要同时查询Python基础课,因为机器学习零基础通常需要先补编程基础。
  • Action:search_courses(topic="python", level="beginner")
  • Observation:[{"name": "Python快速入门", "level": "beginner", "weeks": 4}]

第四轮:

  • Thought:现在的组合是Python快速入门(4周) + 机器学习导论(6周),加起来10周,仍然有空余。查询一下实战项目课程,让计划后半段更充实。
  • Action:search_courses(topic="machine_learning", level="intermediate")
  • Observation:[{"name": "机器学习实战项目", "level": "intermediate", "weeks": 8}]

第五轮:

  • Thought:如果加入机器学习实战项目,总周期会超过3个月。我决定这样安排:前4周学Python快速入门,中间6周学机器学习导论,最后的2周做一个综合性的学习收尾与自测。现在把完整计划生成为Markdown并保存。
  • Action:save_plan(...)
  • Observation:保存成功:学习计划已保存到 /plans/machine_learning_plan.md

第六轮:

  • Thought:计划已成功保存,任务完成。输出最终总结。
  • Final:根据你的要求,我为你生成了12周的机器学习入门学习计划,覆盖Python基础、机器学习核心理论与综合实战自测,各阶段的具体安排和自检方式如下……

这段轨迹展现了ReAct最有价值的部分:模型在第三轮发现课程库存量不够时,自己调整了检索策略,没有硬编造课程。这是纯Prompt生成做不到的自适应行为。我拿到这份轨迹时最直观的感受是,Agent不再是“一问一答”,它真的在一个目标驱动下,像人一样先搞清楚条件,再行动。

4. 实操中的坑与排查实战

4.1 循环不终止,Agent反复调用同一个工具

我实际跑的时候遇到第一个大坑:Agent在查到课程列表之后,反复调用search_courses,每次参数都一样,来回好几次。原因是工具返回结果被原样塞进对话后,模型觉得“信息还不够”,但又不清楚接下来真正该做什么。

解决思路有两层。第一层是靠max_steps兜底,这个必须有,哪怕只是防止账单爆炸。第二层是在System Prompt里加一句:当工具返回数据不足以支撑决策时,说明理由并基于已有信息做出最优判断,继续推进任务。我改完这句之后,循环次数明显下降了。还有一个方案是检测到连续两次相同的工具调用时,主动打断并提醒模型,但这个比较复杂,我的项目里还没用上。

4.2 工具参数解析失败

模型返回的工具参数偶尔会不符合Schema。最常见的两种:一是JSON格式里混入了多余逗号,二是必填字段缺失。我加了一层轻量级的参数校验和修复:

def safe_parse_args(raw_args, required_keys=None): try: args = json.loads(raw_args) except json.JSONDecodeError: args = {} if required_keys: for k in required_keys: if k not in args: args[k] = "" return args

这里经验是:不要指望模型每次都完美按Schema输出。线上项目最好的做法是把“参数校验失败”本身作为一个Observation返回给模型,让它根据错误信息自我修正。我在Demo里做了更温和的处理,缺的字段给空值,但生产环境我建议还是发回重拟,因为空值可能导致工具执行产生错误结果。

4.3 上下文长度爆炸

随着工具调用轮数增加,对话历史里的Observation会越堆越多。尤其是我把整个课程库JSON都作为工具返回时,几轮下来消息体积就很可观。模型上下文窗口有限,历史一旦接近上限,轻则态度变差,重则报错。

我的临时方案是给工具返回加上截断逻辑。比如search_courses返回结果超过5条时,只保留前5条并追加提示“共N条结果,已截断”。另一个思路是在Agent循环里对历史做摘要压缩,这是后续要优化的方向。如果你在做生产级系统,建议在进入Agent循环前估算token占用,达到阈值就触发摘要压缩或遗忘机制。

4.4 模型答非所问,不按计划执行

没加严格的流程约束之前,模型经常跳过工具调用直接回答用户“我可以帮你制定一个学习计划,首先是……”。这本质上是模型习惯了聊天,不习惯干活。

排查下来,根因是System Prompt里的“必须调用工具”约束不够强。后来我在Prompt里明确了顺序:“在生成计划之前,必须先调用get_current_date,不允许跳过此步骤,否则认为你没有完成准备阶段。”同时把生成流程拆成了准备阶段和编排阶段,模型就很少再跳了。如果还出现这种情况,可以把temperature调低到0.2左右,减少模型自由发挥的空间。

5. 后续还可以怎么扩展

5.1 对话式计划调整

现在这个Agent只能一次性生成计划。扩展方向是让用户看完计划后说“第三周时间太紧了,帮我减掉一半内容”,Agent就能基于已有的对话历史,先读取已保存的计划文件,再针对第三周做局部修改。这需要新增一个read_plan工具,让Agent能主动读取文本再修改。本质上是把单轮的ReAct扩展成多轮会话式ReAct,核心循环不变,只是把“用户输入”从一次变成持续交互。

5.2 多Agent分工协作

我这十几天的笔记里,多Agent是另一个大方向。比如规划Agent负责拆解目标,检索Agent专门负责查课程资料,质检Agent负责检查计划是否包含自检机制。每个Agent各自维护一个小型ReAct循环,通过消息交互完成协作。这套架构的好处是每个Agent职责单一,System Prompt约束起来更容易,问题定位也更清楚。代价是多Agent串行调用会增加latency和token消耗,小项目不建议一上来就上这种复杂度。

5.3 持久化与执行追踪

学习计划生成出来只是第一步,计划执行阶段更有价值。扩展思路是把用户的每日学习打卡回填到一个状态文件里,Agent定期对比计划进度与实际进度,生成调整建议。这样Agent就从“一次性规划工具”变成了“长期学习伴侣”,对应的是记忆持久化和状态管理的问题。这也是我接下来几天要实验的方向,后面有了结果再单独写一篇记录。

这次完整实现ReAct Agent,我个人最大的收获不是代码怎么组织,而是真正理解了“目标驱动的循环”这个核心心智模型。如果你也正在学Agent,我建议不要急着上框架,先自己用几十行代码写完一个能跑的循环,把Thought、Action、Observation的交互彻底吃透,后面再接触任何复杂的Agent框架都会觉得顺理成章得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 4:01:37

本地Web交互环境搭建:Express+WebSocket+Vite前后端联调实战

年初接了个带实时交互的网页需求,把页面联调折腾了一轮之后,我深刻意识到一个问题:很多所谓“交互有问题”,根源其实不是代码,而是本地环境压根没搭对。比如直接双击HTML文件调试,fetch接口直接报跨域&…

作者头像 李华
网站建设 2026/10/18 4:01:27

自建CRM系统实战:从数据模型到权限与性能优化的完整指南

1. 从Excel到自建系统:DeskcommCRM立项时最关键的三个决定1.1 为什么通用CRM在我们这里跑不起来DeskcommCRM这个名字,是我和团队一年前定下来的。当时公司的销售团队还在用Excel管客户,几十个销售每人维护一份表格,撞单、漏跟、离…

作者头像 李华
网站建设 2026/10/18 4:01:23

开源CLI代码审查工具:LLM驱动的本地化Git集成方案

1. 项目概述:一个真正能落地的开源代码审查 CLI 工具“open-code-review”这个名字乍一听像某个 GitHub 上刚创建的冷门仓库,但如果你最近在团队里被拉进过几次“人工 Code Review 会议”,盯着满屏 diff 发呆,反复确认if (x ! nul…

作者头像 李华
网站建设 2026/10/18 3:59:30

open-code-review:面向工程治理的开放代码审查协议

1. 项目概述:这不是又一个代码审查工具,而是一次对“人如何协作写代码”的重新定义 “open-code-review”这个名称乍看平平无奇,甚至有点拗口——它不像“GitHub Copilot”那样自带传播力,也不像“SonarQube”那样指向明确的静态…

作者头像 李华
网站建设 2026/10/18 3:57:20

Power Collections 实战:用 MultiDictionary 和 Set 优化 .NET 集合操作

简介:Power Collections 是一套面向 .NET/C# 开发者的通用集合类库,最初由 Wintellect 团队开发并长期维护,旨在弥补标准集合框架在数据结构与算法上的不足,适合需要更丰富容器类型、希望提升代码复用与开发效率的中高级开发者参考…

作者头像 李华
网站建设 2026/10/18 3:57:02

深度学习人脸性别与年龄识别系统实战:从模型训练到部署全流程

毕设做了个深度学习人脸性别年龄识别系统,从数据处理到模型训练再到上线部署,整条链路一个人啃下来,踩了不少坑,也总结出一些可以直接复用的经验。这篇就按我实际做完一版能跑通、能演示、能写进论文的系统来拆解:为什…

作者头像 李华