AI Agent 工具调用编排完全指南:从一次"一个工具异常炸掉整个循环"看懂失败隔离、终止闸与并发调用

2024 年我做一个 AI Agent 要让大模型自己调用一堆工具查数据库搜网页读文件来完成用户交给它的任务。让模型会用工具这件事我压根没多想。第一版我做得很省事 Agent 不就是一个 while 循环把工具列表告诉模型模型说我要调 search 工具我就调把结果拼成一条消息喂回去再问模型模型说完成了循环就结束。本地开发时真不错我问它一个简单问题它调一次工具拿到结果答出来一气呵成。我心里很踏实 Agent 嘛不就是模型说调工具我调喂回去转着圈直到它说完。可等这个 Agent 真正上线面对五花八门的真实任务一串问题冒了出来。第一种最先把我打懵某个工具调用失败了参数传错网络超时抛了个异常我没接住这个异常直接把整个 Agent 循环炸了用户得到的是一个 500 而不是我试了一下没成功。第二种最烧钱 Agent 陷进了死循环它反复调用同一个工具或者在两个工具之间来回横跳就是不收敛我没设上限它能一直转到我的 token 额度烧光。第三种最拖时间模型一轮里要求同时调三个工具我图省事写了个 for 循环一个个串行调三个各要两秒本来并发两秒能完的事我硬生生拖成六秒。第四种最隐蔽有个搜索工具一次返回了几万字我把这一大坨原样塞回上下文才转了三四轮上下文窗口就被这些原始结果撑爆了。我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 Agent 就是模型说调工具我调喂回去的一个循环。这句话把 Agent 循环当成了一段顺顺当当转几圈就停的流程。可它不是。Agent 循环和一段普通的业务流程最根本的区别是它的每一步都由模型这个不确定的东西来驱动。模型可能要求调一个根本不存在的工具可能传一组对不上签名的参数可能永远不说完成可能一轮里就要调五个工具。所以 Agent 循环的本质是一段你不能完全控制却必须为它的每一种失控方式都兜住底的循环。你要做的是把工具调用的失败隔离成可观测的结果喂回去给循环装上步数上限和重复检测这两道闸让一轮里互不依赖的多个工具调用并发执行把工具返回的大结果裁剪之后再喂回上下文。本文从头梳理为什么模型说调我就调的裸循环是错的失败怎么隔离循环怎么装终止闸同轮调用怎么并发工具结果怎么裁剪以及 schema 设计调用审计危险工具确认这些把 Agent 真正做扎实要避开的坑。

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 要写清楚,这是模型调得准的前提。模型靠什么知道"这个工具叫什么、要传什么参数、什么时候该用"?全靠你给它的工具 schemaschema 里的 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 与参数约束写清楚,模型才调得准
危险工具确认 删改类工具不让模型自己拍板,留人工确认闸

避坑清单

  1. 别写没有护栏的裸 Agent 循环,它对模型的每一种出格都毫无防备。
  2. 工具调用包进绝不抛异常的壳,异常兜成 ok=False 的结构化结果。
  3. 失败也要如实编码成 tool 消息喂回模型,让它决定重试还是改道。
  4. 错误信息要具体,写清错在哪,模型才能据此自我修正。
  5. 用有上限的 for 替代 while True,撞步数上限就逼模型直接作答。
  6. 给调用算指纹做重复检测,同指纹太多次就判定原地打转并收尾。
  7. 循环终止靠代码硬约束,绝不靠 prompt 嘱咐模型自觉收敛。
  8. 同一轮内互不依赖的多个工具调用并发执行,别一个个串行等。
  9. 工具大结果裁剪后再进上下文,并明确留一句已截断的提示。
  10. schema 写清楚,每次调用留审计,危险工具留人工确认,工具加超时。

总结

回头看那串"一个工具异常炸掉循环、Agent 原地打转烧光 token、多工具串行白拖几倍、大结果撑爆上下文"的问题,以及我后来在 Agent 上接连踩的坑,最该记住的不是某一段编排代码的写法,而是我动手前那个想当然的判断——"Agent,就是'模型说调工具、我调、喂回去'的一个循环"。这句话错在它把一个本质不可控的过程,当成可控的来写了。我以为这个循环会顺顺当当转几圈就停。可我忽略了一件最要紧的事:这个循环的每一步走向哪里,不由我的代码决定,而由模型决定。而模型是一个不确定的东西——它会幻觉出不存在的工具,会传对不上签名的参数,会永远不说"完成",会一轮里甩出一堆调用。我的代码没有任何办法阻止它这么干。所以一个 Agent 循环,从写下第一行起,就不该问"它会不会出错",而该问"它一定会以各种方式出错,我有没有为每一种都备好护栏"。裸循环的错,不是逻辑写错了,是它压根没有护栏——它一厢情愿地默认模型永远规规矩矩。

所以做好一个 Agent,真正的工程量不在那个 while 循环的骨架上。骨架谁都会写。真正的工程量,在于你要给这个必然失控的循环,补上一整圈护栏:工具会抛异常,你就把每次调用兜成"绝不抛异常"的结构化结果,让失败也变成一条喂回模型的消息;循环可能不收敛,你就装上步数上限和重复检测两道硬闸,让它一定停得下来;同一轮的多个调用互不依赖,你就并发执行,别串着等;工具结果可能很大,你就裁剪之后再进上下文,并留一句"已截断"。这篇文章的几节,其实就是顺着这一圈护栏展开的:先想清楚"裸循环"为什么错,再讲失败怎么隔离、循环怎么装终止闸、同轮调用怎么并发、大结果怎么裁剪,最后是 schema、审计、危险工具确认这几个把 Agent 守扎实的工程细节。

你会发现,带一个 Agent,和现实里"把一摊活儿交给一个新来的助手去办"完全相通。一个不会带人的人会怎么做?他把活儿一甩,就当这个新助手什么都会、办事绝不出岔子——助手拿错了工具,他不知道;助手为一件小事来来回回折腾、半天不收尾,他不拦;助手手上明明有三件互不相干的事可以一起办,却一件干完才碰下一件,他不提醒;助手查回来一大摞材料,原样堆到他桌上、把桌子占得满满当当,他也照单全收。到头来,这个新助手不是不能用,是被他"放养"得处处出乱子。而一个会带人的人怎么做?他从一开始就认定:新助手一定会出岔子,我要做的是替每一种岔子兜好底——把每件交办的事都讲清楚"做不成也没关系,回来如实告诉我哪儿卡住了"(这就是失败隔离);给他定一个"最多折腾到几点必须给我个交代"的死线(这就是步数上限);看他反复做同一件做不动的事,就喊停、让他换条路(这就是重复检测);互不相干的几件事,叮嘱他一起办(这就是并发调用);材料别一摞摞堆过来,挑要紧的说,其余的我要再问你(这就是结果裁剪)。同样是用一个新助手,放养的人被他闹得鸡飞狗跳,会带的人却让他稳稳当当办成事——差别不在"这个助手聪不聪明"本身,只在带他的人,有没有为他"一定会出的那些岔子",事先一一备好接得住的护栏

最后想说,Agent 编排做没做对,差距永远不会在"本地开发、拿简单任务点一下试试"时暴露——本地你喂的都是顺当任务:工具名你亲手写对、参数模型一猜就中、一两步就收敛、结果也就几行字,那四个一厢情愿的"默认"全都恰好成立,裸循环看上去毫无破绽、转得又快又顺。它只在真实的、五花八门的、模型会以各种方式出格的任务洪流面前才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个工具异常没接住而整个循环崩成 500,会因为 Agent 原地打转没人拦而眼睁睁烧光 token,会因为多工具串行把响应硬拖长几倍,还会因为大结果不裁剪而几轮就撑爆上下文窗口;而做了,你的 Agent 会在工具失败时稳稳接住、把失败如实告诉模型让它换路,会在不收敛时被硬闸准时叫停,会把同轮调用并发着跑、把大结果裁剪着喂。所以别等"一个没接住的工具异常把线上 Agent 炸了"那一刻找上门,在你写下那个 while 循环的时候就该想清楚:这个 Agent 工具失败兜住了吗、终止闸装了吗、同轮调用并发了吗、大结果裁剪了吗、危险工具留确认了吗,这一道道护栏,我是不是都替它备齐了?这些问题有了答案,你交付的才不只是一个"本地点一下能跑通"的玩具循环,而是一套面对真实任务洪流、模型怎么出格都兜得住、稳得下来的可靠 Agent。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

深分页性能优化完全指南:从一次"翻到第 5000 页、查询慢了 100 倍"看懂 OFFSET 陷阱与游标分页

2026-5-22 14:31:42

技术教程

定时任务可靠性完全指南:从一次"任务跑成三份、还挂了一周没人知道"看懂防重叠、多实例单跑与可观测

2026-5-22 14:45:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索