从零开始搭一个像样的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框架都会觉得顺理成章得多。