能不止是文件目录: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。比如两级开关,四种组合:
adminis_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 了、缓存被清除,而新请求恰好在这个空窗期判断"断连了",会触发第二次重连。
加了一层pendingReconnectsMap。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 带宽。
cacheScope:ephemeral是默认值,缓存 5 分钟即失效,每个用户的缓存相互隔离。global跨用户共享缓存:系统提示词、工具 schema 这些每个用户都一样的内容只缓存一份,命中率更高,Token 更省。这个机制是我翻 CC 源码才知道的,也说明了一个事实:即使开了ephemeral,用户的上下文信息并不是完全不缓存,只是缓存的生命周期被限制在单用户、短时间内。
用户配置
aac_users.configJSON 字段,个人控制:
| 配置项 | 可选值 | 默认值 | 作用 |
|---|---|---|---|
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,还不足以执行较长时间的连续任务,等以后再说。
现在我的 statusline 配置。
起初是因为上下文窗口只有 200K,等到自动压缩快要触发时(上下文剩余窗口在10%左右)工作可能才做了一半,而压缩总要丢信息。于是写了一个始终显示上下文用量的配置,看着百分比往上走,心里有数。
后来根据自己的需求陆续往上加:当前工作目录、使用的模型、缓存命中率、输入输出 Token 数、预估费用。当然,这也是和 Claude Code 一边聊一边生成的,你也可以这么做。
后来因为我的一些其他项目是老版本node,切换版本后statusline会失效,我还给它绑定了node版本。
Agent 设计也一样。亲手搭一遍和看源码完全是两码事。搭之前我对 Claude Code 的理解就是"挺好用"。搭完之后再看它的源码,才看懂它的 QueryEngine 分层是在解决多消费端的问题、它的 prompt caching 分段策略为什么那样切、它的 Tool 接口为什么留了那么多扩展点。有些东西我评估后没做,场景用不上。但做过评估本身就让认知深了一层:知道它有什么、知道为什么