Featured image of post Agent Tool 动态注册、发现、注入:一个工业级方案

Agent Tool 动态注册、发现、注入:一个工业级方案

从硬编码写死到动态注册中心+MCP双链路,系统拆解大规模Agent系统的工具管理架构

背景:为什么需要动态工具管理

假设你正在设计一个大型 Agent 系统。一开始只有 5 个工具,硬编码是自然的——写一个数组,模型调哪个你都知道。但当工具增长到 50 个、200 个时,两个问题会迫使你重新设计。

第一个问题:运维成本。

硬编码意味着每次新增工具都要:改代码 → 走 CI → 重启服务。在微服务架构里没有人能接受这种模式——为什么 Agent 的工具就要这么原始?

第二个问题,更根本:LLM 的注意力不是无限的。

当一个 Agent 有 200 个工具时,你不能把 200 个 Schema 都塞进上下文。Token 开销是一方面,更重要的是选择困难:工具越多,模型选错工具的概率越大。这是信息过载,不是模型能力问题。

所以动态管理的核心驱动力不是"做不做得到",而是大规模下必须解决这两个问题,否则系统不可扩展


顶层设计:三个正交维度

一个好的动态工具系统,本质上要在三个维度上做解耦:

维度解决什么问题
注册(工具怎么进来)新增工具不重启服务
发现(工具状态怎么感知)运行时感知工具的增删和变更
注入(工具怎么进 LLM 上下文)不把全部工具塞进一个 prompt

三者正交——你可以只做注册不做注入(那注册了也没用),也可以只做注入不做注册(那就还得硬编码)。三个都做,才叫工业级。

五个模块各司其职:Registry 管数据、Loader 管本地生命周期、MCP Pool 管远程生命周期、Orchestrator 管调用、Injector 管上下文筛选


第一链路:本地 Local 工具

注册:选择"注解 + 目录扫描"而非"配置文件"

为什么选择注解+目录扫描?两个原因:

理由一:解耦开发流程。

如果用配置文件注册(YAML 里写 tools: […]),新增工具需要改两个文件——工具本身 + 配置。这看起来只是多一步,但在多人协作的工程里,意味着代码审查、合并冲突、配置同步问题。如果用注解,开发者只新建一个文件,框架自动扫描注册,这是真正的"零侵入"。

理由二:文件系统本身就是最好的"注册表"。

文件存在 = 工具存在,文件删除 = 工具下线。不需要额外维护一份状态配置,用文件系统的事实状态作为真相来源。

伪代码的核心逻辑:

1
2
3
4
5
6
7
8
# 启动时:扫描目录,加载所有 .py 文件
for py_file in plugin_root.rglob("*.py"):
    exec_module(py_file)  # 触发 @register_tool 装饰器 → 自动写入注册中心

# 运行时:watchdog 监听文件增删改
on_create(py_file)   exec_module(py_file)
on_delete(py_file)   registry.disable(py_file.tool_name)
on_modify(py_file)   registry.replace(py_file.tool_name, new_meta)

发现:区分"启动发现"和"运行时发现"

  • 启动发现是静态的——扫描一遍目录,注册所有工具。简单,快。
  • 运行时发现是动态的——通过 watchdog 监听文件变更,实时同步注册表。

为什么需要区分?因为启动发现处理的是存量,运行时发现处理的是增量。存量用扫描(一次性成本),增量用监听(持续成本)。如果运行时也全量扫描,日志里会塞满噪音。

注入:四层策略的设计意图

注入是整个系统里最关键也最容易设计过度的部分。四层模式不是一次性想出来的,是从最简单的"全部注入"逐步演化的结果。

模式 1:全局常驻

意图:有一些工具确实是每次对话都要用的——获取时间、检查网络、简单的文本处理。这些工具放不放都很别扭:不放的话每个对话都要多一次调用去找它们,放的话它们占了上下文但大部分时候用不到。

权衡:只对调用频率极高、Schema 极轻的工具使用。数量不超过 3-5 个。

1
2
3
4
5
# 初始化时,从注册中心筛选 core 分组的工具,永久挂载
self.global_fixed = [
    meta.schema for meta in registry.values()
    if meta.group == "core" and meta.enabled
]

模式 2:意图驱动(核心模式)

这是最主流的方案,也是设计工作量最大的。每一轮用户 query 进来后,先做意图分类,再匹配工具。

为什么不用关键词匹配就完了?因为工具的语义和用户 query 的语义不一定重叠。用户说"帮我查一下这个订单",但工具名可能是 tracking_info_lookup。关键词匹配不到"订单",但意图分类器应该能映射到。

意图分类器为什么不做成全量 LLM?——成本。每一次对话都调一次大模型做分类,Token 开销大、延迟高。

所以工业级的做法是双阶段分类

1
2
3
4
5
6
7
8
def classify(query: str) -> list[str]:
    # 阶段 1:正则(高速,毫秒级)
    for pattern, label in RULES:
        if re.search(pattern, query):
            return [label]

    # 阶段 2:小模型(兜底,几十毫秒)
    return bert_model.predict(query)

正则能匹配的直接返回,匹配不到的 fallback 到小模型。这样 95% 的情况都在 5ms 内完成。

1
2
3
4
5
6
7
8
9
def inject_by_intent(self, query: str, role: str) -> list[dict]:
    tags = self.classifier.predict(query)
    tools = self.global_fixed.copy()
    for tag in tags:
        for name in self.registry.group_index.get(tag, []):
            meta = self.registry.get(name)
            if meta.enabled and role in meta.permission:
                tools.append(meta.schema)
    return tools[:15]  # 硬上限,防止分类器失误导致工具过多

模式 3:权限过滤

这不是功能,是安全基线。任何注入策略都必须附带权限校验——不仅仅是因为安全合规,更实际的原因是:没有权限过滤,你就无法在同一个 Agent 实例上服务不同角色的用户。

1
2
3
# 注入前,校验用户角色是否在工具 permission 白名单内
if ctx["role"] not in meta.permission:
    continue  # 跳过该工具

模式 4:会话手动指定

多 Agent 场景下,父 Agent 知道子 Agent 需要什么工具,不需要意图识别。这种场景下"精确指定"比"猜测"更高效。

1
2
# 上层编排层精确指定工具名,注入控制器精准取出
tools = [registry.get(name).schema for name in specified_names if name in registry]

为什么四层而不是一层?

因为没有一种注入策略能覆盖所有场景。全局常驻覆盖高频,意图驱动覆盖主流场景,权限过滤是安全兜底,手动指定覆盖编排场景。四层共存意味着每个场景都能找到最合适的方案,而不是用一把锤子砸所有钉子。


第二链路:MCP 远程工具

为什么需要单独一个 MCP 链路?

如果本地工具已经覆盖了所有场景,为什么还要 MCP?

答案在于所有权边界

本地工具是 Agent 进程内的——你写代码、你部署、你控制。但真实业务中,大部分工具不属于 Agent 团队。订单查询工具属于订单团队,物流工具属于物流团队,支付工具属于支付团队。

你不能让每个业务团队都把工具写成 Python 文件放到 Agent 的插件目录里——这是耦合,也是安全灾难。

MCP 的核心价值不是"远程调用",而是组织边界隔离。每个团队独立部署、独立运维自己的 MCP Server,Agent 只通过标准协议消费工具。

图:本地工具链路与 MCP 远程链路双架构

动态注册:通过协议而非配置

MCP 的注册机制不同于本地工具的"文件扫描"。它基于协议:Client 在握手阶段调用 list_tools,Server 返回全部工具 Schema。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Client 启动时:连接每个配置的 Server,拉取工具列表
for server_id, conf in configs.items():
    client = await MCPClient(server_id, conf).connect()
    tools = await client.rpc_call("list_tools")
    for t in tools:
        registry.register(ToolMeta(
            name=f"{server_id}_{t['name']}",  # 用 server_id 做命名空间,避免跨服务重名
            schema=t["inputSchema"],
            source=f"mcp:{server_id}",
            group=conf["group"],
            permission=conf["permission"],
        ))

注意 name 的命名方式:server_id + "_" + tool_name。这不是随意设计的。不同 MCP Server 可能暴露同名工具(比如订单 Server 和退款 Server 都有 query),必须用命名空间隔离。

发现:三种机制的取舍

机制成本实时性适用场景
心跳+重连中等检测服务崩溃和恢复
定时轮询低(秒级)感知工具内部变更
配置热更新新增/删除 Server 节点

为什么不只用一种?因为每种机制覆盖一种故障模式

  • 心跳保活解决"Server 挂了我不知道"
  • 轮询解决"Server 没挂但工具列表变了"
  • 配置热更解决"新增了一台 Server"

只做心跳不做轮询,Server 添加了新工具你得等重启才知道。只做轮询不做心跳,Server 挂了要等下一轮轮询(最多 60s)才能发现。

注入统一

MCP 工具注册后,和本地工具共享同一个 AgentInjector。注入层不需要区分工具来源——四种模式全部复用。唯一的区别在调用阶段:

1
2
3
4
5
6
7
# ToolOrchestrator 路由
if meta.source.startswith("mcp:"):
    client = mcp_pool.get(server_id)
    return await client.rpc_call("call_tool", ...)
else:
    instance = meta.tool_cls()
    return instance.run(params)

这一行 if 是整个双链路设计的精髓——注册中心归一化后,上层业务无感知。


幻觉抑制的设计取舍

工具注入中的幻觉抑制,本质是预防而非纠错。一旦工具进了上下文,LLM 选了它就是选了它,你没法"事后撤回"。

所以所有抑制策略都在注入阶段完成:

策略设计意图
数量硬上限(≤15)不是限制工具,是限制选择的复杂度。15 是经验值——超过这个数,模型准确率开始下降
禁用过滤紧急熔断通道。线上出问题直接标记 disabled,不需要重新部署
权限过滤不是为了"安全"的抽象目标,而是为了让同一个 Agent 能服务不同角色的用户
描述约束description 里写明"不可用于 XXX 场景"——从 prompt 层约束调用意愿
注入日志不是为了调试,是为了归因。当模型调错工具时,你能回答"为什么这个工具当时在上下文中"

思考与延伸:从 Tools 到 Skills

(本部分为用户补充思考)

这套架构是为了解决 tools 的使用问题。能不能迁移到 skills 层面?如何提高 skills 命中率?

从架构角度看,Tools 和 Skills 面对的困境是同构的

ToolsSkills共同本质
全量 Schema 注入 → Token 爆炸全量 Skill 列表注入 prompt候选集超过模型注意力范围
工具太多 → LLM 选错Skills 太多 → Agent 忽略信息过载导致选择退化

同构问题应当用同构方案解决。“按需注入"不是 Tools 层的专属模式,而是 Agent 系统处理大量可选项时的通用范式。无论可选项是工具、技能、知识库文档还是 API 端点,当候选集超过有效注意力范围时,就需要一个前置分类层来缩小范围。

如果把这个思路投射到 Skills 管理上:

1
2
3
用户 query → 轻量分类器 → 只注入 Top-3/5 匹配 skills → 节省 80%+ prompt 空间
                ↓ (低置信度)
             退化为全量 skill 列表(兜底)

这和 Tools 的"意图驱动注入"是同一种架构模式。区别在于:Tools 的注入是 LLM 函数调用层面的,Skills 的注入是 Agent 行为引导层面的。

延伸二:Skill 命中率的工程化实践

参考:沐阳 — Skill 装太多,如何提升命中率?4 个实用方法讲透

上文从架构视角讨论了 Tools → Skills 的迁移可行性。而实际工程中,提升 Skills 命中率有四个经过验证的具体方法,它们和本文的工具注入架构形成了有趣的对照:

方法 1:优化 Skill 描述

不要只写"这个 Skill 能做什么”,要写清"什么时候该用、什么时候不该用"。一个合格的描述包含 5 个要素:解决的问题、适用场景、输入输出、不适用场景、与相似 Skill 的区别。

这与本文中工具 Schema 的 description 约束策略一脉相承——从 prompt 层约束调用意愿。

方法 2:建立 Skill Tree(分层路由)

当 Skill 超过 100 个时,平铺列表必然导致选择困难。正确做法是分层:先判断意图大类,再判断子类,最后选择具体 Skill。

1
2
3
用户任务 → 意图分类(信息查询 / 内容处理 / 执行动作)
         → 来源分类(公网 / 邮箱 / 文件)
         → 具体 Skill 选择

这和本文的"意图驱动注入"是同一种模式——先用分类缩小候选集,再做精确选择。

方法 3:负样本描述

写清楚"什么时候不用"比写"什么时候用"更重要。每个 Skill 至少写三类负样本:

  • 相似但不该用的任务(写邮件 vs 发邮件)
  • 容易混淆的相邻 Skill(公网搜索 vs 知识库搜索)
  • 高风险操作的确认边界(支付类必须先确认)

这实际上把"权限过滤"和"描述约束"结合为了一个可执行的规则。

方法 4:召回 + 重排机制

当 Skill 上百个时,不能全塞进 prompt 让模型选。应设计为检索系统:

1
用户 query → 召回候选(向量/关键词)→ 规则过滤 → 模型重排 → 执行

这与本文的 ToolOrchestrator + AgentInjector 架构完全同构。区别在于工具注入是系统层的 Schema 筛选,而 Skill 路由是 Agent 行为层的知识筛选。

四个方法共同揭示了一个规律:当候选项超过模型的注意力边界时,不要指望模型自己解决,而应该用工程手段缩小候选范围。


总结

从架构师的视角来看,这套系统的设计没有"创新",只有合理的权衡

  • 注册选注解+目录扫描而非配置 → 为了开发者体验和零侵入
  • 发现分启动扫描和运行时监听 → 为了区分存量和增量两种工作负载
  • 注入做四层而非一层 → 因为没有一种策略能覆盖所有场景
  • Local + MCP 双链路 → 为了组织边界隔离,而非技术层面的"分布式调用"
  • 幻觉抑制在注入阶段做 → 因为预防成本远低于纠错

每个设计决策背后都有一个具体的工程约束在驱动。理解这些约束,比记住架构图更重要。