2024 年我做一个内部的 AI Agent——一个"运维诊断助手"。想法很性感:给大模型配上一套工具(查日志、查监控指标、查最近的发布记录……),然后值班同学用大白话提问——"订单服务为什么变慢了"——让 Agent 自己去调工具、自己分析、自己给结论。第一版我做得很直接:把用户的问题丢给大模型,把工具清单也告诉它,然后写一个 while True 循环——模型决定下一步调哪个工具,我执行,把结果喂回去,再让模型决定下一步,如此往复,直到模型自己说"任务完成"。本地一测——惊艳:我问"订单服务为什么变慢",Agent 调了一次查监控工具,发现 CPU 飙高,条理清晰地给出了结论。我心里很踏实:"Agent 嘛,不就是给模型一堆工具,让它自己循环着用。"可等它真正上线、面对五花八门的真实问题,事故一个接一个。第一次:有个需要多步排查的问题,Agent 在"查日志"和"查监控"两个工具之间反复横跳,转了八十多步还没给结论,一问下来token 烧掉了好几美元。第二次:某个工具内部抛了异常,我把原始的报错堆栈喂回给模型,模型慌了,开始语无伦次地乱调工具。第三次:模型凭空捏造了一个根本不存在的工具名 restart_database,我的代码 tools[name] 直接 KeyError 崩了。第四次更吓人:Agent 自作主张地决定调用"重启服务"这个工具——在生产环境。我盯着这一连串事故想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Agent,就是给大模型一堆工具,让它自己循环调用,直到它觉得任务完成"。这句话听起来很美,它把 Agent 描述成一个自主、智能的东西。可它漏掉了一件最致命的事——它把整个循环的控制权,完全交给了大模型。而大模型,是一个不可靠的决策者:它会幻觉、会卡死、会无限重复、不知道自己该停。把循环的方向盘整个交给它,就等于让一个会突然走神的司机,开一辆没有刹车、没有限速、没有护栏的车。真正的 Agent 工程,核心不是"给模型工具",而是给这个不可靠的决策者,套上一个你牢牢控制的循环。这篇文章就把 AI Agent 设计梳理一遍:为什么放任模型自由循环必然失控、怎么用步数预算和显式终止把控制权拿回来、工具调用怎么做 schema 校验、错误怎么转成观察让 Agent 恢复、上下文怎么裁剪不爆,以及循环检测、人工确认、可观测性这些把 Agent 真正做对要避开的坑。
问题背景
先把那次 Agent 失控的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个用 while True + "模型自己决定下一步"搭起来的运维 Agent,上线后接连出事:陷入死循环转八十多步烧掉几美元 token;工具抛异常直接让循环崩溃;模型幻觉出不存在的工具名导致 KeyError;甚至自作主张在生产环境调用"重启服务"。
我当时的错误认知:"Agent 就是给大模型一堆工具,让它自己循环调用,直到它觉得任务完成。"
真相:这个想法把整个循环的控制权交给了大模型。但大模型是个不可靠的决策者——会幻觉、会卡死、不知道该停。Agent 工程的核心,是由你的代码掌控这个循环:给它步数预算、显式终止信号、工具 schema 校验、把错误转成观察、裁剪上下文、检测死循环、给危险动作设人工闸。模型只负责"建议下一步",拍板权在你。
要把 AI Agent 做对,需要几块认知:
- 为什么放任模型自由循环必然失控——控制权交错了人;
- 步数预算与显式终止——把循环的控制权拿回到代码手里;
- 工具契约——校验工具名和参数,挡住模型的幻觉调用;
- 错误即观察——让工具的失败变成 Agent 能消化、能恢复的信息;
- 上下文裁剪、循环检测、人工确认这些工程坑怎么处理。
一、为什么放任模型自由循环必然失控
先把这件最根本的事钉死:Agent 的本质是一个"循环",而循环必须有一个可靠的控制者;大模型擅长在每一步"建议下一步做什么",但它绝不是一个可靠的"循环控制者"——它不知道何时该停、会重复、会幻觉、会出错。把整个循环托付给它,失控只是时间问题。
下面这段代码,就是我那个"转八十多步停不下来"的第一版——它把循环整个交给了模型:
def naive_agent_loop(goal: str, tools: dict):
# 反面教材:给模型一堆工具,让它自己【无限循环】调用。
messages = [{"role": "user", "content": goal}]
while True: # 破绽 1:循环没有任何上限
action = llm_decide(messages, tools) # 模型决定下一步
tool = tools[action["tool"]] # 破绽 2:不校验工具名
result = tool(**action["args"]) # 破绽 3:不校验参数、不接异常
messages.append({"role": "tool", "content": str(result)})
# 破绽 4:没有任何终止条件 —— 全靠模型"良心发现"说完成,
# 它不说,这个循环就【永远转下去】。
这段代码没有任何语法错误,在那个顺利的 demo 里也跑得无比漂亮。它的问题不在代码本身,而在一个根本性的角色错配:它默认"模型能像一个理性的人那样,有规划、知进退地把任务做完"。可大模型不是这样的东西——它是一个逐 token 预测的概率模型,它在每一步都能给出一个"看起来合理的下一步",但它没有一个全局的、稳定的"任务进度感"。于是四个破绽逐一爆发:破绽 1——while True 没有上限,模型只要不主动说"完成",循环就永远转,八十步、八百步,token 无底洞般地烧。破绽 2——模型幻觉出一个不存在的工具名,tools[name] 一个 KeyError,整个 Agent 当场崩溃。破绽 3——参数不对、或工具内部抛异常,没人接,循环崩在半路。破绽 4——终止这件大事,完全寄希望于模型"良心发现"。问题的根子清楚了:这个循环的控制权,放错了人。它必须从模型手里,拿回到你的代码手里。
二、步数预算与显式终止:把控制权拿回来
纠正第一版,第一步也是最重要的一步,就是重新定义"谁掌控这个循环"。模型不再是循环的主人,它降级成一个"下一步建议器":它只负责在每一步给出建议,而循环转几次、什么时候停、停在哪——这些决定权,全部回到你的代码手里。具体落地为两件事:一是步数预算(for 取代 while True),二是显式的终止动作。
FINISH = "finish" # 一个特殊的"终止动作",由模型显式发出
def run_agent(goal: str, registry, max_steps: int = 12) -> dict:
"""受控的 Agent 循环:步数有预算,终止有明确信号。"""
messages = [{"role": "user", "content": goal}]
for step in range(max_steps): # 关键 1:循环【有硬上限】
action = llm_decide(messages, registry.schemas())
if action.get("tool") == FINISH: # 关键 2:显式的终止动作
return {"status": "done", "answer": action["answer"],
"steps": step + 1}
obs = registry.invoke(action) # 调用(内部已做校验和容错)
messages.append({"role": "tool", "content": obs})
# 关键 3:步数耗尽仍未完成 —— 这【不是死循环】,而是受控放弃,
# 明确告诉调用方"我没做完",而不是无声无息地烧钱。
return {"status": "budget_exceeded", "steps": max_steps}
这个 for 循环,和第一版的 while True 看起来只差几个字,但控制权的归属彻底反转了。步数预算(max_steps)是一道硬边界:无论模型多么执迷不悟,循环最多转 12 步,就一定会停——它从结构上杜绝了"烧无底洞"。显式终止动作(FINISH)则把"任务完成了吗"这件事,变成一个明确的、可判定的信号:模型要结束,必须发出一个名为 finish 的特殊动作,并附上答案——而不是靠你去猜它那段自然语言里是不是表达了"我完成了"。还有一个关键细节:步数耗尽时,我返回的是 budget_exceeded 状态——这是一次受控的、诚实的放弃,它明确告知调用方"这个任务我没做完",而不是像第一版那样无声无息地空转。控制权拿回来了。但循环里还有一个危险动作:模型说要调某个工具,我的代码就真的去调了——可模型说的那个工具,真的存在吗?参数对吗?
三、工具的契约:用 schema 校验挡住幻觉调用
第一版 tools[action["tool"]] 那一行,藏着一个致命的信任:它无条件相信模型报上来的工具名和参数都是对的。可模型会幻觉——它可能报一个压根不存在的工具名,可能漏传必需的参数,可能把参数传成乱七八糟的格式。所以,在真正执行工具之前,必须有一道校验:模型的每一个工具调用,都要对照工具的"契约"(schema)检查一遍。我们先把工具规范地注册起来:
class ToolRegistry:
"""工具注册表:每个工具都带名字、必需参数和实现。"""
def __init__(self):
self._tools = {} # name -> {"params": set, "func": fn}
def register(self, name: str, required_params: set, func):
self._tools[name] = {"params": required_params, "func": func}
def schemas(self) -> list:
"""暴露给模型的工具清单 —— 模型只能从这份清单里挑。"""
return [{"name": n, "required": list(t["params"])}
for n, t in self._tools.items()]
有了注册表,就能在执行前校验模型给出的调用合不合法:
def validate(self, action: dict) -> str:
"""校验模型给出的工具调用:工具名对不对、参数齐不齐。"""
name = action.get("tool")
if name not in self._tools:
# 模型【幻觉】出一个不存在的工具 —— 绝不能直接崩,
# 而是返回一句明确的错误,让模型下一步改正。
return f"错误:工具 {name} 不存在,请从工具清单里选。"
required = self._tools[name]["params"]
given = set(action.get("args", {}).keys())
missing = required - given
if missing:
return f"错误:调用 {name} 缺少必需参数 {missing}。"
return "" # 返回空字符串,表示校验通过
这道校验的精髓,是在"模型的不可靠输出"和"你的真实代码执行"之间,插了一层硬关卡。模型可以幻觉、可以出错——但它的错误到此为止,绝不会变成一个 KeyError 把整个 Agent 掀翻。更关键的是校验失败之后怎么办:我不是抛异常,而是返回一句人话——"工具 xxx 不存在,请从清单里选"。这句话会被当成一条"观察"喂回给模型,模型看到这个反馈,下一步就能改正。这就引出了 Agent 设计里最重要的一个思想——所有的错误,都不该是"崩溃",而该是"反馈"。
四、错误即观察:让 Agent 从失败里恢复
第一版还有一个大坑:工具内部抛了异常,我要么没接(循环崩了),要么把原始的报错堆栈直接喂回去(模型看不懂、慌了)。正确的思路是 Agent 工程的一条核心原则:把每一个错误,都转化成一条"观察(observation)"。工具调用失败不是循环的终点,而是给模型的又一条信息——让它知道这条路走不通,从而调整下一步。
def invoke(self, action: dict) -> str:
"""调用工具:先校验,再执行,任何错误都【转成观察】喂回去。"""
err = self.validate(action)
if err:
return err # 校验失败:把错误当观察返回
func = self._tools[action["tool"]]["func"]
try:
result = func(**action["args"])
return truncate_observation(str(result))
except Exception as e:
# 关键:工具抛异常【绝不能让整个 Agent 崩】。把异常
# 转成一条人话观察 —— 让模型看到失败、自己调整下一步,
# 比如换个参数重试,或者改走别的工具。
return f"工具执行失败:{type(e).__name__}: {e}"
这里还顺手处理了另一个坑——观察太长。Agent 的上下文,会把每一步的观察都累积进去。可一次"查日志"可能返回几万行;原样塞回上下文,几步之内 token 就爆了,又慢又贵。所以观察在进入上下文前,要先裁剪:
MAX_OBS_CHARS = 2000
def truncate_observation(text: str) -> str:
"""裁剪过长的观察:别让一次工具输出就撑爆上下文。"""
if len(text) <= MAX_OBS_CHARS:
return text
head = text[:MAX_OBS_CHARS // 2]
tail = text[-(MAX_OBS_CHARS // 2):]
omitted = len(text) - MAX_OBS_CHARS
# 关键:一次查询可能返回几万行。原样塞回上下文,几步之内
# token 就爆 —— 保留头尾,中间截断。真要看全量,模型可以
# 用更精确的参数(如带过滤条件)再查一次。
return f"{head}\n...[已省略 {omitted} 字符]...\n{tail}"
"错误即观察"这个原则,从根本上改变了 Agent 面对失败的姿态。第一版里,一个错误就是一次崩溃;现在,一个错误只是一条信息——它和"查到了一条日志"在性质上毫无区别,都是喂给模型、帮它决定下一步的观察。这让 Agent 拥有了一种关键能力:从失败里恢复。参数传错了?模型看到"缺少参数 X",下一步补上。工具超时了?模型看到"执行失败",下一步换个工具。一个健壮的 Agent,不是一个"从不犯错"的 Agent,而是一个"犯了错也能自己绕回来"的 Agent。控制权、工具契约、错误恢复都就位了。但还有一个隐患——模型可能不报错、也不停,只是在原地打转。
五、循环检测与人工确认:守住最后两道闸
步数预算挡住了"无限循环",但挡不住一种更隐蔽的失控:模型在预算之内,反复调用同一个工具、传同样的参数——它没报错,也没进展,只是在原地打转,白白耗光你的步数预算。需要一个循环检测:发现连续几步在重复同一个动作,就主动中断。
def is_stuck(history: list, window: int = 3) -> bool:
"""循环检测:连续多步在重复同一个动作,就是卡死了。"""
if len(history) < window:
return False
recent = history[-window:]
# 关键:模型有时会在同一个工具调用上反复横跳 —— 同样的工具、
# 同样的参数连调 3 次还没进展,就该主动中断,而不是干等
# 步数预算被一步步耗光。
first = (recent[0]["tool"], str(recent[0].get("args")))
return all((h["tool"], str(h.get("args"))) == first
for h in recent)
还有最后一道、也是最重要的一道闸——人工确认。第一版最吓人的事故,是 Agent 自作主张地在生产环境调用"重启服务"。这暴露了一个原则:不是所有工具都能让 Agent 自由调用。只读的工具(查询、搜索、读日志)——错了无非浪费一步,可以放手;但会改变世界的工具(删除、重启、发邮件、改配置)——一旦错了无法挽回,必须在执行前设一道人工闸。
DANGEROUS = {"delete_file", "send_email", "restart_service",
"drop_table", "run_shell"}
def needs_confirmation(action: dict) -> bool:
"""高风险动作必须先经人确认,Agent 不能自作主张。"""
# 关键:只读类工具(查询、搜索)可以让 Agent 自由调用;
# 但会改变世界的动作(删除、重启、发送)必须设一道人工闸。
# 一个失控的 Agent 误重启生产服务,比它多转十圈可怕得多。
return action.get("tool") in DANGEROUS
把循环检测、人工确认整合进 Agent 主循环,它才算真正可以上生产:
def run_agent_safe(goal: str, registry, confirm_fn,
max_steps: int = 12) -> dict:
"""完整受控的 Agent:预算 + 终止 + 循环检测 + 危险动作确认。"""
messages = [{"role": "user", "content": goal}]
history = []
for step in range(max_steps):
action = llm_decide(messages, registry.schemas())
if action.get("tool") == FINISH:
return {"status": "done", "answer": action["answer"]}
if is_stuck(history + [action]):
return {"status": "stuck", "step": step} # 卡死:主动中断
if needs_confirmation(action) and not confirm_fn(action):
obs = "该高风险操作被人工拒绝,请改用其它方式。"
else:
obs = registry.invoke(action)
history.append(action)
messages.append({"role": "tool", "content": obs})
return {"status": "budget_exceeded"}
到这里,那个"不可靠的决策者",已经被牢牢地套进了一个由代码掌控的循环里:步数有预算,终止有信号,工具调用有校验,错误会恢复,打转会中断,危险动作有人把关。但要把 Agent 真正用扎实,还有几个工程坑。
六、工程坑:可观测性、任务分解与评测
五块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:Agent 是个黑盒,必须有可观测性。一次 Agent 运行,内部调了几步、每步用了什么工具、传了什么参数、为什么结束——这些全程要落日志。否则出了问题,你根本无法复盘。
def summarize_trace(history: list, result: dict) -> dict:
"""Agent 可观测性:把一次运行的轨迹汇总成可排查的指标。"""
tool_calls = {}
for h in history:
tool_calls[h["tool"]] = tool_calls.get(h["tool"], 0) + 1
# 关键:Agent 是黑盒,出问题必须能复盘 —— 总步数、各工具
# 调了几次、最终为何结束,都要落进日志和监控。
return {"final_status": result.get("status"),
"total_steps": len(history),
"tool_usage": tool_calls}
坑 2:复杂任务要先规划、再执行。对于步骤多的任务,别让 Agent 走一步看一步——它很容易走着走着就迷路。更稳的做法是先让模型把大任务拆解成一个子任务清单(planning),再逐个执行。有了清单,Agent 每一步都知道自己在整体中的位置,不容易跑偏。坑 3:工具的描述,要像写 API 文档一样精确。模型靠工具的描述来决定何时用、怎么用它。描述含糊,模型就乱用。每个工具的用途、参数含义、返回格式,都要写得清清楚楚。坑 4:Agent 必须有评测集。Agent 的行为是不确定的——同一个问题,两次运行路径可能不同。你改一句提示词、加一个工具,都可能悄悄影响大量任务的成败。必须准备一批有标准答案的任务,每次改动后跑一遍,用成功率来判断这次改动是变好还是变坏。坑 5:给 Agent 的工具,数量要克制。工具太多,模型选择困难、容易选错。优先给少而精、职责清晰不重叠的工具集。下面这张图,把一次受控的 Agent 单步循环串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| Agent 失控 | 把整个循环交给模型,它会幻觉卡死无限重复,失控只是时间问题 |
| 循环控制权 | 模型只负责建议下一步,转几次何时停由你的代码掌控 |
| 步数预算 | 用 for 取代 while True,设硬上限,从结构上杜绝无限烧 token |
| 显式终止 | 模型用一个特殊的 finish 动作宣告完成,而非靠猜它的自然语言 |
| 工具 schema 校验 | 执行前对照契约校验工具名和参数,挡住模型幻觉出的非法调用 |
| 错误即观察 | 工具失败不崩溃,把错误转成一条观察喂回,让模型自己调整 |
| 观察裁剪 | 一次工具输出可能几万行,进上下文前保留头尾截断,防 token 爆 |
| 循环检测 | 连续多步重复同一动作就是卡死,主动中断而非耗光预算 |
| 人工确认闸 | 会改变世界的高风险动作必须先经人确认,Agent 不能自作主张 |
| Agent 评测集 | Agent 行为不确定,改动后用一批标准任务跑成功率判断好坏 |
避坑清单
- 别用 while True 把循环交给模型,它不知道何时停,会无限烧 token。
- 用 for 循环加步数预算设硬上限,步数耗尽要受控放弃并明确报告未完成。
- 终止要靠模型显式发出 finish 动作,别靠猜它的自然语言是否表达了完成。
- 执行工具前必须校验工具名和参数,挡住模型幻觉出的不存在工具,别直接崩。
- 工具抛异常不能让 Agent 崩,把错误转成一条观察喂回,让模型自己恢复。
- 工具输出可能几万行,进上下文前要保留头尾截断,否则几步内 token 就爆。
- 模型会原地打转,要做循环检测,连续重复同一动作就主动中断。
- 会改变世界的高风险动作必须设人工确认闸,只读工具才可让 Agent 自由调用。
- Agent 是黑盒,每步调了什么工具传了什么参数为何结束都要落日志可复盘。
- Agent 行为不确定,必须备标准任务评测集,每次改动后跑成功率判断好坏。
总结
回头看那次"Agent 陷入死循环、烧掉几美元 token 还没完成任务"的事故,以及我后来在 Agent 工程上接连踩的坑,最该记住的不是某一段循环代码,而是我动手前那个想当然的判断——"Agent,就是给大模型一堆工具,让它自己循环调用"。这句话错在它把"自主"当成了"放任"。我以为造一个 Agent,就是把方向盘交给模型、然后退到一边看它智能地把事做完。可大模型,不是一个有稳定理性和全局规划的"人"——它是一个在每一步都能给出像样建议、但对整体进度毫无把握的概率预测器。Agent 工程这件事想清楚的,正是这个:它表面上是在赋予模型能力(给它工具),本质上却是在约束模型的不可靠(给它套上循环)。一个 Agent 系统里,真正智能的部分(模型)恰恰是最不可控的部分;而让整个系统可用的,是包裹在模型外面的那一圈朴素的、确定性的控制代码。
所以做 AI Agent,真正的工程量不在"把工具接给模型"那一下。那一下,任何框架的 quickstart 都帮你做完了。真正的工程量,在于你要始终清醒地记得:循环里坐着一个不可靠的决策者,然后为它可能犯的每一种错,都预先修好一条护栏:它不知道停,你就用步数预算和显式终止替它定边界;它会幻觉,你就用schema 校验挡住非法调用;它会出错,你就把错误转成观察让它恢复;它会打转,你就用循环检测把它叫停;它会闯祸,你就给危险动作设上人工闸。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚放任模型为什么必然失控,再用预算和终止把控制权拿回来,用 schema 校验守住工具调用,用"错误即观察"让 Agent 学会恢复,用循环检测和人工确认守住最后两道闸,最后是可观测性、任务分解、评测这几个把 Agent 做扎实的工程细节。
你会发现,AI Agent 的设计思路,和现实里怎么带一个聪明但毛躁的实习生完全相通。这个实习生很聪明——你交给他一个具体的小任务,他每一步都能干得有模有样。但他毛躁:他会钻牛角尖,在一个死胡同里反复试;他会想当然,以为公司有某个其实不存在的系统;他遇到报错会慌;他甚至可能没经请示就去动了生产环境的开关。一个糟糕的导师,会把任务一丢:"你自己看着办,做完告诉我"——然后等来一场灾难。而一个好导师会怎么做?他会约定一个汇报节奏("最多试 12 步,不行就来找我"——这是步数预算);他会明确什么叫"做完"("做完要正式提交,别含糊地说一句『差不多了』"——这是显式终止);他会告诉他公司到底有哪些系统、怎么用(这是工具 schema);他会教他"报错不可怕,看懂它、绕过去"(这是错误即观察);他会盯着他别钻牛角尖(这是循环检测);而对那些危险的、不可逆的操作,他会立下铁规矩:"这些事,动手前必须先问我"(这是人工确认)。带好一个聪明实习生的关键,从来不是限制他的聪明,而是为他的毛躁,搭好一套不会出大事的框架。
最后想说,Agent 做没做扎实,差距永远不会在那个顺利的 demo 里暴露——演示时你精心挑一个简单问题,模型一两步就漂亮地答完,你会觉得"自主智能"已经实现了。它只在真实的、用户抛来五花八门刁钻问题、工具时不时就抽风、任务需要七拐八绕的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,看着 Agent 在死胡同里转上八十圈、把 token 烧成一笔账单,看着它被一个异常掀翻,甚至胆战心惊地发现它差点在生产环境闯了大祸;而做对了,无论用户的问题多刁钻、工具多不给力,你的 Agent 都稳稳地转在那个有预算、有护栏、有刹车的循环里:它试错、它恢复、它在该停的时候停下,它给你一个靠谱的答案,或者诚实地告诉你"这个我没搞定"——而绝不会失控。所以别等 Agent 烧成一笔账单、等它差点闯下大祸,在你写下第一行"让模型调用工具"的代码之前就该想清楚:这个循环,谁在掌控?它什么时候必须停?模型犯了错,会怎样?它要做危险的事,谁来拦?这几个问题都有了答案,你的 Agent 才不只是 demo 里那个"看起来很智能"的玩具,而是一套把不可靠的模型,真正约束成了可靠生产力的系统。
—— 别看了 · 2026