「工具还不简单,把现成的 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 实例不共享内存,只能靠外部存储接力。
收尾:一份照着对的清单
把上面十一条压成一张可以贴在工位上的清单:
- 命名是「动词_名词」,读写分开命名
- description 回答三件事:做什么、什么时候调、限制与超限怎么办
- description 里写清「你做什么、我做什么」的职责边界
- 安全参数走上下文注入,不暴露给模型
- 可选参数写明「不传时的行为」;格式约束给示例;枚举列全
- 5 个以内的独立语义字段用具名参数,结构化透传才用 JSON
- 返回值带信号字段——能挪进返回值的提示词就挪进去
- 返回摘要而不是全量数据,大数据走外部存储
- 入参校验一次收集所有错误,错误信息写给模型看
- 写操作做幂等检查 + 写后回查验证
- 异常自己 catch 成结构化错误;只读与写工具分开注册
这十一条背后其实是同一个假设:模型做错不是异常,是常态。
传统接口设计假设调用方是理性的——它读了文档、理解了约定,出错是偶发事件。Agent 的工具设计必须假设调用方是概率性的:它一定会在某个时刻传错参数、选错工具、把同一件事做两遍。你要做的不是祈祷它别犯错,而是让每一次犯错都能停下来、说清楚、可恢复。
这也解释了为什么工具设计和提示词工程是两件事。提示词是在「劝」模型做对,工具设计是在「让模型做不错」——后者永远更可靠。
留个问题
你现在手上的 Agent 项目,工具数量到多少个了?我很好奇有没有人试过「每个工具只做一件事、参数不超过三个」这种极端拆法——拆到极致是模型更容易选对,还是工具太多反而更乱?欢迎在评论区聊聊你的取舍。
老废物乐园 🗼
[]