夜雨聆风学习资料网

ARTICLE · 1113452

【开源】拆完 6 万行源码,我看到了企业级 Agent 技能注册中心的正确姿势:SkillHub 深度解读

【开源】拆完 6 万行源码,我看到了企业级 Agent 技能注册中心的正确姿势:SkillHub 深度解读

技能(Skill)已经是 Agent 事实上的"应用单元",但技能散落在每个人的 .claude/skills、.codex/skills 里,没有版本、没有审核、更没有审计。讯飞开源的 SkillHub 给出的答案是:像管理制品一样管理技能。本文基于源码,拆开它的发布流水线、搜索实现、认证设计和 CLI 生态。

规模:后端 1291 个 Java 文件 / 约 6.2 万行 main 代码,52 个 Controller,65 个 Flyway 迁移;前端 464 个源文件;CLI 71 个 TS 模块;外加一个 Python 安全扫描服务。

一、为什么还需要一个"技能注册中心"

Agent Skill 的格式已经被 Anthropic 的 anthropics/skills 广泛推广:一个带 frontmatter 的 SKILL.md,加上 references/、scripts/、assets/ 等配套文件。格式统一是好事,但分发是另一回事——在企业里,"把好用的技能发给全组"目前的做法通常是:发个压缩包、丢进群里、各自解压到自己的目录。于是:

  • 版本不可追溯,出问题不知道谁改了什么;
  • 权限不可控,内部提示词、内部接口文档可能被随手带出去;
  • 没法发现,好的技能沉没在聊天记录里。

SkillHub 的定位是基础设施层:内容仍然可以来自开放集合,而企业自己负责托管、版本、审核、访问控制和分发。

它和 anthropics/skills 是互补而非竞争关系——因为吃的是同一套 SKILL.md 格式,开放仓库里的技能可以直接 publish 进私有注册中心,不改一行:

gitclonehttps://github.com/anthropics/skills exportSKILLHUB_REGISTRY=https://skillhub.your-company.com exportSKILLHUB_TOKEN=YOUR_API_TOKEN npx@astron-team/skillhub@latestpublish./skills/<分类>/<技能名> 

(转发布时记得看每个技能自己的 LICENSE,文档类技能是 source-available 而非开源。)

二、一个仓库,四种交付物

SkillHub 是个 monorepo,但语言横跨四种,每种都对应一个独立可交付的东西:

目录
语言/栈
是什么
server/
Java 21 + Spring Boot 3.2.3
模块化单体,7 个 Maven 模块
web/
TypeScript + React 19 + Vite
控制台前端,TanStack 全家桶
cli/
TypeScript + Bun 构建
@astron-team/skillhub
,单文件 bundle
scanner/
Python (FastAPI)
安全扫描服务,封装 Cisco skill-scanner

先看整体架构,再逐层拆:

三、整体架构设计思路:五个被论证过的取舍

看源码最容易犯的毛病是"数类":这里有几个包、那里用了什么框架。真正值得学的是设计思路——面对同一个问题,它选了什么、又主动放弃了什么。把 docs/01-system-architecture.md 和代码对照着读,可以提炼出 SkillHub 的五个核心取舍:

取舍一:模块化单体优先,而不是微服务。 架构文档开篇就是一句自我约束:"单体优先、模块化单体,一期不拆微服务"。这是清醒的——技能注册中心是万级数据、52 个接口的规模,上微服务只会白白换来分布式事务、跨服务调用链和三套部署清单。但它也没有把单体写成一坨:7 个 Maven 模块依赖单向无环,模块之间只通过接口和领域事件对话,把"将来真要拆"的那条缝提前留好了。skillhub-search、skillhub-notification 都是可以整块搬走的单元。

取舍二:DDD 分层 + 依赖倒置,依赖只进不出。 四层职责在文档 11 节的 "Repository / Query Boundary 约定" 里写得非常细:领域层定义 SkillRepository 这类端口,基础设施层的 JPA 适配器加 @Primary 来实现它,严禁 domain → infra 反向依赖;查询侧还有专门的 App Query Repository 约定和"四步选择规则"(什么时候用 domain repository、什么时候用 app 查询仓储、什么时候允许直连持久层)。这不是教条,它直接决定了"换存储不动业务代码"是否真的成立——ObjectStorageService 的 local/s3 双实现切换就是靠这个活下来的。

取舍三:局部 CQRS,而不是引入一套 CQRS 框架。 写侧老老实实用 JPA;读侧按需优化三处:搜索直连 SQL(代码注释自认是 "deliberate direct-persistence exception")、SkillEngagementProjectionService 用 JdbcTemplate 反规范化维护下载量/评分读模型、V28/V29 用部分索引优化排序。README 把这写成 "CQRS 模式" 和 "事件溯源",但全仓没有命令/查询分层框架,也没有 Event Store——实际是"读模型优化 + 领域事件 + 审计表"这个更务实的中间态。对这个规模的系统,这恰恰是对的选择:完整事件溯源要付出建表、重放、快照三份成本,换来的收益这里用不上。

取舍四:领域事件挂副作用,AFTER_COMMIT + Outbox 保可靠。 20 种领域事件、30+ 处 @TransactionalEventListener,通知、搜索索引、评分、个人命名空间开通全挂在事件上——事件在这里是"副作用挂载点",不是事件溯源。两个细节见功力:索引更新用 AFTER_COMMIT + @Async,避免"索引写了、事务却回滚";跨进程的扫描任务不直接发消息,而是先落 scan_task_outbox 表再由调度器抢占投递,配租约、退避、attempt 比对——业务成功但任务丢失、以及迟到结果覆盖新尝试,这两种事故都被堵死了。

取舍五:契约先行,所有边界可插拔。 后端 springdoc 吐 OpenAPI,前端一条命令生成 1.5 万行类型,CI 里 check-openapi-generated.sh 校验契约同步;更进一步,仓库用 openspec/ 做 spec-driven 变更管理,每个功能变更是 proposal.md + design.md + tasks.md + specs/ 四件套,还有 oss-01/oss-02 两份"契约冻结"文档。同时所有外部依赖都收在开关后面:存储(local/s3)、扫描器(enabled)、搜索引擎、OAuth provider 矩阵、Mock 认证——换掉任何一环,业务代码一行不动。

把五个取舍放在一起,其实是一张表:

设计问题
它的选择
主动放弃的
换来了什么
单体 vs 微服务
模块化单体
独立伸缩
部署与事务的简单
搜索引擎
PG 全文 + 可选语义重排
ES 的调优天花板
零新增组件
认证
Session + 不透明令牌
无状态扩展的便利
凭证可即时吊销
一致性
AFTER_COMMIT 事件 + Outbox
强实时
副作用不丢、不乱序
CQRS / 溯源
局部读模型 + 审计表
完整事件溯源
可维护性

架构的价值不在于画得多大,而在于每个"不做"都有理由。 这是这篇源码里最值得带走的思维方式。

四、后端:教科书式的模块化单体

设计思路落到代码上,就是这 7 个 Maven 模块——依赖方向单向、无环,前面说的"依赖只进不出"在这里被严格执行:

几个值得注意的设计:

1. storage 是叶子模块。skillhub-storage 不依赖任何内部模块,只依赖 Spring Boot 和 AWS SDK,对外只暴露一个 7 个方法的接口:

publicinterfaceObjectStorageService{voidputObject(Stringkey,InputStreamdata,longsize,StringcontentType);InputStreamgetObject(Stringkey);// ... delete / exists / metadata / presignedUrl}

两个实现靠一个开关切换:本地文件系统(skillhub.storage.provider=local,缺省值)和 S3(=s3)。本地实现还有个很讲究的细节——先写同级临时文件,再 ATOMIC_MOVE 覆盖,避免读到写了一半的包(LocalFileStorageService.java)。

2. 依赖倒置不是口号。 domain 只声明 SkillRepository 接口,skillhub-infra 里的 JpaSkillRepositoryAdapter 加 @Primary 提供实现,注释直白地写着 "Primary JPA-backed adapter that fulfills the domain-level SkillRepository contract"。禁止 domain → infra 的反向依赖,这在多模块项目里是容易烂掉的地方,这里守住了。

3. 两套角色模型。 平台级 4 个角色(SUPER_ADMIN / SKILL_ADMIN / USER_ADMIN / AUDITOR)配 8 个权限点,走 RbacService 联表查询;命名空间级是 Owner / Admin / Member 三级。命名空间里的 skill 唯一键也不是常见的 (namespace, slug),而是 (namespace, slug, owner_id) 三元组——V13 迁移专门为此改过约束。

五、发布:一个 zip 要闯七道关

技能发布是这个系统的核心链路,也是安全设计最密集的地方。从 CLI 敲下 skillhub publish 到技能上架,要经过这些步骤:

拆开看几处最见功力的实现:

解包与白名单。SkillPackageArchiveExtractor 解压后会自动剥离单层根目录——用户 tar 打包时多包了一层文件夹,结构不会被污染。接着 SkillPackagePolicy 卡死硬约束:扩展名白名单、≤500 文件(运行时配置实际收紧到 100)、单文件 ≤10MB、整包 ≤100MB,路径深度和长度都有限制,.git、_build 之类目录直接排除。

magic byte 校验。SkillPackageValidator 不只看后缀,还会比对文件头与扩展名——把 .exe 改名成 .md 这条绕过白名单的路是堵死的。

限流。 发布接口的 @RateLimit(category = "publish", authenticated = 10, anonymous = 0) 是全站最严的一档(搜索是 60/20),每分钟 10 次。

安全扫描与状态机。 版本状态是 8 态枚举:DRAFT / SCANNING / SCAN_FAILED / UPLOADED / PENDING_REVIEW / PUBLISHED / REJECTED / YANKED。触发扫描时版本被切成 SCANNING,审核员此时审批会被 ReviewService 直接拒绝——扫描没过,谁也不能上架。

扫描任务的投递用了事务性 Outbox:任务先落 scan_task_outbox 表(和业务同事务),再由 ScanTaskOutboxDispatcher 按 5 秒间隔抢占(租约 2 分钟)、退避(上限 5 分钟)、最多 10 次,投给 Redis Stream,最后由扫描器消费。回调侧还有一段非常"老工程师"的注释:

"A late result is retained on its own audit round but cannot complete a newer attempt."

翻译过来:迟到的扫描结果只记审计,不许覆盖新一次尝试。异步系统里这种乱序防护,是最容易漏、也最能区分"能跑"和"可靠"的地方。

扫描器本身(scanner/skillhub_scanner_app.py,204 行)是个独立加固范本:并发闸门默认 1,超限返回 503 + Retry-After: 30;硬超时 930 秒后 os._exit(124) 让编排拉起新进程;扫描响应里的私钥、Bearer、URL 里的 token 全部正则脱敏成 <redacted>。

审计。audit_log 表从 V1 就存在,AuditLogService.record() 在应用层有 50+ 处调用,隐藏、归档、撤回等治理动作一个不落。

六、搜索:为什么不用 Elasticsearch

很多团队第一反应是"搜索上 ES"。SkillHub 的选择是 PostgreSQL 全文检索,理由很朴素:技能是万级数据,不是亿级。

写侧,V2 迁移就建好了四段权重的 tsvector:

ADDCOLUMNsearch_vectortsvectorsetweight(to_tsvector('simple',coalesce(title,'')),'A')||setweight(to_tsvector('simple',coalesce(summary,'')),'B')||setweight(to_tsvector('simple',coalesce(keywords,'')),'B')||setweight(to_tsvector('simple',coalesce(search_text,'')),'C');CREATEINDEXidx_search_vectorONskill_search_documentUSINGGIN(search_vector);

标题 > 摘要/关键词 > 正文,加 GIN 索引,一条 SQL 搞定。中文召回靠 jieba 分词把 frontmatter、标签、合规元数据富化进 search_text。索引更新挂在 @TransactionalEventListener(AFTER_COMMIT) + @Async 上——事务提交之后再写索引,避免索引先于数据落库的竞态。

读侧的实现在 PostgresFullTextQueryService,它故意绕开 JPA 拼 SQL,类注释把这件事说得很有意思:

"This class is a deliberate direct-persistence exception."(这是一个有意为之的直连持久化例外)

防滥用的细节也做了:查询最多拆 8 个词,2 字以内走前缀匹配,参数化 :tsQuery 防注入,ts_rank_cd 打分 + updated_at DESC, skill_id DESC 保证分页稳定。可选的语义重排更节制——不调外部模型,用 64 维哈希向量(三元组权重 0.35)和文本分合并,离线可用、零外部依赖。

七、认证:这个项目里没有 JWT

这一点值得单独说,因为它和大部分人的预期相反。

三条通道,没有一条是 JWT:

  1. 浏览器会话:密码用 BCryptPasswordEncoder(12) 校验,会话是 Spring Session 存 Redis,Cookie 携带;过期统一返回 401 Session expired。CSRF 走 XSRF-TOKEN Cookie → X-XSRF-TOKEN 请求头。
  2. CLI 令牌:sk_ 前缀 + 32 字节随机的不透明令牌,服务端只存 SHA-256 哈希和前 8 位前缀用于展示,验证是哈希查表而不是签名校验。创建时一次性展示原文,rotate 同名先吊销再新建。
  3. 本地 Mock:X-Mock-User-Id 请求头切换用户,但 MockAuthFilter 挂着双开关——既要 local profile,又要 skillhub.auth.mock.enabled=true(默认 false)。生产环境想误开都难。

为什么不用 JWT?不透明令牌可以服务端随时吊销,配合作用域(scope)和过期时间,对"给 CI/脚本发一个可回收的凭证"这个场景,比自包含的 JWT 更合适;会话同理,Redis 里一删就失效。这是一个务实的取舍,而不是不懂 JWT。

顺带一个数据准确性提醒:中文 README 的环境变量示例里写了 AUTH_JWT_SECRET,但在全仓 Java/yml/pom 里零命中,也没有 oauth2-resource-server 依赖——照抄这个配置不会有任何效果。读文档时留意这类"文档先行于实现"的偏差,是读开源项目的日常。

八、CLI:一条命令打通 17 个 Agent

技能的价值最终要落到"装进哪个 Agent"。SkillHub CLI 的安装抽象做得很干净:

npminstall-g@astron-team/skillhub skillhubsearchpdf skillhubinstallpdf-parser--agentcodex# 指定 Agent skillhubinstallpdf-parser--dir~/.hermes/skills# 或者任意目录

目标解析三选一且互斥:--dir 显式路径最优先;--agent 支持内置的 17 个 profile(codex、claude-code、cursor、dsh、opencode、windsurf、copilot……);什么都不给就自动探测,唯一命中直接装,多命中在终端里交互式多选。

下载落地前有两道校验:bundle 的 fingerprint 必须与注册中心返回值一致,解压时有护栏(≤500 文件、拒绝路径穿越)。装完写入 .skillhub/metadata.json 记住 registry、版本、指纹,skillhub list 靠它对账。

对 ClawHub 生态做了兼容层:坐标用双连字符约定 namespace--skill(如 my-space--my-skill),服务端暴露 /.well-known/clawhub.json 让 ClawHub 客户端自动发现。于是 npx clawhub install 这类现有工具链也能直接指向自托管 SkillHub;但发布协议不兼容,发包还是用第一方 CLI。

九、SKILL.md:协议本身极简

协议核心就这几行:

--- name: pdf-parser description: Extract text and tables from PDF files reliably. version: 1.2.0 license: Apache-2.0---# 正文即 Agent 收到的指令

服务端解析时 name → slug、description → summary,完整 frontmatter 存进 skill_version.parsed_metadata_json;还支持 x-astron- 前缀的平台扩展字段(如合规元数据)。一个有意思的细节:这个项目自己也用这套协议——仓库根目录的 .agents/skills/ 下放着 8 个给 AI Agent 读的开发规范(code-conventions、dev-workflow、testing-and-ci……),用 Agent 管理 Agent 的规范,算是"吃自己的狗粮"。

十、前端:类型安全是一条流水线

前端有两处值得直接抄走:

1. OpenAPI 类型流水线。 一条命令 openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts 从 springdoc 拉 spec 生成 1.5 万行类型,openapi-fetch 按 paths 泛型调用——后端改接口,前端编译期就报错。client.ts 统一处理 CSRF 头、{code,msg,data} 信封解包和全局错误提示。

2. 设计 token 三层落地。index.css(703 行)定义 :root/.dark 双主题变量,注释里明确写着设计意图:"纯白底,冷灰卡片 #f2f3f5,近黑按钮 #202020,无品牌色,所有表达通过灰阶深度";tailwind.config.ts 把颜色全部映射成 hsl(var(--…));再往上是 12 个 shadcn/ui 基础件(button 用 cva 定义了 7 个变体)。换肤只动 token,组件一行不改。

另外,搜索页把关键词、排序、分页、仅看收藏全部镜像进 URL search params,链接可分享可回溯;i18n 三份语言包各 2400+ 行、69 个命名空间,还配了 5 个"三语对齐"测试挡住漏翻——这些小事累加起来就是"成品感"。

十一、部署:一条命令到 K8s

# 国内环境 curl-fsSLhttps://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh\|sh-s--up--aliyun--public-urlhttps://skill.example.com 

交付态是 compose.release.yml 的 5 个容器(web / server / postgres / redis / skill-scanner),server 健康检查通过才放行依赖;生产环境首启会创建 bootstrap 管理员(默认 admin / ChangeMe!2026,务必改掉,发布配置校验脚本会拒绝默认密码)。上 K8s 有两条路:Helm Chart(自带 Bitnami PG/Redis 子 chart、values.schema.json 配置契约、install-upgrade 冒烟测试)或 Kustomize(base + with-infra/external 两种 overlay)。监控是现成的 Prometheus + Grafana,后端 /actuator/prometheus 直接可抓。

开发态则是 make dev-all,compose 只起依赖(PG/Redis/MinIO/扫描器),前后端跑宿主机,热更新最快。

十二、写在最后:几个"反直觉"的结论

读完 6 万行代码,有三个结论和文档的第一印象不同,反而更值得记住:

  1. 没有 JWT。 会话是 Spring Session + Redis,API 是不透明令牌哈希查表。README 里的 AUTH_JWT_SECRET 没有对应实现。
  2. 没有事件溯源。 有的是 20 种 Spring 领域事件 + 30 多处 AFTER_COMMIT 监听,以及一张审计表;真正持久化的"事件"只有 scan_task_outbox(Outbox 模式,不是 Event Store)。
  3. "CQRS" 更准确的说法是读模型优化。 搜索读侧直连 SQL、下载量/评分的反规范化投影、部分索引——是真实存在的读写分离实践,但不是完整的命令/查询分层框架。

这三点不是要挑刺。恰恰相反:一个敢在 README 里写"单体优先"、把乱序扫描防护写进注释、给 Mock 认证挂双开关的项目,值得你把它的源码当作参考实现来读。工程上最稀缺的从来不是炫技,而是这种把边界守严的耐心。

如果你想试一下:

# 30 秒起本地环境(需 Docker) curl-fsSLhttps://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh|sh-s--up # Web: http://localhost:3000   API: http://localhost:8080

SkillHub(Apache 2.0):https://github.com/iflytek/skillhub

相关学习资料