做AI Agent开发这几年,我越来越觉得“思维链(Chain of Thought,简称CoT)”不是一种可选的提示词技巧,而是一套需要认真对待的工程架构。Agent能不能稳定干活,通常不取决于模型基础能力有多强,而取决于它“想”的过程能不能被约束成一条可执行、可检查、可回退的推理链。
这篇文章会从Agent的视角重新理解CoT:它到底解决什么问题、和普通问答里的CoT有什么区别、怎么设计Prompt、怎么搭一个最小可运行的Agent循环,最后是我在真实项目里踩过的一组坑。如果你正准备把大模型接到工具调用、数据库查询、浏览器操作上;或者正被Agent反复“一本正经地胡说八道”搞到头大,那么这篇应该能帮到你。我不打算做理论综述,所有内容都以可复现和可排错为目标。
1. 先想清楚:思维链在Agent里到底扮演什么角色
1.1 没有显式思维链时,Agent为什么总在关键环节翻车
先看一个底层事实:大模型本质上是“预测下一个token”的语言模型。你给它一个问题,它最容易走的路是“根据参数里的记忆直接生成一个看起来像答案的文本”,而不是“先识别问题需要什么信息,再决定要不要调用外部工具”。在普通问答场景里,这种直觉式回答偶尔能蒙对;但在Agent场景里,这就是灾难。
我举一个最典型的场景。用户问:“帮我查一下北京明天的降雨概率,再决定我要不要带伞。”如果Agent在Prompt里没有强制输出思维链,模型很可能直接回答“建议带伞,因为明天可能有雨”。它跳过了查天气这个动作。你问它依据是什么,它还能给你编出一套“低压系统影响”之类的理由。这就是没有CoT的Agent:它把“回答问题”理解成“写作文”,而不是“执行任务”。
用生活里的例子类比:一个不写行动清单的实习生,你让他确认明天要不要带伞,他凭印象说“带伞吧,天阴了”,而不是先打开天气App。思维链的作用,就是逼他把“打开天气App”、“查降雨概率”、“大于某个阈值则带伞”这几步写在纸上。对模型来说,这些写在纸上的文字不是装饰,而是让下一步调用工具、读取结果、再决策有了上下文锚点。
另一个容易被忽略的问题:即使模型第一次正确调用了工具,拿到工具返回结果之后,如果没有显式CoT,它大概率不知道“我现在已经走到哪一步了”。工具返回的是一个结构化文本或一段JSON,模型需要把它和原始问题、已执行的步骤连接起来。没有思维链,这个连接就是断的,模型会在“下一步该干什么”上反复横跳。
1.2 Agent里的CoT不是内心独白,而是一份可审计的决策日志
很多教程会把CoT讲成“让模型把思考过程写下来”,听起来很像内心独白。但在Agent工程里,我更喜欢把它理解成“决策日志”。
这个日志需要满足三个硬性要求。
第一,它能约束行为。模型先写“我需要城市A的天气,然后调用weather工具”,写完之后,下一步就更容易真的去调用weather,而不是绕过工具直接给结论。这相当于给自己下了一个很具体的行动指令。
第二,它能定位错误。当Agent最后结果错了,你可以把整条思维链翻出来,看是“最初的目标理解错了”,还是“工具参数传错了”,还是“观察结果被忽略了”。没有这条日志,你面对的就是一个黑盒,只能靠重新生成碰运气。
第三,它能建立人机信任。用户在等待Agent执行多步任务时,如果只看到一个转圈图标,体验是很差的。但如果你能展示“正在确认目标城市 → 正在查询天气 → 正在对比降雨概率 → 生成结论”,用户会觉得系统的每一步都有据可循。哪怕最终结论是“不用带伞”,用户也能理解为什么。
所以我很反对把思维链做得越隐蔽越好的说法。在Agent里,CoT应该是一个一等公民。设计Agent系统时,你得把“Thought / Action / Observation”三段式当作日志规范来定义,而不是让模型自由发挥写一段散文。
1.3 从普通CoT到Agent级CoT:多了一步工具交互的认知闭环
普通问答里的CoT,本质上是一条直线:问题 → 逐步推理 → 答案。模型一口气把推理写完,整个过程不接触外部世界。
Agent里的CoT不是直线,而是一个带“工具交互”的循环。业内常用“ReAct”这个词来描述这种范式:Reasoning(推理)和 Acting(行动)交替进行。每一步的格式不是“推理到答案”,而是“思考 → 行动 → 观察 → 再思考”。模型必须先想这一步要做什么,然后实际调一个工具,拿到观察结果后,再更新思考,继续下一步。
举一个实际例子:用户要“统计某公司官网今天发布的新闻标题含几个关键词”。普通CoT会直接编一个统计结果。ReAct风格CoT会走这样一条链路:
- Thought:需要先访问官网新闻列表页,找到今天的发布日期范围。
- Action:调用fetch_page,参数是新闻列表URL。
- Observation:拿到页面HTML,发现新闻标题共15条,其中3条在今天发布。
- Thought:这3条标题里需要统计包含“产品”二字的数量,继续提取正文或标题过滤。
- Action:调用filter_titles,参数是刚才观察到的3条标题。
- Observation:返回1条匹配“产品”。
- Thought:信息完整,可以汇总成最终答案。
- Action:final_answer,输出最终结论。
这条链路里,每一个Thought都依赖上一步Observation,每一个Action都必须有明确的输入来源。这已经不是普通CoT“想清楚就能答”的逻辑,而是一种“想到哪、做到哪、看到哪、再想到哪”的闭环。
可以这么理解:普通CoT是给模型一张草稿纸,Agent级CoT是给模型一张工作流工单。工单上每一步都要签收,不能跳步,也不能凭记忆补结论。这也是为什么后文所有的Prompt模板和工程代码,都围绕这个“循环闭环”来做。
2. 怎么设计Agent的思维链:三套可以直接抄的Prompt模板
2.1 单轮任务型:先拆解再执行的CoT模板
如果你的Agent暂时不需要复杂工具,只是想让模型“别瞎猜”,可以用最简单的“先拆解再执行”模板。这个模板特别适合文档问答、基于固定知识库的回复、单次信息提取。
系统提示词可以这么写:
你是一名严谨的任务执行助手。面对用户问题时,你不允许直接凭印象回答。你必须先输出内部思考过程,然后给出结论。 思考过程要求: 1. 复述用户的核心目标。 2. 列出要给出可靠答案所必需的信息项。 3. 判断这些信息是否已经从用户消息中获得。如果缺失,必须明确说明缺失项,并询问用户,或转入指定的查证工具。 4. 只有信息完整时,才基于已有信息组织最终回答。 输出格式: { "thought": "这里写你的推理依据", "answer": "这里写最终答案", "need_more_info": false }这个模板的精髓在于“先列信息项,再判断是否足够”。我见过很多Agent在没有这步时,模型会把“我猜测”“大概率”“可能”这些话混进答案,看起来像在推理,其实是在编。加了信息完整性判断后,模型会主动说“缺少XX,我不能确定”,这比硬答好得多。
实际使用中,你还可以再加一条规则:“最终答案必须引用thought中提到的信息项编号,例如‘基于信息项1和2’。”这能降低模型答非所问的概率。这个模板虽然简单,但它是后面所有复杂CoT的基础。
2.2 多步工具调用型:ReAct风格的思考-行动-观察模板
到了工具调用场景,不能再只用“思考+答案”了。你需要的是“思考+动作+观察”交替循环。下面这套模板我在好几个项目里复用过,稳定性和可解析性都很好。
你是一名任务规划Agent。你只能使用以下工具获取外部信息: - search(query): 搜索公开资料 - weather(city): 查询城市天气 - calc(expression): 计算数学表达式 你必须严格按照以下JSON格式,每次只输出一条动作: {"thought": "你对当前任务的分析和依据", "action": "工具名", "action_input": "传给工具的具体参数"} 规则: 1. 每轮只能输出一个动作,动作必须是上述工具之一。 2. 思考必须与动作匹配:先说明为什么要调用该工具,再给出调用参数。 3. action_input不能凭空生成,必须来自用户原始问题或最近一次工具观察结果。 4. 当你已经获得足够信息时,action使用final_answer,action_input里写最终回答。 5. 如果缺少必要参数,action使用ask_clarification,action_input里写你要问用户的问题。这里有几个值得解释的设计细节。
“每轮只能输出一个动作”是我吃过亏后加上的。早期我让模型一次输出“计划+多个调用”,看起来效率高,但实际运行时,只要其中一个工具参数出错,整个批次就报废,而且很难定位是哪一步错。单步输出虽然多几次请求,但你能在每一步中断、校验、修正,对工程稳定性太重要了。
“action_input不能凭空生成”这条规则能挡住大部分幻觉。模型很容易在thought里写“城市天气很重要”,然后action_input里填一个用户根本没提过的城市。加了这条之后,模型会倾向于输出ask_clarification,而不是乱猜。
在实际使用时,你可以把工具参数schema直接拼到工具名后面,比如weather(city: string, date: string)。模型看到参数约束后,生成格式会明显更规整。
2.3 长链路规划型:计划-执行-反思模板
如果任务要跑很多步,比如“查近一周新闻 → 筛选和某产品相关的内容 → 生成汇总报告”,那么单轮思考-行动循环还不够。你需要让模型先把完整计划写出来,再逐步执行,并且每步结束后做一次“反思”,判断计划是否需要调整。
模板可以设计成“阶段+状态”结构:
你是一个长期任务规划Agent。对于复杂任务,你必须按三阶段执行: 阶段一:规划 输出JSON: {"phase": "plan", "plan": ["步骤1", "步骤2", "步骤3"]} 阶段二:执行 每执行一步,输出: {"phase": "act", "step_index": 1, "thought": "这一步的推理依据", "action": "工具名", "action_input": "参数"} 阶段三:反思 每执行完一步后,先不要急着执行下一步。输出: {"phase": "reflect", "progress": "已完成步骤1/3", "next": "下一步计划", "needs_replan": true 或 false} 如果needs_replan为true,必须重新进入规划阶段,输出新的plan,并说明为什么调整。这个模板的本质是把“计划”和“执行”分离。模型在规划阶段负责想清楚总体路径,在反思阶段负责检查路径是否还成立。这个结构的优势是:即使中途工具返回了意料之外的结果,模型也不会硬着头皮按原计划走,而是会“意识到计划失效,重新规划”。
我用这个模板做过一个“多源资料调研Agent”。它需要先搜索A话题背景,再搜索B产品评价,最后对比输出。如果中间某个搜索返回的数据很少,模型会在反思阶段说“信息不足,需要调整关键词,重新搜索”,而不是拿残缺信息硬写结论。这种“计划-执行-反思”的CoT结构,是长任务稳定性的关键。
3. 把一个CoT Agent跑起来:最小工程实现与关键细节
3.1 最小Agent循环框架怎么搭
理论讲完,直接上代码。下面是一个极简但完整的CoT Agent循环。为了不绑定具体厂商SDK,我用通用HTTP请求调用一个大模型API,工具也做了简化。
import json import requests SYSTEM_PROMPT = """ 你是一名任务规划Agent。你只能使用以下工具获取外部信息: - weather(city): 查询城市天气 - search(query): 搜索公开资料 - calc(expression): 计算数学表达式 你必须严格按照以下JSON格式,每次只输出一条动作: {"thought": "你对当前任务的分析和依据", "action": "工具名", "action_input": "传给工具的具体参数"} 规则: 1. 每轮只能输出一个动作。 2. action_input不能凭空生成,必须来自用户原始问题或上一次工具观察结果。 3. 当你已经获得足够信息时,使用action为final_answer,action_input里写最终回答。 """.strip() def call_llm(messages): resp = requests.post( "https://api.example-model.com/v1/chat/completions", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "model": "your-chat-model", "messages": messages, "temperature": 0.2, "max_tokens": 800, }, timeout=30, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def execute_action(action: str, action_input: str) -> str: if action == "weather": # 这里简写,真实项目里会按照城市名去访问天气服务 return f"{action_input}:晴,23~31℃,降雨概率10%" if action == "search": return f"关于「{action_input}」的搜索结果:找到3条相关条目,待进一步筛选。" if action == "calc": # 警告:生产环境不要直接用eval,这里只演示结构 try: return str(eval(action_input)) except Exception as e: return f"计算失败:{e}" return f"未知工具:{action}"然后写Agent主循环:
def extract_json(text: str): decoder = json.JSONDecoder() start = text.find("{") while start != -1: try: obj, _ = decoder.raw_decode(text, start) return obj except json.JSONDecodeError: start = text.find("{", start + 1) return None def agent_loop(user_query: str, max_steps: int = 8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for step in range(max_steps): raw = call_llm(messages) parsed = extract_json(raw) if not parsed: messages.append({ "role": "user", "content": "上一步输出无法解析,请严格只输出一个JSON对象。", }) continue thought = parsed.get("thought", "") action = parsed.get("action", "") action_input = parsed.get("action_input", "") if action == "final_answer": return action_input, step + 1 observation = execute_action(action, action_input) # 把模型这一步的输出和工具观察都塞回上下文 messages.append({"role": "assistant", "content": raw}) messages.append({ "role": "user", "content": f"工具观察:{observation}\n请基于观察继续思考和行动。", }) return "达到最大步数,未得到最终结论", max_steps这个循环里最关键的是“把模型原始输出和工具观察都塞回上下文”。很多新手会把CoT只当作“生成一次答案前的思考”,但Agent级CoT必须把每一轮thought、action、observation都保留,模型才知道自己刚才做过什么、看到了什么。这也是上一节说“闭环”落到代码里的样子。
3.2 思维链输出格式的解析与清洗
上面代码里我用了一个自定义的extract_json函数,而不是直接json.loads(raw)。原因很简单:模型经常会不好好按格式输出,它在JSON前后加解释文字是常态。比如输出:
好的,我来思考一下。{"thought": "需要先查询城市天气", "action": "weather", "action_input": "北京"} 最终我会给出建议。如果直接json.loads,肯定报错。extract_json的思路是找到第一个{,然后用raw_decode尝试解析,失败就找下一个{。这样能容忍很多“夹带私货”的输出。
但这段实现仍有一个边界情况:如果action_input里本身包含大括号,比如“搜索城市空气质量{良好}”,那么raw_decode会正确按JSON字符串规则解析,不会因为内部大括号而提前截断。这正是比简单括号计数更稳的原因。
在真实项目里,我还会加一层“清洗”:
- 把输出的全角引号替换成半角引号。
- 如果解析失败,尝试用正则匹配
"action": *"([^"]+)"和"action_input": *"([^"]*)"直接抽字段。 - 如果模型把JSON放进了Markdown代码块,先把首尾的
```json和```剥掉。
不要低估这个解析模块的重要性。CoT Agent跑不稳,一半以上的情况是“模型生成了正确意图,但格式歪了,程序识别不了”。一个健壮的解析器,比一百句“请严格输出”都管用。
3.3 控制推理成本:什么时候打断思考、什么时候停止
CoT很费token。同样的任务,不做思维链可能200 token,做了完整CoT可能800到1000 token。加上Agent会跑多轮,成本会成倍上涨。所以不能放任模型无限思考下去,必须设置明确的停止条件。
我常用的控制手段有四个。
第一,限制max_steps。就像上面的max_steps = 8,超出直接中断并返回“未完成”。一个正常任务通常在3到6步内能完成,超过8步,要么是任务太复杂需要重新拆解,要么是模型在绕圈。
第二,限制单轮输出长度。我在调用大模型API时把max_tokens设在600到1000之间。这个长度足以容纳一条thought、action和action_input,但不会让模型写成长篇小作文。给太长反而会让它啰嗦。
第三,重复检测。记录最近几轮thought的哈希或前几十个字符,如果发现模型连续两轮输出了几乎一样的思考,应当强制打断。你可以往消息列表里追加一条:“你已经重复思考同一件事,请直接给出最终答案,或换一个工具。”这往往能把模型从循环里拉出来。
第四,问题路由。不是所有问题都需要CoT。比如“1+1等于几”“怎么用Python打印hello world”这种,直接答就行。我建议在系统Prompt里加一个前置判断:模型先输出{"need_tools": false}或{"need_tools": true}。只有need_tools为true时才启用完整CoT循环。这个开关能省下大量无谓请求。
3.4 安全与可用性取舍:如何不让思维链喧宾夺主
CoT虽然好用,但它把“模型内部推理文本”暴露在了消息流里。这就带来两个需要处理的问题:安全问题和体验问题。
先说体验。如果你把原始thought直接展示给用户,用户会看到模型说“这一步我不太确定”“这个工具参数可能有问题”之类的话,这会影响信任感。实际上,用户只需要知道执行进度和最终结论,不需要看完整推理链。
我的做法是在展示层做“进度摘要”。解析出action后,根据动作类型显示友好文案:查询天气时显示“正在查询天气…”,搜索时显示“正在检索相关资料…”。最终答案只渲染final_answer的action_input。这样既保留了CoT带来的可解释性,又不会让用户陷进流水账。
再说安全。模型在thought里很可能会复述用户输入里的敏感信息,比如手机号、账号ID。如果这些日志被写进第三方日志平台,或在界面上暴露,就是数据泄露风险。我在Prompt里会加一条:“思考过程中不要原样复述敏感字段,用脱敏代号代替。”同时在日志输出时对明显长数字串做掩码处理。
还有一个很隐晦的坑:用户看到thought里写了“我需要调用删库工具”,会误以为系统已经删库了。实际上可能只是模型在思考一个假设。为避免这种误判,对外展示的进度信息要明确区分“计划中”“执行中”“已完成”。不要让用户把“想”当成“做”。
4. 我在真实项目里踩过的CoT Agent的坑:排查手册
4.1 思维链很漂亮,但工具调用一塌糊涂
我见过最多的一类失败:模型的thought写得头头是道,但action_input传得乱七八糟。比如用户说“帮我查下北京明天天气”,模型thought写“用户需要北京的天气信息,目标城市是北京,明天是周四”,然后action调用weather,action_input却填了Wednesday,或者填了城市拼音和全称混用的字符串,导致下游服务识别不了。
根本原因是模型对“工具参数格式”没有概念。它只负责用自然语言思考,不负责严格格式化。解决办法是在Prompt里把每个工具的输入约束写清楚,并且加一条硬性规则:“action_input必须匹配工具的JSON Schema;如果缺少关键参数,不要编造,改为ask_clarification。”
还有一个很有效的技巧,是让模型在thought里先复述“参数来源”。例如:“params:城市=用户问题中的北京,日期=用户问题中的明天。”然后再写action_input。这样模型等于在内部做了一次“变量赋值”,传错参的概率会明显下降。
4.2 Agent陷入“反复思考-不行动”死循环怎么破
第二种常见问题是“光想不动”。模型每一轮都在输出thought,说“这个问题比较复杂,需要更多信息”,但就是不调用任何工具,也不给final_answer。一次跑了十轮,全是类似的话。
我排查下来,多数原因是系统提示里没有说清楚“下一步必须行动”。我后来在每一轮的用户消息末尾都会追加一句:“请先输出一个action,不要只输出思考;如果信息足够,直接final_answer。”这句话看似简单,但对模型的约束效果非常明显。
再配合代码层的重复检测:把thought做一次简单归一化,统计连续重复次数。如果同一句话出现两次,就强制注入一条“你正在重复相同思考,请选择一个新工具,或输出final_answer”。这不是惩罚模型,而是通过外部控制把Agent的状态拉回来。
4.3 思维链外显带来的信息泄露与误判
有一次我把CoT日志直接打到了调试面板上,测试人员顺手截图给产品看,结果产品指着某个thought问:“这里说用户账号可能有异常,到底有没有异常?你们查了吗?”其实模型只是在推理“要不要检查账号状态”,并不是真的发现了异常。从那时起,我就坚决把内部CoT和用户可见信息彻底分开。
处理方法是:在线调试日志只保留action和observation的关键字段,thougt做脱敏或完全隐藏;生产环境日志可以完整保存,但必须走严格的访问权限控制。前端展示的进度状态是“动作 + 结果摘要”,不是模型原始思考。这样既不影响排错,也不会引起产品、用户或法务层面的误会。
还有一类泄露更容易忽略:模型在thought里复述了某个需要保密的查询条件,外部接口可能通过工具调用把这段自然语言传给搜索引擎。所以工具调用参数也要做脱敏检查,尤其是包含长数字、邮箱、账号标识的字段,尽量用占位符替换。
4.4 常见问题速查表
下面这个表,是我做多个CoT Agent后整理的排错速查清单。遇到问题先对照这张表定位,能省不少时间。
| 症状 | 典型原因 | 处理办法 |
|---|---|---|
| JSON解析失败 | 模型在JSON前后添加解释性文字或Markdown代码块 | 使用raw_decode或正则提取JSON;同时重试提示“只输出JSON” |
| action_input凭空编造 | 没有约束参数来源 | 在Prompt中强制“参数必须来自用户或上一次观察”;缺参时输出ask_clarification |
| 连续多轮重复相同thought | 缺少状态感知,或没有强制行动 | 记录thought归一化结果;重复两次后注入打断消息 |
| 不调用工具直接final_answer | 模型认为“凭记忆可答”,或没理解工具必要性 | 增加“若任务需要外部信息,必须先调用工具”的规则 |
| 最终答案没有引用工具结果 | Observation没有进入上下文,或模型忽略 | 把工具观察作为用户消息追加;要求最终回答必须基于最近一次观察 |
| 敏感信息出现在日志或界面 | 内部CoT与用户展示未隔离 | 内部展示只保留动作摘要;日志字段脱敏;Prompt中禁用敏感原文复述 |
| 长任务中途跑偏 | 缺少反思和重规划机制 | 使用“计划-执行-反思”三阶段模板,每步检查needs_replan |
表里的每一行都不是理论,而是“现象-原因-解法”的直接对应。真正排查时,你还需要结合具体日志看是“模型错”还是“代码错”:把模型原始返回打印出来,先确认它到底输出了什么,再决定是调Prompt还是修解析逻辑。
最后再分享一点很个人的体会。我最早做Agent时,以为思维链写得越详细越好,后来发现不是。CoT真正的作用,是“把判断过程暴露给系统”,而不是“把模型想事情的过程变成小作文”。一套好的CoT设计,往往需要针对你的工具、数据和容错方式反复调,不存在万能模板。
如果你刚开始尝试,建议先跑通一个“最简单版本”:一个大模型API、三个工具、一个循环,把CoT模板固定下来,然后让它在真实任务上犯错。观察它在哪一步偏了,再往Prompt里加约束。等流程稳定了,再从单链路CoT升级到多路径CoT。这个过程,比直接套用复杂框架要可靠得多。