遥望星星
发布于 2026-09-14 / 10 阅读
0

在 Agent 中如何定义一个好的工具:把接口写给程序,把工具写给模型

「工具还不简单,把现成的 service 包一层给模型调就行了。」

这是我刚开始做 Agent 时的原话。三个月后我把项目里所有工具重看了一遍,删掉两个、合并三个、重写全部 description,才明白当时那句话错在哪:调用方变了。

以前写接口,调用方是同事、是前端、是另一个服务。他会读文档,会来问,会照着约定传参;参数传错是他的问题。现在调用方是一个概率模型——它不读代码、不问人,只读你写在工具定义里的那几行字,读完就决定调不调、怎么调、拿到结果之后干什么。

这件事把「怎么定义一个工具」从收尾工作变成了设计工作。这篇文章记十一条我认为最值得说的,每一条都从「为什么会有这个问题」讲起,因为它们几乎都是被线上问题追出来的。

核心命题:工具是 LLM 与真实世界之间的合约,好的工具就是一份好的合约——命名精确(模型一看就知道什么时候该调)、参数明确(不需要猜格式)、返回结构化(拿到结果知道下一步该干嘛)、安全兜底(传错参数也不会炸)。它和「写一个给程序员用的好接口」,评价标准差别非常大。

一、命名:动词_名词,让模型从名字就能看出「调了会发生什么」

提问背景:工具从 5 个涨到 30 个之后,模型开始选错工具。第一反应是模型推理能力不行——换模型、调温度、加提示词,都没用。后来把工具列表整个打出来看了一遍才明白:cancel_booking 和 cancel_travel_order 摆在一起,光看名字确实分不清谁是谁。不是模型在猜,是名字没给足信息。

工具名是模型决定「调不调它」的第一线索。好名字 = 一个动词(做什么)+ 一个名词(对什么)。

<code class="language-java">query_travel_order            // 查询差旅单
submit_travel_approval        // 提交差旅审批
cancel_booking                // 取消预订
check_travel_order_conflicts  // 检查行程冲突
review_planner_result         // 审核规划结果
save_flight_api_key           // 保存机票 API Key
</code>

还有一条比命名格式更值得强调:读写分开命名。让模型从名字就能区分「调了有没有副作用」——query_ / check_ / get_ 一律只读,submit_ / cancel_ / save_ 一律有副作用。这一条在第十节还会再用到一次,它是后面很多设计的基础。

二、Description:它不是注释,是模型唯一的使用说明书

提问背景:写 description 的习惯是从写注释带过来的——一句话带过,反正代码里写了什么一看就知道。问题是模型不看代码,它只看这几行。测试时最常见的现象是:工具本身没毛病,但模型总在错误的时机调它,或者你明明已经实现了限制,它还在硬调。追下去,九成是因为 description 里没写「什么时候该调」和「有什么限制」。

好的 description 要回答三个问题:这个工具做什么、什么时候该调、返回什么 / 有什么限制。

<code class="language-java">@Tool(name = "query_weather",
      description = "联网查询目的地城市的天气信息(首选工具,免费)。"
                  + "支持今天及未来 2 天(共 3 天)的天气预报;"
                  + "若 date 超出此范围,工具会返回 beyond_range=true,"
                  + "Agent 应改用 weather-mcp 工具查询。")
</code>

一句话里塞了三件事:做什么(联网查天气)、限制(只支持 3 天)、超限怎么办(返回信号 + 建议改用别的工具)。

复杂工具的 description 还要多写一层:把职责边界讲清楚,告诉模型「你做什么、我做什么」。

<code class="language-java">description = "往返行程规划客观计算引擎:输入候选交通/酒店 + 你(LLM)预先产出的偏好分,"
            + "做去程 × 住宿 × 返程的组合、过滤非法组合、算总价与总耗时、做政策软约束检查,"
            + "最后按三维权重选出 4 类代表方案。"
            + "本工具只做客观数学,绝不解读偏好文本;偏好判断必须由你在 scores 参数里给出。"
</code>

最后一句是关键。不写它,模型就会把「这家酒店位置好、适合带孩子」这类自然语言塞进参数里,期待工具自己「理解」。

我现在固定用四段模板写:

[做什么,一句话]。[什么时候该调 / 前置条件]。[返回什么 / 有什么约束 / 超限怎么办]。[格式要求,如有日期或 JSON]。

三、参数设计:让模型不需要猜

提问背景:最早的设计里,userId 是一个普通参数,让模型从上下文里取出来再传进来。跑了一段时间发现,模型偶尔会传错;更糟的是偶尔「幻觉」出一个格式合法但并不存在的 ID。这类错误不会抛异常,它只会静默地查到别人的数据、或者写坏别人的记录——比抛异常危险得多。

凡是系统里已经有唯一正确值的参数,都不要交给模型填

userId、sessionId 这类安全敏感字段,通过会话上下文透明注入,不在工具签名里出现:

<code class="language-java">// 正确:userId 从会话上下文取,模型看不到这个参数
public String queryTravelPolicy(
        AgentSessionContext sessionCtx,            // 框架自动注入,对 LLM 不可见
        @ToolParam(name = "city") String city) {
    String userId = sessionCtx.getUserId();        // 安全获取
    // ...
}
// 错误:把它暴露成 @ToolParam,模型就有机会传错
public String queryTravelPolicy(
        @ToolParam(name = "user_id") String userId,  // 危险
        @ToolParam(name = "city") String city) { ... }
</code>

判断标准很简单:只要这个参数在系统里已有唯一正确值,就不该让模型填。

可选参数必须写清「不传会怎样」

<code class="language-java">// 必选:没有默认值,模型必须传
@ToolParam(name = "destination", description = "目的地城市") String destination
// 可选:description 里必须说明不传时的行为
@ToolParam(name = "status",
           description = "状态过滤,可选。不传则返回全部状态的记录",
           required = false) String status
</code>

漏了这句,模型不知道不传会怎样,往往每次都硬编一个值塞进来。

格式约束写进 description,并给示例

<code class="language-java">// 格式 + 示例
@ToolParam(name = "departure_date",
           description = "出发日期,必须为 YYYY-MM-DD 格式,例如 2026-07-15") String departureDate
// 枚举值直接列全
@ToolParam(name = "status",
           description = "状态过滤,可选值: DRAFT/SUBMITTED/APPROVED/REJECTED/COMPLETED/CANCELLED",
           required = false) String status
// 复杂结构给出 JSON 形状
@ToolParam(name = "scores",
           description = "每个候选的偏好分 JSON,结构固定:"
                       + "{ transport_scores:{id:0-10,...}, hotel_scores:{id:0-10,...} }") String scores
</code>

模型是「看说明书干活」的。写得越精确,它传参越准。

四、碎片参数还是一个大的 JSON?

提问背景:参数涨到七八个的时候一定会纠结——要不要合并成一个 JSON,参数少了模型是不是更好填?这两种方案在不同项目里都出现过,踩的坑还不一样,所以值得单独说。

<code class="language-java">// 方案 A:多个具名参数
public String submitTravelApproval(ctx,
        @ToolParam(name = "destination") String destination,
        @ToolParam(name = "departure_city") String departureCity,
        @ToolParam(name = "departure_date") String departureDate,
        @ToolParam(name = "return_date") String returnDate,
        @ToolParam(name = "purpose") String purpose)
// 方案 B:一个大 JSON 参数
public String submitTravelApproval(ctx,
        @ToolParam(name = "travel_info", description = "JSON {...}") String travelInfoJson)
</code>

我们最终选了方案 A。原因:每个参数有独立的 description 和独立校验,模型可以逐个填对;而一个大 JSON 参数,模型生成时很容易出现格式错误,一个括号错了整段都不成立,而且报错信息完全没法定位到具体字段。

只有一种情况会用 JSON 参数:结构化数据透传。比如行程规划的候选列表和多维偏好分——它们本来就是一张结构化表,拆成二十个参数只会更糟。

经验法则:5 个以内的独立语义字段 → 拆成具名参数;结构化数据透传 / 字段不固定 → 用 JSON 参数。

五、返回值要带「信号」,而不是只带数据

提问背景:最开始工具的返回值就是数据本身。结果是模型不知道怎么处理边界情况,于是只能在 system prompt 里反复叮嘱——「如果这个单子已经审批通过,取消前要先问用户」「如果超出范围就换另一个工具」。叮嘱一百遍也记不住,而且提示词越堆越长,越堆越没人看。

后来发现,返回值才是控制模型后续行为最牢靠的地方。把「接下来该干嘛」直接做成返回字段:

<code class="language-java">// 信号一:需要用户确认
return new ConfirmRequiredResult(false, true, orderId, currentStatus.getCode(),
    "该差旅单已审批通过,取消后不可恢复。请用户明确确认后再操作(传入 force=true)。");
// → 模型看到 needConfirm=true,就知道该先去问用户
// 信号二:超出能力范围,建议切换
return JSON.toJSONString(Map.of(
    "beyond_range", true,
    "requested_date", date,
    "max_date", maxDate,
    "note", "仅支持 3 天内的天气预报,请改用 weather-mcp 查询"));
// → 模型看到 beyond_range=true,就知道该换工具
// 信号三:功能不可用,给出引导
return JSON.toJSONString(Map.of(
    "available", false,
    "note", "新闻查询功能需配置 app.news-api-key,请联系管理员开通"));
// → 模型看到 available=false,就知道该告诉用户
// 信号四:关联操作提醒
return new CancelResult(true, orderId, associatedBookings,
    "该行程存在 3 条关联预订(机票 2 张、酒店 1 间),请提醒用户是否需要一并取消。");
// → 模型看到非空的关联预订,就知道要追问用户
</code>

原则很简单:凡是「遇到 X 情况就做 Y」的提示词,能挪进返回值里的就挪进去。提示词是全局的、会被稀释的;返回信号是即时的、贴着当前这一步的。

六、返回摘要,不要把整个数据集丢回去

提问背景:一开始觉得「信息给全一点总没坏处」,把查询出来的完整结果直接返回。跑几轮之后问题就来了:上下文迅速膨胀,token 成本飙升;更要命的是模型注意力被淹没,开始漏字段、答非所问——它不是没拿到数据,是数据太多看不过来。

工具返回占用的是模型的上下文窗口。返回越短,Agent 能跑的轮次越多、推理越准。

<code class="language-java">// 工具 A:算完把完整结果存起来,只给模型看摘要
itineraryPlanStore.save(userId, fullResultJson);
return buildCompactSummary(proposals);
// 工具 B:从存储里读完整结果,同样只返回结论
String fullPlan = itineraryPlanStore.load(userId);
return buildReviewSummary(reviews, bestId);
// 反例:把 50 条航班的完整 JSON 直接 return
// → 上下文膨胀、token 暴涨、模型注意力分散
</code>

大数据走 Redis 或文件,只给模型看摘要。

七、入参校验:默认模型给的参数是错的

提问背景:这一条是被坑出来的。模型生成的参数不可信——它可能格式错、可能是空、可能是看起来合理但根本不存在的值。刚上线时相信了它两次,一次查出空指针,一次把日期解析直接炸在业务层。

工具必须在入口处做完整校验,有三个细节值得说:

<code class="language-java">List<String> errors = new ArrayList<>();
if (isBlank(destination))    errors.add("destination(目的地城市)不能为空");
if (isBlank(departureCity))  errors.add("departure_city(出发城市)不能为空");
String dateErr = validateDate(departureDate, "departure_date(出发日期)");
if (dateErr != null)         errors.add(dateErr);
if (!errors.isEmpty()) {
    return toJson(ErrorResult.of("INVALID_PARAM", String.join(";", errors)));
}
// 逻辑校验也放在工具里
if (LocalDate.parse(departureDate).isAfter(LocalDate.parse(returnDate))) {
    return toJson(ErrorResult.of("INVALID_DATE_RANGE", "出发日期不能晚于返回日期"));
}
</code>

一次收集所有错误再返回,不要遇到第一个就 return。模型一次拿到全部问题,下一次调用就能一口气改对;如果每次只告诉它一个,它要来回试很多轮,又慢又费 token。

错误信息写给模型看:包含参数名 + 期望格式 + 实际值,让模型知道怎么修,而不是只知道「错了」。

日期格式用正则 + parse 双保险:^\d{4}-\d{2}-\d{2}$ 加上 LocalDate.parse() 捕获解析异常,只靠一个都容易漏。

八、幂等和写后验证:别假设一次只会写一次

提问背景:模型会重试,也会在多轮推理里把同一件事做两遍。上线第一周就出现了同一份出差申请被创建两次的情况——不是模型有 bug,是分布式系统加上一个概率性的调用方,叠加之后的必然结果。

幂等:写之前先查「是否已存在完全一致的生效中记录」。

<code class="language-java">List<TravelOrder> actives = travelOrderRepository.findActiveByUserIdAndDateRange(
        userId, List.of(DRAFT, SUBMITTED, APPROVED), departureDate, returnDate);
for (TravelOrder o : actives) {
    if (equalsSafe(o.getDestination(), destination)
        && equalsSafe(o.getDepartureCity(), departureCity)
        && equalsSafe(o.getDepartureDate(), departureDate)
        && equalsSafe(o.getReturnDate(), returnDate)
        && equalsSafe(o.getPurpose(), purpose)) {
        return o;   // 命中幂等,直接返回已有单子
    }
}
</code>

命中后直接返回已有记录,模型会告诉用户「已有相同申请」,不会重复提交。

写后验证:写操作返回成功之后,再查一次库确认状态真的落了。

<code class="language-java">private String verifyOrderStatus(String orderId, TravelOrderStatus expected, String toolName) {
    Optional<TravelOrder> opt = travelOrderRepository.findByOrderId(orderId);
    if (opt.isEmpty())                     return errorJson("查库后差旅单不存在");
    if (opt.get().getStatus() != expected) return errorJson("状态与预期不符");
    return null;   // 验证通过
}
</code>

这是典型的防御性编程。事务边界不明确、或者存在异步落库的时候,「写后读验证」是兜底;返回给模型的状态必须和库里的实际状态一致,否则模型会基于一个错误的状态安排下一步。

九、异常绝不穿透到框架

提问背景:业务异常直接往外抛,框架会把它包装成一行 Tool execution failed: ... 丢给模型。难看是次要的,真正的问题是——模型完全看不懂这一行,它只能再试一次,白白浪费一轮推理,而且大概率还是失败。

所有业务逻辑自己 catch、自己返回结构化错误:

<code class="language-java">try {
    // ... 业务逻辑 ...
} catch (Exception e) {
    logger.error("[TOOL][cancel_travel_order] 取消失败 orderId={}", orderId, e);
    return toJson(ErrorResult.of("INTERNAL_ERROR", "取消出差申请失败:" + e.getMessage()));
}
</code>

外部调用要显式超时 + 结构化降级:

<code class="language-java">try {
    String body = webClient.get().uri(url)
            .retrieve().bodyToMono(String.class)
            .block(Duration.ofSeconds(8));      // 明确超时,不要用默认值
} catch (Exception e) {
    return JSON.toJSONString(Map.of("error", "天气查询暂时不可用", "detail", e.getMessage()));
}
</code>

十、读写分离:查询和操作拆成不同的工具

提问背景:一开始所有工具注册给所有 Agent。后来做权限审查时发现,一个只负责信息查询的 Agent 手上也握着「取消预订」的写工具——它理论上可以做它完全不该做的事。单靠提示词约束它别调,是很脆的防线。

按语义拆成两组,注册时分开给:

<code class="language-java">// 只读:无副作用,调错只是多花 token
query_travel_order
query_booking_record
check_travel_order_conflicts
query_travel_policy
// 写:有副作用,需要更严格的校验、幂等和确认门禁
submit_travel_approval
cancel_travel_order
cancel_booking
</code>

拆开有四个好处:模型调查询工具毫无风险;写工具可以单独加校验、幂等和确认;按 Agent 注册时更灵活——只给查询 Agent 注册 read 工具,它物理上不可能改数据;工具总数少了,模型选错的概率也跟着降。

十一、工具之间不要靠模型搬数据

提问背景:当一个工具的输出正好是另一个工具的输入时,最直觉的做法是让模型把 A 的结果搬进 B 的参数里。直到有一次,一个上万 token 的规划结果经模型搬运之后丢了字段,排查了很久才意识到问题出在搬运环节,而不是计算环节。

正确做法是用外部存储做中转,工具之间靠 key 接力:

<code class="language-java">// 工具 A:算完存起来,只返回摘要
itineraryPlanStore.save(userId, fullResultJson);
return compactSummary;
// 工具 B:自己去读完整数据,不需要模型搬运
String fullPlan = itineraryPlanStore.load(userId);
</code>

这解决了三个问题:防上下文膨胀——完整结果不必在每一轮推理里被重新处理;防 JSON 损坏——模型搬大 JSON 时经常丢字段、改格式、截断,从存储里直接读则保证完整;跨 Agent 复用——不同 Agent 实例不共享内存,只能靠外部存储接力。

收尾:一份照着对的清单

把上面十一条压成一张可以贴在工位上的清单:

  1. 命名是「动词_名词」,读写分开命名
  2. description 回答三件事:做什么、什么时候调、限制与超限怎么办
  3. description 里写清「你做什么、我做什么」的职责边界
  4. 安全参数走上下文注入,不暴露给模型
  5. 可选参数写明「不传时的行为」;格式约束给示例;枚举列全
  6. 5 个以内的独立语义字段用具名参数,结构化透传才用 JSON
  7. 返回值带信号字段——能挪进返回值的提示词就挪进去
  8. 返回摘要而不是全量数据,大数据走外部存储
  9. 入参校验一次收集所有错误,错误信息写给模型看
  10. 写操作做幂等检查 + 写后回查验证
  11. 异常自己 catch 成结构化错误;只读与写工具分开注册

这十一条背后其实是同一个假设:模型做错不是异常,是常态。

传统接口设计假设调用方是理性的——它读了文档、理解了约定,出错是偶发事件。Agent 的工具设计必须假设调用方是概率性的:它一定会在某个时刻传错参数、选错工具、把同一件事做两遍。你要做的不是祈祷它别犯错,而是让每一次犯错都能停下来、说清楚、可恢复。

这也解释了为什么工具设计和提示词工程是两件事。提示词是在「劝」模型做对,工具设计是在「让模型做不错」——后者永远更可靠。


留个问题

你现在手上的 Agent 项目,工具数量到多少个了?我很好奇有没有人试过「每个工具只做一件事、参数不超过三个」这种极端拆法——拆到极致是模型更容易选对,还是工具太多反而更乱?欢迎在评论区聊聊你的取舍。

老废物乐园 🗼

[]