2024 年我给团队做一个能自动跑数据分析的 Agent——用户用大白话提一个分析需求(比如"看看上个月哪个渠道的转化率掉得最狠"),Agent 自己去查数据库、算指标、做对比,最后给一段结论。我给它配了几个工具:执行只读 SQL 的 run_sql、做数值计算的 calc。第一版我做得很顺手:写一个系统提示词,告诉模型"你是数据分析助手,一步步思考,可以调用工具",然后在外面套一个循环——调一次大模型,如果它要调工具就执行、把结果塞回对话,如果它说"做完了"就返回答案。本地我拿两三个需求测了测,它跑了几轮、给了像模像样的答案,我心里很笃定:做 Agent 嘛,就是给模型一个目标、一套工具,再加一句"一步步思考",剩下的它自己会想清楚——这 Agent 稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:有的需求,它绕圈子绕了几十轮停不下来——同一个查询反复跑,或者一直说"我还需要再确认一下",就是不收尾,直到把 token 预算烧光。第二种最难缠:它中途会跑偏——本来要分析转化率,做着做着跑去分析别的指标了,最后答得驴唇不对马嘴。第三种最头疼:有一次它第一步的 SQL 就写错了,查出来一份空数据,可它毫不知情,后面所有步骤全基于这份错数据硬推,最后给了一个一本正经的错误结论。第四种最莫名其妙:同一个需求,我测了几次,它每次走的路径都不一样,有时对、有时错,出了问题我根本没法定位是哪一步坏的。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为做一个 Agent,就是给大模型一个目标、一套工具,再加一句"一步步思考",剩下的多步规划它自己会在脑子里完成——我把任务和工具交给它,它就会自己想清楚这事分几步、每步干什么、怎么把上一步的结果用到下一步、什么时候算做完了;这个"规划"是模型一次推理就能隐式搞定的事,我只要在外面套一个循环,傻等它说"做完了"就行。可这个认知是错的。多步任务的规划和执行,根本不是模型一次推理能隐式兜住的东西。大模型的单次推理,只能看到你这一刻喂给它的上下文——它没有"记性",不会自己记得三轮前查到过什么;它没有"进度感",不知道自己已经绕了几圈、离目标还有多远;它不会自己校验,一步算错了它不会停下来;它也不知道什么时候该收手。这些事,模型一个都不会自动替你做。一个能真正跑通多步任务的 Agent,本质不是"模型 + 工具 + 循环"这么个松散的组合,而是一台由你设计的状态机外加一个受你控制的循环:你要决定它是"边想边做"(ReAct)还是"先规划后执行"(Plan-and-Execute);你要把每一步的观察结果显式地拼回上下文,替它维护"记性";你要在每步执行完之后校验它到底成没成功;你要给它一个明确的步数上限和终止条件,替它管住"进度感";你要让它在某步失败时能停下来重新规划,而不是硬推;你还要把整条执行轨迹记录下来,让它可观测、可调试。本文从头梳理:为什么"给个目标加一套工具、让模型自己跑"会失控,ReAct 怎么把推理和行动显式地拆成一个循环,Plan-and-Execute 又是怎么先规划后执行,每一步的状态该如何显式地传下去,校验、终止与重规划如何让 Agent 知道何时停、错了怎么办,以及一些把 Agent 多步规划做扎实要避开的工程坑。
问题背景
先把 Agent 这件事说清楚。这里说的 Agent(智能体),指的是这样一个程序:你给它一个目标和一组工具,它自己决定调用哪些工具、按什么顺序调用,一步步地把目标完成。它和"一次性问答"最大的不同,是它需要多步——一个分析需求往往要先查数据、再算、再对比、再下结论,这中间每一步该做什么,取决于上一步的结果。让模型驱动这种多步流程的核心机制,叫工具调用(tool calling):模型在推理时不直接给答案,而是输出"我要调用某个工具、参数是什么",由外层程序执行这个工具、把结果回传给模型,模型再继续。第一版的错,不在工具调用本身,而在于它把"这些工具按什么顺序、调到什么时候为止"这个多步规划的活,整个甩给了模型去隐式完成。
错误认知是:Agent 就是"目标 + 工具 + 一个循环",多步规划由模型在推理里自动完成,我不用管步骤、不用管进度、不用管校验。真相是:模型单次推理无记忆、无进度感、不自校验;多步规划必须由 Agent 外层显式地管起来——选定范式、回传状态、逐步校验、设上限、能重规划。把这一点摊开,第一版的几类问题就都能解释了:
- 绕圈子停不下来:循环没有步数上限,模型没有"进度感",同一个查询反复跑,几十轮不收尾。
- 中途跑偏:原始目标没有在每一轮被显式重申,几轮之后模型把它丢了,做起了无关的事。
- 一步错满盘皆输:每步执行完没有校验,第一步查空数据也照单全收,后面全基于错数据硬推。
- 路径不可复现、难调试:没有记录执行轨迹,同一需求每次走法不同,出了错无从定位是哪一步。
所以让 Agent 真正可靠,核心不是"提示词写得更聪明",而是一整套工程:选定规划范式、把状态显式回传、每步做校验、设步数上限与终止条件、失败时能重规划、全程留痕可观测。下面六节,就从第一版"让模型自己跑"的想当然讲起。
一、为什么"给个目标加一套工具,让模型自己跑"会失控
第一版的 Agent,核心逻辑朴素到极致:一个 while 循环,调模型,模型要调工具就执行,模型说完成就返回。
# 反面教材:第一版 Agent —— 给个目标和工具,套个循环让模型自己跑
def agent_v1(goal):
messages = [{"role": "system",
"content": "你是数据分析助手,一步步思考,可以调用工具。"},
{"role": "user", "content": goal}]
while True: # 致命:循环没有任何上限
resp = call_llm(messages, tools=TOOLS)
if resp.get("final_answer"): # 模型自己说"做完了",才跳出
return resp["final_answer"]
# 模型要求调用一个工具,执行它,把结果塞回对话
result = run_tool(resp["tool"], resp["args"])
messages.append({"role": "tool", "content": result})
# 本地我拿两三个需求测,它跑了几轮、给了答案,看着挺聪明。
# 可一上线:有的需求它绕圈子绕了几十轮停不下来、token 烧光;
# 有的中途跑偏、答非所问;有的第一步 SQL 就错了,
# 后面全基于错数据硬推 —— 而它自己毫不知情。
问题就藏在这个 while True 里。这个循环把"什么时候停"这个生死攸关的决定,百分之百地交给了模型——只有模型主动说出 final_answer,循环才会结束。可模型并不知道自己已经跑了多少轮、烧了多少 token,它每一轮都只是基于当前上下文,孤立地判断"我是不是该再查一下"。于是它很容易陷进一种自我怀疑的循环:再查一次、再确认一下、好像还差点什么——几十轮过去,就是不收尾。
这一节要建立的认知是:大模型的单次推理,是一个"无状态、无记忆、看不见全局"的瞬间动作——它在第 N 轮做的判断,完全基于你这一刻喂给它的上下文,它既不知道前 N-1 轮自己干了什么,也不知道这趟任务总共该花几轮、现在进行到哪了;凡是需要"跨越多轮、纵观全局"才能做对的决策,模型都做不了,必须由 Agent 的外层代码替它做。第一版最深的想当然,是把模型当成了一个有"心智连续性"的智能体——以为它像一个人一样,接了任务之后,心里始终装着这个任务的全貌和进度,知道自己走到哪一步了、还剩多少。可模型完全不是这样。它每一次被调用,都是一次彻底独立的、从零开始的推理;它对"上一轮"的全部了解,仅仅来自你这一轮主动拼进上下文里的那些文字。这意味着,凡是"跨轮"的东西,模型天然就没有。"我已经查过这个表了"——这是跨轮的记忆,模型没有,除非你把它写进上下文。"我已经绕了二十圈了,该收手了"——这是跨轮的进度感,模型没有,除非你在外层数着轮数。"我最初的目标是分析转化率"——这甚至也是跨轮的,几轮之后,如果你不在每一轮都把原始目标重新喂给它,它真的会把目标本身给忘了,这就是"跑偏"的根源。所以 while True 的错,不是"少写了一个上限"这么简单,它的错是把一整类模型根本无力承担的责任——管全局、管进度、管终止——错误地推给了模型。要做对 Agent,第一步就是把这些责任收回到外层代码手里。而收回来之后,第一件要决定的事,就是这个循环本身该长什么样——这就是下一节的 ReAct。
二、ReAct 循环:把推理和行动显式地交替起来
把控制权收回外层,第一种经典的循环结构叫 ReAct——它的名字是 Reason(推理)和 Act(行动)的合写。它的核心,是强制让 Agent 的每一轮都显式地拆成三段:Thought(模型对当前局面的思考)、Action(它决定这一步做什么)、Observation(这一步真实发生的结果)。
# ReAct:把每一轮显式拆成 思考 -> 行动 -> 观察 三段
def react_step(messages):
resp = call_llm(messages) # 模型先输出一段 Thought + 一个 Action
thought = resp["thought"] # 它对当前局面的推理
action = resp["action"] # 它决定这一步做什么
if action["type"] == "finish":
return {"done": True, "answer": action["answer"]}
# 执行 Action,拿到 Observation —— 这是这一步真实发生的结果
observation = run_tool(action["tool"], action["args"])
return {"done": False, "thought": thought,
"action": action, "observation": observation}
而要让模型乖乖地按 Thought / Action / Observation 这个格式输出,关键在提示词。ReAct 的提示词,本质是给模型立一套必须遵守的"输出体例"。
# ReAct 的提示词模板:逼模型每轮都按固定格式输出
REACT_PROMPT = """你要完成一个数据分析任务。每一轮,你必须严格按以下格式输出:
Thought: 我现在掌握了什么、还缺什么、下一步该做什么
Action: 要调用的工具名 和 参数(JSON)
或者,当你确信任务已完成时,输出:
Thought: 我已经得到了回答问题所需的全部信息
Action: finish(把最终结论写在这里)
可用工具:
- run_sql(sql): 执行只读 SQL,返回查询结果
- calc(expr): 做一个数值计算
注意:每一轮只能有一个 Action;不要一次规划很多步,
做完一步、看到 Observation 后,再想下一步。"""
这一节的认知是:ReAct 的价值,不在于它让模型"更会思考",而在于它把模型那个原本黑箱、混沌、一团糨糊的"自由发挥",硬生生切割成了一个个边界清晰、可被外层程序逐段接管的小单元——Thought、Action、Observation,每一段都有明确的归属,Thought 和 Action 归模型,Observation 归你的真实世界。第一版的循环里,模型每一轮在干什么,是不透明的——它可能在推理,可能在臆想,也可能直接幻觉出一个"工具结果"然后接着往下编,你区分不出来。ReAct 把这件事掰开了:它要求模型每一轮必须先吐一段 Thought、再吐一个明确的 Action,然后——这是最关键的一点——停下来。Action 之后,轮到外层程序登场:由你的代码去真实地执行那个工具,得到一个真实的 Observation,再由你把这个 Observation 喂回给模型。这个"停下来、交给外层"的切口,意义极大。它意味着 Observation 这一段,永远是真实世界的产物,而不是模型想象的产物——模型没有机会自己编造工具结果,因为执行工具的是你的代码。它也意味着,你在每一轮的 Action 之后,都获得了一个干预的机会:你可以在这里校验、可以在这里记录、可以在这里喊停。ReAct 最适合那种"步数事先说不准、走一步才知道下一步该往哪走"的探索性任务——因为它本来就是边想边做、随时根据最新的 Observation 调整方向的。它的代价,是每一轮都让模型重新想一次"下一步干嘛",这种走一步看一步的方式,如果不加约束,恰恰也容易绕圈、容易在原地打转。所以对那些步骤其实比较确定的任务,还有另一种更适合的范式——先把完整计划一次性想好,这就是下一节。
三、显式规划:先生成计划,再逐步执行
ReAct 是"边想边做",每一步都临时决定下一步。但很多任务的步骤其实是相对确定的,这时更稳的范式叫 Plan-and-Execute:把"规划"和"执行"彻底分成两个阶段——先让模型一次性把完整计划列出来,再老老实实地逐步执行这张计划。
# Plan-and-Execute:先让模型一次性列出完整计划,再逐步执行
def make_plan(goal):
# 规划阶段:只规划,不执行 —— 产出一个有序的步骤列表
raw = call_llm(
f"把下面的任务拆成 3 到 6 个有序步骤,每行一步,"
f"只列步骤、不要执行:\n{goal}")
return [s.strip() for s in raw.strip().split("\n") if s.strip()]
def plan_and_execute(goal):
plan = make_plan(goal) # 先有一张完整的计划
results = []
for i, step in enumerate(plan):
# 执行阶段:带着"计划全貌 + 已完成步骤的结果"去做当前这步
r = execute_step(step, plan, results)
results.append(r)
# 所有步骤做完,让模型基于全部中间结果汇总出最终答案
return summarize(goal, plan, results)
这种"先规划后执行"的结构,直接治好了第一版的"跑偏"。因为有一张写死的计划摆在那里,执行阶段每一步要做什么是确定的,模型不再有机会在半路上自由发挥、改主意。执行某一步时,你还要把整张计划和已完成步骤的结果一起喂回去,让模型清楚"我在大图里的哪个位置"。
这一节的认知是:Plan-and-Execute 和 ReAct 的根本分歧,是"在哪个时刻、用多大的视野来做规划决策"——ReAct 是在每一步的当下、只看到局部信息时做微观决策,Plan-and-Execute 是在任务一开始、纵观全局时一次性做完宏观决策;前者灵活但易失焦,后者稳定但欠应变,它们不是谁取代谁,而是适配不同形状的任务。第一版的"让模型自己跑",其实暗合了 ReAct 那种"走一步看一步"的味道,却没有 ReAct 的格式约束,于是把"走一步看一步"的缺点放到了最大:每一步都重新决策,意味着每一步都有"决策偏一点"的风险,几十步累积下来,方向就彻底歪了。Plan-and-Execute 给出的解法很有想法:它把"规划"这个最容易出错、也最需要全局视野的环节,从"散布在每一步里反复做"提前并压缩成"任务开头、纵观全局做一次"。规划的时候,模型看到的是任务的完整面貌,它做出的步骤拆解是带着全局意识的;而一旦计划定稿,执行阶段就退化成一件简单可控的事——按表走,挨个做。这样,"跑偏"就基本被消灭了,因为没有人再有机会在半路改主意。当然它也有自己的软肋:计划是在"还没真正动手、对真实数据一无所知"的时候定的,如果某一步的真实结果和计划的预期严重不符,这张计划就可能从那一步开始失效。所以现实里的选择往往是:步骤高度确定的任务,用 Plan-and-Execute;高度探索性的任务,用 ReAct;而大多数生产任务,用的是两者的结合——先规划出一张计划,执行中一旦发现计划走不通,就触发重新规划。但无论哪种范式,它们都共同依赖一件底层的事:每一步的结果,必须被显式地、正确地传递给下一步——这就是下一节。
四、状态与上下文:每一步的结果要显式地传下去
无论 ReAct 还是 Plan-and-Execute,都有一个绕不开的底层问题:模型是无记忆的,它要做对第 N 步,就必须"看见"前 N-1 步发生了什么。这个"看见",不会自动发生,得靠你把前面每一步的动作和观察,显式地拼成一份上下文喂回去。这份累积起来的执行记录,通常叫 scratchpad(草稿本)。
# 状态:把每一步的"动作 + 观察"显式地累积成一份 scratchpad
def build_context(goal, history):
# history 是到目前为止每一步的记录,显式拼进上下文
lines = [f"任务:{goal}", "", "已经做过的步骤:"]
for i, h in enumerate(history):
lines.append(f"第 {i+1} 步 行动:{h['action']}")
lines.append(f"第 {i+1} 步 观察:{h['observation']}")
lines.append("")
lines.append("请基于以上进展,决定下一步。")
return "\n".join(lines)
# 模型单次推理是"无记忆"的:你不把前几步的观察显式拼回去,
# 它下一轮就完全不知道自己已经做过什么、查到过什么。
# 注意第一行始终重申"任务" —— 这是防止它跑偏的关键。
这一节的认知是:Agent 之所以能表现得像一个"连贯的、有记性的"智能体,完全是一种由你精心维护的"假象"——模型本身没有记性,这份连贯性,是你每一轮都把历史重新拼回上下文、人工"喂"出来的;你的 scratchpad 怎么组织,直接决定了这个 Agent 是聪明还是糊涂。第一版有一个隐藏的、却很致命的误解:它以为往 messages 列表里不断 append,模型就自然"记住"了一切。这个理解只对了一半。对的部分是,append 进去的东西模型确实能看到;错的部分是,它会让你误以为"记忆"是免费的、自动的、无需设计的。可一旦你意识到"模型的记忆 = 你拼进上下文的文字",一连串的设计问题就立刻浮现出来,而且每一个都很要紧。第一,你拼什么。前面每一步的动作和观察都要拼,但尤其重要的是,原始任务必须在每一轮都重新放在最显眼的位置——上一节说过,模型会把目标本身忘掉,而 scratchpad 的第一行始终重申任务,就是最简单有效的"防跑偏"。第二,你拼多少。每一步的观察结果原样累积,上下文会越来越长,几轮之后就可能撑爆——所以观察结果在进 scratchpad 之前,常常需要先做摘要或截断(这一点下一节的工程坑里还会细说)。第三,你怎么组织。是平铺成一段文字,还是结构化成"步骤-动作-观察"的清单?组织得越清晰,模型越容易看懂"我走到哪了",决策质量就越高。所以 scratchpad 不是一个"把历史堆进去"的垃圾桶,它是你为这个 Agent 设计的"工作记忆",是 Agent 工程里你最该用心打磨的东西之一。而有了这份清晰的工作记忆,你才有条件去做下一件事——在每一步之后,校验它到底对不对。
五、校验、终止与重规划:让 Agent 知道何时停、错了怎么办
前面解决了"循环怎么转""状态怎么传"。但还剩两个第一版栽得最惨的问题没解决:一步错了没人发现(满盘皆输),以及循环停不下来(绕圈子)。先看校验——每一步执行完,不能默认它成功了,得主动验一下。
# 每一步执行完,先校验它到底成没成功,再决定要不要往下走
def verify_step(step, observation):
# 规则校验:能用确定性规则判的,绝不交给模型
if observation is None or observation == "":
return {"ok": False, "reason": "这一步没有返回任何结果"}
if isinstance(observation, list) and len(observation) == 0:
return {"ok": False, "reason": "查询结果为空,数据可能不对"}
# 语义校验:让模型判断"这个结果是否真的完成了这一步的目标"
judge = call_llm(
f"步骤目标:{step}\n实际得到的结果:{observation}\n"
f"这个结果是否达成了步骤目标?只回答 yes 或 no 并说明原因。")
return {"ok": judge.lower().startswith("yes"), "reason": judge}
再看终止。Agent 的循环必须有两道独立的刹车:一道是模型主动声明"完成",另一道是外层强制的步数上限——两道缺一不可。
# 终止:必须同时有"步数上限"和"明确的完成判定",缺一不可
def run_agent(goal, max_steps=12):
history = []
for step_no in range(max_steps): # 硬上限:绝不允许无限循环
decision = next_action(goal, history)
if decision["type"] == "finish": # 模型主动声明完成
return {"status": "done", "answer": decision["answer"],
"steps": step_no + 1}
obs = run_tool(decision["tool"], decision["args"])
history.append({"action": decision, "observation": obs})
# 跑满 max_steps 还没结束 —— 这本身就是一个要上报的异常信号
return {"status": "exceeded", "history": history,
"reason": f"{max_steps} 步内未能完成任务"}
最后是重规划。校验发现某一步失败了,正确的反应不是硬着头皮往下走,而是带着失败信息回炉、重新规划。
# 重规划:某一步失败了,不是硬着头皮往下走,而是带着失败信息重新规划
def execute_with_replan(goal, max_replans=2):
plan = make_plan(goal)
for attempt in range(max_replans + 1):
results, failed = run_plan(plan)
if not failed:
return summarize(goal, plan, results)
# 有步骤失败:把"原计划 + 哪一步怎么失败的"一起交回去重新规划
plan = call_llm(
f"原计划:{plan}\n执行到第 {failed['index']} 步失败了,"
f"失败原因:{failed['reason']}\n"
f"请基于已完成的部分,重新规划剩下要做的步骤。")
return {"status": "give_up", "reason": "多次重规划仍无法完成"}
# 关键:失败不可怕,可怕的是"假装没失败、继续往下推"。
# 让 Agent 在失败处停下、重新规划,是它区别于"瞎跑"的核心。
这一节的认知是:一个能用的 Agent 和一个会闯祸的 Agent,差别不在于"它顺利时表现得多聪明",而在于"它不顺利时会怎样"——校验、终止、重规划这三件事,合起来回答的是同一个问题:当事情没按预期发生时,这个 Agent 是会及时发现、停下、想办法,还是会蒙着头一路错到黑。第一版有一个非常乐观、却非常危险的隐含假设:它默认每一步都会成功。SQL 执行了,它就当查到了对的数据;模型说完成了,它就当真的完成了。这个"默认成功"的假设,把 Agent 变成了一个没有任何安全机制的系统。校验,就是拆掉"默认成功"这个假设:每一步执行完,都要用规则或模型主动验一遍——能用确定性规则判的(结果为空、报错、格式不对)绝不含糊,判不了的再交给模型做语义判断。校验之所以是地基,是因为后面两件事都建立在它之上:没有校验,你根本不知道哪一步"失败"了,重规划就无从触发。终止,解决的是另一个方向的失控——绕圈子。它的要点是"两道刹车":模型主动说完成是一道,但你绝不能只靠这一道,因为模型恰恰可能永远不说;所以必须再加一道外层的、它无论如何也绕不过去的硬上限——步数跑满就强制停。而且这里有个容易被忽略的细节:跑满步数停下来,不该被当成"正常结束",它是一个明确的异常信号,意味着这个任务 Agent 没搞定,该上报、该有人看。重规划,则是把"失败"从一个终点变成一个转折点:校验抓到某步失败后,不是直接放弃、也不是硬推,而是把"哪一步、为什么失败"这个信息交回给规划环节,让它生成一张绕开这个坑的新计划。校验发现问题、终止守住底线、重规划给出生路——这三者拼起来,Agent 才第一次有了应对"意外"的能力。把这套"出意外了怎么办"的机制画成一张图,就是下面这张流程图。
[mermaid]
flowchart TD
A[收到一个多步任务] --> B[规划 生成有序步骤计划]
B --> C[取出下一步 带着已有进展去执行]
C --> D[执行工具 拿到观察结果]
D --> E{校验这一步成功了吗}
E -->|失败| F{重规划次数还够吗}
F -->|够| B
F -->|不够| G[上报失败 停止]
E -->|成功| H{任务完成 或 到步数上限了吗}
H -->|否| C
H -->|是| I[汇总中间结果 给出最终答案]
六、把 Agent 多步规划做扎实,要避开的工程坑
前面五节讲清了 Agent 多步规划的核心:选范式、传状态、做校验、设终止、能重规划。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是出了问题最让人抓狂的:Agent 的执行轨迹必须留痕,否则它对你就是一个不可调试的黑箱。
# 坑一:Agent 的每一步都必须留痕,否则出了问题你根本无从查起
import json, time
def trace_step(run_id, step_no, thought, action, observation, verify):
record = {
"run_id": run_id, # 一次完整任务的唯一 id
"step": step_no,
"ts": time.time(),
"thought": thought, # 模型这一步怎么想的
"action": action, # 它决定做什么
"observation": str(observation)[:500], # 实际得到什么
"verify": verify, # 这一步校验是否通过
}
# 一行一条,落到结构化日志,事后可以完整重放整条轨迹
log.info("agent_trace %s", json.dumps(record, ensure_ascii=False))
# 同一个需求每次跑的路径都不同,没有 trace,你永远不知道
# 这次到底是哪一步想歪了 —— 有了逐步轨迹,才谈得上调试。
第二个坑,是上下文膨胀。工具返回的原始结果可能很大,一股脑塞回 scratchpad,几轮下来上下文就爆了。
# 坑二:工具返回的原始结果可能很大,直接塞回上下文会爆
# 反面:run_sql 查出 5000 行,整个塞回去 ——
# 几轮下来,上下文被中间结果撑爆,token 爆炸、还越来越慢
history.append({"action": act, "observation": full_rows}) # 危险
# 正解:观察结果进上下文前,先做摘要 / 截断
def shrink(observation, max_rows=20):
if isinstance(observation, list) and len(observation) > max_rows:
head = observation[:max_rows]
return {"rows": head,
"note": f"共 {len(observation)} 行,只展示前 {max_rows} 行"}
return observation
history.append({"action": act, "observation": shrink(full_rows)})
# 完整结果该落到外部存储 / 文件,上下文里只留一个摘要和指针。
还有几个坑值得点一下。其一,工具本身要给 Agent 返回"友好的错误"——一个工具执行失败时,不要直接抛异常把整个循环炸掉,而要返回一段模型能读懂的错误描述("SQL 语法错误:第 2 行附近"),这样 Agent 才有机会在下一轮自己纠正。其二,要给单次任务设一个总的成本上限——不光是步数,还有累计 token、累计耗时,任何一个触顶都要能熔断,否则一个异常任务能烧掉惊人的费用。其三,Agent 的提示词里,工具的描述要写得极其清楚——每个工具是干什么的、参数是什么、什么时候该用,模型选错工具,往往是因为你没把工具描述清楚。下面把两种 Agent 多步规划范式集中对照一下:
两种 Agent 多步规划范式对照
范式 怎么走 适合 弱点
--------------------------------------------------------------------
ReAct 想一步 做一步 看一步 探索性 步数不定的任务 容易绕圈 走偏
Plan-and-Execute 先列完整计划 再逐步执行 步骤较确定的任务 计划一旦错 全错
Plan 加重规划 先规划 失败处重新规划 大多数生产场景 实现最复杂
原则:多步规划不能全交给模型隐式完成 ——
外层必须有受控循环 状态回传 每步校验 步数上限 与可观测轨迹
这一节这几个坑,串起来是同一个意思:一个 Agent 不是"一段聪明的提示词",而是一个有自己的运行轨迹、自己的资源开销、自己的失败模式的软件系统——你要像对待任何一个生产系统那样,给它配上日志、配上资源限额、配上对依赖(工具)的健壮处理。第一版把 Agent 当成了一个纯粹的"AI 问题":效果不好,就改提示词;还不好,就换个更强的模型。可这一节的每个坑,都不是提示词能解决的,它们全都是工程问题。轨迹留痕,是可观测性问题——Agent 的执行路径天然是不确定的,同一个输入两次跑出来的步骤可能完全不同,这种系统如果不把每一步的 thought、action、observation 都记录下来,出了错你就只能干瞪眼,因为你根本没法复现、没法定位。上下文膨胀,是资源管理问题——Agent 会自己产生大量中间数据(每个工具的返回值),这些数据如果不加控制地累积进上下文,会让 token 成本和延迟双双失控。工具的错误处理、成本熔断、工具描述,无一不是工程活。把 Agent 理解成一个"软件系统"而不是"一段提示词",你的整个做法都会变:你会给它建监控面板,会给它设资源配额,会为它的每一个工具依赖考虑失败兜底,会像 review 一段关键代码那样去 review 它的执行轨迹。Agent 的"智能"来自模型,但 Agent 的"可靠",百分之百来自你围绕模型搭起来的这套工程。
关键概念速查
| 概念 | 说明 |
|---|---|
| LLM Agent | 给大模型一个目标和一组工具,由它自主决定多步调用工具完成任务 |
| 工具调用 | 模型不直接给答案,而是输出"调哪个工具、什么参数",由外层执行 |
| 多步规划 | 把一个目标拆成有序的多个步骤,每步依赖前一步的结果 |
| ReAct | 边想边做的范式,每轮显式拆成思考 行动 观察三段 |
| Plan-and-Execute | 先一次性规划出完整步骤计划,再逐步执行的范式 |
| scratchpad | 累积每一步动作与观察的执行记录,是 Agent 的工作记忆 |
| 步骤校验 | 每步执行后用规则或模型判断它是否真的成功,而非默认成功 |
| 步数上限 | 外层强制的硬刹车,跑满即停,防止 Agent 无限绕圈 |
| 重规划 | 某步失败时带着失败信息回炉,生成一张绕开坑的新计划 |
| 执行轨迹 trace | 逐步记录 thought 行动 观察,让不确定的 Agent 可调试可复现 |
避坑清单
- 不要用 while True 让模型自己决定何时停:必须有外层的步数硬上限。
- 不要每轮丢掉原始目标:scratchpad 第一行始终重申任务,防止跑偏。
- 不要默认每一步都成功:每步执行后必须用规则或模型校验。
- 不要在某步失败后硬推:带着失败原因触发重规划,而非将错就错。
- 不要不记录执行轨迹:thought 行动 观察逐步留痕,否则无法调试。
- 不要把工具原始结果整个塞回上下文:先摘要截断,防止上下文爆。
- 不要让工具失败直接抛异常炸掉循环:返回模型能读懂的错误描述。
- 不要只设步数上限:还要设 token 与耗时的总成本上限并能熔断。
- 不要套用单一范式:探索性任务用 ReAct,确定性任务用 Plan-and-Execute。
- 不要把工具描述写得含糊:工具用途与参数写清楚,模型才不会选错。
总结
回头看第一版那个"给目标加工具、套个 while 循环让模型自己跑"的 Agent,它的失控很典型。它不在某一行代码,而在一个对大模型的根本误解:以为模型像人一样,接了任务就心里装着全局和进度,能自己把多步规划隐式地完成。真相是,模型的单次推理是无状态、无记忆、看不见全局的——它不知道自己绕了几圈、不记得三轮前查过什么、几轮之后连原始目标都会忘。第一版把"管全局、管进度、管终止、管校验"这一整类模型根本无力承担的责任,全甩给了模型,于是它绕圈子、跑偏、一步错满盘皆输,全都顺理成章。
而把 Agent 多步规划做对,工程量并不小。它不是"写一段好提示词"那么简单,而是要选定 ReAct 或 Plan-and-Execute 的范式、要把每一步的状态显式拼成 scratchpad 回传、要在每步之后做规则与语义校验、要设步数与成本的硬上限和明确终止条件、要让 Agent 在失败处能重新规划、还要把整条执行轨迹留痕以便调试、要给工具配好错误兜底和清晰描述。一个真正可靠的 Agent,是这些环节一个不少地拼起来的。
这件事其实很像带一个实习生去完成一个要跑好几个部门的复杂任务。第一版的做法,像是把任务一句话甩给实习生——"你把这事办了"——然后就不管了,指望他自己摸索着全办妥。可一个新人,你这样撒手,大概率会出乱子:他可能在某个环节卡住、反复琢磨同一件事出不来(绕圈子);可能办着办着把最初的目标给跑偏了(走偏);可能第一个部门给的信息就是错的,他没多想、拿着错信息把后面全办了(一步错满盘皆输)。聪明的带法是怎样的?第一,先让他把计划列出来——分几步、先去哪个部门、再去哪个,你过一遍(这就是显式规划)。第二,让他每办完一个环节就回来跟你说一声进展,而不是闷头办到底(这就是把每步观察显式回传)。第三,他每汇报一步,你帮他确认这一步对不对、信息靠不靠谱(这就是校验)。第四,跟他约定好:要是某一步实在办不下去,别硬扛,回来找你一起重新想办法(这就是重规划)。第五,给他一个时间盒——这事最多花两天,到点还没完就必须回来汇报(这就是步数与成本上限)。第六,让他把每一步做了什么、遇到了什么记在本子上,万一最后结果不对,你能顺着本子查出是哪一环歪了(这就是执行轨迹)。一个新人能不能独立办成复杂任务,靠的从来不是他一个人有多聪明,而是你有没有把"列计划、勤汇报、逐步确认、卡住重想、设时限、留记录"这套带人的方法,稳稳地搭在他外面。
这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测 Agent,来来回回就拿那么两三个需求,而且往往是简单、规整、你心里早有预期路径的需求——模型几轮就跑通了,给了个像样的答案,你会觉得"这 Agent 挺聪明的嘛"。真正会把问题撑爆的,是上线后的真实使用:真实用户会提那些模糊的、多歧义的、需要七八步才能搞定的需求,把你那个没有步数上限的循环顶到绕圈几十轮;真实的数据库里一定会有空表、脏数据、会超时的慢查询,制造出"第一步就失败"的局面,而你那个默认每步都成功的 Agent,会拿着错数据一路推到底;真实的高频使用会让"同一需求每次路径都不同"这件事变成调试的噩梦,而你那时才发现自己什么轨迹都没记。这些场景,你本地那两三个顺手的测试需求,一个都模拟不到。所以如果你正在做一个 Agent,别等它在线上绕圈烧光预算、别等用户拿着一个一本正经的错误结论来投诉,才回头怀疑你那个 while 循环。在写下第一行 Agent 代码时就想清楚:我用的是 ReAct 还是 Plan-and-Execute、我的循环有没有步数硬上限、我每一步执行完有没有校验、某步失败了我会不会重规划、我有没有把执行轨迹记下来——把"让模型调用工具"和"让一个 Agent 在真实的模糊需求、脏数据和高频使用下依然可靠"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026