KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

09 · 如何证明客户端真的兼容? — keel 龙骨

## 现场:兼容性 KPI 是「能连上」,直到客户换了 Client

现场:兼容性 KPI 是「能连上」,直到客户换了 Client

发给 Globex 之前,团队对 Incident Bridge 的兼容性很有信心——内部测了三个月,「能连上、能调工具、能拿结果」。

客户接进自己的门户后第一周,工单列表里出现了三次这样的现象:工具调用返回了正确的数据,但客户门户把它渲染成了「调用失败」。

根因是:客户门户按 content 数组里第一项的 type 做渲染分支,而它只实现了 type: "text"。我们的工具在某些分支返回了同样的 content,客户拿来当标准,恰好命中未实现的分支。

更尴尬的是复盘时发现:我们有完整的服务端日志,但没法回答任何一个"到底发生了什么"的问题。 日志里有 tool.finished 和 error=False,看起来一切正常。没有任何字段能把一次调用的两个事件关联起来。

本章解决两件事:怎么证明兼容(而不是证明"能连上"),以及观测到底要建成什么样。

直觉模型:兼容不是"能连上"

「能连上」只证明了 happy path。要证明兼容,至少要覆盖这些维度,并且每个维度都有可断言的最小结果:

维度 最小断言
版本 不支持的版本在进入操作阶段前就被拒,且错误里带 supported 列表
能力 未协商的能力不调用(降级路径也要测)
扩展 一方不支持时的降级或拒绝行为有定义
原语 列表按权限过滤,且返回的 schema 可以校验参数
错误 输入错误 / 权限 / 依赖不可用 / 结果未知可区分
任务 状态迁移合法,重启不丢任务,幂等键不重复执行
传输 断线、取消、重试不会偷偷重复副作用

这张表本身就是验收单据。它还有一个实际用途:当客户报 "不兼容" 时,你可以逐行问是哪一行。 大部分所谓的兼容性问题,最后都定位到这张表的某一格。

回看现场那个事例,它落在「错误」和「原语」两格里——返回了合法但客户没实现分支的结构化结果。这引出一个重要的判断标准:兼容性测试的断言必须写在对契约的理解上,而不是写在一次成功调用上。

一次完整运行

python courses/foundation/mcp-protocol-engineering/course/project/examples/08_compatibility.py

实跑输出:

{'version': True, 'tools': True, 'structured-result': True, 'trace-events': True}
passed: 4 / 4

四项检查,每一项都是布尔断言而不是"看起来正常"。第四项 trace-events 保证每次调用产生了可观测的事件。

把这套东西放进 CI 里,效果是:任何一次改动破坏了上面表格里的某格,构建就红。 这比人工点击靠谱得多,也比"等客户报错"便宜得多。

失败注入:trace_id 字段齐全,但链路是断的

这是本项目在本次修订中修掉的一个缺陷,值得完整走一遍,因为它非常隐蔽。

跑一段流程,打印事件:

server = MCPServer()
server.initialize(server.protocol_version)
principal = verify_token(server.secret, token, "incident:read", "acme")
server.discover(principal)
server.call_tool("incident.search", {"service": "payments"}, token, "acme")
server.call_tool("incident.search", {}, token, "acme")        # 故意缺参数

修复前的实跑输出(每个事件都带 trace_id):

protocol.initialized  trace_id=9482996b... version=2026-07-28
server.discover       trace_id=d8e0cc1a... subject=responder tools=2
tool.started          trace_id=e3cc1af5... tool=incident.search subject=responder tenant=acme
tool.finished         trace_id=9ee980ea... tool=incident.search subject=responder error=False
tool.rejected         trace_id=d1a40233... tool=incident.search subject=responder reason=invalid_input

看出来了吗?五个事件,五个不同的 trace_id。

问题在 EventRecorder.record:

def record(self, name: str, **attributes: Any) -> str:
    trace_id = attributes.pop("trace_id", uuid.uuid4().hex)     # ← 没传就生成一个
    self.events.append({"name": name, "trace_id": trace_id, "at": time.time(), **attributes})
    return trace_id

调用方从来不传 trace_id,于是每个事件各自抽了一个 uuid。字段名存在、值也存在、看起来完全正常——但是同一次调用的 tool.started 和 tool.finished 之间没有任何东西能连起来。

这正是现场那次复盘说不清的原因。它是本章最值得记住的一条教训:可观测性的失败往往是"字段都有值"而不是"字段缺失"。 校验一个观测系统是否真的可用,唯一有效的方式是问:「给我把这个 id 对应的完整链路拉出来。」拉不出来就是坏的。

修复方式是让一次调用共享一个 trace_id,并允许外部传入以便跨服务串联:

def call_tool(self, name, arguments, token, tenant, trace_id=None) -> ToolResult:
    # 同一次调用的所有事件必须共享一个 trace_id,否则 started 与 finished 无法配对
    trace_id = trace_id or uuid4().hex
    ...
    self.events.record("tool.started", trace_id=trace_id, tool=name, ...)
    result = handler(arguments, principal)
    self.events.record("tool.finished", trace_id=trace_id, tool=name, ...)

修复后的实跑输出(前两次调用显式传入同一个 trace_id,第三次不传):

trace-7f3a91  tool.started    tool=incident.search subject=responder tenant=acme
trace-7f3a91  tool.finished   tool=incident.search subject=responder error=False
trace-7f3a91  tool.rejected   tool=incident.search subject=responder reason=invalid_input
786ebc9e3f21  tool.started    tool=service.health  subject=responder tenant=acme
786ebc9e3f21  tool.finished   tool=service.health  subject=responder error=False

现在一次调用的两个事件能被配对了。tests/test_protocol.py 里有一条回归测试断言这件事。

注意这个修复的边界:它只在单个方法内部串联。 真实系统里还需要把 trace_id 跨服务传下去(Trace Context 传播),以及把同一个 Agent 运行的多次调用串到一个 trace 下。本项目做到「同一次调用可配对」,剩下的属于生产替换点。

观测的三层

有了可靠的关联主键之后,观测才有意义。三层数据,用途不同:

层 内容 回答什么问题
事件 / span 协议方法、策略决定、状态迁移、工具调用 「这次请求到底走了哪几步」
指标 调用耗时分布、错误率、取消率、任务停留时长 「整体健康吗,比昨天差了吗」
日志 脱敏后的上下文与异常 「出错那一次的具体输入是什么」

三个回答不了的问题也值得知道:指标不能告诉你单次请求发生了什么,日志不能告诉你趋势,span 太多时需要采样。三者不是替代关系。

值得设成指标的两个比值

本项目记录的这些事件名字,天然可以做除法:

tool.rejected / tool.started     → 参数质量:比值升高说明工具描述或 schema 有问题,
                                   很可能是上游把它改动了UI/提示词改坏了
task.input / task.created        → 等待比例:比值升高说明 inputSchema 没说清楚,
                                   用户被迫返工

第一个比值尤其有用。它升高时通常不是服务端的 bug,而是工具描述或 schema 变了导致模型填错——也就是说它是一个能提前发现「交互退化」的信号,而不是一个基础设施告警。

事件必须能回答的问题

整理审计时至少要能回答:

参数必须脱敏。 尤其是 token、密钥和个人数据——日志是最容易发生二次泄露的地方。

生产替换点

教学实现 生产替换
EventRecorder 列表 OpenTelemetry spans / metrics / logs + SIEM
方法内随机 trace_id(已修) Trace Context 跨服务传播 + 采样策略
unittest 兼容矩阵 Inspector 工具、契约测试、灰度与回滚门禁
内存事件 持久化审计 + 保留策略 + 合规导出
无 Topline 对比 上述比值进 dashboard,配告警阈值

练习与验收

练习(有可观察结果):给 start_diagnosis → provide_diagnosis_input → complete_diagnosis 这条链加一个贯穿的 trace_id,写测试断言三个事件的 trace_id 相同。

验收标准:不传 trace_id 时也能自动串联(方法内部生成),传入时全程复用传入值。若你的实现让三个事件拿到三个不同 id,说明串联没做对。

本章检查点

现在能解释什么

你现在能用一张七维矩阵给兼容性下定义,而不是用「能连上」。你也亲手验证了观测系统最常见的失败形式——字段齐全但无法关联——并且知道修复是一次调用共享一个 trace_id。最后一章把前十章整合起来:把 Incident Bridge 提升为只允许只读诊断、所有长任务都有状态和审计的形态。

进入 keel 阅读