1. 从一次“技能改了但模型还在背旧说明书”说起
Spring AI Alibaba Skills 是给智能体挂载可复用指令包的一套机制,你可以把它理解成给模型准备了一排“说明书抽屉”:平时只把抽屉标签(技能名、一句话描述、路径)贴在系统提示里,模型真要用某个技能时,再调用read_skill(skill_name)把对应SKILL.md的正文拉进上下文。这个“先给目录、后给正文”的做法就是渐进式披露,它最直接的好处是省 Token、防误触,也让技能目录可以越挂越多而不至于把上下文撑爆。
但真正落到工程里,麻烦往往不在第一次加载,而在“改完技能文件之后”。我试过在本地调试一个库存管理技能,把SKILL.md里的表结构从三张表改成四张表,重启应用、重新跑 Agent,结果模型回答里还是老三张表。排查半天才发现:FileSystemSkillRegistry在构建时已经把技能扫描进内存,SkillsAgentHook注入系统提示的也是那份快照,文件改了但注册表没重载,模型自然读的是旧内容。这就是热更新要解决的问题——让SkillRegistry在不重启进程的前提下重新扫描、重新注册,并且保证同一次 Agent 执行内的行为连续。
这篇就围绕SkillRegistry的注册、加载与动态刷新展开,给出可复制的配置片段、渐进式披露的分层示例,以及热更新触发与回滚的验证步骤。适合已经在用 Spring AI Alibaba 搭 Agent、想把 Skills 从“能跑”推进到“能改”的同学。核心检索词先摆在这:Spring AI Alibaba Skills 渐进式披露与热更新,重点就是SkillRegistry配置和SkillsAgentHook的autoReload。
先说清楚 Skills 的目录约定,不然后面配置对不上。每个技能一个子目录,目录名建议和技能name一致,里面必须有一个SKILL.md:
skills/ ├── inventory_management/ │ ├── SKILL.md # 必需 │ ├── references/ # 可选,放参考资料 │ ├── examples/ # 可选,放示例 │ └── scripts/ # 可选,放脚本 └── test-reload/ └── SKILL.mdSKILL.md用 YAML front matter 声明元信息,正文写功能说明、使用方法、可用资源列表:
--- name: inventory_management description: This skill should be used when the user asks about warehouse inventory, stock levels, or inventory database operations. --- # 库存管理技能 ## 功能说明 管理仓库库存,支持查询、更新、盘点。 ## 数据库表结构 - inventory_items:库存主表 - inventory_logs:操作日志 - warehouses:仓库信息 - suppliers:供应商信息 ## 使用方法 调用 execute_inventory_script 执行具体操作。name建议小写字母、数字、连字符,最长 64 字符;description超长会被截断,所以要把“什么时候该用这个技能”写清楚,因为渐进式披露第一阶段模型只能看到它。这里有个容易踩的坑:description写得太泛(比如“库存相关”),模型判断不出该不该读,就会漏掉技能;写得太长又被截断,关键触发词可能正好在截断之外。我的做法是把触发场景前置,像上面那样把“asks about warehouse inventory, stock levels”放最前面。
2. TaoToken 前置:把模型通道和 Key 准备好
Skills 本身不绑定具体模型供应商,但ReactAgent需要一个chatModel才能跑起来。本地调试时我习惯把模型通道统一走 TaoToken,这样换模型只改配置不改代码,也方便对比不同模型在渐进式披露下的表现——有的模型很听话会主动read_skill,有的会硬答,这个差异只有真跑才看得出来。
TaoToken 的定位是给开发者提供统一的模型调用入口,兼容 OpenAI 风格的接口,所以 Spring AI Alibaba 里用 OpenAI 兼容的ChatModel就能接。你需要先拿到 API Key,入口在控制台的 API Keys 页面:
https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewriteBase URL 用https://taotoken.net/api,注意这个地址不带 UTM 参数,配置里原样填就行。模型 ID 按你实际要用的填,比如claude-sonnet-4-5这类,具体可用列表可以在模型对话页面试:
https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite如果你后面要长期跑编码类 Agent、频繁调 Skills,可以看下 Coding Plan,它更适合这种持续调用的场景:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite接入文档在这里,遇到参数对不上可以翻:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite把 Key 和 Base URL 准备好之后,先别急着写 Skills,先用一个最小请求确认模型通道是通的。这一步能帮你把“模型不通”和“Skills 没加载”两类问题分开,不然混在一起排查很痛苦。确认通道没问题,再往下配SkillRegistry。
3. 可复制配置:SkillRegistry 与 SkillsAgentHook 三件套
这一节给的是能直接抄的配置。Spring AI Alibaba 里 Skills 的核心是SkillRegistry和SkillsAgentHook两个东西:前者负责扫描、加载、管理技能,后者负责把技能列表注入系统提示、注册read_skill工具、处理渐进式工具披露和自动重载。
先看模型配置,用 application.yml 走 OpenAI 兼容通道:
spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: claude-sonnet-4-5 temperature: 0.3对应的环境变量在启动前设好:
export TAOTOKEN_API_KEY="你的Key"然后是SkillRegistry的两种构建方式。开发期技能放在src/main/resources/skills下,用ClasspathSkillRegistry最省事,打包后也能读到:
SkillRegistry registry = ClasspathSkillRegistry.builder() .classpathPath("skills") .build();如果你需要在不重新打包的情况下改技能文件,就得用FileSystemSkillRegistry,指向磁盘上的真实目录:
SkillRegistry registry = FileSystemSkillRegistry.builder() .projectSkillsDirectory("D:/java/IdeaProjects/agent-cloud/skills-demo/src/main/resources/skills/") .build();两者的区别很关键:ClasspathSkillRegistry读的是 classpath 里的资源,改文件后通常要重新编译或重新打包才生效;FileSystemSkillRegistry读的是磁盘路径,配合autoReload才能做到改完文件下次调用就生效。热更新场景基本都用后者。
接着是SkillsAgentHook,这是把注册表和 Agent 连起来的桥:
SkillsAgentHook hook = SkillsAgentHook.builder() .skillRegistry(registry) .autoReload(true) .build();autoReload(true)是热更新的开关。它的行为是:每次 Agent 执行前调用一次registry.reload(),但只在同一次 Agent 执行的第一次推理时执行,后续多轮推理不再重载。这个设计是为了保证同一次执行内行为连续——如果模型第一轮读了技能 A 的旧内容,第二轮技能 A 被重载成新内容,模型前后看到的东西不一致,回答就会自相矛盾。
最后把 hook 挂到ReactAgent上:
ReactAgent agent = ReactAgent.builder() .name("skills-agent") .model(chatModel) .saver(new MemorySaver()) .hooks(List.of(hook)) .build();注意这里没有在.tools()里注册任何业务工具。这是渐进式工具披露的前提:业务工具要通过groupedTools绑定到技能上,由 hook 在模型读取技能后才动态暴露。如果你在全局.tools()里注册了,那就变成全量披露,模型一上来就能看到所有工具,渐进式就白做了。
三件套凑齐就是:Base URL(https://taotoken.net/api)+ Key(TAOTOKEN_API_KEY)+ Model ID(claude-sonnet-4-5)。这三个在模型配置里,SkillRegistry和 hook 负责技能侧,两边都配好才能跑通完整链路。
4. 验证请求:从技能列表到 read_skill 再到工具披露
配置写完,得用请求验证每一层是否按预期工作。渐进式披露分三层,我建议一层一层验,别一次全上。
第一层验证:技能列表是否注入系统提示。先只挂 hook,不绑工具,发一个“请介绍你有哪些技能”:
AssistantMessage result = agent.call("请介绍你有哪些技能"); System.out.println(result.getText());预期日志里能看到文件扫描成功:
Loaded skill: inventory_management from .../skills/inventory_management Skills reloaded: 1 total skills模型回复里应该明确列出inventory_management及其描述。如果你去翻发给模型的ChatCompletionRequest,会看到系统消息里有一段类似:
### Available Skills **Project Skills:** - **inventory_management**: Manages the inventory of the warehouse...到这一步,模型只知道“有个叫 inventory_management 的技能”,不知道具体怎么执行。这就是渐进式披露的第一阶段,只给目录不给正文。
第二层验证:模型是否主动调用read_skill。换个问题,问具体操作步骤:
AssistantMessage result = agent.call("请详细介绍一下 inventory_management 技能的具体操作步骤。");预期日志里出现工具调用:
"toolCalls": [ToolCall[id=..., function=ChatCompletionFunction[name=read_skill, arguments={"skill_name": "inventory_management"}]]]系统把SKILL.md正文注入后,模型才能输出详细的表结构和 SQL 示例。如果模型没调read_skill而是硬答,通常是description没写清楚触发场景,或者模型本身对工具调用不敏感,换个模型试试。
第三层验证:渐进式工具披露。把业务工具绑定到技能名上:
ToolCallback dummyTool = FunctionToolCallback.builder("execute_inventory_script", (Map<String, Object> args) -> { System.out.println(">>> 工具被调用了!执行库存脚本..."); System.out.println(">>> 收到的参数: " + args); return "SUCCESS: 库存已更新"; }) .description("执行库存管理的 Python 脚本,参数为操作类型") .inputType(Map.class) .build(); Map<String, List<ToolCallback>> groupedTools = Map.of( "inventory_management", List.of(dummyTool) ); SkillsAgentHook hook = SkillsAgentHook.builder() .skillRegistry(registry) .groupedTools(groupedTools) .build();groupedTools的 key 必须和SKILL.md里的name完全一致,差一个字符就绑不上。然后发一个诱导性 Prompt:
String prompt = """ 请详细介绍一下 inventory_management 技能。 如果该技能支持通过脚本自动化操作,请使用相关工具执行一个“检查库存”的操作。 """; AssistantMessage result = agent.call(prompt);看第一次请求:模型只拿到read_skill工具,看不到execute_inventory_script。模型调用read_skill("inventory_management")后,框架在运行时把execute_inventory_script绑定进当前会话。看第二次请求:工具列表里出现了execute_inventory_script,模型用它生成调用指令。这个“工具跟着技能走”的机制,就是渐进式工具披露,好处是省 Token、防误触、按需授权。
5. 常见错排查:401、local proxy failed、reading choices、OAuth
跑这套东西,报错基本集中在模型通道和技能加载两块。下面按真实报错对照排查。
401 Unauthorized或invalid_api_key:模型通道的 Key 不对。先确认环境变量TAOTOKEN_API_KEY真的被读到了,Spring 里${TAOTOKEN_API_KEY}如果没解析到会传空字符串。再确认 Base URL 是https://taotoken.net/api,别多写或少写路径。Key 可以在 API Keys 页面重新生成:
https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewritelocal proxy failed或连接超时:通常是 Base URL 写错或网络出口有问题。检查配置里有没有多余的斜杠、有没有把/api写成/v1。TaoToken 的地址就是https://taotoken.net/api,原样填。
reading choices相关报错,比如Cannot read field "choices" because response is null:一般是响应体不是预期的 OpenAI 格式,可能是模型 ID 写错导致返回了错误结构,或者请求被中间层拦截返回了 HTML。先单独用 curl 打一次确认返回结构:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}'如果 curl 正常但 Spring 里报错,检查 Spring AI 的版本和 OpenAI 兼容配置是否匹配。
OAuth相关报错:如果你用的是 Claude Code 这类工具接进来,可能会碰到 OAuth 认证流程的问题。Claude Code 接入的配置在文档里有说明:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewriteClaude Code 的接入配置三件套同样是 Base URL + Key + Model ID,Base URL 填https://taotoken.net/api,Key 用 API Keys 页面生成的,Model ID 按实际填。如果 OAuth 流程卡住,先确认是不是把 API Key 和 OAuth 两种认证方式混用了。
技能加载侧的报错:UnsupportedOperationException出现在registry.reload()时,说明你用的SkillRegistry实现不支持重载。ClasspathSkillRegistry通常不支持,换FileSystemSkillRegistry。如果 hook 捕获了这个异常并打了 debug 日志,把日志级别调到 DEBUG 就能看到。
技能没被加载:检查classpathPath或projectSkillsDirectory是否指向了包含技能子目录的父目录,而不是某个技能目录本身。SKILL.md的 front matter 格式错了也会导致解析失败,name和description必须存在。
热更新不生效:确认autoReload(true)开了,且用的是FileSystemSkillRegistry。还要注意reload()只在同一次 Agent 执行的第一次推理时触发,如果你在同一个agent.call()里改文件,本次执行不会重载,得等下一次call()。
6. 热更新触发与回滚的验证步骤
热更新的验证要能观察到“改前”和“改后”的差异,还要能回滚。我用一个test-reload技能来演示。
先准备技能文件skills/test-reload/SKILL.md,初始内容写个明显的标记:
--- name: test-reload description: Use this skill when the user asks to read the test-reload skill content. --- # 测试重载技能 当前版本:V1 内容:这是第一版内容。用FileSystemSkillRegistry加autoReload构建 Agent:
SkillRegistry registry = FileSystemSkillRegistry.builder() .projectSkillsDirectory("path/to/skills/") .build(); SkillsAgentHook hook = SkillsAgentHook.builder() .skillRegistry(registry) .autoReload(true) .build(); ReactAgent agent = ReactAgent.builder() .name("reload-agent") .model(chatModel) .saver(new MemorySaver()) .hooks(List.of(hook)) .build();第一次调用,让模型读技能内容:
AssistantMessage result1 = agent.call("请调用 read_skill 读取 test-reload 技能的内容,并告诉我里面写了什么。"); System.out.println("Agent 回复: " + result1.getText());预期回复里出现“当前版本:V1”。然后手动改文件,把 V1 改成 V2:
当前版本:V2 内容:这是第二版内容,用于验证热更新。保存后等几秒,再发第二次调用:
AssistantMessage result2 = agent.call("请再次读取 test-reload 技能的内容。"); System.out.println("Agent 回复: " + result2.getText());如果热更新生效,第二次回复里应该是“当前版本:V2”。如果还是 V1,检查autoReload是否真的开了、reload()是否被调用(日志里搜Skills reloaded)、以及文件路径是否指向了正确的目录。
回滚验证:把文件改回 V1,再调一次,确认回复回到 V1。这一步能证明重载是双向的,不是只加载一次新内容就锁死。
有个细节要注意:reload()只在同一次 Agent 执行的第一次推理时执行。如果你在agent.call()内部改文件,本次执行不会重载,因为第一次推理已经过了。所以验证时要分两次call(),中间改文件。这个设计是为了保证同一次执行内模型看到的内容一致,避免前后矛盾。
另外,MemorySaver会保留会话历史。如果你在同一个会话里连续调用,模型可能从历史里读到旧内容而不去重新read_skill。验证热更新时建议每次用新的会话,或者清掉 saver 里的历史,确保模型真的重新读了技能文件。
跑通这套之后,你可以把技能目录挂到 CI 流程里,改完技能文件自动触发一次验证调用,确认新内容生效。长期跑编码类 Agent 的话,配合 Coding Plan 会更顺:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite模型对话页面可以用来快速对比不同模型对同一技能的理解差异:
https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite最后留一个我踩过的坑:groupedTools的 key 和SKILL.md的name不一致时,工具不会报错,只是永远不披露,模型会一直说“我没有这个工具”。排查时先打印registry.listAll()确认技能名,再核对groupedTools的 key,两边对上才行。