工具抽象的艺术可插拔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做了几件事把{city}替换为北京→ URL变成https://api.weather.com/v1/北京用RestClient发送GET请求拿到响应 → 返回给LLM对LLM来说它不知道也不关心这个工具背后是Java代码还是HTTP请求。它只看到工具返回了天气数据。这就是统一使用体验的威力——不同来源、不同协议的工具在LLM面前没有区别。4.3 Script执行器的安全沙箱如果外部工具是一个脚本比如用户写了一个Python脚本让Agent执行安全问题就来了。脚本可以做任何事——删文件、访问网络、fork进程。我们的防御是三层第一层白名单只允许python3、bash、node这三种运行时。用户想用sh或perl拒绝。第二层隔离脚本被写入一个临时文件在独立进程中运行。脚本执行完了临时文件删除。即使脚本有问题也不影响Agent主进程。第三层超时30秒必须结束。超过30秒进程被destroyForcibly()强制终止。防止死循环或无限等待。这三层组合起来把风险降到了可控范围。五、Tool注解LLM的工具说明书5.1 注解不只是注解Tool(description计算表达式支持 - * / ** sqrt)publicdoublecalculate(ToolParam(description数学表达式如 3.14*2)Stringexpr){...}这个注解经历了什么Spring AI扫描 → 发现Tool→ 提取方法签名和参数用description字段生成JSON Schema兼容OpenAI格式Schema被拼接到System Prompt中LLM读取所有工具的Schema → 用户提问时选择最匹配的工具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回顾整个设计Skill接口定义了统一的插槽内置/外部分层保证了安全性和灵活性外部Skill的三种类型覆盖了主流需求Tool注解标准化了LLM与工具的沟通方式这些组合在一起形成了一个可扩展的能力体系。未来可以做一个Agent App Store——开发者提交遵循规范的Skill JSON用户一键安装就像手机App Store一样。MCP协议下一篇文章就是往这个方向走的一大步——它标准化了工具的通信协议让任何语言写的工具都能被任何Agent消费。项目链接项目代码day6-agent