Skill Shelf: 给 agent 的 skill 仓库,兼做服务的配置中心
RustAgentMCP配置中心传统服务的行为由 config 决定,AI agent 的行为由 skill 和 prompt 决定。这两样东西本质上是同一类:都是写在代码之外、运行时才去取的行为定义。既然是同一类,就该收进同一个服务统一管理。
Skill Shelf 是我按这个想法写的东西。给 agent 的那半边是 skill 仓库:像 Git 一样管版本、按自然语言需求路由、用反馈驱动 AI 改进,并以 MCP server 直接供 agent 取用。给服务的那半边是配置中心:namespace 隔离、草稿/发布版本化、service token 授权、.env 一键导入。
后端 Rust,前端 React + Tauri,桌面和 Web 同一套。这篇把它的版本模型、路由取舍、反馈闭环和配置中心的设计逐个拆开讲。

起因
我先有的是 prompt-shelf——管 prompt 的小服务。后来开始大量写 Agent Skills,发现两件事撞在一起了。
一是 skill 需要版本管理。skill 不是一段文本,是一个目录包:SKILL.md 加 scripts/、references/、assets/。改一版发现不如上一版,想回退;想开个分支试试新写法;想看两版之间到底差了什么。这些需求和 Git 一模一样。
二是 skill 需要被路由。一个 agent 手里几十个 skill,靠把所有 description 塞进 system prompt 让模型自己挑,是在浪费上下文。更合理的是:agent 拿到一个需求,问仓库「这活儿谁来干」,仓库返回最匹配的那一个或几个。
写到一半意识到第三件事:我那些服务的配置,散在各自的 .env 里,改一个值要登三台机器。而 skill 和 config 在架构位置上是同一个东西——都是「运行时才取的行为定义」。既然版本管理、草稿/发布、授权这些机制都已经为 skill 建好了,配置复用它们几乎是免费的。
于是就有了现在这个形态。规模上:Rust 侧 4191 行(core 1359、server 2342、mcp 306,另有 184 行 e2e 测试),前端 46 个文件 5091 行。
组件与部署形态
一个 Cargo workspace 三个 crate,加一个前端。
skill-shelf-core—— CAS 对象库、版本模型、SKILL.md解析校验、Indextrait。不依赖 HTTP,也不知道 UI 存在,可以脱离 server 单测skill-shelf-server—— Axum REST、JWT 认证、两套配置(自身设置 + 配置中心)。唯一的后端skill-shelf-mcp—— stdio MCP server,把 REST 包成 agent 能用的工具app/—— React 19 + Vite + TanStack + shadcn/ui,src-tauri是纯壳
最早定下、后来证明最省事的一条决定是:前端只通过 HTTP 访问后端,桌面和 Web 走完全相同的代码路径。
Tauri 那层不内嵌 core,不用 invoke() 访问业务逻辑,只负责窗口、托盘、文件对话框,以及启动时把 server 二进制作为 sidecar 拉起来监听 127.0.0.1。桌面的离线能力来自「打包进壳的 server 二进制」,而不是把 core 编进 Tauri。
好处是前端只有一份 axios 客户端,base URL 运行期可配:
用户在设置里填的地址 > 持久化存储 > VITE_API_BASE > 内置兜底Web 存 localStorage,桌面存 Tauri Store,axios 用请求拦截器动态读当前 base URL。所以同一个构建产物既能当桌面壳里的本地客户端,也能填个远程地址连团队的服务器,改完即时生效不用重新构建。
如果当初让 Tauri 直接调 core,就会有两条代码路径、两套错误处理,Web 和桌面每次都要各自验一遍。这个决定省下的是持续的成本,不是一次性的。
像 Git 一样管 skill
版本模型直接照抄 Git 的对象模型,因为它已经是对的:
Blob—— 单个文件内容,按内容哈希存Tree—— 目录清单,path → blob 哈希的有序映射Commit—— 整个目录的不可变快照,id就是提交对象自身的内容哈希,含tree/parent/author/message/timestampBranch—— 命名开发线,指向 head commit,是唯一可变的指针
存储用内容寻址(CAS),落在文件系统上:
/// Every blob (file content), tree (directory manifest) and commit object is
/// stored under `objects/<hh>/<hash>` keyed by the SHA-256 of its bytes, so
/// identical content is stored exactly once — unchanged files are shared
/// across commits for free.
pub fn put(&self, data: &[u8]) -> Result<String> {
let hash = Self::hash(data);
let path = self.path_for(&hash);
if !path.exists() {
fs::write(&path, data)?;
}
Ok(hash)
}put 是幂等的:同样的字节写第二次什么都不做。于是「改了 SKILL.md、没动 scripts/」这种最常见的提交,只新增一个 blob 和一个 tree,其余文件在多次提交间自动共享,不用写任何去重逻辑。
分层是这样:对象库在文件系统(CAS),索引在关系型数据库。skills、branches、feedback、users、路由索引这些需要查询的东西进 DB;不可变的内容对象进 CAS。这样换数据库不影响对象库——DATA_DIR/objects 两个后端通用。
skill 和 prompt 共用这一整套机制,只用 kind 字段区分。prompt 就是「单文件 skill」,没必要为它再造一套。
SKILL.md 是唯一真相
skill 遵循 Agent Skills 规范,校验是硬拦截的——kind=skill 的导入和提交都过一遍,缺 SKILL.md、frontmatter 非法、name 和 skill 身份不符,一律 4xx 加明确原因。规则就是规范写的那些:
name 1-64 字符,只允许小写字母、数字、连字符,不首尾/连续连字符
description 非空,≤ 1024 字符
compatibility ≤ 500 字符POST /validate 单独暴露出来,前端提交前先预检一次,用户不用靠 4xx 才知道写错了。
这里有个当初想了一会儿的问题:基本信息(name / description / license / metadata)到底存哪?存 DB 里查得快,但 SKILL.md 也有一份,两处会漂移;只存 SKILL.md 则列个表都要把每个 skill 的文件读出来解析一遍。
最后的做法是单一真相加缓存:SKILL.md 的 frontmatter 是权威源(可移植、符合规范),commit 成功后把它缓存进 skills 表并刷新路由索引。列表和预览读一行 DB 就有全部基本信息,没有第三份文件,也不会漂移——因为缓存只在 commit 这一个入口写。
解析和校验放在 core::skillmd 里做纯函数,校验时机(import / commit)由 server 层决定,core 的 commit 保持通用。
路由:BM25 召回 + LLM 重排
路由信号就是每个 skill 的 name 加 description——Agent Skills 规范本来就约定 description 要写「什么时候用我」,这正是路由需要的东西。
调用方有两类意图,分别有接口:
精确取用 —— agent 明确知道要
pdf-parse,GET /skill/by-name/{name}或mode=exact直取,不排序
模糊搜索 —— 给一句「帮我把 PDF 转成文本」,mode=fuzzy(默认)返回排序候选,加rerank:true(或mode=smart)启用 LLM 重排
决定不上向量
这是整个项目里我想得最久的一个取舍。语义检索的标准答案是 embedding 加向量库,我最后没上,理由三条:
BM25 已经够好。SQLite 的 FTS5 自带 bm25(),词法召回的质量在「短描述 + 关键词查询」这个场景下相当能打,而且零依赖、离线可用、桌面版直接就有。
LLM 重排补得上语义。它只需要看 N 条短描述,几百 token 一次调用,就能处理「用词不同但意思相近」——而这恰好是 BM25 唯一的短板。而且它复用了 refine 已有的 AI_* 配置,没引入任何新东西。
向量的运维成本全是持续的。向量库要部署、embedding API 要付费、skill 一改就要重算向量、模型换了要全量重建。为了补一个 LLM 重排已经能补的短板,这些成本不划算。
代价也得说清楚,不能只讲好处:LLM 只能从 BM25 召回的候选里选。 如果某个 skill 和 query 零词法重叠,BM25 根本没把它召回,LLM 也救不回来——这正是向量能补而这个方案不能的场景。
缓解手段两个,都在代码里:
/// Small registries skip recall and rerank ALL skills (100% recall).
const RERANK_ALL_THRESHOLD: usize = 15;
/// BM25 candidate pool size for larger registries before rerank.
const RERANK_RECALL_N: usize = 20;skill 总数不超过 15 时直接跳过召回,全量交给 LLM 重排,召回率 100%——而个人和小团队的 skill 库基本都在这个规模里,也就是说这个短板对多数用户根本不会发生。超过 15 才走 BM25 取 Top-20 再重排。
没配 LLM 就优雅降级成纯 BM25。所以三档能力是随配置逐级增强的,不是要么全有要么不可用。
分数不可跨库比较
这一点值得单独拎出来,因为它决定了抽象边界该画在哪。
档 A 的 score 来自 SQLite FTS5 内置的 bm25(),是 SQLite 算的,不是我实现的。而 skill_fts MATCH 和 bm25() 都是 SQLite 专有语法:
CREATE VIRTUAL TABLE skill_fts USING fts5 (skill_id UNINDEXED, name, description);
...
SELECT f.skill_id, s.name, s.description, bm25(skill_fts) AS score
FROM skill_fts f JOIN skills s ON s.id = f.skill_id
WHERE skill_fts MATCH ?1 ORDER BY score LIMIT ?2(bm25() 返回的是负值、越小越好,所以取回来要取反。)
换到 Postgres 就是另一套算法了:tsvector 加 ts_rank,不是 BM25;要真 BM25 得上 pg_search/ParadeDB 扩展。MySQL 又是 MATCH…AGAINST 自己的相关度。
结论有两层。对使用者:分数只在同一次查询内相对有意义,BM25 的 IDF 依赖语料规模,库小的时候绝对值噪声很大,界面上可能就显示成 0.0000;可靠的是排序,不是绝对分。对代码:召回必须抽象成可替换实现,每个后端一套(SqliteFtsRouter / PgFtsRouter),因为算法本就不同。
而 LLM 重排天然跨库——它只吃候选的 name 加 description,跟数据库无关。所以换后端只冲击召回层,重排层完全不用动。这个分界不是设计出来的,是被「分数不可移植」这个事实逼出来的。
顺带一个小地方:FTS5 的 MATCH 表达式不能直接拼用户输入,会被特殊字符搞出语法错误。处理是按非字母数字切词、每个词加引号、用 OR 连起来:
fn build_match_query(query: &str) -> String {
query
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(" OR ")
}
界面上有个路由测试台:输入需求,实时看命中哪些 skill、分数多少。调 description 的措辞时很有用——skill 路由不准,八成是 description 没写清「什么时候用我」。
反馈闭环
评分系统很容易做成死数据:攒了一堆星星,没人看,也不改变任何东西。所以这里从一开始就要求反馈能直接驱动下一版 skill。
反馈模型除了常规的 rating(-1 / 0 / +1 三态)和 content,有两个字段是特意加的:
source 区分 human 和 agent——人在面板里点的,和 agent 用完技能自己回传的,是两种质量不同的信号。
query 记下当初路由到这个 skill 的需求原文。这条让反馈同时服务两件事:优化 skill 内容,以及改进路由质量。query 加 rating 攒起来就是一份天然的路由评测集——「什么需求路由到了它、好不好用」,后续可以直接当重排的额外信号。
优化环节:POST /skill/{id}/refine 取该 skill 当前内容加该版本累积的 open 反馈,调 LLM 产出改进草稿。
关键是草稿落在 refine/* 分支的一个新 commit 上,不直接动 main。人在多文件 diff 界面里逐文件看过才合并。skill 里可能有 scripts/ 这种可执行内容,让模型直接改主线是不可接受的——这条不是谨慎,是必须。
合并之后做三件事:路由索引对该 skill 重建、被采纳的反馈标记 applied、下一次 route 命中的就是改进后的版本。环就闭上了。

MCP:让 agent 直接取用
前面那些都是「管理工具」。让它变成「服务」的是 MCP。
skill-shelf-mcp 以 stdio 运行,把 REST 包成工具,任何兼容 MCP 的 agent 都能直接用:
route—— 给一段需求,返回最匹配的 skilllist_skills—— 浏览/过滤/分页load_skill—— 加载:返回完整SKILL.md正文加打包文件清单read_skill_file—— 按需读某个scripts/或references/文件feedback—— 用完给反馈,闭上改进环get_config—— 从配置中心取某 namespace 的已发布合并配置
load_skill 和 read_skill_file 分开是刻意的渐进披露:agent 先拿到 SKILL.md 正文和文件清单,真需要某个脚本才去读它。一次性把整包塞进上下文是浪费。
get_config 那个工具有点意思——它让 agent 不再读环境变量,而是向配置中心要配置。MCP server 用环境变量 SKILL_SHELF_CONFIG_TOKEN 作为 service token,内部转成 X-Config-Token 头调 resolve。没设 token 时这个工具照常出现在清单里,但调用时返回明确错误,而不是静默失败。token 决定能读哪些 namespace,MCP 不引入任何越权旁路。
配置中心
先分清两套配置,它们在代码里同一个 RuntimeConfig 结构里,但语义完全隔离。
Skill Shelf 自身设置分启动期和运行期两类:
结构性(改了要重启) PORT · DATA_DIR · DB · JWT_SECRET · ADMIN_USERNAME/PASSWORD · LOG_FORMAT · RUST_LOG
运行期(热更新) AI_BASE_URL · AI_API_KEY · AI_MODEL · GITHUB_TOKEN · GITHUB_API_BASE · 任意自定义结构性的决定进程绑定和存储位置,PUT /config 直接拒绝(400)。运行期的 admin 在界面上改完即时生效,不用重启——refine、路由重排、GitHub 导入都是在请求时从 ConfigStore 取快照。取值优先级是 已存值 > 环境变量 > 默认,含 KEY / TOKEN / SECRET / PASSWORD 的键在读取接口打码,GET /config 只告诉你设了没设。
配置中心是给别的服务用的,和自身设置完全隔离——自身的 AI 密钥永远不会经 resolve 泄出去。_global 层放共享默认,每个服务/环境一个 namespace 叠加覆盖,消费方取 namespace X 拿到的是 merge(_global, X) 的明文:
curl -H "X-Config-Token: shelf_…" \
"http://127.0.0.1:8080/config/resolve?namespace=service-a/prod"为什么要版本化
一开始 namespace 就是个裸 KV,改完立刻生效。用了两天就发现不行:改配置和让改动对消费方生效,是两件应该分开的事。手滑改错一个值,线上服务下次重启就读到了。
现在每个 namespace 是 { draft, versions[] }:
编辑改草稿 ——
PUT /config/namespace只动draft,resolve完全不受影响
消费读已发布 ——resolve返回versions.last()合并_global的已发布版
发布 ——publish把draft快照成新版本,版本号单调递增;草稿和最新已发布相同则拒绝
回退 ——rollback把某历史版本载回draft,不直接发布,admin 复核后再publish,也就是「回退了再发布」
diff —— 逐字段对比draft和已发布,added / removed / modified,secret 两侧都打码
回退要走一遍 publish 这点,是我觉得做对了的地方。回退本身也是一次变更,凭什么它不用复核。

鉴权上的两个决定
service token 只存 SHA-256 哈希,明文只在签发时展示一次。每个 grant 记录能读哪些 namespace。
另一个是:关掉认证(不设 JWT_SECRET)时,整个 /config/* 拒绝服务。桌面自用场景下不设 JWT_SECRET 很方便,但配置中心必须例外——没有认证,攻击者就能自助签发一个 token 把所有 namespace 的明文读走。宁可让这个功能在开放模式下完全不可用。/config(自身设置,桌面自用)仍然开放。
还有一条已知限制,写在文档里没藏着:namespace 层的 null 语义是「从本层删除这个 key」,所以无法覆盖式屏蔽 _global 的某个 key——能覆盖值,暂时不能删。_global 会合并进每一次 resolve,任何有效 token 都读得到,所以那里只该放非敏感的共享默认值。
.env 导入

迁移现有服务时最烦的一步是把 .env 一行行敲进界面。所以做了导入:粘贴文本或选文件,前端解析(注释、export 前缀、引号转义都认),导入前逐 key 预览新增 / 覆盖 / 跳过 / 无效行,确认后合并进草稿——发布前不影响任何消费方。
值一律按字符串导入,不做类型猜测。PORT=8080 到底是数字还是字符串,猜错了比不猜更麻烦。
持久化的取舍
配置这种东西,写坏了比读不到严重得多。所以:
写操作是快照 - 落盘 - 提交内存三步,磁盘写失败则运行态完全不变并返回 500,不会出现「内存里改了、文件里没改」的状态。
加载时区分两种失败:文件不存在用默认值(首次启动的正常情况);解析失败直接 panic 拒绝启动。宁可起不来,也绝不用空配置覆盖已有数据——如果解析失败时用了默认值,下一次任何写操作都会把用户的配置全部清掉。
旧的裸 {k:v} namespace 格式按「有没有 versions 键」识别,自动迁移成已发布的 v1,而且没加 deny_unknown_fields,未来新增字段不会让老数据加载失败。
跑起来
后端默认 SQLite,不设 JWT_SECRET 就是开放模式(适合本地和桌面):
cargo run -p skill-shelf-server
# → http://127.0.0.1:8080,数据落在 ./data
JWT_SECRET=change-me DATA_DIR=./data PORT=8080 cargo run -p skill-shelf-server
DB=postgres://user:pass@localhost/skillshelf cargo run -p skill-shelf-server同一份二进制按 DB 环境变量选后端:postgres://… 走 PgIndex,否则 SqliteIndex。CAS 对象库两边通用。两个后端各跑通 e2e 20/20。
前端:
cd app
pnpm install
pnpm dev # http://localhost:5173MCP,给 agent 用:
SKILL_SHELF_URL=http://127.0.0.1:8080 cargo run -p skill-shelf-mcp
# 同时让 agent 从配置中心取配置:
SKILL_SHELF_URL=http://127.0.0.1:8080 \
SKILL_SHELF_CONFIG_TOKEN=shelf_… \
cargo run -p skill-shelf-mcp部署:
docker-compose up -d # server(SQLite 卷)+ nginx 托管前端
docker-compose --profile pg up -d # 换 Postgres
cd app && pnpm tauri build # 桌面,server 作为 sidecar 打包,本地端口 8765界面中英双语,Header 一键切换,跟随系统语言,没引 i18n 库;明暗主题都支持。
几点体会
- 把「同类东西」认出来,能省掉一整套重复实现。config 和 skill 都是运行时才取的行为定义,所以草稿/发布、版本化、授权这些机制只用建一次。这个复用不是硬凑的,是因为它们本来就是一类。
- 抽象边界该由「什么东西不可移植」来画。我没有先设计一个漂亮的 Router trait,是发现 BM25 分数跨库不可比之后,才知道召回必须每后端一套、而重排天然通用。事实先于抽象。
- 优雅降级比功能开关好。没配 LLM 就退回 BM25,而不是报错说「请先配置 AI」。同一个产品在有 key 和没 key 两种状态下都完整可用,桌面离线也能跑。
- 写清楚代价,比只讲好处可信。不上向量的代价是零词法重叠时救不回来,我把它写进了设计文档和这篇文章。藏起来的取舍,后来都会变成别人踩的坑。
- AI 只产草稿,人管合并。skill 里有可执行代码,这条没有商量空间。
refine/*分支加 diff 审核这个形状,是把「AI 有用」和「AI 不可信」两件事同时接受下来的结果。 - 失败时宁可不启动。配置解析失败 panic 而不是用默认值,是因为「静默用默认值」的下一步就是把用户数据覆盖掉。这类错误的正确处理方式是拒绝服务。
- 前后端只留一种关系。桌面和 Web 走同一条 HTTP 路径,代价是桌面要多打一个 sidecar 二进制,收益是永远不用验两遍。
还没做的
按价值排,最缺的还是「给 agent 消费」这条线上的东西:
skill 安全扫描 —— skill 含可执行脚本,导入外部 skill 前应该扫危险操作。这是供应链安全,目前完全没做,是我认为当前最大的缺口
skill 自检 —— 校验SKILL.md引用的scripts/、相对链接是否真的存在;沙箱冒烟跑一下脚本
语义版本与发布通道 —— 现在只有 commit,没有v1.2.0这种 tag,也没有latest/stable通道
使用分析 —— 路由次数、命中率、反馈趋势,这些数据本该喂回「越用越好」的闭环,现在只有原始反馈
从 GitHub 仓库直接拉 skill —— 设计已经写好(下 zipball 复用现有 zip 导入通路),还没实现
CLI ——push / pull / search / route
另外 Postgres 那条路虽然跑通了 e2e,但 CAS 还在本地文件系统,多实例部署要先把对象库换成共享对象存储。
代码在 newdee/skill-shelf,MIT。设计细节在仓库的 DESIGN.md 里,比这篇更啰嗦。
skill 这东西现在还处在「各人自己一个文件夹」的阶段,跟十几年前配置文件散在各台机器上是一个局面。所以这个项目真正想验证的不是某个功能,而是:skill 值不值得一个中心化的服务。 我自己用了这些天,答案是值得——尤其是路由那部分,一旦 agent 能问「这活儿谁来干」,写 skill 的方式都跟着变了,description 会认真写成「什么时候用我」,而不是「我是什么」。