1. 从一次工具调用失败说起:为什么要手写ReAct Agent去年冬天,我在调试一个自动化任务系统时遇到了一个很典型的问题:模型明明知道该调用哪个工具,参数也传对了,但整个流程就是跑不通。日志里显示工具被调用了,返回结果也拿到了,可模型下一轮却像失忆一样,完全忽略了这个结果,继续重复调用同一个工具。当时我第一反应是框架的bug,翻了两天源码才发现,问题出在我对Tool Calling的理解上——我一直以为它是模型的原生能力,实际上它只是ReAct循环里的一环,而循环的终止条件、观察结果的注入方式、消息历史的组织格式,每一个细节都会影响最终行为。这件事之后,我决定抛开所有现成的Agent框架,从零手写一个ReAct Agent。不是为了造轮子,而是想搞清楚三件事:Tool Calling到底是怎么被触发的?观察结果是怎么回到模型上下文里的?循环什么时候该停?这三个问题不搞清楚,用再高级的框架也只是在调参碰运气。这篇文章适合两类人:一类是正在用Agent框架但总觉得“不太可控”的开发者,另一类是好奇ReAct和Tool Calling底层机制的技术爱好者。我会从最核心的循环结构讲起,然后拆解工具调用的触发逻辑,接着讲观察结果的注入方式,最后分享我在手写过程中踩过的几个坑。全程不依赖任何Agent框架,只用最基础的模型调用接口,代码逻辑用伪代码和Python片段说明,你可以直接照着复现。提示:本文假设你已经了解基本的对话模型调用方式,知道什么是system prompt和user message。如果这些概念还不太清楚,建议先补一下基础再往下看。2. ReAct循环的骨架:Thought、Action、Observation三段式到底怎么跑2.1 为什么是这三段,而不是别的结构ReAct这个名字拆开就是Reasoning和Acting,核心思想是让模型在行动之前先“想一下”。这个“想”不是随便想,而是有明确格式约束的推理过程。我最初以为Thought只是一个可选的装饰,后来发现它承担了一个非常实际的功能:给模型一个显式的推理空间,让它在决定调用哪个工具之前,先把当前状态和下一步目标对齐。如果没有Thought,模型直接输出Action,会发生什么?我实测过,在工具数量超过5个的场景下,模型选错工具的概率会明显上升。原因很简单:模型需要在一次前向传播中同时完成“理解当前状态”和“选择工具”两个任务,而Thought相当于把第一个任务单独拎出来,让模型先输出一段自然语言推理,再基于这段推理做选择。这就像你让人直接报答案,他可能蒙;但让他先写个解题思路再报答案,准确率就上来了。Observation是工具执行后的返回结果。这里有个容易忽略的点:Observation不是随便塞回去就行,它的格式和位置会直接影响模型下一轮的推理。我试过把Observation放在assistant消息里,也试过单独作为一条user消息,效果完全不同。后面会详细讲这个坑。三段式的循环逻辑用伪代码表示大概是这样:while not done: thought = model.generate(context + "Thought:") action = parse_action(thought) if action.type == "final_answer": done = True break observation = execute_tool(action) context += f"Thought: {thought}\nAction: {action}\nObservation: {observation}\n"看起来很简单,但每一行都有讲究。parse_action怎么解析?execute_tool的异常怎么处理?context怎么拼接才不会让模型困惑?这些细节才是真正决定Agent能不能跑通的关键。2.2 循环终止条件:模型说“我完成了”就真的完成了吗终止条件是我踩的第一个大坑。最初我的设计是:模型输出一个特殊的Action类型叫finish,我就认为任务结束。听起来很合理对吧?但实际跑起来发现,模型有时候会在信息还不完整的时候就输出finish,因为它觉得“差不多够了”。更麻烦的是,有时候模型会陷入死循环,反复调用同一个工具,每次都输出finish但又不给出最终答案。后来我改成了双重判断:模型必须输出finish并且附带一个非空的最终答案,同时循环次数不能超过预设上限。上限这个事很关键,我一般设8到10轮,超过就强制终止并返回当前最好的结果。为什么是8到10?因为实测下来,大多数任务在5轮以内就能完成,超过8轮的基本都是模型卡住了,再跑下去只是浪费token。还有一个细节:终止时要把完整的消息历史返回给调用方,而不是只返回最终答案。因为调试的时候你需要看到中间每一步的Thought和Observation,不然出了问题根本不知道是哪一步歪了。2.3 消息历史的组织方式:为什么不能简单拼接字符串我一开始图省事,直接把Thought、Action、Observation拼成一个长字符串塞进context。跑简单任务没问题,但任务一复杂就出问题:模型会分不清哪些是它自己说的话,哪些是工具返回的结果。有一次它把Observation里的内容当成了自己的Thought,然后在下一轮里说“根据我之前的推理……”,实际上那段推理是工具返回的JSON。正确的做法是用结构化的消息列表,每条消息有明确的role。我的做法是:system message:放工具定义和格式要求user message:放原始任务描述assistant message:放Thought和Actionuser message:放Observation(注意这里用user角色,不是assistant)为什么Observation要用user角色?因为从模型的角度看,Observation是“外部世界给它的反馈”,而不是它自己生成的内容。用assistant角色会让模型误以为这是它自己的输出,从而在后续推理中产生混淆。这个细节我在三四个不同的模型上都验证过,用user角色明显更稳定。3. Tool Calling的本质:模型到底是怎么“选择”工具的3.1 工具定义不是越多越好,关键是描述的质量很多人以为工具定义就是把函数签名翻译成JSON Schema,参数名、类型、描述填上就行。我一开始也这么想,直到发现模型经常选错工具。后来我把工具描述改了一版,选对率从大概六成提升到了九成以上。改了什么?主要是三点:第一,描述里要写清楚“什么时候用这个工具”,而不只是“这个工具是什么”。比如一个搜索工具,不要只写“搜索互联网”,要写“当需要获取实时信息、最新数据或你不确定的事实性内容时使用此工具”。这样模型在犹豫要不要搜索的时候,就有了明确的判断依据。第二,参数描述要给出示例值。比如query参数,不要只写“搜索关键词”,要写“搜索关键词,例如‘2024年新能源汽车销量’”。模型看到具体示例后,生成参数时会更准确,尤其是涉及日期格式、单位这种容易出错的参数。第三,工具数量控制在7个以内。我试过给模型15个工具,结果它经常在相近的工具之间反复横跳。后来精简到5个核心工具,准确率明显提升。如果确实需要很多工具,可以考虑分组,或者用两级选择:先让模型选工具类别,再选具体工具。3.2 Action的解析:为什么正则比JSON解析更可靠模型输出的Action格式,我试过两种方案:一种是要求模型输出JSON,然后用json.loads解析;另一种是要求模型输出特定格式的文本,然后用正则提取。实测下来,正则方案在稳定性上完胜JSON方案。原因在于,模型生成JSON时经常出现一些微妙的问题:多一个逗号、少一个引号、字符串里有没有转义的反斜杠。这些问题在人类看来很小,但json.loads会直接抛异常。而正则方案对格式的容忍度更高,只要关键字段能提取出来就行。我的Action格式是这样的:Action: tool_name Action Input: {"param1": "value1", "param2": "value2"}解析时先用正则匹配Action:\s*(\w+)提取工具名,再用正则匹配Action Input:\s*(\{.*?\})提取参数JSON。如果JSON解析失败,就尝试用更宽松的方式提取键值对。这样即使模型输出格式有点小瑕疵,也能正常执行。注意:正则里的.*?要用非贪婪模式,否则遇到多个Action时会匹配到最后一个,导致解析错误。这个坑我踩过,调试了半天才发现是正则贪婪模式的问题。3.3 工具执行的安全边界:不要让模型直接操作危险接口手写Agent最大的好处是你完全控制工具执行层。我在工具执行外面包了一层安全检查,主要做三件事:参数校验:检查必填参数是否存在、类型是否正确、值是否在允许范围内。比如一个删除文件的工具,路径参数必须限定在特定目录下。超时控制:每个工具调用设置最大执行时间,超过就强制中断并返回超时错误。没有这个的话,一个卡住的工具会让整个Agent挂起。异常捕获:工具执行抛出的任何异常都要被捕获,转换成模型能理解的错误信息返回。不要让异常直接冒泡到循环外面,否则Agent会直接崩溃。错误信息的格式也很重要。不要只返回“执行失败”,要返回具体的失败原因,比如“参数date格式错误,期望YYYY-MM-DD,实际收到‘2024/01/01’”。这样模型下一轮就能根据错误信息修正参数,而不是盲目重试。4. 观察结果的注入:为什么你的Agent总是“忘记”上一步做了什么4.1 Observation的格式设计:结构化比自然语言更有效我最初把工具返回的原始结果直接塞回context,比如一个搜索工具返回一大段网页文本。结果模型经常抓不住重点,要么忽略关键信息,要么被无关内容带偏。后来我改成工具返回结构化数据,再由一个格式化层转成简洁的Observation。举个例子,搜索工具返回的是JSON数组,每条包含标题、摘要、链接。我不会把整个JSON塞回去,而是格式化成:Observation: 找到3条相关结果: 1. 标题A - 摘要A 2. 标题B - 摘要B 3. 标题C - 摘要C这样模型一眼就能看到关键信息,不会被JSON的括号和引号干扰。如果结果太长,我会截断到前500个字符,并在末尾加“...(结果已截断)”。截断这个事很关键,不截断的话context会迅速膨胀,不仅浪费token,还会让模型注意力分散。4.2 多轮观察的累积:什么时候该丢弃历史ReAct循环跑多了之后,context里会堆积大量的Thought和Observation。我实测发现,超过6轮之后,模型对早期Observation的回忆准确率会明显下降。这不是模型的问题,而是注意力机制本身的限制——context越长,每个token分到的注意力越少。我的处理策略是:保留最近3轮的完整交互,更早的Observation只保留摘要。摘要怎么生成?简单粗暴的方式是只保留每条Observation的第一句话,复杂一点的方式是让模型自己总结。我一般用第一种,因为够用且不额外消耗token。还有一种情况需要特殊处理:如果某一轮的工具调用失败了,那条Observation要保留完整的错误信息,不能摘要。因为错误信息是模型修正行为的依据,摘要后可能丢失关键细节。4.3 观察结果与推理的衔接:如何让模型“接得上”上一轮这是我最开始没注意到的问题:模型有时候会忽略上一轮的Observation,直接开始新的推理。比如上一轮搜索返回了结果,它下一轮却说“我需要搜索一下”。后来我在system prompt里加了一句话:“在每一轮Thought开始时,先总结上一轮Observation的关键信息,再决定下一步行动。”这句话加进去之后,模型“接不上”的情况减少了大概七成。另一个技巧是在Observation末尾加一个引导词,比如“基于以上结果,下一步应该:”。这相当于给模型一个软性的格式提示,让它自然地从观察过渡到推理。不要小看这种引导词,在模型行为控制上,格式提示往往比内容提示更有效。5. 手写过程中踩过的五个坑:每一个都让我重新理解了Agent5.1 坑一:system prompt里工具定义和格式要求打架我一开始把工具定义和输出格式要求都放在system prompt里,结果模型经常混淆:要么把工具定义当成输出格式的一部分,要么在Action里输出工具定义的原文。后来我把它们分开:工具定义放在system prompt的开头,用明确的标记隔开,比如<tools>和</tools>;格式要求放在system prompt的末尾,用<format>和</format>包裹。这样模型能清楚地区分两部分内容。5.2 坑二:模型输出带前缀的Action,正则匹配不到有些模型喜欢在Action前面加“好的,我将调用”之类的自然语言。我的正则一开始只匹配行首的Action:,结果这些带前缀的输出全部解析失败。后来我把正则改成Action:\s*(\w+),不加行首锚定,同时在解析前先做一次清洗,把常见的自然语言前缀去掉。这个坑让我意识到,永远不要假设模型会严格按照你要求的格式输出,解析层必须有一定的容错能力。5.3 坑三:工具返回空结果时模型陷入死循环有一次搜索工具返回了空数组,模型收到Observation: 未找到相关结果之后,下一轮又调用了同一个搜索工具,参数一模一样。然后又是空结果,又调用,循环了五六次才因为达到上限而终止。后来我在Observation里加了引导:“未找到相关结果,建议尝试其他关键词或使用其他工具。”这样模型就知道该换策略了,而不是傻傻重试。5.4 坑四:多工具并行调用时的顺序依赖有些任务需要先调用工具A,拿到结果后再调用工具B。但模型有时候会同时输出两个Action,期望并行执行。如果B依赖A的结果,并行就会出错。我的处理方式是:在system prompt里明确说明“每次只能调用一个工具,等待Observation后再决定下一步”。虽然牺牲了并行效率,但换来了稳定性。如果确实需要并行,可以在工具层做依赖检查,发现依赖未满足就返回错误让模型重新规划。5.5 坑五:最终答案的格式与中间步骤混淆模型有时候会把最终答案也包装成Action格式,比如Action: final_answer,但内容却是空的。或者反过来,在应该输出Action的时候直接给出了最终答案。我的解决方案是在system prompt里用非常明确的格式说明:如果你已经可以回答用户的问题,输出: Final Answer: [你的答案] 如果你需要调用工具,输出: Action: [工具名] Action Input: [参数JSON]同时解析层要能区分这两种情况。我一般先检查是否包含Final Answer:,如果有就直接提取答案并终止循环;否则再尝试解析Action。6. 从手写Agent到理解框架:为什么自己实现一遍这么重要手写这个ReAct Agent花了我大概三个周末的时间,代码量不到500行,但收获远超之前用框架调参的几个月。最大的体会是:框架帮你屏蔽了细节,但也屏蔽了你对问题的理解。当你自己实现一遍之后,再回头看那些框架的文档和源码,会有一种“原来如此”的感觉。比如LangChain的AgentExecutor,之前我一直搞不懂它的max_iterations和early_stopping_method到底怎么配合。自己写完循环之后才明白,max_iterations就是循环上限,early_stopping_method决定达到上限后是返回错误还是返回当前最好的结果。这些概念在手写过程中变得非常具体,不再是抽象的配置项。另一个收获是对模型行为的理解。手写过程中你会被迫思考:模型为什么会输出这个格式?为什么它会忽略Observation?为什么它会在工具选择上犹豫?这些问题的答案,在框架的使用文档里是找不到的,只有自己一步步调试才能体会。如果你也在用Agent框架,我强烈建议你抽时间手写一个最小实现。不需要支持很多工具,不需要复杂的错误处理,只要能跑通“推理-行动-观察”的完整循环就行。写完之后,你对Tool Calling和ReAct的理解会上一个台阶,再用框架的时候也会更有掌控感。最后分享一个我在调试时常用的小技巧:在每一轮循环结束时,把完整的context打印到日志里,包括system prompt、每一条消息的role和content。这样当Agent行为异常时,你可以直接看到模型实际收到的输入是什么,而不是靠猜。这个习惯帮我省了很多调试时间,也让我发现了不少消息格式上的问题。
1. 从一次工具调用失败说起:为什么要手写ReAct Agent去年冬天,我在调试一个自动化任务系统时遇到了一个很典型的问题:模型明明知道该调用哪个工具,参数也传对了,但整个流程就是跑不通。日志里显示工具被调用了,返回结果也拿到了,可模型下一轮却像失忆一样,完全忽略了这个结果,继续重复调用同一个工具。当时我第一反应是框架的bug,翻了两天源码才发现,问题出在我对Tool Calling的理解上——我一直以为它是模型的原生能力,实际上它只是ReAct循环里的一环,而循环的终止条件、观察结果的注入方式、消息历史的组织格式,每一个细节都会影响最终行为。这件事之后,我决定抛开所有现成的Agent框架,从零手写一个ReAct Agent。不是为了造轮子,而是想搞清楚三件事:Tool Calling到底是怎么被触发的?观察结果是怎么回到模型上下文里的?循环什么时候该停?这三个问题不搞清楚,用再高级的框架也只是在调参碰运气。这篇文章适合两类人:一类是正在用Agent框架但总觉得“不太可控”的开发者,另一类是好奇ReAct和Tool Calling底层机制的技术爱好者。我会从最核心的循环结构讲起,然后拆解工具调用的触发逻辑,接着讲观察结果的注入方式,最后分享我在手写过程中踩过的几个坑。全程不依赖任何Agent框架,只用最基础的模型调用接口,代码逻辑用伪代码和Python片段说明,你可以直接照着复现。提示:本文假设你已经了解基本的对话模型调用方式,知道什么是system prompt和user message。如果这些概念还不太清楚,建议先补一下基础再往下看。2. ReAct循环的骨架:Thought、Action、Observation三段式到底怎么跑2.1 为什么是这三段,而不是别的结构ReAct这个名字拆开就是Reasoning和Acting,核心思想是让模型在行动之前先“想一下”。这个“想”不是随便想,而是有明确格式约束的推理过程。我最初以为Thought只是一个可选的装饰,后来发现它承担了一个非常实际的功能:给模型一个显式的推理空间,让它在决定调用哪个工具之前,先把当前状态和下一步目标对齐。如果没有Thought,模型直接输出Action,会发生什么?我实测过,在工具数量超过5个的场景下,模型选错工具的概率会明显上升。原因很简单:模型需要在一次前向传播中同时完成“理解当前状态”和“选择工具”两个任务,而Thought相当于把第一个任务单独拎出来,让模型先输出一段自然语言推理,再基于这段推理做选择。这就像你让人直接报答案,他可能蒙;但让他先写个解题思路再报答案,准确率就上来了。Observation是工具执行后的返回结果。这里有个容易忽略的点:Observation不是随便塞回去就行,它的格式和位置会直接影响模型下一轮的推理。我试过把Observation放在assistant消息里,也试过单独作为一条user消息,效果完全不同。后面会详细讲这个坑。三段式的循环逻辑用伪代码表示大概是这样:while not done: thought = model.generate(context + "Thought:") action = parse_action(thought) if action.type == "final_answer": done = True break observation = execute_tool(action) context += f"Thought: {thought}\nAction: {action}\nObservation: {observation}\n"看起来很简单,但每一行都有讲究。parse_action怎么解析?execute_tool的异常怎么处理?context怎么拼接才不会让模型困惑?这些细节才是真正决定Agent能不能跑通的关键。2.2 循环终止条件:模型说“我完成了”就真的完成了吗终止条件是我踩的第一个大坑。最初我的设计是:模型输出一个特殊的Action类型叫finish,我就认为任务结束。听起来很合理对吧?但实际跑起来发现,模型有时候会在信息还不完整的时候就输出finish,因为它觉得“差不多够了”。更麻烦的是,有时候模型会陷入死循环,反复调用同一个工具,每次都输出finish但又不给出最终答案。后来我改成了双重判断:模型必须输出finish并且附带一个非空的最终答案,同时循环次数不能超过预设上限。上限这个事很关键,我一般设8到10轮,超过就强制终止并返回当前最好的结果。为什么是8到10?因为实测下来,大多数任务在5轮以内就能完成,超过8轮的基本都是模型卡住了,再跑下去只是浪费token。还有一个细节:终止时要把完整的消息历史返回给调用方,而不是只返回最终答案。因为调试的时候你需要看到中间每一步的Thought和Observation,不然出了问题根本不知道是哪一步歪了。2.3 消息历史的组织方式:为什么不能简单拼接字符串我一开始图省事,直接把Thought、Action、Observation拼成一个长字符串塞进context。跑简单任务没问题,但任务一复杂就出问题:模型会分不清哪些是它自己说的话,哪些是工具返回的结果。有一次它把Observation里的内容当成了自己的Thought,然后在下一轮里说“根据我之前的推理……”,实际上那段推理是工具返回的JSON。正确的做法是用结构化的消息列表,每条消息有明确的role。我的做法是:system message:放工具定义和格式要求user message:放原始任务描述assistant message:放Thought和Actionuser message:放Observation(注意这里用user角色,不是assistant)为什么Observation要用user角色?因为从模型的角度看,Observation是“外部世界给它的反馈”,而不是它自己生成的内容。用assistant角色会让模型误以为这是它自己的输出,从而在后续推理中产生混淆。这个细节我在三四个不同的模型上都验证过,用user角色明显更稳定。3. Tool Calling的本质:模型到底是怎么“选择”工具的3.1 工具定义不是越多越好,关键是描述的质量很多人以为工具定义就是把函数签名翻译成JSON Schema,参数名、类型、描述填上就行。我一开始也这么想,直到发现模型经常选错工具。后来我把工具描述改了一版,选对率从大概六成提升到了九成以上。改了什么?主要是三点:第一,描述里要写清楚“什么时候用这个工具”,而不只是“这个工具是什么”。比如一个搜索工具,不要只写“搜索互联网”,要写“当需要获取实时信息、最新数据或你不确定的事实性内容时使用此工具”。这样模型在犹豫要不要搜索的时候,就有了明确的判断依据。第二,参数描述要给出示例值。比如query参数,不要只写“搜索关键词”,要写“搜索关键词,例如‘2024年新能源汽车销量’”。模型看到具体示例后,生成参数时会更准确,尤其是涉及日期格式、单位这种容易出错的参数。第三,工具数量控制在7个以内。我试过给模型15个工具,结果它经常在相近的工具之间反复横跳。后来精简到5个核心工具,准确率明显提升。如果确实需要很多工具,可以考虑分组,或者用两级选择:先让模型选工具类别,再选具体工具。3.2 Action的解析:为什么正则比JSON解析更可靠模型输出的Action格式,我试过两种方案:一种是要求模型输出JSON,然后用json.loads解析;另一种是要求模型输出特定格式的文本,然后用正则提取。实测下来,正则方案在稳定性上完胜JSON方案。原因在于,模型生成JSON时经常出现一些微妙的问题:多一个逗号、少一个引号、字符串里有没有转义的反斜杠。这些问题在人类看来很小,但json.loads会直接抛异常。而正则方案对格式的容忍度更高,只要关键字段能提取出来就行。我的Action格式是这样的:Action: tool_name Action Input: {"param1": "value1", "param2": "value2"}解析时先用正则匹配Action:\s*(\w+)提取工具名,再用正则匹配Action Input:\s*(\{.*?\})提取参数JSON。如果JSON解析失败,就尝试用更宽松的方式提取键值对。这样即使模型输出格式有点小瑕疵,也能正常执行。注意:正则里的.*?要用非贪婪模式,否则遇到多个Action时会匹配到最后一个,导致解析错误。这个坑我踩过,调试了半天才发现是正则贪婪模式的问题。3.3 工具执行的安全边界:不要让模型直接操作危险接口手写Agent最大的好处是你完全控制工具执行层。我在工具执行外面包了一层安全检查,主要做三件事:参数校验:检查必填参数是否存在、类型是否正确、值是否在允许范围内。比如一个删除文件的工具,路径参数必须限定在特定目录下。超时控制:每个工具调用设置最大执行时间,超过就强制中断并返回超时错误。没有这个的话,一个卡住的工具会让整个Agent挂起。异常捕获:工具执行抛出的任何异常都要被捕获,转换成模型能理解的错误信息返回。不要让异常直接冒泡到循环外面,否则Agent会直接崩溃。错误信息的格式也很重要。不要只返回“执行失败”,要返回具体的失败原因,比如“参数date格式错误,期望YYYY-MM-DD,实际收到‘2024/01/01’”。这样模型下一轮就能根据错误信息修正参数,而不是盲目重试。4. 观察结果的注入:为什么你的Agent总是“忘记”上一步做了什么4.1 Observation的格式设计:结构化比自然语言更有效我最初把工具返回的原始结果直接塞回context,比如一个搜索工具返回一大段网页文本。结果模型经常抓不住重点,要么忽略关键信息,要么被无关内容带偏。后来我改成工具返回结构化数据,再由一个格式化层转成简洁的Observation。举个例子,搜索工具返回的是JSON数组,每条包含标题、摘要、链接。我不会把整个JSON塞回去,而是格式化成:Observation: 找到3条相关结果: 1. 标题A - 摘要A 2. 标题B - 摘要B 3. 标题C - 摘要C这样模型一眼就能看到关键信息,不会被JSON的括号和引号干扰。如果结果太长,我会截断到前500个字符,并在末尾加“...(结果已截断)”。截断这个事很关键,不截断的话context会迅速膨胀,不仅浪费token,还会让模型注意力分散。4.2 多轮观察的累积:什么时候该丢弃历史ReAct循环跑多了之后,context里会堆积大量的Thought和Observation。我实测发现,超过6轮之后,模型对早期Observation的回忆准确率会明显下降。这不是模型的问题,而是注意力机制本身的限制——context越长,每个token分到的注意力越少。我的处理策略是:保留最近3轮的完整交互,更早的Observation只保留摘要。摘要怎么生成?简单粗暴的方式是只保留每条Observation的第一句话,复杂一点的方式是让模型自己总结。我一般用第一种,因为够用且不额外消耗token。还有一种情况需要特殊处理:如果某一轮的工具调用失败了,那条Observation要保留完整的错误信息,不能摘要。因为错误信息是模型修正行为的依据,摘要后可能丢失关键细节。4.3 观察结果与推理的衔接:如何让模型“接得上”上一轮这是我最开始没注意到的问题:模型有时候会忽略上一轮的Observation,直接开始新的推理。比如上一轮搜索返回了结果,它下一轮却说“我需要搜索一下”。后来我在system prompt里加了一句话:“在每一轮Thought开始时,先总结上一轮Observation的关键信息,再决定下一步行动。”这句话加进去之后,模型“接不上”的情况减少了大概七成。另一个技巧是在Observation末尾加一个引导词,比如“基于以上结果,下一步应该:”。这相当于给模型一个软性的格式提示,让它自然地从观察过渡到推理。不要小看这种引导词,在模型行为控制上,格式提示往往比内容提示更有效。5. 手写过程中踩过的五个坑:每一个都让我重新理解了Agent5.1 坑一:system prompt里工具定义和格式要求打架我一开始把工具定义和输出格式要求都放在system prompt里,结果模型经常混淆:要么把工具定义当成输出格式的一部分,要么在Action里输出工具定义的原文。后来我把它们分开:工具定义放在system prompt的开头,用明确的标记隔开,比如<tools>和</tools>;格式要求放在system prompt的末尾,用<format>和</format>包裹。这样模型能清楚地区分两部分内容。5.2 坑二:模型输出带前缀的Action,正则匹配不到有些模型喜欢在Action前面加“好的,我将调用”之类的自然语言。我的正则一开始只匹配行首的Action:,结果这些带前缀的输出全部解析失败。后来我把正则改成Action:\s*(\w+),不加行首锚定,同时在解析前先做一次清洗,把常见的自然语言前缀去掉。这个坑让我意识到,永远不要假设模型会严格按照你要求的格式输出,解析层必须有一定的容错能力。5.3 坑三:工具返回空结果时模型陷入死循环有一次搜索工具返回了空数组,模型收到Observation: 未找到相关结果之后,下一轮又调用了同一个搜索工具,参数一模一样。然后又是空结果,又调用,循环了五六次才因为达到上限而终止。后来我在Observation里加了引导:“未找到相关结果,建议尝试其他关键词或使用其他工具。”这样模型就知道该换策略了,而不是傻傻重试。5.4 坑四:多工具并行调用时的顺序依赖有些任务需要先调用工具A,拿到结果后再调用工具B。但模型有时候会同时输出两个Action,期望并行执行。如果B依赖A的结果,并行就会出错。我的处理方式是:在system prompt里明确说明“每次只能调用一个工具,等待Observation后再决定下一步”。虽然牺牲了并行效率,但换来了稳定性。如果确实需要并行,可以在工具层做依赖检查,发现依赖未满足就返回错误让模型重新规划。5.5 坑五:最终答案的格式与中间步骤混淆模型有时候会把最终答案也包装成Action格式,比如Action: final_answer,但内容却是空的。或者反过来,在应该输出Action的时候直接给出了最终答案。我的解决方案是在system prompt里用非常明确的格式说明:如果你已经可以回答用户的问题,输出: Final Answer: [你的答案] 如果你需要调用工具,输出: Action: [工具名] Action Input: [参数JSON]同时解析层要能区分这两种情况。我一般先检查是否包含Final Answer:,如果有就直接提取答案并终止循环;否则再尝试解析Action。6. 从手写Agent到理解框架:为什么自己实现一遍这么重要手写这个ReAct Agent花了我大概三个周末的时间,代码量不到500行,但收获远超之前用框架调参的几个月。最大的体会是:框架帮你屏蔽了细节,但也屏蔽了你对问题的理解。当你自己实现一遍之后,再回头看那些框架的文档和源码,会有一种“原来如此”的感觉。比如LangChain的AgentExecutor,之前我一直搞不懂它的max_iterations和early_stopping_method到底怎么配合。自己写完循环之后才明白,max_iterations就是循环上限,early_stopping_method决定达到上限后是返回错误还是返回当前最好的结果。这些概念在手写过程中变得非常具体,不再是抽象的配置项。另一个收获是对模型行为的理解。手写过程中你会被迫思考:模型为什么会输出这个格式?为什么它会忽略Observation?为什么它会在工具选择上犹豫?这些问题的答案,在框架的使用文档里是找不到的,只有自己一步步调试才能体会。如果你也在用Agent框架,我强烈建议你抽时间手写一个最小实现。不需要支持很多工具,不需要复杂的错误处理,只要能跑通“推理-行动-观察”的完整循环就行。写完之后,你对Tool Calling和ReAct的理解会上一个台阶,再用框架的时候也会更有掌控感。最后分享一个我在调试时常用的小技巧:在每一轮循环结束时,把完整的context打印到日志里,包括system prompt、每一条消息的role和content。这样当Agent行为异常时,你可以直接看到模型实际收到的输入是什么,而不是靠猜。这个习惯帮我省了很多调试时间,也让我发现了不少消息格式上的问题。