工具版本与兼容性治理:能力协商、Schema 演进与灰度下线

系统讲解 MCP 工具的版本与兼容性治理:协议版本与 serverInfo 版本的区别、工具 Schema 的破坏性与非破坏性变更清单、参数重命名与枚举收缩的处理、list_changed 通知与客户端缓存的失效、版本化工具名与就地演进两条路线、网关侧的路由与灰度、以及弃用策略与兼容性测试。

工具一旦发布,它就不再是「你的代码」,而是「别人 Agent 的依赖」。有人在提示词里写了 search_docs(query, top_k),有人在网关里按名字做了白名单,有人把返回的 JSON 字段直接映射进了自己的 UI。此时你顺手把 top_k 改名成 limit,或者把返回里的 count 改成 total,就是一次静默的破坏性变更——它不会报错,只会让别人的 Agent 开始胡言乱语。

MCP 的协议层没有给工具做版本号(tools/list 里没有 version 字段),所以兼容性治理必须由服务器作者自己设计。本文要回答的是:哪些改动是安全的、哪些必须走新工具名、list_changed 通知在升级里扮演什么角色、网关侧怎么灰度、以及怎么把弃用做成一个可执行流程。

1. 三种「版本」不要混淆

版本位置语义变更影响
协议版本initialize 的 protocolVersion通信协议契约握手失败,连接不可用
服务器版本serverInfo.version服务器实现版本仅信息展示,无自动行为
工具契约版本无(需自定义)工具入参/出参契约破坏 Agent 与调用方

1.1 协议版本协商

// 客户端 → 服务器
{ "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {...} } }

// 服务器 → 客户端(不兼容时返回自己支持的版本)
{ "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {} }, "serverInfo": { "name": "docs-server", "version": "2.4.0" } } }

规则很简单:客户端提出自己支持的版本,服务器要么接受,要么返回自己支持的版本,由客户端决定是否继续。协议版本不匹配是连接级失败,不是工具级失败——它和本文讨论的工具契约演进是两回事。

1.2 工具契约版本为什么要自己管

1. 工具定义会进模型上下文(tools/list 的结果通常整体注入)
2. 模型对参数名敏感:改名 ≈ 换了一个工具
3. 客户端与网关会缓存工具定义,缓存过期前会继续按旧契约调用
4. 提示词里可能硬编码了工具名与参数名

2. 变更分类清单

把每一次改动归入下面三类,是兼容性治理的全部基础。

2.1 安全变更(向后兼容)

变更说明注意
新增可选参数老调用不传也能跑必须有合理默认值
扩展 enum 取值老值仍有效客户端若有穷举校验需同步
新增返回字段老字段不动严格模式的消费者可能报错
放宽约束如 maxLength 调大几乎无风险
优化 description提升模型选择率无契约影响

2.2 需谨慎的变更

变更风险缓解
新增必填参数老调用直接失败改为可选 + 服务器侧推导默认
收紧约束(如 minLength)老参数值被拒先告警统计一段时间再收紧
修改默认值行为静默改变显式记录在变更日志,通知调用方
调整返回字段类型消费者解析失败加新字段,老字段保留一个周期
更换错误码/错误文案Agent 依赖文案做分支用稳定错误码,文案可自由改

2.3 破坏性变更(必须新版本)

变更为什么破坏
重命名工具名字是模型选择的唯一键
重命名参数模型按名填参,改名等于新参数
删除参数老调用传了未知参数
删除/重命名返回字段消费者解析失败
收缩 enum(移除取值)老调用传了被移除的值
改变语义(同名不同义)最危险:调用成功但结果错了

最后一行值得强调:语义变更比签名变更更危险。把 search 的默认排序从「相关度」改成「时间」,签名一字未改,所有依赖相关度的调用方都会静默降级。

3. 两条演进路线

3.1 路线 A:版本化工具名

search_docs_v1  →  search_docs_v2
优点:新旧并存、可独立下线、回滚就是切回旧名
缺点:工具列表膨胀(模型选择准确率下降)、描述重复、维护两套实现

适用:破坏性变更不可避免、且调用方无法同步升级(第三方生态、跨团队)。

3.2 路线 B:就地演进 + 兼容窗口

search_docs  (保持名字不变)
  第 1 阶段:新增 limit 参数,top_k 标记 deprecated 但继续接受
  第 2 阶段:两者都传时以 limit 为准,top_k 触发告警日志
  第 3 阶段:观察 N 周无 top_k 调用 → 移除 top_k
const schema = {
  type: "object",
  properties: {
    query: { type: "string" },
    limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
    top_k: { type: "integer", deprecated: true, description: "已弃用,请改用 limit" },
  },
  required: ["query"],
};

function normalize(args: any) {
  const limit = args.limit ?? args.top_k ?? 10;
  if (args.top_k != null) metrics.inc("deprecated.top_k");
  return { query: args.query, limit };
}
优点:工具列表干净、模型选择不受干扰
缺点:需要兼容层与用量统计、下线时机依赖可观测性

适用:自有生态、调用方可控、有指标能判断何时可以下线。

3.3 选择建议

条件推荐
变更破坏性强、调用方不可控路线 A(新名字)
变更可兼容、有调用指标路线 B(就地演进)
语义发生根本改变路线 A(并保留旧工具的只读兼容期)
仅调整 description两条都不用,直接改(但要在变更日志记录)

无论走哪条,弃用必须带日期与迁移指引:只写 deprecated: true 而不说「何时删、换成什么」,等于把问题推给调用方猜。API 版本演进的通用方法论可参考 API 版本化策略 。

3.4 兼容层应该放在哪

就地演进需要一层「新旧参数归一化」。它的位置决定了维护成本:

位置优点缺点
工具 handler 入口实现简单、就近可见每个工具都要写一遍
中间件 / 装饰器统一处理、易测试需要约定命名规范
网关层服务器无感、可集中下线网关需理解每个工具的契约
// 用装饰器统一做参数归一化与弃用打点
function withDeprecations<T extends object>(map: Record<string, keyof T>) {
  return (handler: (args: T) => Promise<CallToolResult>) => async (raw: Record<string, unknown>) => {
    const args = { ...raw } as Record<string, unknown>;
    for (const [oldKey, newKey] of Object.entries(map)) {
      if (args[oldKey] !== undefined && args[newKey] === undefined) {
        args[newKey] = args[oldKey];
        metrics.inc(`deprecated.${oldKey}`);
      }
      delete args[oldKey];
    }
    return handler(args as T);
  };
}

归一化必须幂等且单向:只做「旧名 → 新名」的映射,绝不反向补写旧名,否则会在返回结果里凭空多出已弃用字段,让调用方以为该字段仍受支持。

另外要注意:归一化会掩盖契约不一致。当两个参数同时传入且值不同时,必须明确优先级(通常新名优先)并打点,而不是静默择一——否则调用方永远不知道自己传错了。

4. 通知与缓存失效

4.1 list_changed 的角色

工具集合或工具定义变化后,服务器应推送:

{ "method": "notifications/tools/list_changed" }

客户端收到后重新 tools/list,刷新本地工具定义与注入模型的上下文。

必须推送的时机:
  新增/删除工具
  工具参数或返回值契约变化
  工具 description 大幅调整(影响模型选择)
  工具被临时禁用(如依赖服务故障)

4.2 不推通知的代价

很多客户端会在会话开始时缓存工具列表并长期复用。如果服务器升级后不推 list_changed:

旧客户端 + 新服务器 → 按旧契约调用 → 参数被忽略或报错
更糟:新增的工具永远不会出现在模型的候选里

注意能力协商:客户端在 initialize 里声明 tools.listChanged: true 才表示它愿意处理该通知。未声明时,服务器只能寄希望于客户端下次会话重新拉取。

4.3 与提示词缓存的关系

工具定义通常位于请求的前部(system 区),一旦变化会使提示词缓存整体失效:

缓存命中条件:前缀逐字节一致
工具描述改一个字 → 前缀变化 → 缓存失效 → 下一轮请求成本上升

所以频繁微调 description(「优化一下措辞」)不是零成本的:每次改动都可能让一整批会话的缓存作废。建议把 description 调整批量发布,而不是逐字提交。提示词版本管理与 A/B 的做法可参考 提示词版本管理与 A/B 。

5. 网关与多服务器场景

5.1 网关聚合下的版本问题

网关把多个服务器聚合为一个工具命名空间时,版本策略要额外考虑:

1. 命名冲突:两个服务器都叫 search → 网关需加前缀(docs__search)
2. 版本混装:同一逻辑工具在两个服务器上版本不同 → 网关需路由到指定版本
3. 下线联动:某服务器下线某工具 → 网关需同步移除,否则调用直接失败
# 网关侧的工具路由与版本固定
routes:
  - public_name: docs__search
    upstream: docs-server@2.4.x        # 固定主版本,避免小版本漂移
    tool: search_docs
    timeout_ms: 8000
  - public_name: docs__search_legacy
    upstream: docs-server@1.x
    tool: search_docs
    sunset: 2026-12-31

网关侧的注册、发现与配额细节见 https://plumephp.com/mcp-gateway-registry-proxy/。

5.2 灰度发布

工具契约变更的灰度比代码灰度更麻烦——同一会话内不能混用新旧契约,否则模型会看到互相矛盾的参数定义。

可行做法:
  1. 按会话分流(同一 session 固定路由到同一版本)
  2. 按客户端分流(内部客户端先升级,第三方后升级)
  3. 影子调用:新版本同时接受新旧参数,但只对新会话暴露新描述
不可行做法:
  1. 按请求随机分流(模型上下文里工具定义会抖动)
  2. 同一会话中途切换版本(缓存与上下文不一致)

5.3 回归测试

每次契约变更都要跑三类测试:

1. 契约测试:旧参数组合仍能调用成功(兼容层是否真的生效)
2. 模型侧测试:用基准提示词检查工具选择率与参数填充正确率是否下降
3. 端到端测试:真实任务成功率与耗时对比

第 2 类最容易被跳过,但它是唯一能发现「description 改坏了」的手段——契约测试全绿而模型不再选这个工具,是典型的静默退化。评估方法见 https://plumephp.com/mcp-evaluation-benchmarking/,服务器侧的功能与协议测试见 https://plumephp.com/mcp-server-testing/。

6. 弃用流程模板

一个可执行的弃用流程,四步闭环:

第 0 步|评估
  统计调用量、调用方分布、参数使用分布
  确认没有调用方在用将被移除的能力

第 1 步|公告
  工具 description 里加「已弃用,请改用 X,将于 YYYY-MM-DD 移除」
  返回结果里附警告(不要用 isError,避免中断现有流程)
  变更日志与文档同步

第 2 步|告警与观测
  每次使用弃用能力 → 打点 + 日志
  设置看板:弃用能力调用量趋势

第 3 步|移除
  连续 N 周(建议 ≥ 4)调用量为 0 → 移除
  移除同时推送 tools/list_changed
  保留一个次要版本的"未知参数友好提示"(返回明确错误而非崩溃)

第 1 步里的「不要用 isError」很关键:如果弃用直接返回错误,所有还在用旧参数的 Agent 会立刻失败,等于把兼容期变成了强制升级。正确做法是继续正常工作 + 附警告,把失败留到真正的移除日。

7. 常见陷阱

陷阱症状解决
参数改名直接上线老 Agent 参数填不上新名新增 + 旧名兼容一个周期
改了不推 list_changed客户端按旧契约调用任何契约变化都推通知
逐字微调 description提示词缓存频繁失效批量发布
语义变更不公告调用成功但结果错视为破坏性变更走新版本
灰度按请求随机分流模型看到的定义抖动按会话或按客户端分流
弃用直接返回错误调用方立刻全线失败继续可用 + 附警告
版本化工具名无限累积工具列表膨胀、选择率下降设定下线的 sunset 日期
只做契约测试描述改坏无人发现补模型侧选择率回归

8. 小结

工具版本治理的核心是「把契约当成对外接口,而不是内部实现细节」:

层面要点
分类安全变更直接发;谨慎变更加兼容层;破坏性变更走新版本
路线调用方不可控 → 版本化工具名;自有生态 → 就地演进 + 兼容窗口
通知任何契约变化都推 tools/list_changed,别指望客户端自己刷新
成本description 变更会使提示词缓存失效,批量发布而非逐字提交
灰度按会话/客户端分流,绝不按请求随机
弃用继续可用 + 警告 + 指标观测 + 到期移除,四步闭环

工具是会被人依赖的。给它一个明确的变更分类、一个能观测的弃用流程、一套能发现静默退化的回归测试,升级就不再是「赌调用方没在用这个参数」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「AI工程」更多文章

  1. IDE 与编辑器集成:stdio 生命周期、工作区上下文与诊断回写
  2. 流式响应与进度通知:progress token、日志通知与背压处理
  3. 多模态工具设计:图像、音频与文档内容的返回契约