Nacos3 AI 实战系列(2)· Spring AI Alibaba × Skill 热加载
上一篇我们让"对话"热更新了,这一篇让"技能"也能热更新。
一、凌晨两点的续集
上一篇发出去之后,评论区很多同学问了一个问题:
"Prompt 是能热更新了,但产品说想给 Agent 加个 PDF 解析工具,是不是也得发版?"
我当时回了四个字:是的,逃不掉。
直到凌晨两点,我又一次被叫醒。需求很"简单":让 Agent 拥有"读取 PDF 表格并按指定格式输出"的能力。
传统流程:
- 在 SDK 里加一个
PdfParseTool类 - 写提示词模板、调工具参数、跑测试
- 改
pom.xml版本号 → 提 PR → CI → 灰度 → 全量 - 一晚上过去了
第二天我盯着代码,脑子里浮现一个画面:
Agent 的"工具集"本质上就是一套"插件包",凭什么它不能像
npm install一样热装?
上一篇我们让"菜谱"(Prompt)能热改了。这一篇,我们要让"整套厨具"(Skill)也能热装。改完直接下班,关灯睡觉。
二、从 Prompt 到 Skill:能力跃迁
在动手前,先用一张图说清楚 Prompt 和 Skill 的区别。
Prompt 是"菜谱" :告诉模型"做什么菜"。 Skill 是"整套厨具":直接给 Agent"装备"。
| 维度 | Prompt | Skill |
|---|---|---|
| 内容 | 一段文本模板 | ZIP 包(SKILL.md + 工具 + 知识) |
| 告诉模型什么 | 怎么说、说什么 | 怎么做 、能用什么 |
| 典型用途 | 改语气、加约束 | 加 PDF 解析、SQL 执行等"动手能力" |
| 存储 | 内存 Map | 远端 Nacos + 本地磁盘 |
方案对比
光说 Skill 好不够,我们看看市面上都有什么方案:
| 方案 | 内容形式 | 存储方式 | 集成方式 | 热更新 |
|---|---|---|---|---|
| 写死在代码 | 工具类 | 编译进 JAR | @Resource |
❌ |
| 外部 jar 包 | Java 类 | Maven 仓库 | 改依赖 | ❌ |
| 本地文件 + 定时扫描 | JSON/YAML | 服务器本地 | 自造 loader | ⚠️ 分钟级 |
| Nacos3 Skill | ZIP 包 | 远端托管 | 官方 Registry 抽象 | ✅ 秒级 |
说了这么多抽象优点,不如直接看 Nacos3 控制台长啥样:

这就是 Nacos3 自带的 Skill 管理界面:上传 ZIP 包、版本号、灰度按钮、变更历史一目了然,Agent 的"装备库"在这里是一等公民。
这就是 Nacos3 的野心:它想把 Agent 的整个技能栈 都纳入配置中心管理,跟上一篇的 Prompt 一个套路。
为什么是 Nacos3 Skill? 三个理由(跟上一篇呼应):
- 零额外基础设施:上一篇 Nacos 已经在跑了,复用一套
- Skill 是"一等公民":跟 Prompt 平级,控制台直接管 ZIP 包
- 官方契约 :Spring AI Alibaba 的
AbstractSkillRegistry强依赖 Nacos AI 协议,换了别的配置中心不兼容
三、架构设计:3 层拆解
老规矩,先画图再撸代码。这一篇比 Prompt 简单一点(少一个动态代理层),3 层架构:
光看架构不够直观,下面这张时序图把"启动 → 调用 → 热更新"三个阶段画在同一张图里:
看【热更新阶段】那块 :运营上传新版本 ZIP → Nacos 长轮询推到 Sync → 流式下载 + 解压 → 刷新 Registry → Agent 下次调用自动用上新版本。整个过程 Agent 0 感知、0 介入。
4 个关键设计决策
① 为什么 Skill 用 ZIP 而不是 JSON/YAML?
Skill 不是一段文本,它是一个"包":
SKILL.md(核心提示词)tools/*.py(工具脚本)knowledge/(知识库文件)resources/(二进制资源、模型文件)
单文件装不下,ZIP 才是容器标准。天然支持目录、二进制、跨平台。
② 为什么本地落盘而不是纯内存?
这是一道架构师面试题的标准答案。
- 体积大:单个 Skill 可能几 MB 到几十 MB(特别带模型的),全放内存 GC 压力爆炸
- 可观测:磁盘上能看到文件,运维/SRE 排查问题方便
- 可扩展 :外部脚本能直接读
/data/skills/pdf-parser/latest/script.py
架构师思维:能用磁盘解决的事别用内存。内存是用来换速度的,不是用来堆数据的。
③ 为什么继承 AbstractSkillRegistry?
Spring AI Alibaba 的 SkillsAgentHook / SkillAgent 强契约 ,必须实现 AbstractSkillRegistry 那一套接口。
不继承就用不了官方 Agent ------ 这是绕不开的硬约束。
我们的 NacosSkillRegistry 继承它,免费拿到 get() / listAll() / loadFullContent() 这些方法,不用自己造。
④ 为什么用 {skillName}/{version}/ 目录结构?
bash
/data/skills/
├── pdf-parser/
│ ├── 1.0.0/ ← 旧版本,保留
│ ├── 1.1.0/ ← 历史版本
│ └── latest/ ← 软链 or 实际目录,指向当前生效版本
latest 是约定,不是特殊处理。历史版本自然保留 → 版本回滚、A/B Test 天然支持。
这种"按目录分版本"的设计思路,跟 Linux 装软件用
/usr/local/{name}/{version}一个路数,老而弥坚。
四、代码实战:从 0 到 1
完整代码在 scm-ai-framework/skill(地址脱敏)。这里只讲骨架和为什么。
4.1 引入依赖
跟上一篇完全一样,纯净版的坑已经踩过,这里不重复:
xml
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-api</artifactId>
<version>${nacos3.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-common</artifactId>
<version>${nacos3.version}</version>
</dependency>
4.2 配置文件
@ConfigurationProperties 全量配置,业务方零感知:
yaml
scm:
ai:
skills:
enabled: true
server-addr: 127.0.0.1:8848
namespace: public
root-dir: /data/skills
preload: false
list:
- pdf-parser
- sql-executor
- code-reviewer
几个坑提前说:
root-dir留空会自动跨平台适配:Windows 走${java.io.tmpdir}/skills,Linux/Docker 走/data/skills- 容器化部署必须把
/data/skills挂 PV,否则容器重启数据丢 list里的 Skill 名必须在 Nacos 控制台预先创建好
4.3 NacosSkillRegistry:内存 + 磁盘双层
Registry 负责对外暴露 Skill 列表,对内维护"内存 + 磁盘"双层数据:
java
@Slf4j
public class NacosSkillRegistry extends AbstractSkillRegistry {
@Getter
private String skillsRootDir;
@Builder.Default
private SystemPromptTemplate systemPromptTemplate = SystemPromptTemplate.builder()
.template(DEFAULT_SYSTEM_PROMPT_TEMPLATE)
.build();
// WHY: 版本号缓存,单独维护是为了 O(1) 查询
private final Map<String, String> versionCache = new ConcurrentHashMap<>();
// CRITICAL: 父类的 skills 字段是 volatile + 同步控制,写入必须走 updateSkill
public void updateSkill(SkillMetadata metadata) {
String version = extractVersionFromPath(metadata.getSkillPath());
if (version != null) {
versionCache.put(metadata.getName(), version);
}
skills.put(metadata.getName(), metadata);
log.info("【NacosSkillRegistry】更新 Skill: {}, 版本: {}", metadata.getName(), version);
}
// WHY: 从路径偷版本号 ------ 路径 /data/skills/pdf-parser/latest 末段就是版本
private String extractVersionFromPath(String skillPath) {
if (skillPath == null || skillPath.isEmpty()) return null;
try {
return Paths.get(skillPath).getFileName().toString();
} catch (Exception e) {
return null;
}
}
@Override
protected void loadSkillsToRegistry() {
if (skillsRootDir == null || skillsRootDir.isEmpty()) {
skillsRootDir = getDefaultSkillsRootDir().toString();
}
versionCache.clear();
Path rootPath = Paths.get(skillsRootDir);
if (!Files.exists(rootPath)) {
log.info("技能根目录不存在: {}", skillsRootDir);
return;
}
// CRITICAL: 扫描目录树 {skillName}/{version}/SKILL.md
try (var skillDirs = Files.list(rootPath)) {
skillDirs.filter(Files::isDirectory).forEach(skillDir -> {
String skillName = skillDir.getFileName().toString();
try (var versionDirs = Files.list(skillDir)) {
versionDirs.filter(Files::isDirectory).forEach(versionDir -> {
var skillMdPath = versionDir.resolve("SKILL.md");
String description = readDescription(skillMdPath);
SkillMetadata metadata = SkillMetadata.builder()
.name(skillName)
.description(description)
.skillPath(versionDir.toString())
.source("nacos")
.build();
skills.put(skillName, metadata);
versionCache.put(skillName, versionDir.getFileName().toString());
});
}
});
}
}
}
架构师要点:
- 父类
skills字段是volatile+ 同步的 ,我们只能在updateSkill里写,不能直接put extractVersionFromPath是个小巧思:路径末段天然就是版本号,省掉单独存版本字段description从 SKILL.md 的 frontmatter 里抠出来 (YAML 头部---之间的内容),用于 Agent 决策
4.4 NacosSkillSync:下载 + 订阅
这是整套方案的发动机------长轮询 + ZIP 下载 + 流式解压三连击:
java
@Slf4j
public class NacosSkillSync {
private final NacosSkillSyncProperties properties;
private final NacosSkillRegistry registryWrapper;
private AiService aiService;
// WHY: 幂等控制,同一个 Skill 不会被重复订阅
private final Map<String, String> subscribedSkills = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
try {
this.aiService = AiFactory.createAiService(buildProperties());
// 遍历配置,逐个订阅
if (hasSkillList()) {
for (String skillName : properties.getList()) {
subscribeSkill(skillName);
}
}
registryWrapper.reload();
} catch (NacosException e) {
throw new RuntimeException(e);
}
}
private java.util.Properties buildProperties() {
java.util.Properties props = new java.util.Properties();
props.put(PropertyKeyConst.SERVER_ADDR, properties.getServerAddr());
// CRITICAL: 强制 AI 模块降级使用 HTTP 传输,当前 skill 只支持 http 协议
// WHY: Nacos 3.2.2 默认走 gRPC,但 Skill 服务只支持 HTTP,不设这个会报协议错
props.setProperty(AiConstants.AI_TRANSPORT_MODE, "http");
if (StrUtil.isNotBlank(properties.getNamespace())) {
props.put(PropertyKeyConst.NAMESPACE, properties.getNamespace());
}
return props;
}
public synchronized void subscribeSkill(String skillName) {
if (subscribedSkills.containsKey(skillName)) return;
try {
aiService.subscribeSkill(skillName, null, null, new AbstractNacosSkillListener() {
@Override
public void onEvent(NacosSkillEvent event) {
log.info("Skill 变更通知: name={}, version={}",
event.getSkillName(), event.getResolvedVersion());
handleSkillChanged(event.getSkillName(), event.getResolvedVersion());
}
});
subscribedSkills.put(skillName, "subscribed");
} catch (Exception e) {
log.error("订阅 Skill 失败: {}", skillName, e);
}
}
private void handleSkillChanged(String skillName, String newVersion) {
downloadAndExtractSkill(skillName, newVersion);
registryWrapper.reload();
}
// CRITICAL: 流式下载 + 解压,内存峰值只跟单个文件有关
public void downloadAndExtractSkill(String skillName, String version) {
try {
byte[] zipContent = aiService.downloadSkillZip(skillName);
if (zipContent == null || zipContent.length == 0) return;
Path extractPath = extractZip(zipContent, skillName, version);
refreshSingleSkillCache(skillName, extractPath);
} catch (Exception e) {
log.error("下载 Skill 失败: {}", skillName, e);
}
}
private Path extractZip(byte[] zipContent, String skillName, String version) throws IOException {
Path rootPath = registryWrapper.getRootPath();
Path skillDir = rootPath.resolve(skillName);
// WHY: 约定 "latest" 目录表示当前生效版本,覆盖前先清空
Path extractPath = StrUtil.isBlank(version)
? skillDir.resolve("latest")
: skillDir.resolve(version);
// CRITICAL: 用 Files.walk 倒序删除(先删文件再删目录)
if (Files.exists(extractPath)) {
Files.walk(extractPath)
.sorted(Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) {}
});
}
Files.createDirectories(extractPath);
// 流式解压,不落中间文件
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipContent))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String entryName = entry.getName();
// 去掉 ZIP 里多余的顶层目录(如 pdf-parser/)
if (entryName.startsWith(skillName + "/")) {
entryName = entryName.substring(skillName.length() + 1);
}
if (entryName.isEmpty()) {
zis.closeEntry();
continue;
}
Path targetPath = extractPath.resolve(entryName);
if (entry.isDirectory()) {
Files.createDirectories(targetPath);
} else {
Files.createDirectories(targetPath.getParent());
Files.copy(zis, targetPath);
}
zis.closeEntry();
}
}
return extractPath;
}
}
几个架构师要点:
synchronized+subscribedSkills双重防重:启动时多 Bean 并发初始化,加锁保证幂等- 流式解压 :
ZipInputStream一边读一边写,内存峰值只跟单个文件大小有关,不会把整个 ZIP 装内存 Files.walk倒序删除 :先删文件再删目录,否则会抛DirectoryNotEmptyExceptionHTTP_TRANSPORT_MODE=http是救命的一行(生产实践会细讲)
解压完的目录长这样:

SKILL.md 是 Skill 的"灵魂文件"------Agent 通过读它来决定什么时候用这个 Skill 、输入输出约定是什么。
4.5 三个组件的装配
Registry、Sync、Properties 是三个独立的组件,需要一个 @Configuration 把它们串起来:
java
@Configuration
@EnableConfigurationProperties(NacosSkillSyncProperties.class)
public class NacosSkillAutoConfiguration {
@Bean
public NacosSkillRegistry skillRegistry(NacosSkillSyncProperties properties) {
NacosSkillRegistry registry = NacosSkillRegistry.builder()
.skillsRootDir(properties.getRootDir())
.build();
registry.reload();
return registry;
}
@Bean
public NacosSkillSync skillSync(NacosSkillSyncProperties properties,
NacosSkillRegistry registry) {
return new NacosSkillSync(properties, registry);
}
}
跟上一篇的差异:
- 上一篇用
BeanPostProcessor无侵入增强 - 这一篇用显式
@Configuration------因为 Skill 的初始化是命令式的(要主动 subscribe、主动 download),不像 Prompt 那种被动替换
4.6 在 Spring AI Alibaba Agent 中使用 Skill
这是本篇的杀手锏------光把 Skill 同步到本地没用,得让 Agent 真的能用上。
① AbstractSkillRegistry 是谁?
Spring AI Alibaba 提供的官方抽象类,定义了 Agent 调用 Skill 的标准契约:
get(name)- 拿单个 SkilllistAll()- 列所有 SkillreadSkillContent(name)- 读 SKILL.md 全文getSkillLoadInstructions()- 生成给 Agent 的提示词片段
我们的 NacosSkillRegistry 继承它,自动获得兼容性------任何 Spring AI Alibaba 的 Agent 都能直接用。
② Agent 如何"看见" Skill
Spring AI Alibaba 提供了 SkillsAgentHook,一行代码集成:
java
@Component
public class PdfAnalystAgent {
@Autowired
private ChatClient chatClient;
@Autowired
private NacosSkillRegistry skillRegistry;
public String analyze(String pdfPath, String userQuery) {
// SkillsAgentHook 会在调用前自动注入 Skill 列表到 system prompt
return chatClient.prompt()
.system("你是一个 PDF 文档分析助手。")
.user("PDF 路径: " + pdfPath + "\n问题: " + userQuery)
.advisors(new SkillsAgentHook(skillRegistry))
.call()
.content();
}
}
发生了什么?
SkillsAgentHook调skillRegistry.getSkillLoadInstructions()拿到 Skill 列表- 拼成
- Skill: pdf-parser - 解析 PDF 表格...塞进 system prompt - Agent 看到这些 Skill,根据用户输入自动选择用哪个
③ 自定义 system prompt 模板
我们覆盖了 getSystemPromptTemplate(),可以自定义 Skill 列表的展示格式:
java
@Override
public SystemPromptTemplate getSystemPromptTemplate() {
return SystemPromptTemplate.builder()
.template("""
你可以使用以下技能:
{{#each skills}}
- 【{{name}} v{{version}}】{{description}}
调用方式: skill.{{name}}(input)
{{/each}}
""")
.build();
}
高阶玩法:按用户角色过滤(普通用户看不到 admin 类 Skill)、按场景动态显示(金融场景才显示合规 Skill)。
最终效果 :产品新加一个 pdf-parser Skill → 上传 Nacos → Agent 下次调用自动具备 PDF 解析能力。零代码改动、零发版。
五、生产实践:架构师必看的 6 个细节
写完代码只是开始,下面这些是上线前要想的。
① Nacos 3.2.2 的 HTTP 传输坑 ⚠️(重点讲)
这是我们生产环境真金白银踩出来的坑,必须单独说。
现象
启动后 subscribeSkill() 报:
makefile
NacosException: protocol not supported
或者更隐蔽的:控制台显示订阅成功,但永远收不到变更通知。
原因
Nacos3 把 AI 模块的传输层抽象了出来,默认走 gRPC (为了性能)。但当前版本的 Skill 服务实现只支持 HTTP(Nacos 团队还在迁移),两者对不上。
解决
一行配置(NacosSkillSync.java:125):
java
// WHY: 强制 AI 模块降级使用 HTTP 传输,当前 skill 只支持 http 协议
props.setProperty(AiConstants.AI_TRANSPORT_MODE, "http");
教训
升级 Nacos 时先在测试环境验证这个参数是否还兼容。3.2.x 升 3.3.x 时,HTTP 模式可能会被移除。
我们已经在 Nacos 社区提了 issue,等官方 Skill 服务支持 gRPC 后,这行代码就可以删了。
② 磁盘清理策略
{skillName}/{version}/ 目录结构有个副作用:旧版本永远不会被自动删除。
上线 3 个月后,/data/skills/ 涨到 50GB。
解决方案:
java
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨 3 点清理
public void cleanOldVersions() {
// 保留 latest + 最近 2 个版本,删其他的
for (String skillName : registryWrapper.getAllVersions().keySet()) {
cleanOldVersionsForSkill(skillName, 2);
}
}
③ ZIP 路径穿越风险
entry.getName() 没过滤 ../ 的话,恶意 ZIP 可以把文件写到目录外:
bash
SKILL.md
../../../etc/passwd ← 攻击载荷
修复方案:
java
Path targetPath = extractPath.resolve(entryName).normalize();
if (!targetPath.startsWith(extractPath)) {
throw new IOException("Bad zip entry: " + entryName);
}
这是个老生常谈的洞,但真有不少生产事故是它引起的。
④ 跨平台路径与容器化
| 环境 | 默认路径 | 建议 |
|---|---|---|
| Windows 开发 | ${java.io.tmpdir}/skills |
默认 OK |
| Linux 测试 | /data/skills |
没问题 |
| Docker 容器 | /data/skills ⚠️ |
必须挂 PV |
| K8s | /data/skills ⚠️ |
用 PVC 或者改用 /tmp/skills 接受重启丢失 |
⑤ SKILL.md frontmatter 解析
现在的 extractDescription() 是用 indexOf("---") 硬找的------脆且丑,YAML 里有特殊字符就崩。
建议换成 SnakeYAML:
java
Yaml yaml = new Yaml();
Map<String, Object> frontmatter = yaml.load(content);
return (String) frontmatter.get("description");
⑥ 监控告警
| 指标 | 告警阈值 |
|---|---|
/data/skills 磁盘占用 |
> 80% |
| ZIP 下载失败率 | > 5% |
| ZIP 解压失败率 | > 1% |
| 监听器心跳超时 | > 30s |
subscribedSkills.size 与配置不一致 |
立即告警 |
六、写在最后
回到开头那个凌晨两点的故事。
现在产品再加需求,流程变成了:
上传 Skill ZIP → Nacos 控制台保存 → 1 秒后全网 Agent 拥有新技能 → 关灯睡觉 😴
双子篇回顾:
- 上一篇:让"菜谱"(Prompt)热更新
- 这一篇:让"厨具"(Skill)热装载
一个架构师最大的价值,不是写最酷的代码,而是让团队不用再为一些无聊的事熬夜。
关注不迷路 ✨
如果觉得有帮助,欢迎点赞、收藏、关注三连 👍
有任何问题欢迎评论区交流,下期见 👋