工具抽象的艺术:可插拔Skill系统的设计哲学
2026/8/4 13:58:17 网站建设 项目流程

工具抽象的艺术:可插拔Skill系统的设计哲学


上一篇文章我们理解了ReAct循环——LLM在"思考-行动-观察"中迭代。但当时我们只是"假设"工具已经存在。实际上,工具系统本身的设计就是一门学问:如何让Agent的工具像手机App一样,安装即可用,卸载即消失?


一、回顾上一篇文章的遗留问题

在Day1中,我们说Agent = LLM + 工具 + 循环。工具是怎么来的?最简单的做法:

// 硬编码一个计算器工具@Tool(description="计算数学表达式")publicdoublecalculate(Stringexpr){...}// 注册给LLMchatClient.defaultTools(newCalculatorTool());

这样Agent就能算数学题了。但问题很快就来了:

你突然想让Agent也能查天气。你得写一个新的WeatherTool类、修改代码、重新编译、重新部署。过几天,用户想要一个翻译功能。再写代码、再部署。再后来,不同用户需要不同的工具,你没法为每个用户定制。

工具不能"安装"——必须"硬编码"——这是第一个要解决的问题。

二、Skill接口:给工具一个统一的"插槽"

2.1 从硬编码到接口抽象

所有工具都有一个共同点:有名字、有描述、能开关。所以用接口统一它们:

publicinterfaceSkill{Stringname();// 工具叫什么Stringdescription();// 工具做什么(这是写给LLM看的)booleanisEnabled();// 当前是否启用voidsetEnabled(booleanenabled);// 开/关}

任何实现了这个接口的类,就是一个"可插拔"的工具:

  • 实现了接口 → 可以被Agent发现
  • isEnabled()→ 可以运行时开关
  • name()+description()→ LLM得知这个工具的存在

2.2 这个接口故意不做"执行"

注意——Skill接口没有执行方法(比如execute())。为什么?

因为执行逻辑由Spring AI的@Tool注解处理。把"工具管理"和"工具执行"分开:

  • Skill接口:负责生命周期管理(是什么、是否启用)
  • @Tool注解:负责执行逻辑(怎么做)

如果强行把execute()放到Skill接口里,反而会把事情搞复杂——不同工具的参数完全不一样,统一签名几乎不可能。

2.3 启用/禁用是如何工作的?

SkillRegistry维护一个Skill列表。每次构建ChatClient时:

1. 遍历所有Skill 2. 过滤出 isEnabled() == true 的 3. 把它们作为工具注册给LLM 4. 被禁用的Skill对LLM完全不可见

前端点一下"禁用计算器"按钮 → 更新isEnabled状态 → 下次对话时,LLM不知道有计算器这个工具了。零代码改动、零重启。

三、内置Skill vs 外部Skill:一个关键的分层

3.1 为什么要分两层?

不是所有工具都该以同样的方式管理。

内置Skill(代码层面):

  • 计算器、翻译、记忆读写——这些都是核心能力
  • 行为完全可预期(代码是我们写的)
  • 性能要求高(频繁调用,不能有中间层开销)

外部Skill(数据库存储):

  • 用户自己导入的天气查询、代码执行、第三方API
  • 来源不可信(用户写的脚本可能有风险)
  • 需要安全管理(沙箱、超时、白名单)

如果在同一个抽象里混在一起,就会产生矛盾:内置Skill不需要安全检查(拖慢性能),外部Skill必须安全检查。统一抽象要么让内置Skill承受不必要开销,要么让外部Skill缺少保护。

3.2 所以分层是必然选择

内置Skill: Java代码 → @Tool注解 → Skill接口 → SkillRegistry管理 外部Skill: 数据库存储 → ImportedSkillRegistry解析 → ExternalSkillCallback适配 → 统一注册

层次有明确的边界:内置的在代码层,外部的在数据层。但在LLM面前,它们是一样的——都变成了工具说明书,LLM不关心工具是Java代码还是数据库查出来的。

四、外部Skill的三种类型:解决不同的问题

外部Skill分为PROMPT、TOOL、MCP三种。不是随意分的,每种解决一类问题。

4.1 PROMPT类型:不是"工具",是"行为准则"

PROMPT类型的本质是可插拔的System Prompt片段

举例:一个"代码审查规范"的PROMPT Skill,内容是:

你是一个代码审查者,检查以下方面: - 命名是否清晰? - 是否有潜在的空指针? - 是否有安全漏洞(SQL注入、XSS)?

当用户说"帮我审查这段代码"时,SkillRouter检测到"审查"这个关键词 → 激活该PROMPT → 内容被注入到System Prompt中。LLM不会知道这是一个"Skill"——它只看到System Prompt里多了一段审查规则。

4.2 TOOL类型:把外界API变成Agent的"肢体"

TOOL类型用一个通用的JSON格式描述一个外部工具:

{"name":"get_weather","description":"查询城市天气","parameters":{"type":"object","properties":{"city":{"type":"string"}}},"executor":{"type":"http","url":"https://api.weather.com/v1/{city}","method":"GET"}}

当LLM调用get_weather(city="北京")时,ExternalSkillCallback做了几件事:

  1. {city}替换为北京→ URL变成https://api.weather.com/v1/北京
  2. 用RestClient发送GET请求
  3. 拿到响应 → 返回给LLM

对LLM来说,它不知道也不关心这个工具背后是Java代码还是HTTP请求。它只看到"工具返回了天气数据"。

这就是统一使用体验的威力——不同来源、不同协议的工具,在LLM面前没有区别。

4.3 Script执行器的安全沙箱

如果外部工具是一个脚本(比如用户写了一个Python脚本让Agent执行),安全问题就来了。脚本可以做任何事——删文件、访问网络、fork进程。

我们的防御是三层:

第一层:白名单
只允许python3bashnode这三种运行时。用户想用shperl?拒绝。

第二层:隔离
脚本被写入一个临时文件,在独立进程中运行。脚本执行完了,临时文件删除。即使脚本有问题,也不影响Agent主进程。

第三层:超时
30秒必须结束。超过30秒,进程被destroyForcibly()强制终止。防止死循环或无限等待。

这三层组合起来,把风险降到了可控范围。

五、@Tool注解:LLM的"工具说明书"

5.1 注解不只是注解

@Tool(description="计算表达式,支持+ - * / ** sqrt")publicdoublecalculate(@ToolParam(description="数学表达式,如 3.14*2")Stringexpr){...}

这个注解经历了什么:

  1. Spring AI扫描 → 发现@Tool→ 提取方法签名和参数
  2. description字段生成JSON Schema(兼容OpenAI格式)
  3. Schema被拼接到System Prompt中
  4. LLM读取所有工具的Schema → 用户提问时选择最匹配的工具
  5. LLM根据@ToolParam的description构造参数

从这个流程可以看出:@Tool(description="...")不是写给人看的注释,而是写给LLM看的说明书。description的质量直接决定LLM能否正确选择和使用工具。

5.2 描述措辞的实验

两个描述,同一个工具:

描述A: "计算表达式" 描述B: "计算数学表达式。支持加减乘除、幂运算。当用户问计算题时必须调用此工具,禁止心算。"

测试"3.14 × 256等于多少":

  • 描述A → Agent有约40%机率心算(不调工具),可能得到错误答案
  • 描述B → Agent几乎100%调用工具,返回精确结果

差的描述让LLM"偷懒"(反正我会心算,不调工具了)。好的描述明令禁止偷懒。

六、从工具系统到"Agent App Store"

回顾整个设计:

  1. Skill接口定义了统一的"插槽"
  2. 内置/外部分层保证了安全性和灵活性
  3. 外部Skill的三种类型覆盖了主流需求
  4. @Tool注解标准化了LLM与工具的沟通方式

这些组合在一起,形成了一个可扩展的能力体系。未来可以做一个"Agent App Store"——开发者提交遵循规范的Skill JSON,用户一键安装,就像手机App Store一样。

MCP协议(下一篇文章)就是往这个方向走的一大步——它标准化了工具的"通信协议",让任何语言写的工具都能被任何Agent消费。


项目链接

项目代码:day6-agent

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询