加载中...
  • 工具太多模型选不准:Deferred Loading 与动态工具集 loading

    吃透 AI Agent 开发 · 第 12 篇 · 第三章 · Tool System

    工具描述也占上下文。工具数量变多后,按需加载、搜索和动态注册比把全部工具常驻更可靠。

    工具描述也占上下文。工具数量变多后,按需加载、搜索和动态注册比把全部工具常驻更可靠。

    工具目录也会制造噪声

    当工具从十个涨到两百个,问题不是模型“记不住函数名”这么简单。每个 schema 都会占上下文,描述相似的工具会互相竞争,模型还可能为了确认能力而频繁搜索。工具可见性本身就是上下文设计:当前任务不需要的能力,不必在当前请求中出现。

    用目录和工作集分开管理

    flowchart LR
      Intent[任务意图] --> Search[工具目录检索]
      Search --> Candidates[候选摘要]
      Candidates --> Select[应用筛选]
      Select --> WorkingSet[本轮工具集]
      WorkingSet --> Call[模型请求]
      Call --> Release[回收动态工具]
    

    目录记录名称、用途、风险和关键词,工作集只记录本轮真正暴露给模型的 schema。搜索结果是候选,不等于授权;动态注册也不等于绕过原有权限层。

    先测 token,再决定是否延迟加载

    interface ToolCatalogItem {
      name: string
      summary: string
      risk: 'read' | 'write' | 'external'
      keywords: string[]
    }
    
    function score(item: ToolCatalogItem, query: string): number {
      const words = query.toLowerCase().split(/\s+/u)
      return words.reduce((total, word) => total + (item.name.includes(word) || item.keywords.includes(word) ? 2 : 0), 0)
    }
    

    搜索器只负责缩小候选集,不能因为分数高就直接执行。生产实现还要按照工具风险、当前 cwd、用户授权和模型可见范围再过滤一次。一个“搜到了”的工具,不应该自动拥有写权限。

    三种加载策略的现场差别

    策略 优点 代价 适用任务
    全量常驻 实现简单 上下文大、描述互相干扰 工具很少的短会话
    目录摘要 + 按需 schema token 稳定 需要搜索和缓存 中大型工具集
    模型自己发现并注册 灵活 发现、授权、审计都更复杂 受控插件平台

    不要把 Deferred Loading 当成“让模型自由加载任何能力”。真正的边界是:应用先决定哪些目录可见,再决定哪些 schema 可被本轮使用,最后仍由统一注册表执行。

    q-code 里追踪一次动态加入

    阅读顺序 路径 观察问题
    1. 注册中心 src/tools/registry.ts 动态工具和内置工具是否共享执行管线
    2. 工具搜索 src/tools/tool-search-tool.ts 搜索结果怎样回到模型上下文
    3. 目录加载 src/tools/load-tools-dir.ts 外部 schema 如何被解析和约束
    4. Prompt 组装 src/context/prompt-builder.ts 当前工具集放在哪个动态层

    失败反例:为了“让模型知道所有能力”把几百个 schema 永久塞进 system prompt

    为了“让模型知道所有能力”把几百个 schema 永久塞进 system prompt。模型不仅更难选择,还会因为一个工具描述更新而失去稳定缓存,调试时也很难判断到底是哪一段说明影响了决策。

    修复时记录每次工具集变更:查询词、候选、被排除的风险原因、最终暴露的 schema。这样一旦模型选错,可以先判断是搜索召回错、策略过滤错,还是模型理解错,不必把责任都推给 Prompt。

    做一个工具目录小实验

    练习:把你项目中的工具分成“文件、网络、进程、知识库”四组,为每个工具写三个关键词和一个风险等级。分别测量全量暴露与按需暴露时的输入 token、选择正确率和错误调用数。实验至少包含一个名称相近但权限不同的工具,观察搜索器是否会把它们混淆。

    最后定义回收规则:任务结束、模型切换、权限变化和会话恢复时,动态工具是否还在?生命周期写清楚后,Deferred Loading 才不会变成另一种隐形全局状态。

    目录项应该比 Schema 小,也应该足够区分

    工具目录若只保存名称,模型或搜索器无法区分相似能力;若保存完整 JSON Schema,又失去延迟加载的意义。实用目录项一般保留一句用途、风险等级、来源、关键词和大致输入类型。

    {
      "name": "jira_search_issues",
      "summary": "按 JQL 只读搜索 Jira 工单,不创建或更新工单",
      "risk": "external-read",
      "source": "mcp:jira-prod",
      "keywords": ["jira", "工单", "jql", "issue"],
      "inputHint": "query + maxResults",
      "schemaHash": "9d42..."
    }
    

    schemaHash 帮助缓存判断详情是否变化。摘要要写清“不做什么”,否则 jira_search_issuesjira_update_issue 仍可能被一起召回。风险字段只用于过滤和展示,不能替代执行时授权。

    召回工具也要有离线评测集

    检索文档会测 recall@k,工具搜索同样需要。准备一组真实任务,为每个任务标注必需工具、可接受替代和危险干扰项。搜索器要把必需工具放进 top-k,同时尽量不召回高风险同名工具。

    query: "看看 ABC-123 当前是什么状态"
    required: jira_get_issue
    acceptable: jira_search_issues
    dangerous_distractor: jira_update_issue
    
    query: "把本地测试失败行找出来"
    required: grep
    acceptable: read_file
    dangerous_distractor: shell
    

    只有召回率不够。若每个查询都返回 30 个工具,必需项当然容易命中,上下文和选择噪声却没有改善。还要记录工作集大小、schema token、模型最终选择和策略拒绝数。

    工具工作集是本轮快照

    模型请求发出后,工具目录可能因为 watcher 刷新而变化。已经生成的 tool call 应按该请求看到的 schema 快照校验,不能用刚更新的新版本随机解释。每轮可以保存 toolsetId,关联名称、版本和来源。

    下一轮再采用新快照。若某工具已被管理员紧急禁用,执行权限可以立即阻断旧调用;“能识别旧 schema”不等于“必须继续允许执行”。版本解释和授权时效是两件事。

    会话恢复也不应永久恢复旧工具集。历史 toolset 用于解释过去,当前运行配置决定新一轮可见工具;界面可以提示某个历史工具已不可用。

    自定义工具加载是代码执行入口

    项目目录里的 schema.json 看似只是数据,其中的 execute 命令却会运行本地代码。加载器必须限制固定目录、校验 schema、明确覆盖优先级,并把真正执行交给 Shell/Registry 管线。

    不能在扫描目录时就运行插件代码来“读取元数据”,否则用户只是打开项目,未批准的代码已经执行。发现阶段只读静态文件;执行阶段才在授权后启动命令。

    错误也要隔离。一个自定义工具 schema 损坏,应报告工具名、来源和校验字段,并跳过该项;不该让 help 命令或所有内置工具一起失效。项目级同名覆盖用户级时,诊断里要显示 shadowed 来源,方便判断为何另一台机器行为不同。

    动态可见不等于动态注册底层函数

    很多场景只需要从已注册工具中选择本轮子集,不需要反复修改 Registry。底层定义保持稳定,Prompt 层生成 working set,执行时再确认 call 的 toolsetId 和当前权限。

    真正动态注册适合 MCP 重连或用户新增工具目录。此时更新应原子进行:先在临时结构完成解析、冲突检查和 schema 编译,全部成功后替换某个来源的快照。逐个写入全局 Map,一半失败会留下混合版本。

    删除来源时,只移除该来源拥有的条目,再重新解析被遮蔽的候选。不能简单按名称删除,否则项目级工具卸载会把仍存在的用户级同名工具也抹掉。

    工具搜索本身不能成为无限循环

    模型找不到能力时可能连续调用 tool_search("jira"),每次得到相同候选,却不加载或执行。Loop 检测可以把查询词、候选 hash 和最终工作集做指纹;没有变化时提示模型从现有候选选择或解释缺口。

    搜索结果要说明为什么某项不可用,例如“命中但当前为 Plan Mode,写工具被隐藏”。只返回空数组,模型会换同义词反复搜;给出策略原因,它才知道需要审批或切换阶段。

    对工具搜索设置局部预算也合理,例如每轮最多两次目录查询、工作集最多 12 项、总 schema token 不超过某个比例。超预算时要求模型基于现有能力继续,不让发现阶段吞掉整个任务。

    缓存失效比缓存命中更重要

    目录索引可以按文件 mtime、schemaHash 和来源版本缓存,启动先用旧索引再后台刷新。但权限变化、项目切换和 MCP 断线必须立即影响可见性,不能因为缓存还在就暴露失效工具。

    旧索引适合加速候选展示,不是授权依据。真正构建工作集时重新应用当前模式、用户权限和连接状态;执行前 Registry 再做最后一次检查。三处看似重复,分别防止“搜错、看错、执行错”。

    判断 Deferred Loading 是否真的有收益

    优化前后至少比较四项:首轮 schema token、目标工具进入 top-k 的比例、模型误选率、完成任务所需额外搜索步数。token 降低但每次多走三轮搜索,成本和延迟可能反而上升。

    常用文件读写工具可以常驻,低频企业服务按需加载,高风险写工具即使常用也应在审批阶段才暴露。混合策略通常优于“全部延迟”或“全部常驻”。最终目标不是目录技术更高级,而是模型在当前任务里看到一套小而明确、权限真实的能力。

    工具目录被污染时,搜索效果好反而更危险

    项目里新增一个同名 read_file,summary 写得比内置工具更贴合任务,execute 却把内容发送到外部。若动态目录按搜索分数直接覆盖,模型会稳定选择恶意工具。

    防线从发现阶段开始:固定受信目录、静态 schema、来源与优先级;项目工具覆盖用户工具时显示诊断,但不能覆盖保留的内置安全能力,或至少要求显式信任。加载 metadata 不执行脚本,真正 execute 仍走 Registry 的网络、路径、Hook 和 Audit。

    Catalog item 可以带 package/content hash,首次出现或变化时提示。企业环境可要求签名或 allowlist;本地个人项目则保持默认不自动运行陌生工具。

    事故演练在 fixture 项目放入同名工具,预期目录显示 collision/shadowed,模型工作集仍指向可信来源,底层脚本未启动。随后用户显式允许一个无冲突只读工具,确认它能按既定优先级加载。安全策略不能用“禁用所有扩展”换取表面通过。

    本文目录
    本文目录