演示里的 Agent 能查天气、查订单、发邮件,一路顺滑。接进真实系统之后,成功率掉到七成以下,而且每次失败的形态还不一样:有时参数缺一个字段,有时同一个工具被连调三次,有时它干脆停在半路不返回。
把失败日志按原因归类,会发现模型只是背锅的那个。真正的问题分布在五个工程细节上,每一个都能单独修。
一、参数 schema 太宽,模型就开始猜#
最常见的写法是把参数全定义成字符串,再在描述里补一句「格式见接口文档」。模型看不到文档,只能猜。
几条实用的收紧方式:
- 能用枚举就别用字符串,状态、类型、动作都列出来;
- 必填项写进
required,可选参数越少越好; - 关掉
additionalProperties,别让模型自由加字段; - 嵌套超过两层就拍平,
{"a": {"b": {"c": 1}}}的出错率明显高于{"a_b_c": 1}; - 日期统一用 ISO 格式,金额统一到分并注明单位。
参数描述要写「什么时候该调用」,而不只是「这个参数是什么」。模型选错工具,很多时候是因为描述里没写适用条件。
二、错误信息是写给模型看的#
工具抛异常,你把 stack trace 或一句 500 塞回上下文,模型只能盲试:换个参数再调一遍,再失败,再换。三次之后步数耗尽。
有效的错误返回至少包含三样东西:错在哪、正确的形状长什么样、一个可照抄的例子。
还有一类更隐蔽的「假成功」:查不到数据就返回空数组,HTTP 状态还是 200。模型会认为「查过了,没有」,然后自信地编一个答案。查无结果应该显式返回 {"ok": false, "reason": "not_found"}。
错误还要分级。参数格式错误、权限不足这类问题,重试一百次结果一样,应该直接把原因告诉用户;超时和限流才值得重试。在工具契约里统一带上 retryable 字段,模型看到 false 就知道该换路子,而不是再试一次。
三、重试把「查一次」变成「扣两次」#
工具调用失败后自动重试,是很多框架的默认行为。读操作重试无害,写操作重试就是事故:用户被扣两次款,工单被创建两条。
三个防线,从便宜到贵:
- 写操作必须带幂等键,键由业务参数派生,服务端做去重;
- Agent 层记录已执行的调用指纹(工具名加参数哈希),重复的直接拦掉;
- 高风险写操作不自动执行,先返回「待确认」,由用户点一下。
第三条看起来降低了自动化程度,但它把「Agent 自作主张」变成「Agent 提议」,出事时的责任边界清楚得多。
四、工具超过十个,选择就开始崩#
工具越多,模型选错的概率越高,这和上下文里塞太多文档是同一个问题。十几二十个工具全量塞进请求,除了贵,还会让名字相近的工具互相干扰,比如 get_user、fetch_user_info、query_user_detail 同时存在。
处理办法是分层:先按业务域选一个分组,再在组内选具体工具。或者干脆按当前会话状态动态挂载,只有相关工具出现在请求里。
工具命名也值得花十分钟:动词加名词,别用缩写,描述里写清「不适用于什么」。删掉半年没被调用过的工具,收益比优化 prompt 大。
五、没有预算,Agent 会自己绕圈#
没有步数上限的循环,遇到一个永远失败的工具就会无限重试。加上预算之后,还要定义「什么算完成」——是拿到最终答案,还是连续两步没有新信息。
后者更实用:连续两次调用的返回值高度相似,基本可以判定在原地打转,此时应该停下来,让模型基于已有信息作答,或者转人工。还有一种更隐蔽的失控:模型不断把大段工具输出重新塞进上下文,token 涨到几十万,账单比错误答案更早到。给每轮记录累计 token,超阈值就截断历史,只保留最近一次工具结果和最初指令。
"""agent_loop.py —— 带步数预算、调用去重与参数校验的最小循环
依赖:pip install openai;把 execute 换成你的真实工具实现即可"""
import hashlib, json, os
from openai import OpenAI
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"))
TOOL_SPEC = {"type": "function", "function": {
"name": "get_order",
"description": "按订单号查状态。仅在用户给出订单号时调用。",
"parameters": {"type": "object",
"properties": {"order_id": {"type": "string", "description": "形如 SO-123456"}},
"required": ["order_id"], "additionalProperties": False}}}
def execute(name, args):
if not args.get("order_id", "").startswith("SO-"):
return {"ok": False, "error": "order_id 必须以 SO- 开头,如 SO-123456"} # 指导修正
return {"ok": True, "status": "shipped", "amount": 128.5}
def run(user_input, max_steps=6):
messages = [{"role": "user", "content": user_input}]
seen = set()
for _ in range(max_steps):
resp = client.chat.completions.create(model="your-model", messages=messages, tools=[TOOL_SPEC])
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
return msg.content or ""
for call in msg.tool_calls:
args = json.loads(call.function.arguments or "{}")
key = hashlib.sha1(f"{call.function.name}{sorted(args.items())}".encode()).hexdigest()
if key in seen: # 同一调用重复出现,不再执行
result = {"ok": False, "error": "该调用已执行,请换参数或直接总结"}
else:
seen.add(key)
result = execute(call.function.name, args)
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False)[:2000]})
return "已达步数上限,基于已知信息作答"
注意最后那行截断:工具结果动辄几千字,全塞回上下文既贵又会把早期指令挤掉。保留关键字段,把长列表转成计数加前三条。
| 失败现象 | 根因 | 修法 |
|---|---|---|
| 参数缺字段、类型错 | schema 太宽或没有枚举 | 收紧 schema,关掉额外字段 |
| 反复重试同一工具 | 错误信息无法指导修正 | 返回错因、正确形状和示例 |
| 写操作重复执行 | 无幂等键、自动重试 | 调用指纹去重,写操作加幂等键 |
| 选错工具 | 工具过多、命名相近 | 分组挂载,命名动词加名词 |
| 停在半路不返回 | 缺步数预算与完成定义 | 设 max_steps 与终止条件 |
结论#
- 工具失败先看 schema:能枚举就不留自由字符串,嵌套不超过两层。
- 错误信息要能指导下一步,包含错因、正确格式和例子;查无结果要显式失败。
- 写操作从设计上就该幂等,Agent 层的调用指纹只能当第二道防线。
- 工具数量控制在十个以内,超出就按业务域分组或按会话动态挂载。
- 每个 Agent 都要有步数、时间和 token 三种预算与明确的完成条件,并留下失败调用的工具名、参数和错误返回——一个月后你会发现失败集中在两三个工具上。
把 Agent 当成一个需要清晰接口、明确报错和硬性预算的调用方来设计,成功率会上来。指望换一个更强的模型解决这些,通常只是把失败推迟到下一个接口。
完