从零掌握 Spring AI Alibaba Skill:定义、注册与渐进式披露
做AI应用开发这一年多,我最大的感受是:模型能力再强,落不了地也白搭。你让大模型去查个天气、算个运费、调个内部接口,它自己干不了,必须靠工具调用。而在Spring生态里,Spring AI Alibaba的Skill机制,就是专门来解决这个问题的。
Skill说白了就是把“模型能调用的能力”封装成一个标准化的Java组件。你定义好输入输出,注册到框架里,大模型在对话中就能根据用户意图自动触发。这里面的难点不在于写一个类,而在于理解它的设计哲学——渐进式披露。这个词听起来玄乎,其实就是一套“先声明、后审计、再调用”的机制,让技能不是裸奔式暴露给模型,而是挂了一道闸门。这篇文章我从头到尾拆一遍,把定义、注册、披露的完整链路讲清楚,新手能照着做,老手也能查漏补缺。
1. 内容整体设计与思路拆解
1.1 Skill机制到底解决了什么问题
先聊一个具体的场景。假设你要做一个企业内部的工单系统助手,用户跟AI说“帮我把昨天杭州地区的所有未处理工单标记为紧急”。大模型听得懂这句话,但它碰不到数据库,也调不到业务接口,所以它只能回复:“我无法执行此操作”。
这不是模型笨,而是你没有给它“手”。Skill机制干的事,就是给模型配备标准化的“手”——把查询工单、修改状态、统计数量这些动作,封装成一个个技能类,注册到框架里。当模型识别到用户意图和某个技能匹配时,会按照技能定义的参数格式,自动组装调用请求,触发后端逻辑执行。
这个过程里,有几个点是刚需:
- 参数必须结构化。模型不知道你的接口需要什么字段,只有你把参数定义清楚,它才能正确生成调用参数。
- 技能要有语义描述。模型靠描述来判断“这个请求该不该用这个技能”,描述写得好不好,直接决定了调用的准确率。
- 执行过程要可控。不是所有技能都能让模型随意触发,有些涉及数据修改的操作,必须经过校验或审计。
Spring AI Alibaba的Skill机制,就是围绕这三个点展开的。它不是一个简单的“函数注册中心”,而是一套包含定义、注册、描述、审计、调用的完整链路。理解了这个链路,你才能真正用好它。
1.2 渐进式披露的核心思想
渐进式披露(Progressive Disclosure)这个名字,我在第一次接触时也琢磨了一会儿。翻译成人话,就是:不要一次性把技能的全部信息暴露给模型,而是分阶段、按需披露。
传统做法是,把所有工具的函数定义一股脑塞给模型。GPT-4级别的模型能处理几十个工具定义,但效果会随着数量增加而显著下降——模型会犹豫该用哪个,甚至传错参数。而渐进式披露的思路是:
- 第一阶段:只给模型暴露技能的“名称 + 一句话描述”,让它知道“有这个能力”。
- 第二阶段:当模型认为某个技能匹配当前对话时,框架再把详细的参数结构、约束条件披露给模型,引导它生成精确的调用参数。
这样做的好处非常明显。一是大幅降低模型在工具选择上的困惑度,二是让技能具备“审核关卡”——在详细参数披露之前,你可以插入权限校验、业务审计等逻辑,不符合条件的请求直接拦截,根本到不了模型那个环节。
我做一个类比:春晚上每个节目都有个节目单,观众看节目单就知道大概演什么。但如果你把每个节目的完整剧本、演员台词全部打印出来贴在门口,观众反而不知道该看什么了。渐进式披露就是“先给节目单,等你决定看这个节目了,再把剧本递给你”。
1.3 这套设计带来的实际收益
在实际项目中,我发现渐进式披露带来的收益远超预期。最直接的一点是Token消耗显著下降。之前我的一个项目里,定义了20多个工具,平均每轮对话光工具定义就要消耗大几千Token。改成渐进式披露后,模型每轮对话看到的只是所有技能的名称和短描述,Token消耗直接少了一个量级。
更重要的是,安全性得到了实质提升。以前工具定义全量暴露,模型可能会在参数注入一些不合理的内容——不是模型坏,而是它不理解业务边界。现在每次触发技能前,框架会先校验请求者身份、检查参数合法性、记录审计日志,这些都通过了才进入模型的参数生成环节,等于给技能调用加了一道防火墙。
2. Skill的定义方式与核心注解解析
2.1 一个最小可用的Skill长什么样
在Spring AI Alibaba中,定义一个Skill非常Java化。你不需要继承什么奇特的基类,就是写一个普通的Spring组件,然后打上注解。最简单的一个技能长这样:
@Component @Tool public class WeatherSkill { @ToolParam(description = "城市名称,如杭州、上海、北京") public String getWeather(String city) { // 调用天气服务,返回结构化JSON return weatherService.query(city); } }看到了吗,一个@Tool注解标在类上,方法上用@ToolParam描述参数,然后这个方法就被框架自动识别为一个可调用的skill。getWeather的返回值会作为工具调用的结果返回给模型,由模型整理成自然语言回复给用户。
这里有个容易踩的坑:@Tool如果标在类上,那么这个类的所有public方法都会被注册为技能。如果你只想暴露特定的方法,就别标在类上,而是标在方法上。我更推荐方法级别的标注,颗粒度更细,可控性更强,尤其是当一个类里有多个方法、但只想暴露其中一两个的时候。
2.2 参数绑定:从简单类型到复杂对象
如果你的技能方法只有一个String参数,上面的写法就够了。但实际业务中,参数往往复杂得多。比如一个工单查询技能,可能需要城市、时间范围、状态等多个字段。Spring AI Alibaba支持直接将多个参数映射到一个对象上:
@Component public class OrderSkill { @Tool(name = "queryOrders", description = "查询工单列表,支持按城市、时间、状态筛选") public OrderPageResult queryOrders(@ToolParam OrderQuery query) { return orderService.pageQuery(query); } } @Data public class OrderQuery { @ToolParam(description = "城市名称") private String city; @ToolParam(description = "开始时间,格式yyyy-MM-dd") private String startDate; @ToolParam(description = "结束时间,格式yyyy-MM-dd") private String endDate; @ToolParam(description = "工单状态,可选值:PENDING/PROCESSING/RESOLVED") private String status; }这种对象绑定的方式有几点好处:
- 参数结构清晰,模型参考对象字段定义就能生成完整的JSON参数。
- 后续扩展字段不需要改方法签名,只改对象即可,兼容性更好。
- 可以做嵌套对象,比如OrderQuery里套一个UserInfo对象,模型会递归理解。
实际开发中我的建议是:凡是参数超过两个的技能,一律用对象方式定义。好处不只是整洁,更重要的是给参数约束提供了空间——你可以在字段上用@ToolParam标注可选值、格式要求,模型在生成参数时就有了明确的规范指引,传错参数的概率大幅下降。
2.3 返回值的序列化设计
Skill方法的返回值处理,直接关系到模型能不能“看懂”结果。我见过不少新手在这里栽跟头:返回一个Java对象,框架默认用JSON序列化,结果某些字段名不规范,或者含有大量无关数据,模型拿到后难以解析。
两个原则供参考:
- 返回值必须是自解释的。字段名要语义清晰,最好带上业务含义。比如返回一个Map,key用
orderId、statusDesc,而不要用id、desc这种模糊命名。 - 只返回必要字段。模型需要在多个技能调用之间传递信息,返回无关大字段会白白消耗Token,也可能干扰模型的判断。
我通常会定义专用于工具调用的DTO,而不是直接返回实体类。比如查询工单,实体里可能还有内部备注、日志信息,这些不应该暴露给模型。单独定义一个OrderToolResult,只包含订单号、状态、城市、处理人等模型需要的信息。
2.4 技能的描述文案是核心资产
很多开发者把精力放在逻辑实现上,却忽略了description的编写。但说实话,在AI应用的Skill定义中,描述文案的重要性不亚于代码逻辑。模型就是靠这个描述来判断“该不该调用这个技能”的。
我总结出一个有效的描述模板:
[动作] + [对象] + [使用场景] + [关键约束]举例:
- 差:
查询工单 - 好:
查询工单列表。当用户需要查看工单信息、工单进度或筛选工单时使用。支持按城市、时间、状态组合筛选。此技能只能查询,不能修改工单状态。
两者差距一目了然。第二个描述给了模型足够的信息来做决策,同时通过“只能查询,不能修改”这类约束,帮助模型避免误调用修改类的技能。这里我强烈建议写完描述后,把“模型拿到这句话会不会把这个技能用于其他用途”作为自检标准,多打磨几次,效果会有肉眼可见的提升。
3. Skill注册与会话级隔离机制
3.1 注册的两种方式:自动扫描与手动编程
Spring AI Alibaba支持两种Skill注册方式。
方式一:自动扫描
依赖Spring的组件扫描机制,只要在配置类上标注@ComponentScan,框架会自动找到所有标注了@Tool的Bean并注册。这种方式适合技能数量不多、项目结构规整的场景,零配置,开箱即用。
方式二:手动编程式注册
当技能需要按条件动态注册(比如根据用户订阅的套餐决定暴露哪些技能),就需要编程式注册:
@Configuration public class SkillConfig { @Bean public ToolContext toolContext(List<Object> toolBeans) { ToolContext context = new ToolContext(); // 根据业务条件决定注册哪些技能 if (subscriptionService.hasPremium(userId)) { context.registerTool(new PremiumAnalysisSkill()); } context.registerTool(new BasicQuerySkill()); return context; } }手动注册的好处是可以做精细化控制。同一个技能接口,不同用户看到的可用技能列表不一样,这在SaaS多租户场景下尤其重要。你可以做一个技能注册中心,根据租户权限、套餐等级动态组装。
3.2 会话(Session)机制在Skill调用中的角色
Skill的调用不是孤立的,它发生在一次AI对话的上下文中。Spring AI Alibaba引入了会话隔离机制,确保每次对话的Skill调用互不干扰。
我看过一些团队的实现,他们会给每个对话分配一个SessionId,Skill方法内部通过SessionContext获取当前会话信息,比如用户身份、权限级别、对话历史摘要。这样写技能时就能做到:
@Service public class SensitiveDataSkill { @Tool(description = "查询客户的敏感信息,仅限管理员角色") public String getCustomerSecret(@ToolParam String customerId, ToolContext toolContext) { String role = toolContext.getSession().getUserRole(); if (!"ADMIN".equals(role)) { return "权限不足,当前角色无法访问敏感信息"; } return secretService.query(customerId); } }当Skill方法需要访问会话级的用户信息时,Spring AI Alibaba会通过方法参数注入ToolContext,这样你就能在技能代码里做权限判断。这在传统工具调用里要费不少功夫才能实现,在框架里是开箱即用的。
3.3 会话超时与状态清理
在线对话场景中,会话是有生命周期的。如果一个Skill内部维护了状态(比如分页游标、临时计算结果),而会话长期不清理,会带来两个问题:内存泄漏、状态串扰。
建议做法是:
- 短期会话做TTL控制,比如30分钟无交互自动过期。
- 关键状态不要只放在内存里,而是持久化到Redis等外部存储,以SessionId为key。
- Skill方法内部尽量无状态,把状态交给基础设施管理。
一开始你可能觉得这不是Skill该关心的事,但等到线上出了“用户A的查询结果串到了用户B的对话里”这种事故,你就会明白会话隔离的重要性了。
4. 渐进式披露机制的原理与实现
4.1 工具调用的完整链路:从意图识别到结果返回
先画一条主线,看看一个Skill调用从触发到返回,中间经过了哪些环节:
- 用户输入问题,模型分析意图。
- 模型根据已披露的技能元数据(名称+描述),决定调用哪个技能。
- 框架接收到模型发出的工具调用请求。
- 框架校验调用者身份、权限,做审计记录。
- 框架把详细的参数Schema披露给模型,模型补齐参数。
- 框架执行技能方法,拿到结果。
- 结果返回给模型,模型组织自然语言回复。
渐进式披露就发生在第2步和第5步之间。可以理解为,第2步是“粗选”,第5步是“精调参数”。两个阶段之间,留出了安全审计与权限校验的空间。
4.2 如何实现技能的“先声明后披露”
Spring AI Alibaba支持一种机制,将工具的声明信息与详细Schema分离。声明的数据量很小,而Schema只有在工具被选中后才会注入给模型。
在实现层面,核心是控制“工具Schema的加载时机”。框架会维护一个ToolRepository,里面存放技能的完整定义。默认情况下,模型能看到的是技能的索引信息,当模型发出了调用请求后,框架才会加载该技能的详细Schema并返回给模型,引导模型补全参数。
这个设计给我带来的启发是:你的技能定义要分成两个层级来编写。第一层是索引信息,要短小精悍,通常不超过50个字;第二层是详细Schema,要严谨完整,字段约束都要写清楚。我把这个作为团队开发规范,写技能时先写索引描述,再去完善Schema细节,两个部分独立校对,效果很好。
4.3 用拦截器给渐进式披露加上权限闸门
渐进式披露最有价值的应用场景,就是和拦截器结合,实现安全的“先审后调”。
@Component public class AuditInterceptor implements ToolCallInterceptor { @Override public boolean beforeCall(ToolCall call, ToolContext context) { // 审计日志 auditLogger.log("tool_call", call.getToolName(), context.getSession().getUserId(), call.getArguments()); // 权限校验 UserRole role = context.getSession().getUserRole(); if (!role.hasPermission(call.getToolName())) { // 拒绝并返回提示,阻止后续执行 call.setBlocked(true); call.setBlockReason("当前角色无权限调用此技能"); return false; } return true; } @Override public Object afterCall(ToolCall call, Object result, ToolContext context) { // 调用后处理,如记录返回值摘要 return result; } }这个拦截器的位置很关键——它运行在模型发出调用请求之后、框架执行技能方法之前。也就是说,模型如果尝试调用一个它不该调用的技能,在这里就会被拦截下来,技能体根本不会执行。
因为有渐进式披露,模型在生成调用请求前只看到了技能的名称和一句话描述,没有看到详细的参数规格和内部实现。这意味着即使某个技能被恶意触发,攻击者也无法拿到更多信息来构造后续的攻击。我说的“攻击者”,在很多情况下其实就是模型本身——它不是为了攻击,而是因为理解偏差而误触发了不该触发的操作。
这时候你就能理解,为什么渐进式披露不只是为了省Token,它更是一套纵深防御体系里不可或缺的一环。
4.4 动态披露:根据上下文决定披露深度
在某些场景下,你甚至可以做动态披露——同一个技能,面对不同的用户上下文,披露的Schema粒度不同。比如一个“数据分析”技能:
- 普通用户:看到的是简化版Schema,只能传少量参数。
- 高级用户:披露完整Schema,支持所有筛选条件。
- 管理员:额外披露底层查询接口,甚至支持导出。
这种设计的好处是,技能本身是同一个,但面向不同权限的调用方暴露的能力边界不同。模型不是人为划分的“聪明”或“笨”,而是它拿到的参数Schema本身就不同,自然生成的调用请求就会在合理范围内。
不过坦白说,动态披露做到位并不容易,需要沉淀一套Schema管理方案。我的建议是先从静态的两级披露做起,跑通了再考虑动态化。前期不要过度设计。
5. 实操实录:一个订单查询Skill的完整落地
5.1 需求设定与前置准备
纯理论讲了这么多,下面用一个完整的例子来串联全部环节。假设我们做一个面向客服人员的AI助手,核心需求是让客服人员通过自然语言查询订单数据,并且只有特定角色才能访问客户联系方式。
技术栈:Spring Boot 3.x + Spring AI Alibaba最新稳定版 + Redis(用于会话存储)。
5.2 定义Skill的详细步骤与代码实现
第一步:定义参数对象
@Data public class OrderQueryParam { @ToolParam(description = "订单号,精确匹配") private String orderId; @ToolParam(description = "客户手机号后四位,模糊筛选") private String mobileTail; @ToolParam(description = "订单状态:CREATED-已创建,PAID-已支付,SHIPPED-已发货,FINISHED-已完成", allowableValues = {"CREATED", "PAID", "SHIPPED", "FINISHED"}) private String status; }这里有个细节:allowableValues属性可以限定枚举取值,模型在生成参数时就不会随意发挥,很大程度上避免了参数错误。
第二步:定义返回DTO
@Data public class OrderResult { private String orderId; private String customerName; private String mobileMasked; // 脱敏手机号 private String statusDesc; private BigDecimal amount; private String createTime; }注意,我把手机号做成了脱敏字段mobileMasked。为什么不直接返回完整手机号?因为技能结果默认会回传给模型,模型可能会在后续对话中把手机号复述出来。对客服人员,完整手机号是有必要看到的,但不能通过模型输出,应该通过专门的页面或接口展示,避免敏感信息在对话链路中扩散。
第三步:编写核心Skill逻辑
@Component public class OrderQuerySkill { @Tool(name = "queryOrderInfo", description = "查询订单信息。当用户询问订单状态、订单详情、物流进度时使用。" + "支持通过订单号精确查询,或通过手机尾号+状态组合筛选。" + "此技能只读,不修改任何数据。") public OrderResult queryOrder(@ToolParam OrderQueryParam param, ToolContext ctx) { // 会话审计:记录谁在什么条件下查了订单 String userId = ctx.getSession().getUserId(); auditService.log(userId, "query_order", param); // 权限检查:只有客服角色可以查询完整订单 if (!ctx.getSession().hasRole("CUSTOMER_SERVICE")) { OrderResult denied = new OrderResult(); denied.setOrderId(param.getOrderId()); denied.setStatusDesc("无权限查看订单详情"); return denied; } return orderService.queryDetail(param); } }这段代码展示了一个核心实践:技能内部也要有权限校验,不要完全依赖外部拦截器。双重保险总比单点可靠,尤其是涉及订单这种业务敏感数据时。
第四步:注册与测试
将这个类放在Spring Boot主类的子包下,通过组件扫描自动注册。启动应用后,用Swagger或者直接调用AI接口测试:
用户输入:“查询订单20241120001的状态”
模型应当:
- 分析到用户需要订单信息。
- 匹配到
queryOrderInfo技能。 - 提取参数
orderId=20241120001。 - 将调用请求交给框架,框架审计通过后执行技能。
- 技能返回结果,模型整理成“您的订单20241120001状态为已支付,金额298.00元,下单时间2024-11-20 14:30”这段话。
如果模型没有正确触发技能,优先检查技能的description是否写得足够清晰,这是最常见的原因。
5.3 从实际项目中学到的经验教训
第一,别让技能方法做太多事。一个技能只做一件事,比如queryOrderInfo就只要查询,不要在里面顺手更新缓存、发送通知。技能拆分越细,模型越容易理解,排查问题也越简单。
第二,技能命名尽量用动宾结构。queryOrderInfo比orderInfo好,createNewTicket比ticketCreate好。模型对动词的敏感度远高于名词,命名时把动作前置,能显著提升匹配率。
第三,日志要带上SessionId。线上排查问题时,如果没有SessionId,你根本串不起来一条完整的对话链路。我习惯在技能方法入口统一打一条日志,输出SessionId、工具名、参数摘要,排查问题效率能翻倍。
6. 常见问题与排查技巧实录
6.1 技能不触发:多半是描述问题
症状:用户提问明显在技能能力范围内,但模型没有调用任何工具,直接给了文本回答。
排查步骤:
- 确认技能Bean已注册到Spring容器,检查启动日志中是否有技能注册记录。
- 检查
@Tool注解是否在类或方法上写对位置,方法是否是public。 - 最关键的是,审视技能的描述。把描述给另一个同事看,问他“这个技能是做什么的,看到这句话你能联想起哪些用户问题”,如果对方犹豫,说明描述还不够具体。
我的经验是,很多“不触发”问题,不是代码Bug,而是描述和真实能力之间有认知偏差。比如你写“查询订单信息”,但用户说的是“帮我看看我昨天买的东西发货没”,模型未必能把“发货”和“订单查询”关联起来。改成“查询订单状态、物流进展。当用户询问发货情况、快递进度、订单详情时使用”,触发成功率会明显上升。
6.2 参数错乱:约束不够,模型自由发挥
症状:技能触发了,但模型传入的参数五迷三道。比如把日期传成了“明天”,状态传成了“还没发货”这种自然语言。
解决办法:
- 尽量使用枚举参数
allowableValues,从源头限定取值范围。 - 在字段描述里注明格式,例如
日期格式yyyy-MM-dd,不要传自然语言,实测下来效果显著。 - 如果参数确实允许任意字符串,考虑在技能方法内部做归一化转换。
我见过最离谱的一次,模型把“金额大于1000”解析成了amount="大于1000"。加了字段描述金额为数值类型,单位为分后,问题彻底消失。描述里的每一个字,都是在和模型进行“协议对齐”。
6.3 会话串线:Stateful技能状态没清干净
症状:用户A查询了订单,切换到用户B继续对话,B看到的消息里居然出现了A的订单信息。
原因:技能内部使用了实例变量或静态变量存储“当前查询结果”。
排查与修复:
- 检查技能类里是否有非final的成员变量,如果有,改成局部变量或从
ToolContext中获取。 - 确认会话隔离配置生效,SessionId在对话链路中正确传递。
- 排查Redis缓存key是否漏掉了SessionId维度。
这种问题大多出现在开发早期的demo代码里。根治办法就是让Skill方法保持无状态,所有上下文都通过方法参数传递。这条铁律比任何框架层面的隔离都可靠。
6.4 敏感信息输出到对话:返回DTO没脱敏
症状:模型在回答中输出了客户完整手机号、内部备注等敏感字段。
原因:技能返回值直接用了实体类,实体类的全字段被序列化并回传给模型。
解决思路:
- 为Skill专门定义返回DTO,只包含模型需要的最小字段集。
- 凡是涉及个人隐私的字段,一律先脱敏再返回。例如
mobile变成138****1234。 - 后台管理系统如果想要完整信息,就不要走AI对话链路,单独走页面权限控制。
我反复强调这一点,是因为在真实线上一旦出现敏感信息泄露,不只是技术问题,还会上升为安全事故。Skill返回值的脱敏设计,必须从一开始就刻在脑子里。
7. 一些进一步优化的方向
聊完踩坑,再说两个我觉得值得进阶思考的方向。
一个是把Skill做成可观测的。每次调用都记录调用的技能、耗时、Token消耗、是否命中,做一套工具调用监控大盘。这能帮你发现哪些技能是高频的,哪些技能从未被触发。未触发的技能大概率是描述有问题,或者根本没有对应的用户场景,这时候就要考虑优化描述或者干脆下线。
另一个是把技能编排纳入业务流程。单技能解决的是单一动作,但真实业务往往是多步骤的。比如“先查订单,再基于订单信息创建售后工单”,这就涉及技能之间的编排。目前Spring AI Alibaba的Skill机制主要是单技能调用,编排需要自己封装。我建议的做法是:定义业务流程类,内部调用多个Skill方法,并维护步骤状态。这种能力后期很有价值,可以持续投入。
Skill机制的核心价值,说到底是把AI的能力边界从“聊天”扩展到了“行动”。定义和注册是基础,渐进式披露才是让这套体系在真实生产中安全可用的关键。开发时多花一点时间在描述打磨、权限校验、返回脱敏上,上线后就能省下大把排查问题的时间。希望这篇基于实际项目经验的梳理,对你有实在的参考价值。