Agent 工具返回值怎么设计:成功回什么、失败回什么、报错给谁看
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
agent 拿到工具返回之后动作开始变形——已经成功的写操作又做一遍、对着一句报错重试到轮次耗尽、或者干脆编一个结果继续往下走——多数人第一反应是模型不听话,于是去加约束、换更贵的模型。真正的成因通常在返回值这一层:你回给模型的那串字符串,没有把它做下一步判断所需要的信息带上。模型不是不听话,是没得听。
这里先说清楚本篇和站内两篇相近文章的分工:工具的名字、描述、参数 schema 该怎么写,看给 Agent 设计工具;agent 挑错工具、对着同一个工具空转该按什么顺序排查,看Agent 选错工具、反复调同一个工具。那两篇管的是「调用之前」。本篇只管「调用之后」——工具跑完往回吐的那段内容该长什么样,成功回什么、失败回什么、错误信息到底写给人还是写给模型。
一、先分因:现象都出在返回之后,成因只有四类
排查的第一步不是改代码,是把最近几轮的工具返回原文完整打出来看一眼。很多人调 agent 的时候只看模型说了什么,从来没看过工具真正回了什么,于是一直在错误的一层上折腾。
把返回原文摊开之后,成因基本落在四类里:
- 信息缺失:返回里没有模型做下一步决策必需的字段,比如新建资源之后没回 id。
- 状态不明:返回里分不清「这次真的改了」还是「本来就是这样」,模型只能靠猜。
- 错误语义丢失:所有失败都长一个样,模型没法区分该重试、该改参数、还是该停下叫人。
- 体积失控:一次返回塞回来几千上万字,把上下文挤爆,模型开始忘记早前的指令。
下面这张表按现象倒推成因,用它把手上的问题先归位:
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 同一个写操作被重复执行 | 成功返回里没有可识别的资源标识,模型分不清刚才那次是不是自己做的 | 打印最近几轮返回,看成功分支里有没有唯一 id、版本号或明确的动作词 | 成功返回补资源标识与动作类型,见第二节 |
| 对着同一个报错重试到轮次上限 | 失败返回没有区分可重试与不可重试 | 看返回文本里有没有明确的「能不能重试」结论;把 HTTP 状态码单独打出来 | 按第三节的四分类改造错误结构,401/403 明确标注不要重试 |
| 模型宣称做完了,实际什么都没发生 | 工具实现里异常被吞掉,返回了空串或者一句 ok | 搜工具实现中的裸 except / catch,看后面是不是直接 return 成功 | 异常一律不许吞,见避坑清单第 1 条 |
| 返回一长串之后模型开始跑偏、前面的约束不认了 | 返回体积失控 | 统计单次工具返回的字符数,以及一轮任务里的累计量 | 改成摘要加句柄再加分页,见第二节 |
| 模型把报错原文当文案抄进最终答复 | 错误信息写成了给人读的一整段自然语言 | 看 error 字段是不是长句散文,有没有栈信息 | 拆成结构化字段加动作建议,详细内容进日志,见第四节 |
| 同一个参数反复填错 | 校验失败的返回只说了「参数不合法」 | 看校验失败分支回了什么,有没有字段名和期望格式 | 报错里带字段名、期望格式和一个合法示例 |
第三行那种「谎报成功」值得单独警惕,因为它不报错、不中断,很容易一路走到线上才被发现,相关的识别办法在Agent 谎报任务成功里讲得更细。
二、成功返回:回「做了什么、结果在哪、下一步能干什么」
成功分支最常见的写法是把上游响应原样透传,或者干脆 return "ok"。前者体积失控,后者信息为零,两头都不对。
一个可用的成功返回,回三样东西就够了:
- 动作类型。用固定词区分
created/updated/unchanged/deleted。这一项是防重复执行的关键——模型看到unchanged,就知道目标状态已经达成,不必再来一次。只回ok: true,它没法判断刚才那次调用到底改没改东西。 - 资源标识。id、版本号、路径,凡是后续能拿来引用的都要回。没有标识,模型下一步想操作同一个对象时只能靠名字模糊匹配,很容易匹配到别的。
- 一句人话摘要加下一步可用动作。摘要压缩事实,动作列表把选择空间收窄。
写成结构大概是这样:
{
"ok": true,
"action": "created",
"resource_id": "doc_8f21",
"summary": "已创建文档,3 段正文,当前为未发布状态",
"next": ["publish_doc(resource_id)", "get_doc(resource_id)"]
}
大结果一律不要整坨回。正确姿势是回句柄:总条数、前几条摘要、一个能继续取的偏移量或游标,让模型显式再要一次。截断这件事必须显式告知,否则模型会以为自己看到的就是全部,然后基于残缺数据下结论:
MAX_CHARS = 2000 # 按你自己的上下文预算定,不是什么通用常数
def clip(text: str, offset: int = 0) -> dict:
chunk = text[offset:offset + MAX_CHARS]
truncated = offset + len(chunk) < len(text)
return {
"content": chunk,
"truncated": truncated,
"total_chars": len(text),
"next_offset": offset + len(chunk) if truncated else None,
"hint": "内容已截断,用 next_offset 继续读取" if truncated else "",
}
还有一点容易被忽略:返回的结构要稳定。同一个工具在不同分支回不同形状的 JSON,模型解析起来就得每次重新猜。字段可以为空,但键要在。结构不稳带来的连锁反应,结构化输出不稳定那篇有更多例子。
三、失败返回:先把错误分四类,再决定回什么
「失败」在工具这一层是个太粗的概念。模型面对失败只需要回答一个问题:下一步我该干什么。所以错误必须先分类,分类的依据就是「下一步动作不同」。
A 类,瞬时故障,等一下再来。 典型是 429、500/502/503,以及 ETIMEDOUT、ECONNRESET 这类网络层错误。返回里标 retryable: true,并说明应当退避后重试。具体退避多久、允许重试几次,各家服务的规则不同而且会调整,以官方最新说明为准——工具返回里别硬写一个数字当真理,把节奏交给调用侧的重试策略去管。重试策略本身怎么设计,见Agent 任务失败后的重试。
B 类,调用方自己能修。 参数校验失败、格式不对、引用了不存在的资源。这类要 retryable: true,但重试的前提是改参数,所以必须把「改哪里、改成什么样」写清楚:
{
"ok": false,
"code": "INVALID_ARGUMENT",
"field": "start_date",
"expected": "YYYY-MM-DD",
"example": "2026-07-01",
"retryable": true,
"agent_action": "修正 start_date 的格式后重新调用本工具"
}
只回一句「参数不合法」,模型只能穷举着试,试几轮就把轮次耗光了。
C 类,必须人来处理。 401、403、凭据过期、权限不足、提示额度已用尽、内网自签证书导致的证书链校验失败——这些无论重试多少次结果都一样。返回里 retryable: false,并且把动作写成「停止重试,向使用者报告需要人工处理什么」。这一类如果标错,代价最直接:agent 会在一个注定失败的调用上把整个轮次预算烧完。
D 类,其实不是错误。 查询执行成功但命中 0 条、目标状态本来就符合预期、幂等操作发现已经做过。这些一律走成功分支,用 action: "unchanged" 或者 count: 0 表达。把空结果当错误抛出去,模型会误以为系统坏了,转而去做各种「修复」动作,那才是真正的灾难开端。
四类之外还有一个判断:这个错误该不该让模型看见。有些底层重试、连接池抖动,工具内部消化掉就好,回给模型只会增加噪声。原则是——凡是不改变模型下一步动作的信息,就不要往返回里放。
四、错误信息给人看还是给模型看:答案是两份
这是本篇标题里那个问题的正面回答:不要试图用一段文字同时服务两个读者。人和模型需要的东西几乎不重叠。
给模型的那份,特征是短、结构化、只含决策依据:错误分类、是否可重试、下一步动作、必要的字段名。不含调用栈,不含内部主机名、连接串、SQL 原文、任何凭据片段。理由有两条:一是这些内容对模型的决策毫无帮助,纯占地方;二是模型可能把返回原文当素材复述进最终答复,敏感信息就这么漏出去了。
给人的那份,特征是全:完整调用栈、上游原始响应、请求耗时、时间戳。这份写进日志,返回体里只带一个可关联的 trace_id,人拿着这个 id 去日志里捞。这样做还有个附带好处——排查时你能确认模型看到的到底是什么,不用靠猜。
措辞上也有讲究。给模型的错误信息要写成对执行者的指令,不要写成对用户的道歉。「请求处理失败,请稍后再试」这种话对模型是零信息量;「凭据无效,停止重试,需要使用者更新访问令牌」才是可执行的。
人工复现的时候,最省事的办法是把这次调用还原成一条 curl,注意用 -i 把状态码和响应头一起看到,凭据走环境变量不要写死在命令里:
curl -i -X GET "https://api.example.com/v1/docs/doc_8f21" \
-H "Authorization: Bearer $API_TOKEN"
看到 401 还是 403、有没有限流相关的响应头,往往一眼就能定位到底是 C 类还是 A 类。这一步是人做的,不该让 agent 代劳——它看不到你的终端,也不该拿到你的令牌。
五、什么情况下别再折腾
返回值这一层是可以雕到没完的,得给自己划止损线。
改了两轮返回结构还是老样子,就换层排查。 如果字段该补的补了、错误该分类的分类了,模型的行为没有肉眼可见的变化,那问题大概率不在返回值,而在工具选择或者上下文管理。回到Agent 选错工具、反复调同一个工具那条线去查,别在返回值里继续加字段——字段越加越长,反而把上下文占满了。
根因是环境问题时,返回值只能让它更快停下,修不了它。 401/403、证书链校验失败、DNS 解析不到,这些改再漂亮的错误结构也不会变成成功。此时正确的收益预期是「让 agent 三秒内停下并说清需要人做什么」,而不是「让 agent 自己搞定」。把这条想明白,能省下很多无谓的调试时间。
回滚点要先留好。 工具返回结构本质是接口契约,改它会同时影响 agent 行为和下游解析。开工前起一个独立分支,改完对比一下影响面:
# 开工前:从基线分支拉一条独立分支(main 换成你自己的基线分支名)
git switch -c fix/tool-return-shape
# 改完并提交之后,再看影响面;三点写法只统计本分支新增的提交
git diff --stat main...HEAD
第二条要在提交之后跑才有输出——三点比较的是「基线与本分支的共同祖先」到 HEAD,工作区里没提交的改动不计入。想在提交前就看一眼,用 git diff --stat main 这种两点写法,它会把未提交的改动一起算上。
真要退回去,共享分支上用 git revert <sha> 生成一个反向提交,而不是 git reset --hard 硬拽——后者会把别人已经拉走的历史搞乱。
有一条路是「把决策收回来」。 如果某个流程无论返回值怎么设计,模型都稳不住,考虑把多步判断合并进工具内部,一次调用把事做完,只回最终结果。少一次模型决策,就少一次不确定性。这不是认输,是把不适合交给模型的部分放回代码里。
六、避坑清单
裸 except 吞掉异常然后返回成功。 会踩是因为写工具的时候图省事,想着「别让 agent 崩」,于是加了个兜底捕获,结果把真实故障也一起吃了。避法:捕获必须落到具体异常类型,兜底分支也要返回 ok: false 并带上错误类型;宁可让 agent 看到失败,也不要让它基于假成功往下走。
把上游响应原样透传回去。 会踩是因为透传最省代码,而且本地测试时数据量小看不出问题。真实数据一上来,几千字的 JSON 直接把上下文挤爆。避法:工具层做投影,只留模型用得上的字段,大结果走摘要加句柄。
用自然语言表达状态。 「好像没找到相关内容」这种话,模型有时理解成失败、有时理解成空结果,行为就随机了。避法:状态用固定枚举字段承载,自然语言只做补充说明,不承担语义。
把空结果当错误抛。 会踩是因为很多内部接口习惯了「查不到就 404」。避法:在工具层把「查询成功但 0 条」翻译成成功返回加 count: 0,别让模型误判系统故障。
错误信息里夹带敏感内容。 数据库连接串、完整 SQL、内网地址、令牌片段,都可能跟着上游报错一起被拼进返回。模型不知道哪些不能说,复述出去就是事故。避法:错误信息走白名单拼装,只允许列出的字段进返回体,其余进日志。
成功返回不带资源标识。 会踩是因为创建接口经常只回一个 200,写工具的人没觉得需要补。后果是重复创建。避法:任何写操作的成功返回,必须带上能唯一定位结果的标识和动作类型。
上游改字段,工具层静默跟着变。 会踩是因为工具直接引用上游字段名,上游一改,返回结构就变了,而且不报错。避法:工具层做一次显式映射,并为返回结构写一个最小的契约测试,字段缺失就让测试红,而不是让 agent 在线上迷惑。
收个尾
工具返回值不是函数的返回值,它是喂给模型的输入。判断一段返回设计得好不好,只用一个标准:模型读完这段内容,能不能唯一确定下一步动作。不能,就是缺信息;能但读得很累,就是噪声太多。
上线前照着过一遍:
- 成功返回里有没有动作类型和资源标识?
- 大结果是不是摘要加句柄,截断有没有显式标注?
- 错误分没分类,
retryable标得对不对,401/403 是不是明确写了不要重试? - 给模型的错误信息里有没有栈、密钥、连接串这类不该出现的东西?
- 空结果走的是成功分支还是错误分支?
- 返回结构在所有分支上形状一致吗?有没有契约测试兜着?
六个问题都过了,agent 那些「重复做、无脑重试、谎报成功」的毛病,大概能消掉一多半。剩下的那部分,才轮到去怀疑模型。