2024 年我做一个 AI Agent,要让大模型自己调用一堆工具——查数据库、搜网页、读文件——来完成用户交给它的任务。让模型"会用工具"这件事,我压根没多想。第一版我做得很省事:Agent 不就是一个 while 循环?把工具列表告诉模型,模型说"我要调 search 工具",我就调,把结果拼成一条消息喂回去,再问模型;模型说完成了,循环就结束。本地开发时——真不错:我问它一个简单问题,它调一次工具、拿到结果、答出来,一气呵成。我心里很踏实:"Agent 嘛,不就是'模型说调工具、我调、喂回去',转着圈直到它说完?"可等这个 Agent 真正上线、面对五花八门的真实任务,一串问题冒了出来。第一种最先把我打懵:某个工具调用失败了——参数传错、网络超时、抛了个异常——我没接住,这个异常直接把整个 Agent 循环炸了,用户得到的是一个 500,而不是"我试了一下没成功"。第二种最烧钱:Agent 陷进了死循环——它反复调用同一个工具,或者在两个工具之间来回横跳,就是不收敛,我没设上限,它能一直转到我的 token 额度烧光。第三种最拖时间:模型一轮里要求同时调三个工具,我图省事写了个 for 循环一个个串行调,三个各要两秒,本来并发两秒能完的事,我硬生生拖成六秒。第四种最隐蔽:有个搜索工具一次返回了几万字,我把这一大坨原样塞回上下文,才转了三四轮,上下文窗口就被这些原始结果撑爆了。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Agent,就是'模型说调工具、我调、喂回去'的一个循环"。这句话把 Agent 循环当成了一段顺顺当当、转几圈就停的流程。可它不是。Agent 循环和一段普通的业务流程,最根本的区别是:它的每一步,都由"模型"这个不确定的东西来驱动。模型可能要求调一个根本不存在的工具,可能传一组对不上签名的参数,可能永远不说"完成"、一直要求调下去,可能一轮里就要调五个工具。所以 Agent 循环的本质,是"一段你不能完全控制、却必须为它的每一种失控方式都兜住底的循环"。它不是"会不会出错"的问题,而是"一定会以各种方式出错,你有没有为每一种都准备好护栏"的问题。你要做的,是把工具调用的失败隔离成"可观测的结果"喂回去、给循环装上"步数上限"和"重复检测"这两道闸、让一轮里互不依赖的多个工具调用并发执行、把工具返回的大结果裁剪之后再喂回上下文。真正做好一个 Agent,核心不是"把工具列表丢给模型、它说调就调",而是把工具调用的失败兜成结构化结果、给循环装上终止闸、并发执行同轮调用、裁剪工具结果、设计好工具 schema、给每次调用留审计、给危险工具留人工确认。这篇文章就把 Agent 的工具调用编排梳理一遍:为什么"模型说调我就调"的裸循环是错的、失败怎么隔离、循环怎么装终止闸、同轮调用怎么并发、工具结果怎么裁剪,以及 schema 设计、调用审计、危险工具确认这些把 Agent 真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个"模型说调工具我就调"的裸 Agent 循环,在面对真实任务后冒出一串问题:某次工具调用抛了异常,整个循环被炸,用户收到 500;Agent 反复调同一个工具、不收敛,一直转到 token 烧光;模型一轮要调多个工具,我串行执行,白白拖长几倍耗时;工具返回的大结果原样塞回,几轮就把上下文窗口撑爆。
我当时的错误认知:"Agent,就是'模型说调工具、我调、喂回去'的一个循环。"
真相:这个认知错在它把 Agent 循环想成了一段"可控的、顺顺当当转几圈就停"的流程。可 Agent 循环恰恰是不可控的:循环的每一步走向哪里,不由你的代码决定,而由模型的输出决定。模型这个东西,会幻觉出不存在的工具名,会传不符合 schema 的参数,会在该收尾时不收尾、一轮轮地继续要求调工具,也会在一轮里同时甩出好几个工具调用。你的代码没有办法阻止模型这么干——你能做的,只是为它的每一种"出格"都准备好接得住的护栏。所以写一个 Agent,真正的工程量不在那个 while 循环的骨架上(骨架谁都会写),而在那一圈护栏上:工具失败了,要隔离成一条"失败结果"喂回去,而不是让异常冒出来炸掉循环;循环可能不收敛,要给它装上步数上限和重复检测这两道闸;同一轮的多个调用互不依赖,要并发执行而不是串行;工具结果可能很大,要裁剪之后再进上下文。一旦你接受"Agent 循环是一段必然失控、你必须为每种失控兜底的循环"这个前提,后面的设计就都顺理成章了。
要把 Agent 的工具调用编排做对,需要几块认知:
- 为什么"模型说调我就调"的裸循环是错的——它对模型的每一种"出格"都毫无防备;
- 失败隔离——把工具调用的异常兜成结构化结果,失败也喂回模型;
- 终止闸——给循环装上步数上限与重复检测,绝不让它无限转;
- 并发调用——同一轮里互不依赖的多个工具调用,并发执行;
- 结果裁剪——工具返回的大结果裁剪后再喂回上下文;
- schema 设计、调用审计、危险工具确认这些工程坑怎么处理。
一、为什么"模型说调我就调"的裸循环是错的
先把这件最根本的事钉死:一个 Agent 循环里,真正在"做决定"的不是你的代码,是模型。你的代码只是一个"执行器"——模型说调什么,它就调什么。这就意味着,你的代码必须假设模型的输出是"敌意的、不可信的":它给的工具名可能根本不存在,它给的参数可能对不上工具的签名,它可能永远不说"我完成了",它可能一口气要求调五个工具。一个裸循环之所以错,不是因为它的逻辑写错了,而是因为它"默认模型永远输出得规规矩矩"——默认工具名总是对的、参数总是合法的、循环总会在几步内自然收敛、结果总是小小的一段。这四个"默认",在本地用简单任务测试时全都成立,所以裸循环看起来毫无问题;可一旦上线面对真实任务,这四个"默认"会被逐个击穿,每击穿一个,就对应一类线上事故。
下面这段代码,就是我那个"面对真实任务就原形毕露"的第一版裸循环:
# 反面教材:一个没有任何护栏的 Agent 循环
def run_agent(user_input, tools):
messages = [{"role": "user", "content": user_input}]
while True: # 破绽 1:没有步数上限,可能永远转下去
resp = model.chat(messages, tools=tools)
messages.append(resp.message)
if not resp.tool_calls: # 模型不调工具了,当作完成
return resp.content
for call in resp.tool_calls: # 破绽 3:多个工具调用只能串行
result = tools[call.name](**call.args) # 破绽 2:工具一抛异常,整个循环就炸
messages.append({"role": "tool", "tool_call_id": call.id,
"content": result}) # 破绽 4:result 多大都原样塞回去
这段代码在本地开发时表现不错,因为本地我喂给它的都是简单任务:工具名我亲手写对、参数模型一次就猜对、一两步就收敛、返回的结果也就几行字。它的问题不在某一行语法上——这段循环的语法完全正确——而在它对模型抱有的四个一厢情愿的"默认":默认 tools[call.name] 一定取得到(可模型会幻觉工具名,这里会直接 KeyError)、默认 tools[call.name](**call.args) 一定不抛异常(可参数对不上签名、工具内部出错都会抛,异常会顺着栈一路冒出来、炸掉整个循环)、默认 while True 总会自然退出(可模型不收敛时它永远不退)、默认 result 总是小段文本(可它可能是几万字)。这四个"默认"对应的,正是开头那四类线上事故。问题的根子清楚了:裸循环不是"写错了",而是"它把一个本质不可控的过程,当成可控的来写了"。要做对,得反过来——假设模型会以每一种方式出格,然后为每一种出格,补上一道护栏。下面四节,就是这四道护栏。
二、失败隔离:把工具调用的异常兜成结构化结果
第一道护栏,补的是破绽 2:工具一抛异常就炸掉整个循环。这里的关键转变是一句话:对 Agent 来说,"工具调用失败"不是一个"异常",而是一种"结果"。一个普通函数调用失败,你可以让异常往上抛、让调用方处理;可 Agent 循环里,工具调用失败之后,下一步该怎么走,恰恰应该交给模型来决定——也许它会换一组参数重试,也许它会改用别的工具,也许它会直接告诉用户"这条路走不通"。要让模型有机会做这个决定,你就必须把失败"如实地、结构化地"喂回给它,而不是让异常冒出来把循环掐断。所以第一步,是把每一次工具调用,都包进一个"绝不抛异常"的壳里:
import json
def safe_execute_tool(tools, call):
"""把一次工具调用包成"绝不抛异常"的结构化结果。"""
fn = tools.get(call.name)
if fn is None: # 模型幻觉出一个不存在的工具
return {"ok": False, "error": f"未知工具: {call.name}"}
try:
value = fn(**call.args)
return {"ok": True, "result": value}
except TypeError as e: # 参数对不上工具的签名
return {"ok": False, "error": f"参数错误: {e}"}
except Exception as e: # 工具内部抛出的任何异常
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
有了这个壳,工具调用无论成败,都会返回一个干净的字典——要么 ok=True 带结果,要么 ok=False 带错误描述,异常再也不会冒出来炸循环。接下来,把这个结果编码成模型能读懂的一条 tool 消息——注意,失败的情况也要编码进去,而且要如实告诉模型错在哪:
def to_tool_message(call, outcome):
"""无论成功失败,都编码成一条 tool 消息喂回模型 —— 失败也是一种"结果"。"""
if outcome["ok"]:
body = outcome["result"]
else:
# 关键:把错误如实告诉模型,它才有机会换个参数重试、或改走别的路
body = {"error": outcome["error"]}
return {"role": "tool", "tool_call_id": call.id,
"content": json.dumps(body, ensure_ascii=False)}
这里的认知要点是:失败隔离的精髓,是"把失败从控制流里挪到数据流里"。在裸循环里,一次工具失败是一个"异常"——它属于控制流,它会改变程序走向(把循环炸掉)。失败隔离做的事,是把它降级成一条"数据"——一个 ok=False 的字典、一条 content 里写着 error 的 tool 消息。一旦失败变成了数据,它就不再有能力擅自改变控制流了:循环照常往下转,只不过模型这一轮收到的,是一条"刚才那个工具没调成,原因是 XXX"的消息。而这恰恰是我们要的——因为"失败之后怎么办",本就该由模型决定,不该由一个 try 没接住的异常替它决定。还有一点容易被忽略:错误信息要"如实",不能含糊。你回一句"工具调用失败了"是没用的,模型不知道该怎么改;你回"参数错误:search_orders() 缺少必填参数 keyword",模型下一轮就知道要把 keyword 补上。错误信息写得越具体,模型自我修正的成功率就越高。失败隔离让循环"炸不掉"了,但它还可能"停不下来"——这就要靠第二道护栏。
三、终止闸:给循环装上步数上限与重复检测
第二道护栏,补的是破绽 1:while True 可能永远转下去。Agent 不收敛有两种典型形态:一种是步数太多——它一轮轮调下去,任务越绕越复杂,就是不给最终答案;另一种是原地打转——它反复用同样的参数调同一个工具,或者在两个工具之间来回横跳。这两种形态,要用两道独立的闸来卡:第一道是步数上限,管住"调得太多";第二道是重复检测,管住"原地打转"。先看步数上限——把 while True 换成一个有上限的 for,撞到上限就逼模型基于现有信息直接作答:
def run_agent(user_input, tools, max_steps=12):
"""带步数上限的 Agent 循环:转够 max_steps 步还没完,就强制收尾。"""
messages = [{"role": "user", "content": user_input}]
for step in range(max_steps):
resp = model.chat(messages, tools=tools)
messages.append(resp.message)
if not resp.tool_calls:
return resp.content # 模型给出最终答案,正常结束
for call in resp.tool_calls:
outcome = safe_execute_tool(tools, call)
messages.append(to_tool_message(call, outcome))
# 走到这里 = 步数耗尽:逼模型基于现有信息直接作答,绝不无限转下去
messages.append({"role": "user",
"content": "已达步数上限,请基于现有信息直接给出回答。"})
return model.chat(messages).content
步数上限管住了"调太多次",但它管不住"原地打转"——一个反复用同样参数调同一个工具的 Agent,在撞到步数上限之前,已经白白烧掉了一大把 token。所以还要第二道闸:给每次调用算一个指纹,同一个指纹出现太多次,就判定它卡住了:
import hashlib
def call_signature(call):
"""用 工具名 + 规范化参数 算一个指纹,用来识别"同一个调用"。"""
raw = call.name + json.dumps(call.args, sort_keys=True, ensure_ascii=False)
return hashlib.md5(raw.encode("utf-8")).hexdigest()
class LoopGuard:
"""重复检测:同一个调用出现太多次,说明 Agent 卡在原地打转了。"""
def __init__(self, max_repeat=3):
self.seen = {}
self.max_repeat = max_repeat
def check(self, call):
sig = call_signature(call)
self.seen[sig] = self.seen.get(sig, 0) + 1
# 同样的工具 + 同样的参数调了太多次 —— 结果不会变,继续就是浪费
return self.seen[sig] <= self.max_repeat
下面这张图,把装上两道终止闸之后的 Agent 循环画出来:
这里的认知要点是:Agent 循环必须有"绝对的、不依赖模型配合的"终止保证。注意"不依赖模型配合"这几个字——很多人会想:那我在 prompt 里嘱咐模型"别调太多次"不就行了?不行。prompt 里的嘱咐是一种"软约束",模型可能听、也可能不听,你不能把"循环会不会停下来"这件事的命运,交到一个不确定的东西手里。步数上限和重复检测是"硬约束":它们写在你的代码里,由你的代码强制执行,模型再怎么不收敛,撞到 max_steps 那一刻,循环也一定结束。这两道闸还分工明确:步数上限是"总量闸",防的是"绕来绕去就是不收尾";重复检测是"原地闸",防的是"用同样的输入反复做同一件事"——后者尤其阴险,因为同样的参数调同样的工具,结果根本不会变,这种循环纯粹是在烧钱,而且可能在步数上限内就把 token 烧掉一大半。两道闸都要有,缺一个都堵不严。还有个细节:撞到上限时,不要直接抛错或返回空,而要"逼模型基于现有信息作答"——它转了这么多步,手里多少攒了些信息,让它给个尽力而为的回答,比甩用户一个 500 要体面得多。循环炸不掉、也停得下来了,接下来该让它"跑得快"——这要靠第三道护栏。
四、并发调用:同一轮里互不依赖的工具调用一起执行
第三道护栏,补的是破绽 3:同一轮的多个工具调用被串行执行。现代的模型,一轮里可以一次性甩出好几个工具调用——比如用户问"对比一下 A、B、C 三个订单",模型会一口气要求 search_orders 三次。这三次调用互相之间没有任何先后依赖:查 A 不需要先拿到 B 的结果。可我那个 for 循环,却把它们一个个串起来等:
# 反面教材:模型一轮要调 3 个工具,却被一个个串行执行
for call in resp.tool_calls:
outcome = safe_execute_tool(tools, call) # 每个工具各等 2 秒
messages.append(to_tool_message(call, outcome))
# 3 个互不依赖的工具,串行 = 2 + 2 + 2 = 6 秒
# 它们之间没有任何先后依赖,本该一起发出去、一起等
正确的做法是:把同一轮里的多个工具调用,一起发出去、并发地等。总耗时就不再是各个工具耗时之和,而是取决于最慢的那一个:
import asyncio
async def run_tool_calls(tools, tool_calls):
"""同一轮里的多个工具调用互相独立,并发执行,总耗时取决于最慢的那个。"""
async def one(call):
# to_thread 把同步工具丢进线程池,这样多个工具能真正并发地跑
outcome = await asyncio.to_thread(safe_execute_tool, tools, call)
return to_tool_message(call, outcome)
# gather 把 N 个调用一起发出去:3 个各 2 秒,并发后总耗时约 2 秒
return await asyncio.gather(*(one(c) for c in tool_calls))
这里的认知要点是:判断多个工具调用能不能并发,标准只有一个——它们之间有没有"数据依赖"。所谓数据依赖,是指 B 的输入参数,需要用到 A 的输出结果。如果有,那 A 和 B 必须串行,B 得等 A;如果没有,它们就是互相独立的,本该并发。而关键事实是:模型在"同一轮"里一次性甩出来的那一组工具调用,天然就是互相独立的——因为模型甩出它们的时候,手里还没有任何一个的结果,它不可能让其中一个依赖另一个的输出。如果模型需要"先拿 A 的结果、再据此决定调不调 B",它会分两轮来做:这一轮只调 A,拿到结果喂回去,下一轮再调 B。所以规律很清晰:同一轮内的多个调用,并发;跨轮的调用,串行。你的并发,就并发在"同一轮"这个范围里,不会并发错。这个优化的收益还会随任务复杂度放大——任务越复杂,模型一轮里同时甩出的工具越多,串行和并发的差距就越大。循环跑得快了,但它还可能被工具结果"撑爆"——这要靠第四道护栏。
五、结果裁剪:别把工具的大输出整个塞回上下文
第四道护栏,补的是破绽 4:工具返回的大结果被原样塞回上下文。Agent 的上下文窗口是有限且宝贵的,而每一轮的工具结果都会累积进 messages、一直带到后面每一轮。一个搜索工具一次返回几万字,你原样塞回去,这几万字就会霸占上下文、跟着你转完剩下的每一轮——才转三四轮,窗口就被这些原始结果撑爆了。解法是:工具结果进上下文之前,先裁剪:
MAX_TOOL_CHARS = 4000
def truncate_tool_result(text):
"""工具返回的大结果,裁剪后再喂回上下文 —— 别让一次搜索撑爆窗口。"""
if len(text) <= MAX_TOOL_CHARS:
return text
head = text[:MAX_TOOL_CHARS]
dropped = len(text) - MAX_TOOL_CHARS
# 明确告诉模型"这里截断了",它要更多就再调一次工具(带分页或更窄的关键词)
return head + f"\n\n...[已截断 {dropped} 字,如需更多请缩小查询范围]"
这里的认知要点是:工具结果裁剪,本质是在管理一个"会累积的、有上限的"共享资源——上下文窗口。要看清两件事。第一,工具结果不是"用一次就扔"的,它一旦进了 messages,就会跟着 Agent 转完剩下的每一轮,它占的那块空间是"长期占用",不是"一次性"。所以一个几万字的结果,代价不是"这一轮多花几万字",而是"剩下每一轮都多花几万字"。第二,模型真正需要的,往往只是那个大结果里的一小部分——一次搜索返回 50 条,模型可能只关心最相关的前几条。把 50 条原样塞回去,既撑窗口、又用一堆噪音淹没了真正有用的信息。裁剪要做对,有一个关键动作:截断之后,必须明确留一句"这里截断了"。如果你悄悄截断、不留痕迹,模型会以为它看到的就是全部,据此做出错误判断;留一句"已截断 N 字,要更多请缩小查询范围",模型就知道"我看到的不全",它需要更多时,会再调一次工具、用更窄的关键词或分页去拿。裁剪不是把信息藏起来,是把"要不要拿更多"这个决定,明明白白地交还给模型。四道护栏齐了,最后是几个把 Agent 真正用到生产里才会撞见的工程坑。
六、工程坑:schema 设计、调用审计、危险工具确认
四道护栏之外,还有几个工程坑,不处理就会让你的 Agent 要么调不准、要么出了事查不清、要么闯下不可挽回的祸。坑 1:工具的 schema 要写清楚,这是模型调得准的前提。模型靠什么知道"这个工具叫什么、要传什么参数、什么时候该用"?全靠你给它的工具 schema。schema 里的 description 含糊,模型就调得含糊;参数的类型、必填、取值范围写不清,模型就传错:
# 工具的 schema:把工具"长什么样"明确描述给模型,是它正确调用的前提
SEARCH_TOOL = {
"type": "function",
"function": {
"name": "search_orders",
"description": "按关键词查询订单。仅在用户明确要查订单时调用。",
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "搜索关键词"},
"limit": {"type": "integer", "description": "返回条数,1-50",
"default": 10},
},
"required": ["keyword"],
},
},
}
坑 2:每一次工具调用都要留审计。Agent 出了岔子——答错了、绕远了、烧了太多 token——你得能复盘它到底调了哪些工具、传了什么参数、各花了多久、成没成。没有审计日志,Agent 就是个黑盒,出了问题只能干瞪眼:
import time, logging
audit = logging.getLogger("agent.audit")
def execute_with_audit(tools, call):
"""每次工具调用都留痕:调了什么、参数、耗时、成败 —— 出问题能复盘。"""
start = time.monotonic()
outcome = safe_execute_tool(tools, call)
cost_ms = (time.monotonic() - start) * 1000
audit.info("tool=%s args=%s ok=%s cost=%.0fms",
call.name, call.args, outcome["ok"], cost_ms)
return outcome
坑 3:危险工具不能让模型自己拍板。查询类工具调错了,最多是结果不对、重调一次;可删数据、发邮件、退款转账这类工具,一旦调错就无法挽回。这类工具不能让模型自己决定就执行,必须留一道人工确认:
# 危险工具(删数据、发邮件、转账)不能让模型自己拍板,要留人工确认
DANGEROUS = {"delete_order", "send_email", "refund"}
def execute_guarded(tools, call, confirmed_ids):
"""危险工具:必须拿到人工确认过的 call.id,否则只回执、不执行。"""
if call.name in DANGEROUS and call.id not in confirmed_ids:
return {"ok": False,
"error": "该操作需用户确认,已暂停。请向用户复述意图并征求同意。"}
return safe_execute_tool(tools, call)
坑 4:工具数量别失控。给模型挂的工具越多,它挑错工具的概率就越大,而且每个工具的 schema 都要占 prompt 的 token。一个 Agent 的工具最好控制在十几个以内;真有几十个工具的需求,要按场景分组,一次只给模型挂当前场景用得上的那一小撮。坑 5:工具要保证幂等或可重试。有了重试和重复检测,同一个工具可能被调用多次。查询类工具天然幂等;可"创建订单"这种写操作,被调两次就会建出两条——这类工具要么自己保证幂等(比如用一个幂等键),要么在 Agent 层面严格防重。坑 6:别把整个 messages 无限带下去。就算每个工具结果都裁剪过,Agent 转十几轮下来,累积的消息也会很长。轮次多的 Agent,要做历史压缩:把前面若干轮的工具调用与结果,总结成一小段"中间结论",替换掉原始的长消息。坑 7:工具的执行也要有超时。一个工具内部调了外部接口、那个接口卡住了,这个工具就会把整个 Agent 循环挂在这一步。每个工具的执行都要包一层超时,超时了就当一次"失败结果"喂回模型,别让单个工具拖垮整个 Agent。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 裸循环的错 | 默认模型输出总规矩,对它的每一种出格都毫无防备 |
| 失败隔离 | 把工具异常兜成 ok=False 的结构化结果,不让它炸循环 |
| 失败也是结果 | 失败如实编码成 tool 消息喂回,让模型决定下一步 |
| 步数上限 | 用有上限的 for 替代 while True,撞上限就强制收尾 |
| 重复检测 | 给调用算指纹,同指纹出现太多次判定为原地打转 |
| 硬约束终止 | 终止靠代码强制,不靠 prompt 嘱咐模型自觉 |
| 并发调用 | 同一轮内互不依赖的工具调用并发执行,耗时取最慢 |
| 结果裁剪 | 大结果裁剪后再进上下文,并明确标注已截断 |
| schema 设计 | description 与参数约束写清楚,模型才调得准 |
| 危险工具确认 | 删改类工具不让模型自己拍板,留人工确认闸 |
避坑清单
- 别写没有护栏的裸 Agent 循环,它对模型的每一种出格都毫无防备。
- 工具调用包进绝不抛异常的壳,异常兜成 ok=False 的结构化结果。
- 失败也要如实编码成 tool 消息喂回模型,让它决定重试还是改道。
- 错误信息要具体,写清错在哪,模型才能据此自我修正。
- 用有上限的 for 替代 while True,撞步数上限就逼模型直接作答。
- 给调用算指纹做重复检测,同指纹太多次就判定原地打转并收尾。
- 循环终止靠代码硬约束,绝不靠 prompt 嘱咐模型自觉收敛。
- 同一轮内互不依赖的多个工具调用并发执行,别一个个串行等。
- 工具大结果裁剪后再进上下文,并明确留一句已截断的提示。
- schema 写清楚,每次调用留审计,危险工具留人工确认,工具加超时。
总结
回头看那串"一个工具异常炸掉循环、Agent 原地打转烧光 token、多工具串行白拖几倍、大结果撑爆上下文"的问题,以及我后来在 Agent 上接连踩的坑,最该记住的不是某一段编排代码的写法,而是我动手前那个想当然的判断——"Agent,就是'模型说调工具、我调、喂回去'的一个循环"。这句话错在它把一个本质不可控的过程,当成可控的来写了。我以为这个循环会顺顺当当转几圈就停。可我忽略了一件最要紧的事:这个循环的每一步走向哪里,不由我的代码决定,而由模型决定。而模型是一个不确定的东西——它会幻觉出不存在的工具,会传对不上签名的参数,会永远不说"完成",会一轮里甩出一堆调用。我的代码没有任何办法阻止它这么干。所以一个 Agent 循环,从写下第一行起,就不该问"它会不会出错",而该问"它一定会以各种方式出错,我有没有为每一种都备好护栏"。裸循环的错,不是逻辑写错了,是它压根没有护栏——它一厢情愿地默认模型永远规规矩矩。
所以做好一个 Agent,真正的工程量不在那个 while 循环的骨架上。骨架谁都会写。真正的工程量,在于你要给这个必然失控的循环,补上一整圈护栏:工具会抛异常,你就把每次调用兜成"绝不抛异常"的结构化结果,让失败也变成一条喂回模型的消息;循环可能不收敛,你就装上步数上限和重复检测两道硬闸,让它一定停得下来;同一轮的多个调用互不依赖,你就并发执行,别串着等;工具结果可能很大,你就裁剪之后再进上下文,并留一句"已截断"。这篇文章的几节,其实就是顺着这一圈护栏展开的:先想清楚"裸循环"为什么错,再讲失败怎么隔离、循环怎么装终止闸、同轮调用怎么并发、大结果怎么裁剪,最后是 schema、审计、危险工具确认这几个把 Agent 守扎实的工程细节。
你会发现,带一个 Agent,和现实里"把一摊活儿交给一个新来的助手去办"完全相通。一个不会带人的人会怎么做?他把活儿一甩,就当这个新助手什么都会、办事绝不出岔子——助手拿错了工具,他不知道;助手为一件小事来来回回折腾、半天不收尾,他不拦;助手手上明明有三件互不相干的事可以一起办,却一件干完才碰下一件,他不提醒;助手查回来一大摞材料,原样堆到他桌上、把桌子占得满满当当,他也照单全收。到头来,这个新助手不是不能用,是被他"放养"得处处出乱子。而一个会带人的人怎么做?他从一开始就认定:新助手一定会出岔子,我要做的是替每一种岔子兜好底——把每件交办的事都讲清楚"做不成也没关系,回来如实告诉我哪儿卡住了"(这就是失败隔离);给他定一个"最多折腾到几点必须给我个交代"的死线(这就是步数上限);看他反复做同一件做不动的事,就喊停、让他换条路(这就是重复检测);互不相干的几件事,叮嘱他一起办(这就是并发调用);材料别一摞摞堆过来,挑要紧的说,其余的我要再问你(这就是结果裁剪)。同样是用一个新助手,放养的人被他闹得鸡飞狗跳,会带的人却让他稳稳当当办成事——差别不在"这个助手聪不聪明"本身,只在带他的人,有没有为他"一定会出的那些岔子",事先一一备好接得住的护栏。
最后想说,Agent 编排做没做对,差距永远不会在"本地开发、拿简单任务点一下试试"时暴露——本地你喂的都是顺当任务:工具名你亲手写对、参数模型一猜就中、一两步就收敛、结果也就几行字,那四个一厢情愿的"默认"全都恰好成立,裸循环看上去毫无破绽、转得又快又顺。它只在真实的、五花八门的、模型会以各种方式出格的任务洪流面前才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个工具异常没接住而整个循环崩成 500,会因为 Agent 原地打转没人拦而眼睁睁烧光 token,会因为多工具串行把响应硬拖长几倍,还会因为大结果不裁剪而几轮就撑爆上下文窗口;而做对了,你的 Agent 会在工具失败时稳稳接住、把失败如实告诉模型让它换路,会在不收敛时被硬闸准时叫停,会把同轮调用并发着跑、把大结果裁剪着喂。所以别等"一个没接住的工具异常把线上 Agent 炸了"那一刻找上门,在你写下那个 while 循环的时候就该想清楚:这个 Agent 工具失败兜住了吗、终止闸装了吗、同轮调用并发了吗、大结果裁剪了吗、危险工具留确认了吗,这一道道护栏,我是不是都替它备齐了?这些问题有了答案,你交付的才不只是一个"本地点一下能跑通"的玩具循环,而是一套面对真实任务洪流、模型怎么出格都兜得住、稳得下来的可靠 Agent。
—— 别看了 · 2026