Skill 与 MCP 集成、项目后记 能不止是文件目录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 中的 iddisplay_name展示名称frontmatter 中的 name无则用目录名skill_description技能描述frontmatter 中的 descriptionsource来源global管理员维护或user个人创建user_full_name归属用户global 技能为systemscope分类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 或 Grepoutput_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 一开始生成的版本有 N1 查询问题先查出技能列表再逐个查用户的禁用记录手动让它改成 JOIN 查询才解决。MCP只做 stdioMCP 集成是技能系统之后的下一个能力扩展。Claude Code 支持五种 transportstdio、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 号接入 MCP4 月 25 号一天交了 5 个稳定性相关的 commit。一开始我试过直接搬 Claude Code 的连接机制。但它俩的运行时前提完全不同CC 是单用户本地 CLIMCP 连接生命周期等于用户会话启动连上干完活退出最长几十分钟。子进程崩了无所谓下次启动 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 判断远程 transportSSE/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 调用轮数防死循环llmLogModefull/truncated/nonetruncatedLLM 调用日志详细程度调试时切 fulltoolDisplayModefull/input-only/name-onlyfull前端对Tool执行结果的展示粒度cacheScopeephemeral/globalephemeral提示词缓存范围单用户 / 跨用户共享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.configJSON 字段个人控制配置项可选值默认值作用themeModedark/light跟随系统前端主题色outputModeconcise/detailedconciseLLM 输出风格简洁 / 详细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 接口为什么留了那么多扩展点。有些东西我评估后没做场景用不上。但做过评估本身就让认知深了一层知道它有什么、知道为什么