本文是「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 就认为它符合业务规则。
字段缺少描述时,模型需要猜测格式。订单查询的 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 与外部系统的调用接口;格式不兼容会增加迁移和复用成本。本文不对这一问题提供结论。
