从零手写ReAct Agent:深入理解Tool Calling与LLM工具调用本质 1. 从零手写一个ReAct Agent我为什么要重复造轮子市面上Agent框架已经多到挑花眼LangChain、AutoGPT、MetaGPT、CrewAI随便挑一个都能在半小时内跑通一个能查天气、能搜网页的Demo。那我为什么还要花整整两个周末从零手写一个ReAct Agent答案很简单用框架的时候我根本不知道Tool Calling到底是怎么发生的。调一个initialize_agent(tools, llm, agentzero-shot-react-description)然后Agent就能自己决定调哪个工具、传什么参数、拿到结果后怎么继续推理。看起来很神奇但神奇背后是一团黑箱。当Agent行为不符合预期时——比如该调搜索的时候它硬编了一个答案该传JSON的时候它传了一段自然语言——我完全不知道该改哪里。调Prompt换模型升级框架版本全靠试。这种状态让我很不舒服。作为一个写了十年代码的人我信奉一个原则你无法调试你不理解的东西。所以我决定把ReAct Agent拆开用最原始的方式手写一遍不依赖任何Agent框架只用最基础的LLM API看看Tool Calling的本质到底是什么。这篇文章就是这次拆解式学习的完整记录。我会从ReAct的理论基础讲起然后一步步手写一个能用的Agent重点解释每一步为什么这么设计最后分享我在这个过程中踩过的坑和对Tool Calling本质的理解。如果你也在用Agent框架但总觉得心里没底或者想自己动手做一个可控的Agent这篇内容应该能帮到你。先给个预期读完你不会有哇Agent好简单的错觉但你会清楚地知道LLM是如何决定调用工具的以及为什么有些Agent看起来很蠢。这两件事搞明白了用任何框架都会顺手很多。2. ReAct与Tool Calling先把概念理清楚2.1 ReAct到底是什么别被名字唬住ReAct这个词是Reasoning Acting的缩写2022年由几位研究者在一篇论文里提出。核心思想用一句话概括让LLM在输出最终答案之前先显式地输出它的推理过程并根据推理结果决定是否调用外部工具。传统LLM的工作方式是输入问题 → 输出答案。中间发生了什么你完全看不到。ReAct改变了这个流程它要求LLM按照一个固定的模式工作Thought: 我需要先查一下今天的天气 Action: search_weather Action Input: {city: 北京} Observation: 北京今天晴气温25度 Thought: 我已经拿到了天气信息可以回答了 Final Answer: 北京今天晴天气温25摄氏度。这个Thought → Action → Observation → Thought → ...的循环就是ReAct的核心。LLM不再是一次性给出答案而是一步一步地思考、行动、观察、再思考直到它认为可以给出最终答案为止。这里有个关键点很多人会忽略ReAct本质上是一种Prompt Engineering技巧不是模型能力。LLM本身并不知道什么是Action它只是被Prompt引导着按照这个格式输出文本。真正解析Action和Action Input、执行工具、把结果拼回Prompt的是你写的代码。理解这一点非常重要因为它意味着Agent的智能程度很大程度上取决于你的Prompt设计和解析逻辑而不是模型本身有多强。2.2 Tool Calling的本质一场结构化的文本接龙现在说说Tool Calling。很多人以为Tool Calling是某种高级的模型能力其实剥开来看它的本质是一场精心设计的文本接龙游戏。整个流程是这样的你在System Prompt里告诉LLM你有这些工具可以用格式是Action: 工具名和Action Input: 参数LLM根据用户问题输出一段符合格式的文本比如Action: search和Action Input: {query: ...}你的代码用正则或字符串解析从LLM的输出里提取出工具名和参数你的代码执行对应的工具函数拿到结果你把结果作为Observation拼回对话历史再次调用LLMLLM看到Observation后继续输出Thought和下一步Action或者给出Final Answer重复3-6直到LLM输出Final Answer或达到最大轮数看到没LLM从头到尾只做了一件事根据当前的文本上下文预测下一段最合理的文本。它没有调用任何东西真正调用工具的是你的代码。所谓Tool Calling不过是LLM学会了按照你要求的格式输出文本而你的代码学会了从文本里提取结构化信息。这个认知一旦建立很多困惑就迎刃而解了为什么Agent有时候不调工具因为LLM觉得直接回答更合理或者你的Prompt没引导好。为什么参数格式老是错因为LLM在预测文本它可能输出{query: 北京}也可能输出query: 北京取决于你的Prompt和模型习惯。为什么换个模型Agent就崩了因为不同模型对格式的遵循能力不同有的模型天生更听话。2.3 为什么不用Function Calling API你可能会问现在OpenAI、Claude、通义千问都提供了原生的Function Calling API直接传一个JSON Schema就能让模型返回结构化的工具调用为什么还要手写文本解析两个原因。第一原生Function Calling是黑箱的升级版。它确实更稳定、更规范但你对模型如何决定调用哪个工具这件事的理解并没有加深。而且一旦模型返回的调用不符合预期你依然不知道该怎么调。第二手写一遍能让你理解原生API在做什么。当你自己实现了文本解析版的Tool Calling后再看原生Function Calling的文档你会发现它只是把Prompt引导文本解析这一步内置化了本质没变。理解了本质你就能更好地设计工具描述、处理边界情况、调试异常行为。当然生产环境我建议用原生Function Calling稳定性和准确性都更好。但学习阶段手写一遍的价值无可替代。3. 手写ReAct Agent的核心设计3.1 整体架构四个模块一个循环我的实现非常克制整个Agent就四个模块LLM客户端负责调用大模型API传入对话历史拿回文本输出。我用的是一个兼容OpenAI接口的国内模型便宜且够用。工具注册表一个字典key是工具名value是工具函数和描述。工具函数就是普通的Python函数接收参数返回字符串。Prompt构造器负责把System Prompt、对话历史、工具列表拼成一个完整的Prompt。解析器与主循环从LLM输出里提取Action和Action Input执行工具把Observation拼回历史循环直到Final Answer。整个数据流是这样的用户问题 → 构造Prompt → 调用LLM → 解析输出 ↓ ↓ ←──── 拼回Observation ←── 执行工具 ←──┘ ↓ 输出Final Answer这个架构没有任何魔法每一行代码你都能看懂。下面我逐个模块拆解。3.2 工具注册表为什么描述比函数本身更重要工具注册表看起来最简单但其实是整个Agent里最需要花心思的地方。先看代码import json class ToolRegistry: def __init__(self): self.tools {} def register(self, name, description, func): self.tools[name] { description: description, func: func } def get_description(self): lines [] for name, info in self.tools.items(): lines.append(f- {name}: {info[description]}) return \n.join(lines) def execute(self, name, args): if name not in self.tools: return f错误工具 {name} 不存在 try: return str(self.tools[name][func](**args)) except Exception as e: return f工具执行出错{str(e)}代码本身平平无奇但工具描述的质量直接决定了Agent的智商。我一开始写的描述是搜索工具输入查询词结果Agent经常在不需要搜索的时候调搜索需要搜索的时候又自己编答案。后来我把描述改成了- search: 当需要获取实时信息、最新新闻、或你不确定的事实性内容时使用。 输入格式{query: 搜索关键词}。不要用它来查询你已知的常识。改完之后Agent的行为立刻合理了很多。这背后的逻辑是LLM是根据描述来判断什么时候该用这个工具的。描述写得越清楚、越具体、越有边界感LLM的判断就越准。实操心得工具描述要包含三要素——什么时候用、输入格式是什么、什么时候不要用。第三点最容易被忽略但对减少误调用非常关键。3.3 Prompt设计ReAct的灵魂所在Prompt是整个ReAct Agent的灵魂。我参考了原始论文的格式但做了不少调整。核心Prompt长这样你是一个可以使用工具的智能助手。请严格按照以下格式回答问题 你可以使用以下工具 {tool_descriptions} 请严格按照以下格式输出 Question: 用户的问题 Thought: 你的思考过程说明你下一步要做什么 Action: 要使用的工具名必须是[{tool_names}]中的一个 Action Input: 工具的输入参数必须是合法的JSON格式 Observation: 工具返回的结果 ... (Thought/Action/Action Input/Observation 可以重复多次) Thought: 我现在知道最终答案了 Final Answer: 对用户问题的最终回答 重要规则 1. 每次只能输出一个Thought和一个Action不要一次性输出多个步骤 2. Action Input必须是合法的JSON不要包含注释或多余文字 3. 当你已经能回答问题时直接输出Final Answer不要再调用工具 4. 如果工具返回错误尝试换一种方式或换一个工具 开始 Question: {user_question} Thought:这个Prompt里有几个设计细节值得展开说。第一为什么要显式列出工具名[{tool_names}]因为LLM在生成Action:后面的内容时如果没有明确的候选集它可能会编造一个不存在的工具名。把工具名列表放在Prompt里相当于给它一个词表大幅降低编造概率。第二为什么强调每次只能输出一个Action因为LLM有偷懒倾向它可能一次性输出完整的推理链包括多个Action和Observation。但Observation是工具执行后才有的LLM不可能提前知道。如果它编造了Observation整个流程就乱了。所以必须明确限制它一次只走一步。第三为什么要求Action Input是JSON因为JSON是最容易解析的结构化格式。虽然LLM偶尔会输出不合法的JSON但相比自然语言JSON的解析成功率高得多。而且JSON能表达嵌套参数扩展性好。第四为什么最后要加一个Thought:这是一个引导词相当于告诉LLM从这里开始续写。实测下来加上这个引导词后LLM输出格式的稳定性明显提升。3.4 解析器正则表达式是够用的解析LLM的输出我用的是正则表达式。很多人觉得正则太脆弱应该用更复杂的解析器。但实测下来只要Prompt约束得当正则完全够用而且简单可靠。import re import json def parse_output(text): # 先检查是否有Final Answer final_match re.search(rFinal Answer:\s*(.), text, re.DOTALL) if final_match: return {type: final, content: final_match.group(1).strip()} # 提取Action action_match re.search(rAction:\s*(\w), text) if not action_match: return {type: error, content: 未找到Action} # 提取Action Input input_match re.search(rAction Input:\s*(.?)(?:\nObservation|\nThought|$), text, re.DOTALL) if not input_match: return {type: error, content: 未找到Action Input} action action_match.group(1).strip() raw_input input_match.group(1).strip() # 尝试解析JSON try: # 清理可能的markdown代码块标记 raw_input raw_input.strip().strip() if raw_input.startswith(json): raw_input raw_input[4:].strip() args json.loads(raw_input) except json.JSONDecodeError: return {type: error, content: fAction Input不是合法JSON: {raw_input}} return {type: action, action: action, args: args}这段代码有几个细节re.DOTALL让.能匹配换行符因为LLM的输出经常跨行。提取Action Input时用了非贪婪匹配(.?)并在遇到Observation或Thought时停止防止把后续内容也吞进去。解析JSON前先清理markdown代码块标记因为LLM很喜欢把JSON包在json里。解析失败时返回错误信息而不是抛异常这样错误信息可以作为Observation反馈给LLM让它自己纠正。踩坑记录我一开始用re.search(rAction Input:\s*(.), text)结果LLM输出多行JSON时只匹配到第一行导致解析失败。后来改成非贪婪多行模式才解决。这个坑很典型建议一开始就用re.DOTALL。3.5 主循环控制流与终止条件主循环把上面所有模块串起来def run_agent(user_question, max_steps8): history fQuestion: {user_question}\n for step in range(max_steps): prompt build_prompt(history) llm_output call_llm(prompt) history llm_output \n parsed parse_output(llm_output) if parsed[type] final: return parsed[content] if parsed[type] error: observation f解析出错{parsed[content]}。请检查你的输出格式。 history fObservation: {observation}\nThought: continue # 执行工具 observation tool_registry.execute(parsed[action], parsed[args]) history fObservation: {observation}\nThought: return 达到最大步数限制未能得出最终答案。几个关键设计最大步数限制是必须的。没有它Agent可能陷入死循环反复调用同一个工具。我设的是8步一般任务3-5步就能解决8步足够应对复杂情况又不至于浪费太多token。错误处理策略是把错误信息作为Observation反馈给LLM。这是ReAct的一个精妙之处LLM看到错误后往往能自己调整。比如它调了一个不存在的工具看到工具不存在的提示后下一轮通常会改用正确的工具名。历史累积是线性的每一步的Thought、Action、Observation都拼接到history里。这意味着Prompt会越来越长token消耗越来越大。对于简单任务没问题复杂任务需要考虑历史压缩策略这个后面再聊。4. 完整实操从零跑通一个能查天气和搜索的Agent4.1 环境准备与依赖我用的环境很朴素pip install openai requests就这两个。openai库用来调用兼容OpenAI接口的模型requests用来做真实的搜索和天气查询。不需要LangChain不需要任何Agent框架。模型我选的是一个国内的中等规模模型通过兼容OpenAI的接口调用。选它的原因很简单便宜、响应快、对中文支持好。你完全可以用任何你手头有的模型只要它支持标准的Chat Completion接口。from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://your-api-endpoint/v1 ) def call_llm(prompt): response client.chat.completions.create( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.1, max_tokens1024 ) return response.choices[0].message.content注意temperature0.1。ReAct Agent需要的是稳定的格式遵循不是创意。温度调低能显著减少格式错误。我试过0.7LLM经常自由发挥输出格式五花八门解析器疲于奔命。4.2 定义两个真实工具为了演示我定义两个工具一个查天气一个搜索。查天气我用的是一个公开的天气API搜索我用的是一个简单的网页搜索接口。import requests def search_web(query): 模拟搜索实际使用时替换为真实搜索API # 这里用的是一个示例接口实际需要替换 try: resp requests.get( https://api.example.com/search, params{q: query}, timeout10 ) data resp.json() results data.get(results, [])[:3] if not results: return 没有找到相关结果 return \n.join([f- {r[title]}: {r[snippet]} for r in results]) except Exception as e: return f搜索失败{str(e)} def get_weather(city): 查询天气 try: resp requests.get( https://api.example.com/weather, params{city: city}, timeout10 ) data resp.json() return f{city}今天{data[condition]}气温{data[temp]}摄氏度 except Exception as e: return f天气查询失败{str(e)}注册工具registry ToolRegistry() registry.register( search, 当需要获取实时信息、最新新闻、或你不确定的事实性内容时使用。 输入格式{\query\: \搜索关键词\}。不要用它查询你已知的常识。, search_web ) registry.register( get_weather, 查询指定城市的当前天气。输入格式{\city\: \城市名\}。 只在用户明确询问天气时使用。, get_weather )4.3 组装Prompt并跑通第一个案例Prompt构造器def build_prompt(history): tool_desc registry.get_description() tool_names , .join(registry.tools.keys()) system f你是一个可以使用工具的智能助手。请严格按照以下格式回答问题 你可以使用以下工具 {tool_desc} 请严格按照以下格式输出 Question: 用户的问题 Thought: 你的思考过程 Action: 要使用的工具名必须是[{tool_names}]中的一个 Action Input: 工具的输入参数必须是合法的JSON Observation: 工具返回的结果 ... (可以重复) Thought: 我现在知道最终答案了 Final Answer: 最终回答 重要规则 1. 每次只能输出一个Thought和一个Action 2. Action Input必须是合法JSON 3. 能回答时直接输出Final Answer 4. 工具出错时尝试换一种方式 开始 {history} return system现在跑一个测试result run_agent(北京今天天气怎么样适合出门吗) print(result)我实际跑的时候LLM的输出是这样的Thought: 用户想知道北京今天的天气我需要调用天气查询工具。 Action: get_weather Action Input: {city: 北京}工具返回北京今天晴气温25摄氏度拼回历史后LLM继续输出Thought: 我已经知道北京今天晴天25度这个温度很适合出门。 Final Answer: 北京今天晴天气温25摄氏度非常适合出门活动。完美。整个流程走通了。4.4 多步推理案例需要组合工具的任务单工具调用太简单来个需要多步的result run_agent(帮我查一下最近有什么关于人工智能的重要新闻然后告诉我今天适合在户外看这些新闻吗)这个任务需要先搜索再查天气最后综合判断。我实际跑的输出Thought: 用户需要两个信息AI新闻和天气。我先搜索新闻。 Action: search Action Input: {query: 人工智能 最新新闻} Observation: - 某研究团队发布新模型... - 某公司开源新框架... Thought: 已经拿到新闻现在需要查天气来判断是否适合户外。 Action: get_weather Action Input: {city: 北京} Observation: 北京今天晴气温25摄氏度 Thought: 新闻和天气都拿到了可以综合回答。 Final Answer: 最近的AI新闻主要有...。北京今天晴天25度很适合在户外阅读这些新闻。两步工具调用逻辑清晰答案完整。这就是ReAct的威力它把一个复杂问题拆解成了多个可执行的步骤每一步都有明确的推理依据。4.5 参数计算与选择max_steps和temperature怎么定这两个参数没有标准答案但有一些经验值可以参考。max_steps我建议从8开始。统计下来80%的任务在3步内完成15%在4-5步只有5%需要6步以上。设8步能覆盖绝大多数情况同时避免极端情况下的无限循环。如果你的任务特别复杂可以调到12但要配合历史压缩否则token消耗会爆炸。temperatureReAct Agent建议0.1-0.3。太低0会导致LLM过于死板遇到稍微变化的情况就不知道怎么处理太高0.5会导致格式不稳定。0.1是我实测下来格式遵循和灵活性平衡最好的值。max_tokens至少512建议1024。因为LLM的输出包含Thought、Action、Action Input有时候Thought会写很长。512经常被截断导致Action Input不完整。实操心得如果你发现Agent经常在Action Input处被截断先检查max_tokens是不是太小。这个坑我踩过排查了半天以为是Prompt问题结果是token限制。5. 踩坑实录那些让我抓狂的典型问题5.1 LLM不按格式输出怎么办这是最常见的问题。表现包括不输出Action:直接给答案、Action Input不是JSON、一次输出多个Action、编造不存在的工具名。我的排查顺序是这样的第一步检查Prompt是否足够明确。很多时候是Prompt太宽松。比如你只说使用工具LLM可能觉得直接回答也行。要明确说必须使用工具或当需要外部信息时必须调用工具。第二步检查temperature。如果temperature高于0.3先降到0.1试试。格式问题十有八九和温度有关。第三步检查模型能力。有些小模型对格式的遵循能力确实差。如果你用的是7B以下的模型格式错误率高是正常的换个大点的模型立竿见影。第四步加few-shot示例。在Prompt里给一两个完整的ReAct流程示例LLM的格式遵循率会大幅提升。这是最有效的手段之一。我整理了一个速查表问题表现最可能原因解决手段不输出Action直接回答Prompt未强制要求加必须调用工具约束Action Input非JSON温度过高或模型弱降温度、加JSON示例一次输出多个ActionPrompt未限制步数明确每次只输出一个Action编造工具名未列出候选工具Prompt中显式列出工具名输出被截断max_tokens太小调到1024以上5.2 工具调用死循环为什么Agent反复调同一个工具这个坑很隐蔽。表现是Agent反复调用同一个工具每次参数略有不同但就是不给Final Answer。根本原因通常是工具返回的结果没有让LLM满意它觉得信息不够于是再调一次。比如搜索工具返回的结果太笼统LLM觉得没回答用户问题就换个关键词再搜搜来搜去都是类似结果陷入循环。解决方法有三个第一在工具描述里明确一次调用即可。比如搜索工具的描述加上调用一次即可获得足够信息不要重复调用。第二在Prompt里加约束。如果同一个工具已经调用过两次请基于已有信息给出Final Answer不要再调用。第三优化工具返回。如果工具返回的信息质量差LLM自然会想再试。提升工具返回的信息密度能从根源上减少循环。我实测下来第二和第三结合最有效。单纯加约束有时LLM会假装遵守但工具返回质量上去了它自然就不想再调了。5.3 历史累积导致token爆炸ReAct的历史是线性累积的每一步的Thought、Action、Observation都拼进去。跑个五六步Prompt可能就三四千token了。如果工具返回的结果很长比如搜索返回了十条结果token消耗更快。我的处理策略是分层压缩Observation压缩工具返回的结果如果超过500字截断并加...。LLM不需要看完整的长文本关键信息在前500字通常就够了。Thought压缩历史中的Thought可以精简只保留关键决策信息。但这一步比较tricky容易丢失上下文我一般不动。滑动窗口如果历史超过一定长度只保留最近N步的完整记录更早的只保留Action和Observation的摘要。对于大多数任务Observation压缩就够了。我实测下来加了Observation截断后token消耗降低了40%左右效果几乎没影响。5.4 工具返回错误后Agent不会自我纠正理想情况下工具返回错误LLM看到错误信息后应该调整策略。但实际中LLM经常无视错误继续用同样的方式调用或者直接放弃。改进方法是在错误信息里明确给出纠正建议。比如def execute(self, name, args): if name not in self.tools: available , .join(self.tools.keys()) return f错误工具 {name} 不存在。可用的工具是{available}。请从这些工具中选择。 try: return str(self.tools[name][func](**args)) except TypeError as e: return f参数错误{str(e)}。请检查参数名和格式是否正确。 except Exception as e: return f工具执行出错{str(e)}。请尝试换一种方式或换一个工具。关键是告诉LLM下一步该怎么做而不是只告诉它出错了。这个改动让我的Agent自我纠正成功率从不到30%提升到了70%以上。6. 手写之后的顿悟Tool Calling的本质与边界6.1 本质再确认LLM只负责说代码负责做手写一遍之后我对Tool Calling的理解彻底清晰了。整个过程中LLM做的事情只有一件根据上下文预测下一段文本。它预测出的文本恰好符合Action: xxx和Action Input: {...}的格式于是我的代码能解析出来并执行。所谓LLM决定调用工具准确的说法是LLM预测出了一段看起来像工具调用的文本。这个区别很重要因为它意味着LLM不会真正理解工具的作用它只是根据描述和上下文判断在这个位置输出这个工具名和参数是最合理的。工具的可靠性取决于描述的清晰度和Prompt的约束力而不是模型有多聪明。调试Agent的本质是调试Prompt和解析逻辑而不是调试模型。理解了这一点你就能明白为什么有些Agent看起来很蠢不是模型不行是Prompt没写好或者工具描述有歧义或者解析逻辑太脆弱。6.2 原生Function Calling vs 手写文本解析手写完之后我回头看了原生Function Calling的机制发现它本质上就是把Prompt引导文本解析这一步内置化了。你传一个JSON Schema模型返回一个结构化的tool_calls对象省去了你写Prompt和解析器的麻烦。对比一下维度手写文本解析原生Function Calling稳定性依赖Prompt和模型模型原生支持更稳可控性完全可控可深度定制受限于API设计学习价值高理解本质低黑箱生产适用一般推荐调试难度高但可控低但出问题难查我的建议是学习阶段手写一遍生产环境用原生Function Calling。手写让你理解本质原生让你稳定交付。两者不矛盾。6.3 这个Agent的边界在哪里手写这个Agent让我清楚地看到了它的能力边界它能做的格式化的多步推理、工具调用、基于观察的决策、简单的错误纠正。它做不到的真正的长期记忆、复杂的规划、多Agent协作、对工具结果的深度理解。比如如果你问它一个需要十步以上推理的问题它大概率会在中途迷失。如果你给它一个返回结构化数据的工具它可能不知道怎么解析。如果你让它同时协调多个工具它会手忙脚乱。这些边界不是手写实现的问题而是ReAct范式本身的限制。理解这些限制你就能判断什么时候该用ReAct什么时候该换更复杂的架构。7. 后续可以怎么扩展这个手写Agent是一个起点不是终点。基于它你可以往几个方向扩展。方向一加记忆。目前的历史是单次会话的会话结束就没了。你可以加一个向量数据库把历史对话存起来下次会话时检索相关记忆拼进Prompt。这就是长期记忆的雏形。方向二加规划。目前Agent是走一步看一步没有全局规划。你可以加一个Planning步骤让LLM先制定一个多步计划然后逐步执行。这就是Plan-and-Execute架构。方向三加反思。目前Agent不会回顾自己的行为。你可以加一个Reflection步骤让LLM在给出Final Answer前先检查自己的推理是否有漏洞。这就是Reflexion架构。方向四换原生Function Calling。把文本解析换成原生API提升稳定性和准确性。这是生产化的必经之路。我个人在实际操作中的体会是手写一遍的价值不在于最终产物而在于过程中的理解。当你亲手处理过LLM输出的格式错误、亲手设计过工具描述、亲手调试过死循环你对Agent的认知就从调包升级到了理解。这个升级是任何框架文档都给不了的。最后分享一个小技巧如果你也想手写一遍建议从只有一个工具开始。一个工具的情况下LLM的选择空间小格式错误率低你能快速跑通整个流程建立信心。跑通后再加第二个、第三个工具逐步增加复杂度。一上来就搞五六个工具大概率会在格式问题上卡住打击积极性。循序渐进才是学习Agent的正确姿势。