Skip to content

工具设计与参数 Schema:工具多了模型就选不准 ​

1. 本节产出 ​

一套工具治理方案:工具注册表(含分级与标签)、按场景动态挂载(只给模型 5~8 个候选)、参数 Schema 设计规范、以及一个能验证「模型选得准不准」的测试。

2. 前置依赖 ​

3. 为什么工具一多选不准 ​

实测数据(同一组任务,只改工具数量):

挂载工具数工具选择准确率平均轮次
396%2.8
891%3.1
1578%4.4
3054%6.9

准确率在 15 个之后断崖下跌。原因:

  1. 上下文占用:30 个工具的 Schema 可能占 3000+ Token,挤压了任务的上下文;
  2. 选择困难:选项越多,模型越容易选到「语义相近但不合适」的那个;
  3. 注意力稀释: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: stringstatus: enum[...]
必填明确全部可选真正需要的才必填
字段名自解释p1orderId
描述写清边界「查询订单」「按订单号查状态;只返回未归档订单;查不到返回 NOT_FOUND」
单位与格式datedate (yyyy-MM-dd)

最后一条最常被漏:date 没写格式,模型可能给你 2026/10/3、Oct 3, 2026、2026-10-03 三种格式,你的解析器要写三个分支。

4.4 工具返回值的规范 ​

规范说明
精简只返回模型需要的字段,不要整个 DTO
明确失败查不到返回 NOT_FOUND:...,不要返回空
可行动失败信息要说清「下一步该做什么」
限制长度超长结果截断(如 2000 字符),避免污染上下文
不带敏感信息手机号、身份证脱敏后再返回

5. 代码走查 ​

5.1 工具元数据 ​

java
// src/main/java/com/example/harness/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
// src/main/java/com/example/harness/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
// src/main/java/com/example/harness/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. 生产避坑 ​

  1. 一次挂载超过 10 个工具,选择准确率会明显下降。这是本节最重要的量化结论。做法:必须做动态筛选,不要图省事把全部工具挂上。如果你发现模型乱调工具,第一个要查的就是挂载数量。
  2. 工具描述里的「边界」比「功能」更重要。写清「不支持什么」「失败返回什么」,能显著减少模型的无效调用。特别是日期、金额、枚举这类有格式的字段,务必在描述里写明格式。
  3. 返回值必须精简。直接把数据库实体序列化返回,一次可能几千 Token,几轮下来上下文就爆了。做法是专门定义一个「给模型看的 DTO」,只保留必要字段并控制 toString()。

8. 延伸与锚点 ​

  • 思考题:一个任务需要「先查库存、再下单、再通知」,多个 Agent 各司其职会不会更好?(答案在 03-04 多 Agent 协作)
  • 代码锚点:git checkout ch03-03-tool-design
  • 下一课时:03-04 多 Agent 协作模式
  • 对应课件:L03-03 工具设计