能不止是文件目录:Web 多租户下的技能管理

Claude Code 的技能管理纯靠文件系统:每个技能是一个目录,里面除了 SKILL.md,还可以放脚本、模板、配置文件等辅助文件。加技能的方式很灵活:可以从别人那里拷贝一个目录过来,也可以让 Claude Code 自己根据会话历史总结出一套操作流程、自动生成 SKILL.md 和配套脚本。不管哪种方式,最终都是落在 skills/ 目录下,启动时扫描一遍,读到什么用什么。CLI 用户在自己的机器上操作,文件管理器就能搞定,不需要什么管理界面。

V2 不行。Web 用户不会去服务器上编辑文件,admin 需要一个管理后台来增删改查技能。直接改文件系统也不是不行,但启用/禁用、显示名称、分类 scope 这些元数据需要一个结构化的载体。总不能每次查"当前启用了哪些技能"都去扫描一遍文件系统。

所以加了一层数据库。aac_skill_registry 表存元数据,aac_user_disabled_global_skills 表管用户级禁用。这两张表在上一篇的 ER 图里已经出现过,这里展开讲它们具体怎么用。

文件系统是技能的源码,数据库是技能的注册表。启动时 syncSkillsDirectory() 扫描 data/skills/ 目录下的每个 SKILL.md,解析 YAML frontmatter,把元数据 upsert 到数据库。已经存在的记录不覆盖用户修改过的字段(scope、is_enabled、default_prompt),只更新名字和描述。

aac_skill_registry 表:

字段 含义
id 主键
name 技能名(目录名或 frontmatter 中的 id)
display_name 展示名称(frontmatter 中的 name,无则用目录名)
skill_description 技能描述(frontmatter 中的 description)
source 来源:global(管理员维护)或 user(个人创建)
user_full_name 归属用户(global 技能为 system
scope 分类:general(通用)或 workflow(业务流程)
default_prompt 默认提示词(可选,覆盖系统提示词)
is_enabled 全局启用开关(管理员控制)

aac_user_disabled_global_skills 表:

字段 含义
user_full_name 用户名
skill_name 被禁用的技能名

联合主键 (user_full_name, skill_name),有记录表示该用户主动关掉了这个技能。管理员关了全局开关(is_enabled = 0),所有用户都不可见;管理员开着但用户在自己的记录里关了一行。这叫两级开关,后面会展开讲。

为什么不全放数据库?有两个原因。第一,技能文件正文在运行时经常被 LLM 通过 Read 工具读取,从文件系统读比从数据库拼字符串自然得多。第二,文件系统方便做基线管理。skills/ 目录存模板的原始版本,data/skills/ 存运行时副本。升级基线技能时,新增或修改的文件同步过去,admin 的配置不受影响。两套目录的分离让"升级基线"和"保留用户修改"不再冲突。

多租户的Skill安全模型

文件系统加上数据库,技能管理框架搭好了。但很快一个问题就冒了出来:用户能不能偷看技能源码?

Claude Code 的安全模型不需要考虑这个。CLI 用户在自己的机器上运行,读什么文件自己说了算。V2 的前提完全不同:一份财务业务技能的 SKILL.md 里可能包含银行对账逻辑、账户字段映射、特殊处理规则,这些是给 Agent 执行用的,不能让普通用户在对话里通过"帮我读一下那个技能文件"就看到全部内容。

输入端拦截?做不到。LLM 执行技能时需要读取技能目录下的脚本文件和数据模板。如果 Read 工具的路径检查直接拒绝所有技能目录,技能就废了:LLM 读不到技能文件,自然执行不了技能逻辑。

所以设计是: LLM 的读取不限制,在输出端做拦截。具体实现在 chat.ts 的 SSE 事件处理里:当 tool_result 事件准备推送给前端时,检查三个条件:操作者不是 admin、工具是 Read 或 Grep(output_mode 为 content 时)、读取路径在技能目录下。三个条件同时满足,把 content 替换为"处理成功"。

LLM 能读到技能源码,所以能执行技能;用户只能看到"处理成功",看不到技能源码。这个机制不依赖 LLM 的自觉性,权限在输出端卡住。

技能文件的安全是两层防护叠加。HTTP API 层是第一层:读技能文件的路由都挂了 adminMiddleware,普通用户没法通过接口直接访问。SSE 内容过滤是第二层:普通用户通过 LLM 的 Read/Grep 工具间接读取时,返回结果被替换为"处理成功"。两层各守一道门:HTTP 层防直接访问,SSE 层防间接泄露。

Skill管理后台:CRUD 的冰山

安全模型落地后,开始搭管理 UI。最初以为就是几个表单和表格,结果越做越多。

功能清单:

  • 技能列表,带两级开关,全局启用/禁用、用户级启用/禁用

  • 技能详情,实时预览 SKILL.md 正文和 frontmatter、在线编辑

  • 导入导出,单个技能 ZIP、批量导出带注册表元数据

  • 文件管理,技能目录浏览、脚本和模板文件在线编辑、新建/重命名/删除

  • 基线技能管理,模板目录的版本控制

后端路由文件 skill.ts 占了一个 57KB 的单文件,是项目中最大的。不是因为逻辑复杂:大部分是 CRUD,增删改查一个技能记录和它的文件。大是因为 Web 管理涉及的操作维度太多。列表筛选要支持 source、scope、is_enabled、user_enabled 四个维度;导入要兼容 ZIP 和单个文件两种格式;导出要支持单个和批量;文件管理要递归遍历目录、提供每个文件的读写接口。

大部分是 Agent 生成后调整量不大的活。但开关逻辑和同步逻辑需要手动梳理清楚,并告诉Agent。比如两级开关,四种组合:

admin is_enabled 用户禁用记录 用户能否看到该技能
0 无关 不可见
1 无记录 可见
1 有记录 不可见

这个逻辑用 JOIN 查询能搞定,但 Agent 一开始生成的版本有 N+1 查询问题(先查出技能列表,再逐个查用户的禁用记录),手动让它改成 JOIN 查询才解决。


MCP:只做 stdio

MCP 集成是技能系统之后的下一个能力扩展。Claude Code 支持五种 transport:stdio、SSE、HTTP、WebSocket、SDK。V2 当前只实现了 stdio。

不是能力问题,是需求问题。业务上需要接入的 MCP server本次仅需要支持mineru PDF 做解析、数据分析服务,是命令行程序,通过 uvx 或直接调用可执行文件启动,走 stdio 完全够用。其他 transport 的代码没有删,而是留了占位符:每种 transport 都有完整的 Zod schema 定义和类型守卫函数,但 connectToServerImpl() 里非 stdio 的分支直接 throw new Error('not yet implemented')

4 月 30 号专门做过一次 SSE transport 的预研。对比了 Claude Code 源码和三个开源项目(Open WebUI、Continue、Cline)的进程管理方案,结论是需要做 managed 模式:Node.js 进程管理 MCP server 子进程的生命周期,加上 SSE 持久连接的长连接保活。但当前没需求,先不动。配置 schema 和类型都准备好了,时机到了直接填实现。

这个取舍和前面 02 篇的工具删减是同一个逻辑:实现的边界由业务需求画,不由源码能力画。 Claude Code 支持不代表 V2 需要支持,留好接口比做出用不上的功能更有价值。

连接断了怎么办:MCP 稳定性调试

MCP 连接管理是整个项目调试时间最长的部分。4 月 16 号接入 MCP,4 月 25 号一天交了 5 个稳定性相关的 commit。

一开始我试过直接搬 Claude Code 的连接机制。但它俩的运行时前提完全不同:CC 是单用户本地 CLI,MCP 连接生命周期等于用户会话:启动连上,干完活退出,最长几十分钟。子进程崩了无所谓,下次启动 cc 自动重连。uvx 的依赖缓存是持久的,首次下载后永远在本地。CC 只需要"会话级"的连接管理。

V2 是长驻多用户服务端,MCP 连接要一直活着,几天甚至几周。子进程可能自己崩、可能 idle timeout、可能有多个用户同时调用同一个 MCP server。Docker 每次部署清缓存,uvx 每次都要重新下载 60-90 秒的依赖。V2 需要的是"服务级"的连接生命周期管理:一套 CC 几乎没考虑过的稳定性机制。照搬解决不了,必须自己搭,下面按踩坑顺序讲。

第一个坑:并发重连

场景:用户发了一条消息,LLM 返回了 3 个 tool_use,都需要调用同一个 MCP server。3 个 tool 调用是并行的,几乎同时检测到 server 已断开。如果每个调用各自触发重连,会启动 3 个重复的子进程,后面的要么被系统拒绝,要么互相抢占端口。

翻 Claude Code 源码发现它用 memoize 解决这个问题,不过不是常规的 memoize(缓存函数返回值),而是"缓存 Promise 对象"。核心机制:第一次调用 connectToServer('mineru') 时,创建一个 Promise 执行连接逻辑,立即存入 Map。第二次和第三次并行调用发现缓存里有同一个 key 的 Promise,直接返回它,三者等待同一个连接结果。连接完成后根据状态决定清不清缓存:resolve 为 'failed' 或 'needs-auth' 时清除,让后续可以重试;reject 时也清除。

第二个坑:重连进行中又有新请求

memoize Promise 解决了"同时触发重连"的问题。但重连进行中(Promise 还在 pending),又有新请求在这个窗口期检测到断连。按理说新请求复用同一个 Promise 就行。但如果重连在某个时间点失败了、Promise reject 了、缓存被清除,而新请求恰好在这个空窗期判断"断连了",会触发第二次重连。

加了一层 pendingReconnects Map。startAutoReconnect() 开始时先检查是否有同名的重连正在进行,有就直接返回同一个 Promise,不启动新的。两个防重机制是互补的:memoize Promise 防"并发启动",pendingReconnects 防"串行触发"。

第三个坑:uvx 启动延迟

uvx 命令首次运行时要下载 Python 依赖包,60 到 90 秒。本地运行无所谓,但 Docker 每次重新部署都要重新下载,默认的 30 秒连接超时直接失败。

为什么没把 mineru 的依赖直接打进基础镜像?因为 mineru/magic-pdf 依赖 PyTorch,光这一个就 1GB+。基础镜像里只装了轻量 Python 包(pandas、openpyxl、pdfplumber 等),如果把 PyTorch 和 mineru 全家桶全打进去,镜像体积会翻好几倍,每次 CI 构建和推拉镜像都变成灾难。权衡之后选了按需下载:让 uvx 首次启动时拉依赖,Docker 部署慢就慢一点,镜像体积保持可控。

改成 120 秒超时解决了问题,但设计上不优雅:下载超时和协议握手超时应该是两个独立的值,下载慢不代表连接会失败。改进方案写了 design doc,优先级不高一直没落地。uvx 只在首次使用时下载,后续有缓存,生产环境可以预先跑一次预热。

不止是重连:断连原因要分类

连接断了就重连,这个直觉不够。实际上不是所有断连都该重连:

MCP 连接断开(或连接失败)
  │
  ├── 永久配置错误
  │    ENOENT / 权限拒绝 / 配置文件格式错误
  │    → 直接放弃,状态设为 failed,拒绝所有等待中的请求
  │    原因:重连一百次也不会好
  │
  ├── 临时传输错误
  │    ECONNRESET / ETIMEDOUT / EPIPE / EHOSTUNREACH / ECONNREFUSED
  │    → 触发重连,连续 3 次失败后关闭 transport 并标记失败
  │    原因:网络抖动或进程临时崩溃,重连有意义,但不能无限重试
  │
  └── 工具调用时断连
       code === -32000 或消息含 "Connection closed"
       → 自动重连 + 重试工具调用 1 次
       原因:跟着 agent loop 走,不是独立重连循环;成功继续,失败交给 LLM 判断

远程 transport(SSE/HTTP/WS)的重连策略预留了但没用上:指数退避,初始 1 秒、上限 30 秒、最多 5 次尝试。有 pendingReconnects 防并发,不会跑出两个重连循环。

这轮稳定性打磨从 4 月 16 号到 4 月 25 号,差不多一周。最早的版本只有"断了就连",没有分类、没有退避、没有防并发。最后的实现每一层都是踩出来的,不是设计出来的。

还有一个不太优雅的妥协:uvx 超时设成 120 秒之后,所有 stdio MCP server 的连接超时都变成了 120 秒。如果以后接入一个应该 5 秒握手就完成、30 秒没好就该放弃的 MCP server,这个 120 秒会掩盖真实的连接问题。目前没问题是因为所有接入的 MCP server 都是同类场景。典型的"满足当前需求但不具备通用性"的实现。

技能和 MCP 解耦

技能和 MCP 在 V2 里配合最紧密的场景是 PDF 解析。技能文件写着业务流程:先提取 PDF 中的表格数据,验证金额一致性,生成对比表。MCP 提供执行能力:mineru-mcp 负责 PDF 解析和数据提取。技能说做什么,MCP 做怎么做。

但在代码层,它们是两个独立系统。Skill 系统只管文件系统的 SKILL.md 和数据库的 skill_registry 表,不知道 MCP 的存在。MCP 系统只管子进程连接和工具注册,不关心 Skill 的内容。

它们之间的配合靠的是 Skill 文件里的显式指令。比如 PDF 解析技能,SKILL.md 里会明确写:"使用 mcp__mineru__extract_pdf 提取 PDF 内容,不要自己尝试下载 Python OCR 包本地处理"。Skill 负责教 LLM 做什么、用什么工具做,MCP 负责提供那个工具。LLM 不需要自己判断用哪种 PDF 方案,也不需要尝试用 Bash 装依赖。Skill 替它做了这些决定。

这种配合方式的好处是解耦但明确。换一个 PDF 解析服务,改两处就行:Skill 文件里的工具名,以及 MCP 配置里的 server 连接。先提取表格、验证金额、生成对比表,这套业务逻辑不需要动。加一个新的分析能力,只添加一个 MCP server 并在对应 Skill 里指定使用即可,现有 Skill 不受影响。V1 的 Skill 和 MCP 是耦合的,V2 通过这种方式把它们变成了可以独立升级又明确协作的两个系统。这是整个重写过程中最有价值的架构收获之一。


产品层面的配置能力

Skill 和 MCP 是给 Agent 的能力。除此之外,从产品角度还加了一些配置项,让管理员和用户能按自己的需求调整 Agent 的行为,分全局和用户两级。

全局配置

aac_sys_config 表,管理员控制。

配置项 可选值 默认值 作用
maxTurns 数字 20 单次对话最大 Tool 调用轮数,防死循环
llmLogMode full / truncated / none truncated LLM 调用日志详细程度,调试时切 full
toolDisplayMode full / input-only / name-only full 前端对Tool执行结果的展示粒度
cacheScope ephemeral / global ephemeral 提示词缓存范围:单用户 / 跨用户共享
  • maxTurns 中”轮数”的定义:V1 简单粗暴地把一次请求或一次回复算一轮。但实际上 LLM 一次回复里可能包含多个 tool_use,每个 tool_use 执行完结果回传 LLM 后又可能触发新的 tool_use。V2 和 CC 对齐了定义:一轮 = 一次完整的 LLM 调用 + 该调用中所有 tool_use 的执行 + 结果拼接。如果 LLM 调了 3 个 Tool,这 3 个 Tool 并行执行完、结果回传,才算一轮结束。20 轮意味着 LLM 最多被调用 20 次,而不是最多交互 20 次。

  • llmLogMode控制服务器打印LLM调用日志的详略程度:

    • full 记录完整请求体和响应体:系统提示词、消息列表、每条 tool_use 的输入输出。调试 Agent 行为时全靠它。

    • truncated 只记录提示词前几百字符的摘要,日常用,既能看到 Agent 在干什么又不撑爆日志。

    • none 完全不记录 LLM 调用内容,生产环境对日志体积敏感时开启。

  • toolDisplayMode控制页面展示给用户的Tool执行情况:

    • full 展示完整的 tool_result 内容。

    • input-only 只展示工具被调用时的参数,不展示执行结果。

    • name-only 只显示工具名称和运行/成功/失败状态,参数和结果都不展示。后两种适合轻量对话场景:不想被大段工具输出干扰,也省了 SSE 带宽。

  • cacheScopeephemeral 是默认值,缓存 5 分钟即失效,每个用户的缓存相互隔离。global 跨用户共享缓存:系统提示词、工具 schema 这些每个用户都一样的内容只缓存一份,命中率更高,Token 更省。这个机制是我翻 CC 源码才知道的,也说明了一个事实:即使开了 ephemeral,用户的上下文信息并不是完全不缓存,只是缓存的生命周期被限制在单用户、短时间内。

用户配置

aac_users.config JSON 字段,个人控制:

配置项 可选值 默认值 作用
themeMode dark / light 跟随系统 前端主题色
outputMode concise / detailed concise LLM 输出风格:简洁 / 详细

outputMode 实际上对应的是 Anthropic API 的 output_style 参数。和 llmLogMode 不同:那是控制后端日志里记多少,这个是控制 LLM 本身说多少。

  • concise 下 LLM 回答精简、不啰嗦,适合高频业务操作

  • detailed 下 LLM 会展开解释每一步的推理过程,适合需要审计轨迹留痕的场景。

CC 作为 CLI 工具没有这些概念:所有东西要么硬编码要么靠环境变量。Web 应用天然要求更多配置项,给了管理员和用户各自的控制空间:谁的事归谁管。

后记

项目的最终命运

这个项目是公司内部的技术探索+赛马项目。从 2 月底折腾到 4 月底,V1 到 V2,两版代码、两次完全不同的架构。下班后和周末的时间基本都泡在里面:开发、调试、翻 Claude Code 源码。不是赶进度,是越研究越有东西可挖,有点回到刚毕业那会儿的好奇心和兴奋感。

我自认为 V2 的完成度已经很高:

  • 使用相同模型时,它的实际表现和 CLI 版 Claude Code 非常接近,Agent 循环、Tool 调度、上下文管理这些核心机制是对齐的

  • 多租户 Web 架构和 Skill/MCP 解耦又让它比 CLI 版本多了可扩展的空间。

项目最后阶段,用户的 bug 清单清空后几乎不再新增——偶尔冒出来一个,也活不过两个小时。那几天我甚至有点闲,开始琢磨周边技术:对比各个 LLM 在业务场景下的实际表现、优化 Docker 镜像的构建速度和体积、研究怎么让用户在同一个会话里自由切换配置的 LLM 而不用重开对话。

但因为现实中的种种原因,V2最终没有被采用。说不失落是假的,但这两个月确实没虚度。

我学到了什么

起初我以为只要将需求丢给 AI,就可以去做其他事情,等着出结果就好了。现在才明白,以前积累的编程经验、架构判断和工程直觉不是没用了,只是换了个形式:不再是逐行写实现,而是设计 harness:拆任务、定接口、划模块边界、管数据流向。AI 负责在框架里填代码,人负责保证框架本身不跑偏。尤其是在使用非顶尖模型的情况下,更需要Harness Engineering,其质量决定了产出物的下限。

这两个月我也对 Claude Code做了深度的体验。结合源码的理解,慢慢摸到了写好一个 Skill 的门道:不是越长越好,是指令要精确、边界要清楚、给 LLM 的决策空间要刚好够用。上下文控制这件事也从被动变成了主动:知道什么时候该 /clear 重置会话、什么时候用 /rewind 回退到某个节点重新来、什么时候开 /branch 并行探索不同的方向。这些操作单独看不复杂,但在一个两万行的项目上,不会用的人半小时陷在一个问题上出不来,会用的三分钟切个分支就绕过去了。降低幻觉不是靠运气,是靠控制上下文窗口里塞了什么。

一些高级功能也顺带踩了一遍:配了 git hook 自动拦截 commit message 里 Claude Opus 4.6 的署名、折腾了 statusline 的配置让终端信息展示更符合自己的习惯。不过手动拉起子 Agent 做并行任务、复杂的权限策略配置这些还没机会深入用,毕竟我主力使用的是glm-5,还不足以执行较长时间的连续任务,等以后再说。

image

现在我的 statusline 配置。

起初是因为上下文窗口只有 200K,等到自动压缩快要触发时(上下文剩余窗口在10%左右)工作可能才做了一半,而压缩总要丢信息。于是写了一个始终显示上下文用量的配置,看着百分比往上走,心里有数。

后来根据自己的需求陆续往上加:当前工作目录、使用的模型、缓存命中率、输入输出 Token 数、预估费用。当然,这也是和 Claude Code 一边聊一边生成的,你也可以这么做。

后来因为我的一些其他项目是老版本node,切换版本后statusline会失效,我还给它绑定了node版本。

Agent 设计也一样。亲手搭一遍和看源码完全是两码事。搭之前我对 Claude Code 的理解就是"挺好用"。搭完之后再看它的源码,才看懂它的 QueryEngine 分层是在解决多消费端的问题、它的 prompt caching 分段策略为什么那样切、它的 Tool 接口为什么留了那么多扩展点。有些东西我评估后没做,场景用不上。但做过评估本身就让认知深了一层:知道它有什么、知道为什么

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐