本文是「Agent 开发实践与思考」系列第 2 篇。系列目录:
- 2024
- 07-02 我手写了一个 Agent:从一问一答到”思考-行动-观察”循环
- 08-06 工具调用踩坑记:schema、描述与错误返回怎么写(本篇)
工具描述是写给模型看的产品文档
在这套调用链里,模型能直接用于选择工具的信息主要来自工具名和描述,也来自当前对话、系统指令和已有工具结果。它看不到实现代码,也不应该靠猜测判断这个工具背后连的是测试库还是生产库。因此描述不是写给同事看的接口注释,而是写给模型看的决策提示。它不会像人一样稳定地追问,看不懂时可能猜测,所以描述要尽量明确。
第一条经验是名字要动词化。search_orders 一看就知道是查订单的,order_tool 什么信息都没有。名字是模型做选择时最先看到的东西,歧义从名字就开始了。
第二条更重要:描述里写清”什么时候用、什么时候别用”,比写”这是什么”更有用。我最早的版本给工单查询工具写的描述是”查询数据”。结果模型在什么场景都调它:用户问订单它调,用户问物流它也调,甚至闲聊时它也调一次试试。改成下面这样之后,误调基本消失:
search_tickets: 查询工单列表。当用户问到售后进度、投诉处理状态、
工单流转情况时使用。不要用于查询订单本身的信息(用 search_orders),
也不要用于查询物流(用 query_logistics)。支持按手机号或工单号过滤。差别在于把工具之间的边界写出来了。模型一次能看到所有工具的描述,你在描述里说明其他工具负责什么,可以帮助它做路由,但这只是概率上的引导,不是确定性控制。工具越多,描述越需要减少歧义,同时仍要在程序侧校验调用是否符合业务条件。
描述也不是越长越好。我写过一个两百多字的描述,把业务背景、数据来源、返回字段全塞了进去,效果反而变差。后来的体会是模型在描述里找的是”决策依据”,不是”实现细节”。什么时候用、什么时候别用、输入输出是什么,这三样够了。返回字段的逐个解释应该放在错误处理和文档里,不要堆在做选择的地方。还有个小发现:改完描述要重新看日志验证,有一次我把描述改”清楚”了,误调率反而上升,因为新描述里多提了一个场景关键词,模型见到那个词就联想过来。描述里的每个词都是触发器,写的时候要想着这一点。
参数 schema 的坑
function calling 用 JSON Schema 描述参数,模型按 schema 生成调用参数。schema 写得松,模型可生成的参数范围就更大,出错机会也可能增加,但 schema 本身不是确定性控制。服务端仍需重新解析和校验参数,不能因为调用符合 schema 就认为它符合业务规则。
第一个坑:字段没有描述,模型会猜。订单查询有个 date 字段,我一开始只写了类型 string。于是模型有时传 "2024-04-01",有时传 "last week",还有一次传了 "2024年4月"。后端解析不了的那些就成了报错。给字段补上 "日期,格式 YYYY-MM-DD" 之后,在我记录的调用中这类错误没有再出现。教训是 schema 里每个字段都要有 description,哪怕你觉得字段名已经够直白。字段名和 description 都服务于模型和程序的协作,但 description 额外提供了格式和语义约束,仍不能替代服务端校验。
第二个坑:能收口的地方放开。订单状态字段一开始是自由字符串,模型会编出一些不存在的状态值。改成 enum 把合法的七个状态列出来之后,在这组 case 中问题消失了。原则是能用 enum 就不要用自由文本,但 enum 只限制传入值的集合,不能证明当前用户有权查询这些状态,也不能保证业务语义正确。
第三个坑:必填和可选没想清。可选参数太多,模型会倾向于把它们都填上,填的值经常是它从对话里脑补出来的。订单查询最早有六个可选参数,日志里大量出现模型给 end_date 填”今天”、给 order_type 填它猜的分类的情况,查出来的结果和用户要的对不上,它还理直气壮地基于错误结果往下答。我后来把可选参数砍到最少,只有确实需要区分的才保留,其余的拆到不同工具里去。反过来,必填参数要慎用:一个信息如果用户经常不给,设成必填就会逼模型向用户追问或者编造。手机号在我们场景里用户一定会给(登录态带出来),所以设为 required;时间范围用户经常不说,就让它可选,后端默认查最近三个月。
改完之后的 schema 长这样:
{
"name": "search_orders",
"description": "查询订单列表。当用户询问订单状态、订单金额、下单时间时使用。不要用于查询物流轨迹(用 query_logistics)或售后工单(用 search_tickets)。",
"parameters": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"description": "下单手机号,11 位数字"
},
"status": {
"type": "string",
"enum": ["pending_payment", "paid", "shipped", "delivered", "refunding", "closed", "all"],
"description": "订单状态过滤,不过滤时传 all"
},
"start_date": {
"type": "string",
"description": "查询起始日期,格式 YYYY-MM-DD"
}
},
"required": ["phone"]
}
}工具粒度:拆与不拆的账
设计工具时绕不开一个问题:一个大工具包一堆参数,还是拆成多个小工具。我两个方向都试过。
大工具我试过一种极端形式:一个 order_query 工具,里面用 action 参数区分查列表、查详情、查物流,每个 action 又有自己的一套参数。这等于把路由问题从工具间挪到了参数里,模型照样要选,只是选错的时候日志更难看,而且 schema 里堆满了一堆互斥的参数说明,又臭又长。大工具的问题是模型要在一堆参数里做组合判断,错的方式五花八门。
小工具的问题是调用链变长。我把”查订单”拆成”查订单列表、查订单详情、查订单物流”三个工具之后,发现模型为了回答”我上个月的订单到哪了”要连调三次:先查列表拿到订单号,再查详情,再查物流。每次调用都可能错,单次成功率 95% 的话,三次串起来就只剩 86% 左右,错误沿着链条累积。而且每多一轮调用就多一轮延迟和 token 成本,用户等得明显更久。
现在的经验是:模型选错工具的代价大于多调几次的代价,所以默认倾向拆。但拆的边界应该跟着”用户意图”走,不跟着”数据库表”走。用户脑子里有”查订单”和”查物流”两个意图,但没有”查订单头表”和”查订单行表”的意图,后者拆出来只会让模型困惑。判断标准是:这个工具对应的行为,用户能不能用一句话说清。能,就值得单独存在;不能,就该并进别的工具。按这个标准,我最后把订单详情和物流合了回去,因为用户的意图”我的订单到哪了”天然是连在一起的,一个工具直接返回订单状态加最新物流节点,调用链从三次降到两次,成功率明显回升。
错误返回是给模型的第二轮输入
这一点我花的时间最长才想明白。工具执行失败时返回什么,决定了模型下一步做什么。返回的信息模型读得懂,它就能自我修正,这一轮任务就救回来了;读不懂,它要么换个参数瞎试,要么向用户编一个理由。
坏的返回长这样:
{ "error": "Internal error: NullPointerException at OrderRepository.findByDate(OrderRepository.java:142) ..." }或者更糟,直接 {"error": "failed"}。stack trace 对模型没有任何可操作的信息,它不知道是自己参数传错了还是系统坏了,只能猜。猜的动作一般是把同样的参数再传一遍,然后陷入循环。
好的返回要告诉模型三件事:哪里错了、期望什么、你传了什么。比如:
{
"error": "invalid_param",
"message": "参数 start_date 格式应为 YYYY-MM-DD,收到的是 \"2024年4月\"。请转换格式后重试,例如 \"2024-04-01\"。"
}拿到这种返回,模型下一轮的修正准确率很高,因为它不需要猜错因。我现在把错误返回当成提示词的一部分来写:它的读者不是排查问题的工程师,是下一轮要继续干活的模型。给模型的报错文案和给人看的报错文案,是两个不同的东西,值得分开维护。
还有一个细节:工具超时和部分失败要区分。超时意味着”可以再试一次”,参数错误意味着”再试也一样”。用不同的 error code 区分开,模型就能做出不同的决策,而不是一视同仁地重试。查无结果也要和出错分开:{"error": "not_found"} 和 {"orders": []} 对模型是两个信号,前者它会倾向于换参数再试,后者它会如实告诉用户”没查到”。我们的业务里”手机号下没有订单”是正常情况,返回空数组之后,模型回答”您的手机号下最近三个月没有订单,是不是用了别的手机号下单”的比例明显高了,这比报错路径体验好得多。
错误返回的打磨没有尽头,方法只有一个:把失败 case 的完整对话拉出来,看模型在收到错误返回之后的那一轮说了什么。它猜对了,说明返回的信息够;它猜错了,返回里就缺东西。这个迭代我做了四五轮,每一轮都能从日志里找到新的猜错方式。
收尾
这一个月的感受是,Agent 工程里”手艺”的成分比想象中重。模型能力是基础条件,工具定义是工程上能直接控制的部分。描述、schema、错误返回,每一样都不难,但都要对着真实日志里的 bad case 一遍遍改,没有一次写对的捷径。它们可以影响模型的选择和修正,却不能替代权限校验、参数校验和执行结果核对。
另一个越来越明显的问题:现在每个 Agent 框架都自定义一套工具格式,OpenAI 的 function calling 是一种,各家开源框架又各有各的定义方式。我给内部系统写的这套工具,换个框架就要重新包一层。工具是 Agent 和真实世界之间的接口,接口各家私有,生态就长不起来。这个问题现在没有答案,先记下来,等它发酵。

