Appearance
工具设计与参数 Schema:工具多了模型就选不准
1. 本节产出
一套工具治理方案:工具注册表(含分级与标签)、按场景动态挂载(只给模型 5~8 个候选)、参数 Schema 设计规范、以及一个能验证「模型选得准不准」的测试。
2. 前置依赖
3. 为什么工具一多选不准
实测数据(同一组任务,只改工具数量):
| 挂载工具数 | 工具选择准确率 | 平均轮次 |
|---|---|---|
| 3 | 96% | 2.8 |
| 8 | 91% | 3.1 |
| 15 | 78% | 4.4 |
| 30 | 54% | 6.9 |
准确率在 15 个之后断崖下跌。原因:
- 上下文占用:30 个工具的 Schema 可能占 3000+ Token,挤压了任务的上下文;
- 选择困难:选项越多,模型越容易选到「语义相近但不合适」的那个;
- 注意力稀释:Lost in the Middle 效应在工具列表上同样成立。
结论:一次只给模型 5~8 个工具。 超过就必须做筛选。
4. 核心原理
4.1 工具注册表
工具元数据 = {
name, description,
riskLevel: READ_ONLY / WRITE / DESTRUCTIVE / NEED_APPROVAL,
tags: ["order", "query", "finance"],
owner: "订单团队",
timeout: 10s,
enabled: true
}riskLevel 和 tags 是两个关键字段:前者驱动权限控制(03B-11),后者驱动动态筛选(本节)。
4.2 动态挂载:三级筛选
全部工具(30 个)
│
▼ 一级:按用户权限过滤(写工具只给有权限的人)
候选(15 个)
│
▼ 二级:按任务类型/标签过滤(查订单 → 只给 order 标签)
候选(8 个)
│
▼ 三级:按语义相似度排序,取 Top-K
最终挂载(5~8 个)三级筛选是本节的核心方案。第一级保证安全,第二级靠规则(快、免费),第三级靠向量检索(处理长尾)。
4.3 参数 Schema 设计规范
| 规范 | 反例 | 正例 |
|---|---|---|
| 参数 ≤ 3 个 | queryOrder(a,b,c,d,e) | queryOrder(orderId) |
| 枚举限定取值 | status: string | status: enum[...] |
| 必填明确 | 全部可选 | 真正需要的才必填 |
| 字段名自解释 | p1 | orderId |
| 描述写清边界 | 「查询订单」 | 「按订单号查状态;只返回未归档订单;查不到返回 NOT_FOUND」 |
| 单位与格式 | date | date (yyyy-MM-dd) |
最后一条最常被漏:date 没写格式,模型可能给你 2026/10/3、Oct 3, 2026、2026-10-03 三种格式,你的解析器要写三个分支。
4.4 工具返回值的规范
| 规范 | 说明 |
|---|---|
| 精简 | 只返回模型需要的字段,不要整个 DTO |
| 明确失败 | 查不到返回 NOT_FOUND:...,不要返回空 |
| 可行动 | 失败信息要说清「下一步该做什么」 |
| 限制长度 | 超长结果截断(如 2000 字符),避免污染上下文 |
| 不带敏感信息 | 手机号、身份证脱敏后再返回 |
5. 代码走查
5.1 工具元数据
java
// ch03-agent/src/main/java/com/aitech/agent/tool/ToolMeta.java
public record ToolMeta(
String name,
String description,
RiskLevel risk,
Set<String> tags,
Duration timeout,
boolean enabled
) {
public enum RiskLevel {
READ_ONLY, // 只读,可自由调用
WRITE, // 写操作,需权限
DESTRUCTIVE, // 删除类,需审批
NEED_APPROVAL // 无论如何都要人工批准
}
}5.2 注册与发现
java
// ch03-agent/src/main/java/com/aitech/agent/tool/ToolRegistry.java
@Component
public class ToolRegistry {
private final Map<String, ToolMeta> metas = new ConcurrentHashMap<>();
private final Map<String, Object> beans = new ConcurrentHashMap<>();
/** 扫描所有带 @ToolMeta 的 Bean 方法并登记 */
@PostConstruct
void scan(ApplicationContext ctx) {
for (Object bean : ctx.getBeansWithAnnotation(ToolComponent.class).values()) {
for (Method m : bean.getClass().getDeclaredMethods()) {
Tool tool = m.getAnnotation(Tool.class);
ToolMeta meta = m.getAnnotation(ToolMeta.class);
if (tool != null && meta != null) {
register(bean, m);
}
}
}
log.info("已注册工具 {} 个", metas.size());
}
/** 三级筛选 */
public List<Object> selectFor(String goal, List<String> userAcl, int maxTools) {
// 一级:权限
var byRisk = metas.values().stream()
.filter(ToolMeta::enabled)
.filter(m -> permitted(m.risk(), userAcl))
.toList();
// 二级:标签匹配(用任务关键词简单匹配)
var byTag = byRisk.stream()
.filter(m -> tagMatches(m.tags(), goal))
.toList();
// 三级:不足则按描述与目标的相似度补齐
List<ToolMeta> picked = byTag.size() >= maxTools
? byTag.subList(0, maxTools)
: topBySimilarity(byRisk, goal, maxTools);
log.info("为任务挂载 {} 个工具:{}", picked.size(), names(picked));
return beans(picked);
}
}5.3 参数描述示例
java
// ch03-agent/src/main/java/com/aitech/agent/tool/OrderTools.java
@ToolComponent
public class OrderTools {
@Tool(description = """
根据订单号查询订单当前状态与物流信息。
订单号格式:SO 开头加 8 位数字,例如 SO20260915。
只返回未归档订单;已归档订单返回 NOT_FOUND。
查不到时返回 NOT_FOUND,不要猜测订单状态。
""")
@ToolMeta(name = "queryOrder", risk = READ_ONLY,
tags = {"order", "query"}, timeout = "10s")
public String queryOrder(
@ToolParam(description = "订单号,格式 SO 加 8 位数字,如 SO20260915")
String orderId) {
...
}
@Tool(description = """
修改订单状态。此操作会真实改变业务数据,需确认后执行。
只允许从 PAID 改为 SHIPPED,其他流转会被拒绝。
成功返回新状态,失败返回具体原因。
""")
@ToolMeta(name = "updateOrderStatus", risk = NEED_APPROVAL,
tags = {"order", "write"}, timeout = "15s")
public String updateOrderStatus(
@ToolParam(description = "订单号,格式 SO 加 8 位数字")
String orderId,
@ToolParam(description = "目标状态,只能取 SHIPPED 或 CANCELLED")
String targetStatus) {
...
}
}5.4 返回值精简
java
// 给模型看的 DTO:只放需要的字段
public record OrderBrief(String orderId, String status,
String shippedAt, String carrier) {
public static OrderBrief of(Order o) {
return new OrderBrief(o.getId(), o.getStatus().name(),
fmt(o.getShippedAt()), o.getCarrier());
}
@Override
public String toString() {
return "订单 %s 状态 %s,承运商 %s,发货时间 %s"
.formatted(orderId, status, carrier, shippedAt);
}
}用 toString() 控制给模型看的内容,而不是直接返回实体。这样给前端的对象和给模型的文本可以各自演化。
6. 跑起来
bash
git checkout ch03-03-tool-design
mvn -q test -Dtest=ToolSelectionTest期望输出:
注册工具总数:28
场景「查询订单状态」→ 挂载 6 个:queryOrder, queryLogistics, ... ✅ 选中目标工具
场景「修改订单状态」→ 挂载 5 个:updateOrderStatus, ... ✅ 选中目标工具
场景「查天气」 → 挂载 4 个:getWeather, ... ✅
无写权限用户 + 修改类任务 → 挂载 0 个写工具 ✅ 权限生效| 检查项 | 通过标准 |
|---|---|
| 挂载数量 | 每次 ≤ 8 个 |
| 选择准确 | 目标工具在挂载列表中的比例 ≥ 90% |
| 权限过滤 | 无权限用户看不到写工具 |
| 参数格式 | 传入错误格式时工具返回明确提示,不崩溃 |
| 返回值长度 | 单个 Observation ≤ 2000 字符 |
7. 生产避坑
- 一次挂载超过 10 个工具,选择准确率会明显下降。这是本节最重要的量化结论。做法:必须做动态筛选,不要图省事把全部工具挂上。如果你发现模型乱调工具,第一个要查的就是挂载数量。
- 工具描述里的「边界」比「功能」更重要。写清「不支持什么」「失败返回什么」,能显著减少模型的无效调用。特别是日期、金额、枚举这类有格式的字段,务必在描述里写明格式。
- 返回值必须精简。直接把数据库实体序列化返回,一次可能几千 Token,几轮下来上下文就爆了。做法是专门定义一个「给模型看的 DTO」,只保留必要字段并控制
toString()。
8. 延伸与锚点
- 思考题:一个任务需要「先查库存、再下单、再通知」,多个 Agent 各司其职会不会更好?(答案在 03-04 多 Agent 协作)
- 代码锚点:
git checkout ch03-03-tool-design - 下一课时:03-04 多 Agent 协作模式
- 对应课件:L03-03 工具设计