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 变了导致模型填错——也就是说它是一个能提前发现「交互退化」的信号,而不是一个基础设施告警。
事件必须能回答的问题
整理审计时至少要能回答:
- 谁(subject、tenant)?
- 在什么时候(时间戳 + 版本)?
- 用了什么能力(tool、resource、scope)?
- 策略怎么判的(allow/deny + 原因码)?
- 结果属于哪一类(成功 / 输入错 / 权限 / 依赖不可用 / 结果未知)?
- 能不能关联到一次完整运行(trace id、request id、task id)?
参数必须脱敏。 尤其是 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 字段」为什么不能证明可观测性是通的?正确的验证动作是什么?
tool.rejected / tool.started这个比值升高,最可能的技术原因和业务原因分别是什么?
现在能解释什么
你现在能用一张七维矩阵给兼容性下定义,而不是用「能连上」。你也亲手验证了观测系统最常见的失败形式——字段齐全但无法关联——并且知道修复是一次调用共享一个 trace_id。最后一章把前十章整合起来:把 Incident Bridge 提升为只允许只读诊断、所有长任务都有状态和审计的形态。