生态与工具链

Article / 生态与工具链

Agent 的扩展生态

MCP、Skill、Hook 与 Slash 命令的扩展机制,以及扩展点做成配置还是代码的判断。

NovaCode MCPSkillHook插件生态

给 Agent 加能力有两种做法:改它的代码,或者接上外部生态。后者让使用者不写代码也能扩展 Agent,是一个 Agent 项目走向平台的关键一步。以 NovaCode 的四种扩展面(MCP 接工具、Skill 装操作手册、Hook 挂生命周期动作、Slash 命令留人工入口)为案例,可以看清扩展机制的设计与代价。

MCP 把外部工具接进同一个注册表

MCP(Model Context Protocol,模型上下文协议)是一个开放协议,让 Agent 用统一方式连接外部工具与数据服务器:服务器实现一次协议,所有支持 MCP 的客户端都能接。在此之前,每个客户端都要为每个工具单独写适配,工作量是工具数与客户端数的乘积;MCP 把它变成加法。NovaCode 支持两种传输:stdio 是本地子进程,环境变量只继承 PATH 加显式声明的变量,声明值支持环境变量展开;Streamable HTTP 走 url 加请求头。前者适合本机工具,后者适合远程服务;选择标准也简单——需要访问本机文件、命令或私有环境时用 stdio,跨机器、需要统一鉴权与部署时用 HTTP。启动时逐服务器连接,单个失败只记 warning,不影响其他服务器与主流程,连接失效会自动重连——一个第三方服务器不可用,不该让整个 Agent 起不来。工具 schema 就是 MCP 的接口:名字、描述、参数一起构成模型看到的世界,服务器改一次描述,模型对工具的理解也会跟着变。

mcp_servers:
  - name: context7
    command: npx
    args: ["-y", "@upstash/context7-mcp"]
  - name: my-http-mcp
    url: https://example.com/mcp
    headers:
      Authorization: "Bearer ${MY_TOKEN}"

工具统一命名为 mcp__{服务器}__{工具},双下划线保证「服务器名/工具名」边界可逆;权限规则匹配的是 server__tool 合成串。命名空间不是形式上的要求:没有稳定名字,就没法写「允许某服务器下所有工具」这样的规则。

工具 schema 也要按 token 预算加载

MCP 工具接进来只是第一步,怎么放进模型请求是更实际的问题。工具 schema(描述工具名、参数与用法的 JSON)渲染在 system 之后、messages 之前,只要 tools 数组变化,它后面的整段对话历史 prompt cache(提示词缓存,命中缓存的输入按很低比例计费)全部失效。代码注释里记录了一组实测数据:2 万 token 的历史下,往 tools 数组末尾加一个工具,缓存命中率从 99.4% 掉到 9.5%。这解释了为什么「动态加载工具」不能随手做。

NovaCode 按 MCP schema 总量分三档加载,阈值是上下文窗口的 10%(按 2.5 字符一个 token 估算)。eager 模式下 schema 总量低,工具全量进 tools 数组,同时不暴露工具搜索与统一调用入口,既省 token 也避免诱导模型绕路。native 模式面向官方 Anthropic 端点:工具留在数组里打延迟加载标记、随请求带 beta header,模型搜索时由服务端把 schema 展开进上下文,tools 数组字节不变。dispatch 模式面向不支持上述字段的其他端点:MCP 工具完全不进数组,模型先经 ToolSearch 读 schema,再用统一入口 mcp_call 调用。加载模式在会话启动时算一次就不再变,是否暴露检索与分发工具也一次算定。

三档是为不同端点能力做的适配,谈不上复杂度阶梯:eager 最省事,适合工具少的场景;native 依赖官方端点的延迟加载能力,tools 数组字节不变,是缓存最友好的形态;dispatch 兼容性最好,代价是模型要多花一轮先查 schema、再发起调用。模式在会话启动时一次算定还带来一个副作用:服务器中途新增或修改的工具,要等下一段会话才会被看到——这是一次用可见性换缓存稳定性的交换。

ToolSearch 支持按名字精确加载与关键词评分检索;mcp_call 解决模型自由生成参数的类型漂移,按目标工具的完整 JSON Schema 做确定性修正——数字形态字符串转数字、"true" 转布尔、单键包裹数组拆包、逗号分隔字符串切分,修不了的原样交给服务器报错,因为服务器的域内错误对模型更有指导性。这类修正看起来不起眼,但模型生成的参数形态不稳定是常态,一个确定性的兼容层能显著减少无意义的失败重试。这套设计的目标是缓存稳定性,省 token 只是副产品:工具数组是请求前缀的一部分,这是协议决定的事实。

Skill 的加载与执行

Skill 把一个可复用的工作流写成 Markdown,配一段 YAML frontmatter 声明元数据,放进技能目录就生效,不需要改代码。加载有优先级:项目级覆盖用户级;单个条目解析失败只记 warning 并跳过,不影响其他 Skill。名字必须是小写字母开头、仅含小写字母数字与连字符,描述必填。

---
name: git-commit
description: 按 Conventional Commits 规范生成提交信息
mode: inline        # inline | fork
context: full       # full | recent | none
---
提交前先看 `git diff`……(SOP 正文)

执行分两条路径。inline 把正文激活进当前对话,并记录到压缩恢复状态——上下文被压缩后技能仍在,这是「激活」与「插入一条消息」的区别。fork 把正文交给隔离子 Agent 执行,只把结果摘要带回主对话,context 决定喂多少历史:recent 取最近五条消息,full 把每条按 200 字截断,none 不带历史。热重载有两个触发点:读取时总是重新解析源文件、失败回退缓存版本;界面每轮对话前检查技能目录的修改时间,变化就整体重载并重新注册斜杠命令,同时刷新系统提示词里的可用清单,让模型即时看到新增条目。技能还能从外部来源安装,安装后即时注册命令;查看来源、模式与路径都有对应的命令入口。技能与斜杠命令共用同一套解析语义,这意味着团队里分享一个 Markdown 文件,就能同时获得模型侧的能力与人工侧的入口。

这里也有明确的边界:fork 路径构造子 Agent 时没传权限检查器与 Hook 引擎,技能体内的工具调用完全绕过检查(S6);teammate worker 注册加载工具时没有注入技能执行器,声明 fork 的 Skill 在队友进程里会退回 inline 执行。能力仍然可用,但隔离语义与主进程不一致——扩展机制如果不同时定义「执行语义」和「安全语义」,就会在不同宿主演化出不同行为。

Hook 与 Slash 命令

Hook 允许用户在生命周期点插入动作:记录审计、注入行为约束、调用外部服务。枚举声明了 15 个事件,覆盖会话、轮次、工具、消息与系统五类;动作有四类:command 起子进程、prompt 把文本注入系统提示词、http 在 executor 里发请求避免阻塞事件循环、agent 目前是占位实现。配置校验很严格:事件、动作类型、必填字段、条件表达式任何一处不合法都会在启动时报错退出,而不是静默忽略——静默忽略会让用户以为规则生效了,这比拒绝启动更糟。

hooks:
  - id: block-rm
    event: pre_tool_use
    if: 'tool == "Bash" && args.command =~ /rm -rf/'
    reject: true
    action:
      type: command
      command: 'echo "blocked" >&2'

pre_tool_use 是唯一支持 reject 的事件,且必须同步执行,位置在权限检查之前;条件表达式支持相等、不等、正则与 glob,但一个表达式内不允许混用 &&||,混用直接报配置错误,要求拆成多条规则——用一点表达力换条件可读、可预测。四类动作覆盖了大部分自动化诉求:审计用 command、约束用 prompt、外部系统联动用 http;agent 动作目前是占位实现——扩展面能宣称的能力,以实际接通的部分为准,枚举里有不代表能用。实际有调用点的事件是 10 个,post_tool_use 只在非交互执行路径触发。声明面大于实现面是扩展系统的典型漂移:能力清单从注册表自动生成、或至少被测试覆盖,这类声明才有依据。

Slash 命令是给人用的扩展点:11 个固定处理器覆盖会话操作,命令带类型(本地执行、需要操作界面、把文本作为用户消息发出);动态注册包括 worktree、任务、trace、每个 Skill 一条同名命令,以及用户目录与项目目录下的 Markdown 命令——文件名即命令名,子目录用冒号拼命名空间。写一个 Markdown 文件就能扩一条命令,和 Skill 是同一个成本模型。命令注册时对名字与别名做全局查重,冲突直接报错;也可以标记为隐藏,不进补全面板。用户命令支持描述、参数提示与别名,正文里的占位符会被替换,没有占位符时参数以补充段落追加——它和 Skill 的差别只是触发方式与执行位置。

扩展点该做成配置还是代码

把 NovaCode 的六类扩展点按改动成本排一下:新增 Skill 是写一个 Markdown 文件;新增 Hook 与 MCP 服务器是往配置里加一段;新增工具要继承工具基类并注册;新增模型协议要实现一个客户端子类;新增前端要消费同一份事件流。前两类是配置,后三类是代码。

判断标准大致是:扩展点是「数据与行为声明」还是「新的执行语义」。之所以要区分,是因为两类扩展的失败方式不同:配置写错可以在启动校验里拦住,代码写错要靠测试与评审,混在一起会把问题推到最难发现的运行时。SOP、生命周期动作、外部工具接入属于数据与声明,适合做成配置,用户自己就能加;新工具的执行逻辑、新协议的归一化、新前端的渲染循环属于代码,抽象成基类与契约更合适。把后者硬塞进配置会得到一个不完备的 DSL,把前者写成代码会让扩展成本高到没人用。

无论哪种,扩展机制都需要四件套:稳定契约,让扩展点与宿主解耦——工具基类定义了名字、描述、参数模型与执行入口,模型协议的归一化让主循环不认识任何一家供应商的流式格式;故障隔离,一个服务器连不上只记 warning、一条规则解析失败只跳过自己;权限继承,扩展不能绕过宿主的安全链,Skill fork 正是反例;可发现性,模型能从系统提示词看到 Skill 清单,用户能从补全面板看到命令。最后回到一个常被混淆的关系:Function Calling 是模型侧能力,决定「调用哪个函数、参数是什么」;MCP 是客户端与服务器之间的协议,决定「工具从哪里来、怎么发现、怎么执行」。两者不冲突:MCP 服务器提供工具,客户端把它转成各家模型的工具定义,模型再用 Function Calling 发起调用。MCP 管集成的成本,Function Calling 管调用意图的表达。

插件生态的演进大致有三步:最早工具写死在宿主里,加能力等于改代码;后来出现配置式扩展,Skill 与 Hook 这类声明让用户自助;再往后是协议式生态,MCP 让工具与客户端解耦,出现可复用的服务器市场。每一步都在降低扩展成本,也都在扩大信任面——写死的工具在代码评审里被检查,配置和协议进来的工具只能在运行时校验,因此入口校验、权限继承与故障隔离需要随生态一起升级。扩展生态越繁荣,权限与信任就越不能只靠约定,需要由宿主的权限链强制。