注意力稀释:当 113 个工具让模型降智时,工程师该怎么做?

简介:工具越多,模型分到每个工具的注意力越少;用三层懒加载、多维检索、description 召回锚点和 per-tool 熔断,确保递到模型面前的候选集永远小而精准

事故:run_test 和 run_etl 长得像双胞胎

简介:能跑的东西一定会被要求跑更多,工具数从 22 涨到 113 后,模型开始选错工具

  • 场景
    • 数据质量审核 Agent,上线时 22 个工具,三个月后涨到 113 个
    • Agent 连续调用 run_etl(真实迁移数据),而不是 run_test(只做验证)
      • 调了 7 次,把 340 万条记录从预发布库迁进了生产库
    • 查日志:调用记录完好、参数格式正确;查 Prompt:也没问题
  • 根因:两个工具的描述长得像双胞胎
text 复制代码
run_test:执行数据处理操作
run_etl:执行数据处理操作
  • 113 个工具没人有心思打磨每个描述,模型在约 0.9%(1/113)的注意力预算里没能区分这两行字
  • 不是模型变蠢了,而是工具给得太多,注意力被稀释了

注意力稀释:一道让模型"降智"的数学题

简介:注意力是分蛋糕,Token 预算还会被工具 schema 物理吞噬

  • 注意力稀释
    • Transformer 自注意力把相关性得分归一化到 0, 1,总和为 1
    • 候选越多,每个分到的平均权重越低
      • 10 个工具:每个约 10% 的注意力预算
      • 200 个工具:每个只剩 0.5%
  • Token 预算被物理吞噬
    • 一个中等复杂度的工具 schema(name + description + parameters)约 200--600 token,按 350 估算
工具数 仅工具 schema 的消耗
113 ≈ 39,550 token
200 ≈ 70,000 token,超过 128K 窗口的一半
  • 结论
    • 注意力稀释 + Token 吞噬 + 位置衰减三重效应叠加,准确率断崖式下跌
    • 这是数学约束,不是调 Prompt 能解决的

L1/L2/L3 三层懒加载

简介:借鉴 CPU 多级缓存的局部性原理,常用工具放近处,冷门工具放远处

  • 经验值
    • 一次推理,模型能看到的工具上限是 20 个
    • 超过 20 个:按层懒加载
    • 超过 50 个:必须上检索
  • 三层结构
层级 加载方式 规模 例子
L1 核心层 System Prompt 常驻,跨任务通用,绝对缓存 5--8 个基础只读工具 read_file、search_code、get_user
L2 领域层 按角色注入 每个角色 10--15 个 ops-tools、data-tools、qa-tools
L3 专用层 按需检索,用完即焚 50--200+ 冷门工具 检索索引,Top-K 注入
  • 加载流程
  • 图中红色的 Tool Router 是代码逻辑,绿色是最终交给模型的候选集
  • 落地:ToolRegistry 负责注册、分层、按角色路由
java 复制代码
public class ToolRegistry {
    private static final int MAX_VISIBLE_TOOLS = 20;
    private static final int L3_MAX_CONTRIBUTION = 3;
    private static final int L3_TRIGGER_THRESHOLD = 15;

    private final List<AgentTool> l1Core = new ArrayList<>();
    private final Map<String, List<AgentTool>> l2Domain = new HashMap<>();
    private final List<AgentTool> l3Cold = new ArrayList<>();
    private final ToolSearchIndex l3Index;
    private final ToolCircuitBreaker breaker;

    /** L1 全量 + L2 角色匹配 + L3 按需检索,中间过滤熔断工具 */
    public List<AgentTool> getActiveTools(String role, String intent) {
        Map<String, AgentTool> active = new LinkedHashMap<>();
        // L1:核心常驻(过滤熔断中的)
        l1Core.stream()
              .filter(t -> breaker.isAvailable(t.name()))
              .forEach(t -> active.putIfAbsent(t.name(), t));
        // L2:角色匹配
        l2Domain.getOrDefault(role, List.of()).stream()
                .filter(t -> breaker.isAvailable(t.name()))
                .forEach(t -> active.putIfAbsent(t.name(), t));
        // L3:仅当 L1 + L2 还有余量时才触发检索
        boolean hasIntent = intent != null && !intent.isBlank();
        if (hasIntent && active.size() < L3_TRIGGER_THRESHOLD && !l3Cold.isEmpty()) {
            l3Index.search(intent, L3_MAX_CONTRIBUTION).stream()
                   .filter(t -> breaker.isAvailable(t.name()))
                   .forEach(t -> active.putIfAbsent(t.name(), t));
        }
        // 硬上限:无论如何不能超过 20
        return active.values().stream().limit(MAX_VISIBLE_TOOLS).toList();
    }
}
  • 用这套 registry 注册出事的两个工具
java 复制代码
ToolRegistry registry = new ToolRegistry();
registry.register(new ReadFileTool(), Tier.L1);          // L1:全局常驻
registry.register(new RunTestTool(), Tier.L2, "qa");     // L2:QA 角色专属
registry.register(new RunEtlTool(), Tier.L2, "data");    // L2:Data 角色专属

List<AgentTool> qaTools = registry.getActiveTools("qa", "验证数据格式");
System.out.println(qaTools.stream().map(AgentTool::name).toList());
// [read_file, run_test]
  • QA Agent 的候选集里根本看不到 run_etl
    • 安全边界在代码里,不在 Prompt 里
    • 与工具原子化配合:拆分让权限拦截有挂载点,分层加载让拆分后的工具只出现在该出现的上下文里

L3 检索:让候选工具在多维上竞争

简介:冷门工具的检索不需要先判断意图再选路径,而是让所有候选在工具名、描述、领域上同时打分

  • 反直觉结论:绝大多数工具不需要向量检索
    • 工具名本身就是形式化地址,像函数名或 API 路径,不存在语义模糊
    • 向量模糊匹配反而引入噪声
      • restart_pod(重启容器)和 restart_service(重启系统服务)在向量空间里可能很近
      • 但两者副作用完全不同
  • 解法:放弃二分法,多维加权竞争
维度 信号强度 打分方式
工具名 最强 精确词命中 > 词内子串命中 > 归一化全名兜底
描述 次强 查询词以独立单词出现在描述里
领域 交叉信号 查询词精确等于领域标签(如 db_、ops_、crm_)时加分
java 复制代码
public List<AgentTool> search(String query, int maxResults) {
    Set<String> queryTerms = tokenize(query);
    record Scored(AgentTool tool, double score) {}
    return tools.values().stream()
            .map(tool -> new Scored(tool,
                    scoreName(tool, queryTerms)           // 1. 工具名:精确 > 子串 > 全名归一化兜底
                  + scoreDescription(tool, queryTerms)    // 2. 描述:词边界匹配
                  + scoreDomain(tool, queryTerms)))       // 3. 领域:精确标签匹配
            .filter(s -> s.score() > 0)
            .sorted(Comparator.comparingDouble(Scored::score).reversed())   // 多维加权,让分数自己说话
            .limit(maxResults)
            .map(Scored::tool)
            .toList();
}
  • 没有 if...else 的分支判断,所有候选在所有维度上一起跑,让数据自己说话
  • 设计考量
    • 工具名解析要到位
      • snake_case、CamelCase、领域前缀(db_query)、特殊前缀(mcp__server__action)都要切成最小语义单元
      • 把 restart_pod 当成不可分割的整体,用户查"重启 pod"时工具名维度就无法命中
    • 不同 Agent 域,权重不同
      • 运维 / DBA 这类 procedural agent:工具名几乎就是查询内容,name 权重拉满
      • 客服 / 销售这类 natural language agent:输入更口语化,抬高描述权重
    • 这是同一套评分框架下权重的连续滑动,不是开关式地选 A 还是 B
      • A/B 测试和域适配变成"调几个浮点数"的工程问题,而不是重新设计检索路径

description 是召回锚点,不是功能手册

简介:工具池变大后,description 既要告诉模型"我是干什么的",还要成为检索评分的关键输入

  • description 的两个角色
    • 决策:站在模型角度,说明工具干什么(工具决策契约的六要素)
    • 检索:多维评分里描述维度的输入,写得烂得分就是 0,进了候选集也会被埋没
  • 写法顺序
    • 先写"何时用",再写"是什么";先写"和谁互斥",再写"怎么用"
  • 反模式:功能堆砌,召回失败
java 复制代码
@Tool(name = "update_crm", description = "更新 CRM 系统中的客户信息。支持更新客户等级、标签、联系方式等字段。")
public class UpdateCrmTool implements AgentTool { /* ... */ }

@Tool(name = "update_ticket", description = "更新工单信息,包括状态、负责人、优先级等字段。")
public class UpdateTicketTool implements AgentTool { /* ... */ }
  • 200 个工具里,"更新某系统中的某信息"是最高频的描述模式,没有区分度
    • 两个工具描述维度得分一样,只能靠工具名硬撑,一点查询噪声就会混在一起
  • 生产版:触发条件优先,互斥边界清晰
java 复制代码
@Tool(name = "update_crm", domain = "crm", sideEffectLevel = SideEffectLevel.MEDIUM, description = """
        修改 CRM 系统中的客户主数据。
        【触发条件】当用户要求修改客户等级(如升级为 VIP)、调整客户标签、更新联系人信息时使用。
        【互斥】修改工单状态或工单负责人,请使用 update_ticket,不要使用本工具。
        update_crm 只操作客户维度的数据,不操作工单维度的数据。
        【副作用】修改会同步至下游 CDP 系统,不可撤销。
        """)
public class UpdateCrmTool implements AgentTool { /* ... */ }

@Tool(name = "update_ticket", domain = "cs", sideEffectLevel = SideEffectLevel.MEDIUM, description = """
        变更服务工单的流转状态或负责人。
        【触发条件】当用户要求关闭工单、转派工单、标记工单为处理中时使用。
        【互斥】修改客户档案信息(等级 / 标签 / 联系方式),请使用 update_crm,不要使用本工具。
        update_ticket 只操作工单维度,不操作客户维度。
        【约束】ticket_id 格式为 TKT-XXXXXXXX,不接受工单标题。
        """)
public class UpdateTicketTool implements AgentTool { /* ... */ }
  • 为什么有效
阶段 效果
检索阶段 查询"把客户升级 VIP","客户""等级""升级"命中 update_crm 的描述;domain 为 crm 再加分,总分远高于 update_ticket
推理阶段 看到"当......时使用",不用自己推理何时该调;看到"互斥",能在两个相似工具间做出确定性选择
  • description 完整模板
text 复制代码
【一句话】核心功能,动词 + 对象
【触发条件】当用户说 / 要求 / 提到......时使用(关键词即描述维度的召回锚点)
【互斥】和哪个相似工具形成排他关系,边界词是什么
【约束】参数格式、权限、副作用
【不适用】什么场景不该用(负面示例比正面示例更有区分度)
  • 好的 description 做两件事:告诉检索系统"我应该在什么时候被找到",告诉推理模型"我和邻居的边界在哪里"

per-tool 熔断器:一个工具挂了,不该拖垮整个 Agent

简介:Loop 层熔断挂起整个 AgentRun,粒度太粗;工具级熔断只隔离故障工具,其余工具照常运行

  • 场景
    • legacy_billing_api 背后的第三方支付接口挂了,每次调用都返回 500
    • 模型重试 → 失败 → 再重试,死循环浪费 Token、撑爆上下文
    • 其他 113 个工具都正常,整个 Loop 却像被一块砖卡住
  • 状态机
  • 图中绿色是正常状态,橙色是熔断状态
java 复制代码
public class ToolCircuitBreaker {
    private enum State { CLOSED, OPEN, HALF_OPEN }

    private static final class BreakerState {
        int failures;
        long lastFailureAt;
        State state = State.CLOSED;
    }

    private final Map<String, BreakerState> states = new HashMap<>();
    private final int threshold;
    private final long recoveryNanos;

    public ToolCircuitBreaker(int threshold, Duration recovery) {
        this.threshold = threshold;
        this.recoveryNanos = recovery.toNanos();
    }

    public synchronized boolean isAvailable(String name) {
        BreakerState s = state(name);
        if (s.state == State.OPEN) {
            // 超过恢复期则放行一次探测,否则拒绝
            if (System.nanoTime() - s.lastFailureAt >= recoveryNanos) {
                s.state = State.HALF_OPEN;
                return true;
            }
            return false;
        }
        return true;   // CLOSED 或 HALF_OPEN 均允许调用
    }

    public synchronized void recordFailure(String name) {
        BreakerState s = state(name);
        s.failures++;
        s.lastFailureAt = System.nanoTime();
        if (s.state == State.HALF_OPEN) {                          // 探测失败 → 立即重新熔断
            s.state = State.OPEN;
            return;
        }
        if (s.failures >= threshold && s.state == State.CLOSED) {   // 连续失败达阈值 → 熔断
            s.state = State.OPEN;
        }
    }

    public synchronized void recordSuccess(String name) {
        BreakerState s = state(name);
        s.failures = 0;
        s.state = State.CLOSED;                                     // 探测成功 → 恢复正常
    }

    private BreakerState state(String name) {
        return states.computeIfAbsent(name, k -> new BreakerState());
    }
}
  • 挂进 ToolRegistry
java 复制代码
public ToolRegistry(int failureThreshold, Duration recoveryTimeout) {
    // 默认:连续失败 3 次熔断,60 秒后放行探测
    this.breaker = new ToolCircuitBreaker(failureThreshold, recoveryTimeout);
}

public void recordFailure(String name) { breaker.recordFailure(name); }

public void recordSuccess(String name) { breaker.recordSuccess(name); }

/** 单个工具或全部工具的熔断状态,接到可观测性栈里 */
public Map<String, String> toolHealth(String name) { /* ... */ }
  • 效果
    • 模型永远不会看到一个"已知故障"的工具,也就不会对着它反复重试
    • 运维通过 toolHealth() 第一时间看到哪个工具在 flapping,而不是等用户投诉
  • 注意点
    • 两种熔断要分清:Loop 熔断挂起整个 AgentRun(粗粒度保护),工具熔断只隔离单个工具(细粒度韧性)
    • 两者都要线程安全,HALF_OPEN 只允许一次探测,否则熔断器形同虚设

回到开头:三层防线挡住 340 万条误迁

简介:把概率模型的不可靠性,死死按在工程容错的范围内

  • 第一道:run_test 属于 QA 角色,run_etl 属于 Data 角色,审核 Agent 眼里根本没有 run_etl
  • 第二道:即使路由失误把两个工具都塞给模型,description 里写死了互斥条件,模型也不用掷骰子
  • 第三道:就算模型调了 run_etl 且报错,连续失败 3 次后熔断器把它踢出候选集,不会有第 2 到第 7 次重试
  • 铁律
    • 任何一次推理,模型可见工具数必须 ≤ 20
    • 候选集控制是架构问题,不是 Prompt 问题
    • 工具检索让候选在工具名、描述、领域上同时竞争,加权求和取 Top-K,形式化信号优先
    • description 是召回锚点:先写何时用,再写是什么
    • 工具级熔断器与 Loop 熔断器分别解决不同粒度的问题
  • 一条主线:把确定性交给工程,把概率性留给模型
    • 模型擅长在有限选项里做选择,不擅长在海量选项里做搜索

延伸思考:超级客服如何动态切换角色

简介:同一轮对话里既要查 CRM、又要处理工单、还要查账单,全加载会超过 20 个,只加载一个角色又切换不了

  • 思路:把角色当成工具目录的二级索引,反过来"检索该激活哪个角色"
    • 每轮 PREPARE 根据当前意图、实体、动作和 Plan 下一步,给 CRM、客服、账单等角色卡打分
    • 只激活一个主角色,必要时带一个只读副角色
    • 常驻工具 + 主角色工具 + 少量副角色工具,合计硬截断到 20
  • 防抖
    • 只有模型本轮确实调用了副角色的写工具,下一轮才把它升成主角色
    • 加冷却时间和意图相似度阈值
  • 跨角色写流程
    • 由编排器拆成串行子任务
  • 注意点
    • 20 不是协议规定,而是当前模型的注意力预算
    • 工具继续增多时,应改成工具搜索和按需加载

面试/考试记忆点

  • 工具太多不是模型变蠢,而是注意力被稀释:候选越多,每个工具分到的注意力越少
  • 一个工具 schema 约 350 token,200 个工具就吃掉 128K 窗口的一半以上
  • 经验值:一次推理可见工具 ≤ 20,超过 20 分层懒加载,超过 50 必须上检索
  • L1 常驻核心只读工具、L2 按角色注入领域工具、L3 冷门工具检索 Top-K,路由用代码不用 LLM
  • 工具名是形式化地址,L3 检索优先多维加权(工具名 > 描述 > 领域),不必向量化
  • description 是召回锚点:先写触发条件和互斥边界,再写功能
  • per-tool 熔断器(CLOSED / OPEN / HALF_OPEN)只隔离故障工具,模型永远看不到已知故障的工具
  • 安全边界在代码里,不在 Prompt 里:角色隔离 + 描述互斥 + 工具熔断三层兜底
相关推荐
梦醒江南1 小时前
Agent 卡在网页上时,为什么不能换个真实入口继续?
人工智能·agent
用户460381291941 小时前
MCP 给了 Agent 一堆“手”,Skill 才教它怎么干活:两者到底什么关系?
agent·mcp
for_ever_love__2 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略
大模型真好玩2 小时前
用TRAE、DeepSeek-Harness、DeepSeek-V4.1-Flash-1017再现特斯拉Roadster汽车飞行名场面
人工智能·agent·deepseek
遇码2 小时前
不用转场引擎做幻灯片转场:相机滑动、黑场淡入与真全屏的完整做法
人工智能·数码相机·ai·rust·大模型·agent
薛定谔的猫-菜鸟程序员3 小时前
22 分 10 秒 vs 2 小时 44 分:AtomCode 与 opencode 项目开发对比实测,国产agent Atomcode 这么猛吗?
agent·opencode·atomcode
Eloudy3 小时前
ADR-042: Gas Town 与 Beads 分析 —— 对 Claude Flow V3 的启示
agent
墨心@3 小时前
AgentSessionRuntime 分析
agent·pi·coding agent·harness
墨心@4 小时前
Pi Services 层
agent·pi·coding agent·harness