最近帮一个完全没写过AI代码的朋友梳理Agent概念,发现他卡住的地方根本不是"API怎么调",而是"Agent到底凭什么决定下一步"。这个问题问得很实在。市面上讲Agent的文章不少,但大多默认读者已经懂提示词、懂模型调用、懂循环控制,结果真正的小白看十篇还是晕。
这篇我把两个最核心的东西拆开揉碎讲:思维链(CoT)解决"模型怎么把推理过程外显",ReAct框架解决"推理和行动怎么形成闭环"。两者合在一起,就是一个小白也能看懂、能上手复现的最小Agent。我会给完整思路、代码骨架、还有实测中踩过的坑,建议收藏后跟着敲一遍。
1. 先搞清楚Agent在解决什么问题:大模型有"脑子"但没"手"
1.1 大模型本质上还只是"文本生成器"
很多人以为大模型什么都会,其实它的核心能力只有一项:根据输入文本,预测最合理的下一个词。你给它一句话,它把后续文本补完,仅此而已。
这意味着一个很反直觉的结论:你再怎么问"今天北京下雨吗",它都没法真正去查天气,因为它没有联网能力、没有传感器、没有执行动作的通道。它只能凭训练时学到的知识"猜",猜错也不奇怪。这就是为什么很多人发现,模型聊天很厉害,一到"帮我办件事"就露馅。
你可以把大模型想象成一位知识渊博但双手被绑住的专家。他知道雨伞是用来挡雨的,知道天气影响出行,但你让他去拿把伞,他做不到。Agent要做的事情,就是给这位专家松绑,给他装上"手"。
1.2 Agent = 大脑 + 工具 + 循环
Agent这个词听起来玄乎,拆开就三样东西:
- 大脑:大模型本身,负责理解、推理、决策。
- 工具:一段可以执行的函数,比如查天气、算算术、搜网页、发邮件。
- 循环:模型思考 -> 决定调用工具 -> 拿到结果 -> 再思考 -> 再调用 -> 直到得出结论。
重点在这个"循环"。普通聊天是一次性的"问-答",Agent是"问-想-做-看-想-做-答"的多轮闭环。模型每走一步,都要根据上一步的结果重新决策,这才是Agent和普通聊天机器人的本质区别。
这也正好对应标题里的两个关键词:思维链决定了模型"想得好不好",ReAct框架决定了"想和做怎么衔接"。没有前者,模型很容易答非所问;没有后者,模型就算想了也落不了地。
1.3 不要把"套壳应用"当成Agent
市面上好多产品号称Agent,其实只是给模型加了个搜索功能。判断标准很简单:看它在拿到工具结果之后,会不会自己决定下一步。
| 类型 | 典型行为 | 算不算Agent |
|---|---|---|
| 聊天机器人 | 一问一答,不调用外部工具 | 不算 |
| 套壳搜索 | 固定把用户问题丢给搜索,再把结果贴给模型 | 不算严格意义上的Agent,流程固定 |
| 脚本工作流 | 写死步骤:先查A,再算B,再输出C | 不是Agent,是自动化脚本 |
| 真正的Agent | 模型自己决定先查A还是先算B,甚至中途改变计划 | 算 |
一句话总结:如果决策是模型做的、路径是动态的、能根据反馈修正,那才是Agent。如果步骤是写死的,那叫工作流,两者最大的区别就在"自主性"。
2. 思维链CoT:让大模型"把草稿写在外面"
2.1 为什么直接问复杂问题容易翻车
先做一个实验。你直接问模型:"一个商店买3个苹果和2个橙子,苹果5元一个,橙子4元一个,一共多少钱?"模型大概率能答对。但如果你把问题变成:"有5个盒子,每个盒子里有7个乒乓球,其中3个盒子里的球是红色的,每个红色盒子中有2个球被标记了,请问总共有多少个标记球?"这时候直接作答,模型可能就开始胡编了。
原因在于大模型的生成方式。它是一个词一个词往后蹦的,生成后面内容时只能看到前面的内容,相当于一个只能心算、不能打草稿的人。你让它直接在一个三步计算问题上给出答案,它很容易在中途"算串行"。但如果你允许它把中间步骤写到纸面上,每写一步,后面的生成就多了一点点上下文,错误率会显著下降。
这就是思维链(Chain of Thought,简称CoT)的核心逻辑:把隐式的推理过程,变成显式的文本中间步骤。不是在教模型新知识,而是帮模型把思考过程摊开来,减少跳跃性错误。
2.2 一行提示词的威力
最经典的做法是Few-shot思维链:给模型看一个带推理过程的例子,再让它照着做。但更省事的是Zero-shot思维链,也就是在问题后面加一句神奇的话。
普通问法:
问:一辆车以60公里/小时的速度行驶2小时后又以80公里/小时行驶1小时,总路程是多少? 答:加上思维链的提示:
问:一辆车以60公里/小时的速度行驶2小时后又以80公里/小时行驶1小时,总路程是多少? 答:让我们一步一步想。就这么一句"让我们一步一步想",模型就会自动输出:"首先计算第一段路程:60乘以2等于120公里。然后计算第二段路程:80乘以1等于80公里。最后相加:120加80等于200公里。"正确率提升非常明显。
原理也简单:这句话给了模型一个"输出推理过程"的指令。模型在训练语料中见过大量"一步一步思考"的文本,你一旦触发这个模式,它就会顺着把草稿写出来,每一步都基于前一步的文本,错误累积的机会小了,结果也更可解释。
2.3 小白最容易误解思维链的两件事
第一,把思维链理解成"把答案写进提示词"。这不是思维链,这是泄题。思维链要的是模型自己经历推理过程,不是你把结论喂给它。你可以在提示词里给示例、给中间步骤示范,但不能替它回答当前问题。
第二,觉得思维链越长越好。实际不是这样。问题越简单,强制性加长推理反而可能引入错误。比如"1+1等于几",你让它"让我们用十步来思考",它可能真的给你编出十步无关内容,最后还错了。思维链的长度要和问题复杂度匹配,简单问题不要滥用。
一个比较通用的提示词模板,小白可以直接抄:
你是一个擅长逐步推理的助手。当面对复杂问题时,请按照以下方式作答: 1. 先复述问题,确定目标。 2. 把问题拆解为若干子步骤,逐步写出推理过程。 3. 最后给出结论,并标明依据。 注意:推理过程要简洁,不要无关发挥。等把这个模式跑熟,再去接触ReAct,你会发现两者天然互补:思维链管"推理",ReAct管"推理之后动手做"。
3. ReAct框架:推理与行动交替的"工作回路"
3.1 从"想清楚再干"到"边想边干"
思维链解决了一个问题:模型能把推理过程写出来。但它有一个局限:推理完就结束了,没有行动,没有反馈。如果中间需要外部信息——比如查天气、查数据库、调用计算器——纯思维链无能为力。
ReAct框架的出现就是针对这个问题。它的名字是Reason(推理)和Act(行动)的合成:通过提示词引导模型,在每一轮里同时产出推理和行动,然后观察行动结果,再进入下一轮推理。推理和行动交替进行,像侦探破案一样:先根据线索提出假设,然后去验证,拿到新线索再修正假设,直到锁定结论。
这比"想好再做"更接近真实工作方式。真实工作中,很少有一次规划到底的情况,更多是边做边调整。ReAct就是把这个过程模型化。
3.2 ReAct循环的标准四段式
一个标准的ReAct循环,每一轮模型输出包含三部分:
- Thought:我现在需要什么信息,下一步该做什么。这是思维链的体现。
- Action:我要调用哪个工具。
- Action Input:传给工具的具体参数。
系统拿到Action和Action Input后,执行对应的工具函数,把返回结果以Observation(观察结果)的形式追加到对话里。模型看到Observation后,继续输出下一轮Thought,周而复始。直到模型认为信息足够,输出Final Answer结束任务。
举一个直观的Agent输出文本:
Thought: 用户想知道北京明天的天气,并且需要判断是否需要带伞。我目前没有天气数据,需要先查询气象信息。 Action: get_weather Action Input: {"city": "北京", "date": "明天"} Observation: {"city": "北京", "date": "明天", "weather": "小雨", "temperature": "18-23℃"} Thought: 天气是小雨,用户可能需要带伞。接下来我需要确认用户是否还有其他需要,如果没有,可以直接给出建议。 Action: 无 Final Answer: 北京明天小雨,气温18到23摄氏度,建议带伞出行。注意最后两步:当模型觉得不需要再调用工具时,它应该输出Final Answer而不是继续Action。这个"终止能力"非常关键,后面会讲实操时如何保证它不失控。
3.3 为什么这个格式对模型友好
ReAct能火,不只是因为它概念简单,更因为它对模型极其友好:
- 结构固定。Thought、Action、Action Input、Observation四个字段轮换,格式清晰,模型不容易发挥偏。
- 天然支持纠错。模型第一轮决策错了,拿到Observation后可以在下一轮Thought里自我修正。比如发现查了天气发现没雨,就不用带伞了。
- 可解释性强。每一条决策都有迹可循,调试时只要看Thought就知道模型为什么这么选。
对比一下三类方案的差异:
| 方案 | 推理过程 | 工具调用 | 纠错能力 | 适合场景 |
|---|---|---|---|---|
| 纯CoT | 有 | 无 | 弱 | 纯逻辑推理、数学题 |
| 一次工具调用 | 无 | 有 | 无 | 固定格式的查询 |
| ReAct | 有 | 有 | 强 | 需要多步信息收集和决策的复杂任务 |
理解到这里,你已经掌握Agent最核心的理论框架了。接下来是动手环节,也是最容易劝退小白的地方——我会用最直白的方式带你实现一个最小可用的Agent。
4. 手写一个最小ReAct Agent:核心代码与运行逻辑
4.1 环境准备与工具函数设计
先说明一下选型。作为入门,我建议先用成熟的商用大模型API,因为它的输出格式稳定性好,响应速度快,适合跑通逻辑。你不需要在本地部署模型,那属于进阶折腾。不管你最后选哪家服务,都先确认三件事:能处理多轮对话、系统提示词足够稳定、返回延迟在可接受范围。
接下来是工具函数。Agent要调用工具,你得先把工具"交给"模型。设计原则只有一条:让工具结果尽量结构化、尽量短。我写两个最简单的工具做演示:
import json import re import requests def get_weather(city: str, date: str) -> str: """模拟查天气的工具,真实场景换成天气API。""" # 这里直接用模拟数据,避免依赖外部服务 fake_db = { ("北京", "明天"): {"weather": "小雨", "temperature": "18-23℃"}, ("上海", "明天"): {"weather": "多云", "temperature": "22-28℃"}, ("广州", "明天"): {"weather": "晴", "temperature": "26-32℃"}, } data = fake_db.get((city, date)) if data is None: return json.dumps({"error": "暂无该城市天气数据"}) return json.dumps(data, ensure_ascii=False) def calculate(expression: str) -> str: """执行数学运算,只支持加减乘除和括号。""" try: # 清理表达式,只保留数字和运算符 expr = re.sub(r'[^0-9+\-*/(). ]', '', expression) result = eval(expr) return json.dumps({"result": result}) except Exception as e: return json.dumps({"error": f"表达式无效:{str(e)}"})注意几个细节:
- 工具函数的返回值统一是JSON字符串,方便模型解析。
- get_weather用了模拟数据,你不必真的接天气API,先跑通流程。
- calculate里用eval只是为了演示,生产环境一定要用安全解析库。
4.2 系统提示词怎么写
一个小白的常见错误是:系统提示词写得又短又空,然后抱怨模型不按套路出牌。实际上,ReAct的效果七成靠提示词。下面这个模板可以直接抄:
你是任务型助手。你需要通过反复调用工具来完成任务。 你有以下工具可用: get_weather(city, date): 查询指定城市、指定日期的天气。city为城市名,date为"明天"/"今天"/"后天"。 calculate(expression): 执行数学运算。expression为字符串形式的算术表达式。 使用规则: 1. 当需要信息或计算时,按照以下格式输出(不要多写其他内容): Thought: 你现在是怎么想的,简述下一步计划。 Action: 工具名称 Action Input: 传给工具的JSON参数 2. 工具返回内容用Observation:标签标出,你会看到。 3. 如果信息已经足够回答用户,直接输出: Final Answer: 对用户的最终回答 4. 不要编造工具结果。如果工具返回错误,尝试换一种方式。 5. 最多尝试5次工具调用,如果仍不能完成,请基于已有信息给出最合理的回答。这段提示词里有几个关键设计:
- 明确告诉模型有哪些工具、参数是什么、什么时候用。
- 明确要求输出格式,"不要多写其他内容"能减少模型乱发挥。
- 明确终止条件:信息够了就输出Final Answer。
- 明确容错条件:工具报错就换方式,而不是硬编。
4.3 主循环的Python实现
写主循环之前,先想清楚它要做什么:把模型的输出解析成Action和Input,执行工具,把Observation追加进对话历史,然后再次调用模型,直到输出Final Answer或达到步数上限。
下面是一个极简但完整的主循环:
# 假设你已经有一个call_llm函数,它接收messages列表,返回模型文本 # 这里省略具体API调用,避免绑定具体服务商 def call_llm(messages): """调用大模型,返回回复文本。""" # 伪代码:requests.post(url, json={"messages": messages}) pass def parse_action(text): """从模型输出中解析Action和Action Input。""" action_match = re.search(r"Action: (\w+)", text) input_match = re.search(r"Action Input: (\{.*?\}|.+)", text, re.S) if action_match: action = action_match.group(1).strip() action_input = {} if input_match: raw_input = input_match.group(1).strip() try: action_input = json.loads(raw_input) except Exception: action_input = {"input": raw_input} return action, action_input return None, None tools = { "get_weather": get_weather, "calculate": calculate, } def run_agent(user_query, max_steps=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for step in range(max_steps): response = call_llm(messages) print(f"[Step {step}] {response}") if "Final Answer:" in response: return response.split("Final Answer:")[-1].strip() action, action_input = parse_action(response) if action is None: # 模型没有给出合法Action,追加提醒让它重新输出 messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": "你没有给出有效的Action,请按格式重新输出。"}) continue if action not in tools: messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"工具{action}不存在,请选择可用工具。"}) continue tool_result = tools[action](**action_input) # Observation作为user消息传给模型,是ReAct的标准做法 messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"Observation: {tool_result}"}) return "已达到最大步数,未能完成作答。尝试优化提示词或增加max_steps。"三个细节值得展开:
第一,为什么Observation用user角色传而不是system角色?因为在大多数API实现里,user角色代表"外部环境给模型的消息",Observation正是环境反馈,用user更贴近语义,模型也更容易正确响应。
第二,当解析失败时,不要直接终止,而是追加一条"请重新按格式输出"的提醒。这能显著提高成功率,模型会自己修正格式。
第三,max_steps是保险丝。没有它,模型可能一直Action下去,把上下文撑爆,费用也会失控。
4.4 一个完整的运行示例
假设用户输入:北京明天会下雨吗?如果下雨,帮我算一下5个人需要几把伞(如果每2人共用一把伞)。
模拟运行日志如下:
[Step 0] Thought: 用户想知道北京明天的天气,我需要先查询天气数据。 Action: get_weather Action Input: {"city": "北京", "date": "明天"} [Step 0 Observation] {"city": "北京", "date": "明天", "weather": "小雨", "temperature": "18-23℃"} [Step 1] Thought: 北京明天有小雨,用户需要雨伞。每2人共用一把伞,5个人需要5除以2,向上取整为3把。我需要计算一下。 Action: calculate Action Input: {"expression": "ceil(5/2)"} [Step 1 Observation] {"result": 3} [Step 2] Thought: 计算结果是3。北京明天有小雨,5个人建议带3把伞。 Final Answer: 北京明天有小雨,5个人建议带3把伞,够用。你能清楚看到模型的每一步决策。这就是ReAct最吸引人的地方——它不是黑箱,每个中间环节都可追踪、可回溯、可调试。
5. 实战踩坑:五个让Agent"看起来像傻子"的常见问题
理论跑通了,真正的挑战才刚刚开始。我把自己实测中遇到最多的五个坑列出来,每个都给出了完整的排查思路和修复方案。
5.1 模型停不下来:一直Action,永远不输出Final Answer
这是我遇到的第一个问题。模型非常勤快,一会儿查天气,一会儿查计算器,就是不收尾。根本原因通常是提示词里没有明确"什么时候该停止",或者工具调用到后面已经拿不到新信息了,但模型还在机械地重复。
排查链路是这样的:先看日志里每轮Tool Result是否真的给模型带来了新信息;如果连续两轮的Thought几乎一样、Action也一样,说明模型在空转。修复方法有两个:
- 提示词强制加一句:"如果没有新的信息可以帮助你回答,请直接使用Final Answer。"
- 代码层设置max_steps硬上限,跑到上限就强制返回当前已有的回答。
两个方法配合使用,空转率能降到很低。
5.2 Thought和Action混在一起,正则解析失败
模型有时候会犯懒,不按"Action:"单独一行输出,而是把Action混在一大段文字里。我的parse_action用正则找"Action: (\w+)",如果模型输出的是"我认为应该去查一下天气。Action:get_weather"(注意中文冒号、有空隙),正则就抓不到。
排查链路:打印模型原始输出,人工看看它到底怎么写Action。如果发现格式不统一,两个修复手段:
- 在提示词里给一个few-shot示例,明确告诉它"Action:"必须单独占一行,Action Input必须是JSON。
- 解析器做成容错版本:同时找"Action:"和"Action:",去掉多余空格,甚至尝试JSON片段提取。
这个小改进能让成功率从六成提到九成以上。
5.3 Observation太长太乱,模型开始复述JSON
有一次我让Agent查询一个列表数据,工具返回了十几行的JSON,结果模型在下一轮Thought里不分析数据,而是把JSON原样复述了一遍,仿佛在凑字数。原因很直接:Observation塞了太多无关字段,模型注意力被干扰了。
修复思路从工具端下手:
- 工具返回只保留结论性字段,比如天气工具直接返回"小雨,18到23摄氏度,建议带伞",而不是一坨JSON。
- 必要的大结果做截断,最多保留前500字符。
- Observation前面加一个固定的引导语,比如"Observation(工具返回结果,请只提取关键信息):"。
记住一个原则:Observation是给模型看的"情报摘要",不是给工程师看的日志。面向模型的内容越精炼,Agent表现越好。
5.4 上下文越堆越长,模型开始忘记最初的任务
多轮工具调用之后,对话历史里全是Observation,模型会慢慢偏离最初的目标。典型表现是:用户问的是"对比两地天气并推荐出行建议",跑到第五轮Agent还在算第二地的气温,第一地的信息已经忘了。
排查时你会发现,不是模型能力不够,而是上下文里关键信息被淹没了。最有效的修复方法:
- 每轮循环开始前,在system消息里重新注入原始用户问题。
- 同时压缩早期Observation,把已经用过的工具结果只留结论,丢掉过程。
- 如果任务实在复杂,就让模型每一步结束后先小结当前进度,再决定下一步。
我说过一句话,被朋友评价"扎心但真实":你的Agent不是不够聪明,是记忆太差。
5.5 接入了"会闯祸"的工具,测试时差点出事
这一点是血泪教训。Agent一旦接入发邮件、下订单、删文件这类有副作用的工具,模型误调用一次的代价可能很大。我见过一个Demo里接了发送邮件工具,模型在测试时真的触发了一封邮件发送,幸好收件人是测试邮箱。
解决方案分三层:
- 测试阶段用Mock工具:只返回预定义结果,不真正执行副作用。比如发送邮件工具直接打印"【测试】给xxx发送邮件"并返回"发送成功"。
- 生产环境加权限控制:工具执行前要求用户确认关键参数。
- 在提示词里明确工具的"可调用边界":什么时候能调用、什么时候绝对不能调用。
对小白来说,第一条最实用。别嫌麻烦,Mock工具能让你放心地反复测试各种边界Case。
6. 从"跑通Demo"到"像真Agent":三个关键的进阶姿势
6.1 给工具写好"使用说明书"
很多人的工具描述是"get_weather(city): 查天气",模型最多只知道它能查天气,不知道什么时候该用它、参数怎么填。要像写说明书一样描述工具:
get_weather(city, date): 查询指定城市、指定日期的天气。city用中文城市名,date取值"今天"/"明天"/"后天"。返回天气、温度。适用场景:用户询问天气、出行建议、穿衣建议等。这个改动小,效果立竿见影。模型选择工具的准确率会显著上升,因为它在做决策时需要的不只是"这个工具是什么",还有"这个工具适不适合当前场景"。
6.2 复杂任务先拆解再执行
用户问"帮我规划一个三天北京行程,要避开雨天",这种任务如果直接进ReAct循环,模型很容易东一榔头西一棒子。进阶做法是先让模型输出一个Plan,再进入ReAct循环执行。
一个简单的扩展提示词:
当你接收到复杂任务时,先输出Plan,格式如下: Plan: 1. 查询北京三天的天气。 2. 根据天气安排室内外活动。 3. 汇总成行程建议。 执行完Plan后,逐步完成每个子目标。每完成一个子目标,在下一步Thought中说明进度。这种"先规划,后执行"的模式,其实就是业界常说的Plan-and-Execute思路。它不会让模型变聪明,但会让Agent的行为更有序,尤其在多步骤任务里,效果差距非常明显。
6.3 不用重复造轮子,但要能看懂轮子
我见过不少新人一上来就用现成的Agent框架,结果日志里出现Thought、Action、Observation时完全看不懂,更不知道怎么调试。这就像不会手写SQL却先学ORM一样,概念都飘着。
我的建议是:
- 先手写一遍ReAct循环,把今天这个最小版本跑通。
- 再去看主流框架怎么实现同样的事,这时候你会觉得它的设计每一条都有道理。
- 最后再逐步增加工具种类、任务拆解、记忆压缩这些高级功能。
走完这条路,你对Agent的理解会高于大部分"只会在框架里拖节点"的人。因为你知道循环里每一步在做什么、为什么这样做,出问题你也能顺着日志找到原因。
顺带分享一个小技巧:调试Agent时,把每轮Thought、Action、Observation分别用不同前缀打出来,比如前面加[T]、[A]、[O],哪怕只是print到控制台,调试体验也能直线上升。很多框架自带的日志就是这个思路,你现在手写一遍,就等于提前锻炼"日志阅读肌肉"。
带新手做Agent项目这一年多,我发现最容易让人放弃的地方不是代码,而是"模型不听话"的挫败感。但当你真正理解了CoT和ReAct之后,这种挫败感会慢慢变成一种掌控感:你看到模型输出Thought,你会知道它现在在想什么;看到它Action,你会预判它下一步会做什么;当它跑偏的时候,你甚至能一眼看出是哪一轮Observation误导了它。这种能力靠看一百篇文章是攒不出来的,一定要亲手跑一个循环,哪怕是最小最简陋的版本。跑通了,Agent的大门才算真正向你打开。