深度剖析 MCP 的两个错误处理通道差异,展示工具失败但 HTTP 200 的情况如何隐藏成本泄露。通过 cost attribution 的真实案例揭示可观测性的重要性。
在我部署了成本归因后的几周,我盯着一个看起来很健康的 trace,却理不出个所以然。
同一个 session 内对同一个工具的六次调用。全部 HTTP 200。所有 span 状态都是 OK。总耗时约四秒。trace 中没有任何迹象表明出了问题。
那个工具六次都失败了。每次都是同样的错误。Agent 根本察觉不到。
我之所以发现这个问题,仅仅是因为我在 v0.5.0 中构建的成本归因显示了一个逻辑操作产生了六次 InvokeModel 的计费。trace 说一切都成功了。美元说明了问题所在。
这个差异正是 v0.6.1 要解决的。
MCP 有两个错误通道,其中只有一个的行为符合预期。
协议错误——未知方法、格式错误的请求——会作为 JSON-RPC 错误对象返回。这些会正常传播。
工具错误则不同。当工具执行失败时,服务器返回一个成功的 JSON-RPC 响应,结果中包含 isError 标志:
HTTP/1.1 200 OK
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"isError": true,
"content": [
{ "type": "text", "text": "connection refused: 10.0.0.5:5432" }
]
}
}
没有任何东西抛出异常。没有任何东西 reject。通用的仪表化工具读取传输状态,看到 200,并将 span 标记为 OK。
现在想象 agent 看到什么。它把那个错误文本作为一个普通的工具结果收到。从模型的角度来看,什么都没有失败——它只是收到了读起来像在抱怨它的输入的内容。所以它做了合理的事情:重新措辞参数后再试一次。
工具以相同的方式失败。Agent 再试一次。
每次重试都会将累积的上下文重新发送给 Bedrock,而上下文由之前失败的结果增长了。所以重试变得逐渐昂贵:
一个重试循环的说明性 token 增长——实际数字完全取决于你的上下文大小和 agent 的重试行为。
六次计费的 InvokeModel 调用产生了什么都没有,却没有一个错误 span 来指向问题。
v0.6.1 监视同一个工具在一个 session 内以相同的失败指纹重复失败的情况。当这种情况超过阈值时,它发出一个事件来承载整个循环,而不是让它分散在 N 个无法区分的 span 中。
Span event: mcp.loop.detected
mcp.loop.length 6
mcp.loop.wasted_tokens_in 54000
mcp.loop.wasted_tokens_out 1800
mcp.loop.wasted_cost_usd 0.19
mcp.loop.duration_ms 4310
mcp.loop.first_span_id 7b2e...
mcp.loop.first_trace_id c81a...
mcp.loop.session_id sess-4471
mcp.failure.fingerprint a3f8c21d94b06e77
first_span_id 是在凌晨 2 点救你时间的那个。它指向循环开始的 span,这就是实际根本原因所在的地方——其他五个只是回声。
mcp.tool.loop.detected Counter
mcp.tool.loop.length Histogram
mcp.tool.loop.wasted_tokens Histogram tokens
mcp.tool.loop.wasted_cost_usd Histogram USD
mcp.tool.loop.duration Histogram ms
与 v0.5.0 相同的分工:计数器为你提供告警和趋势线,span 事件在告警触发时为你提供深入分析。
检测基于来自 v0.4.0 的失败指纹化,这是让它有用而不是嘈杂的部分。
原始错误字符串不会分组。同样的连接断开会每次产生不同的消息——不同的 IP、不同的请求 ID、不同的路径。所以指纹通过一个管道来规范化错误,该管道剥离 UUID、路径、数字和十六进制字符串,然后对结果进行哈希并截断为 16 个十六进制字符:
connection refused: 10.0.0.5:5432 → a3f8c21d94b06e77
connection refused: 10.0.0.7:5432 → a3f8c21d94b06e77
timeout after 30000ms on req_88a1 → 6d10b4e7c2f3a915
timeout after 30000ms on req_91c4 → 6d10b4e7c2f3a915
相同的根本原因,相同的指纹,无论附带的细节如何。六个失败和六条不同的消息折叠成一个检测到的循环——这是正确的解读,因为它是一个问题。
这也是为什么 v0.6.1 不能在 v0.4.0 和 v0.5.0 之前发布。循环事件需要指纹来知道失败是相同的,并且需要成本归因来说明循环成本是多少。任何一个特性都不能单独产生这个结果。
无新的设置。如果你已经运行了这个库,thrash detection 默认是打开的:
import { instrumentMcpServer } from "opentel-mcp";
const server = instrumentMcpServer(mcpServer, {
serviceName: "mcp-server"
});
任何在 60 秒内以相同指纹失败三次或以上的工具都会自动被标记。
这是一个 Bedrock 工具,如果下游表消失将会 thrash:
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { instrumentMcpServer } from "opentel-mcp";
const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });
server.tool("lookup_customer", async ({ query }) => {
try {
const rows = await db.query(query);
const response = await bedrock.send(new InvokeModelCommand({
modelId: "amazon.nova-pro-v1:0",
body: JSON.stringify({
messages: [{ role: "user", content: [{ text: summarize(rows) }] }]
})
}));
const result = JSON.parse(new TextDecoder().decode(response.body));
return {
content: [{ type: "text", text: result.output.message.content[0].text }],
_meta: { usage: result.usage, model: "amazon.nova-pro-v1:0" }
};
} catch (err) {
// This is the shape that goes unnoticed: HTTP 200, isError in the payload
return {
isError: true,
content: [{ type: "text", text: `query failed: ${err.message}` }]
};
}
});
instrumentMcpServer(server, { serviceName: "customer-tools" });
重命名底层表,agent 会在放弃前重试这个 4 到 6 次,每次都向 Bedrock 计费。使用 v0.6.1 你会得到一个 mcp.loop.detected 事件,包含总计。
每个字段都可以在代码中或通过环境变量覆盖,这使得无需重新部署就能按环境调整:
无效的环境值会无声地回落到默认值。它们永远不会抛出异常。
指纹是无界的——每个新 bug 都是一个新指纹,永久的。Session ID 更糟。把这两者之一放在一个指标维度上,你就为每个 bug 和每个 session 创建一个新的时间序列,永远。在 CloudWatch 中这很快会变得昂贵;在任何后端最终都会失败。
所以每个指标只携带 gen_ai.tool.name。指纹和 session ID 存在于 span 事件上,其中高基数是安全的,当你深入分析时你无论如何都能得到完整的细节。白名单在代码中强制执行,而不是通过约定,因为"记住不要在这里添加属性"不是一个能在与未来版本的自己接触后存活的策略。
循环检测必须记住最近的失败,这意味着它持有状态。stdio 传输上的 MCP 服务器运行进程的生命周期——有时是几周。无界映射会成为一个慢内存泄漏,只在最长运行的部署中显现,这正是你最不想调试的部署。
所以它是一个带 TTL 的 LRU:键上限、读取时的懒惰过期和写入时的摊销扫描。故意没有 setInterval——一个实时定时器会保持 Node 事件循环活跃并阻止服务器干净地退出,这是它自己的一个微妙 bug。
这是最可能被错误配置的设置,所以值得明确说明。
循环检测需要一个 session 边界。将两个客户端的失败合并到一个桶中,你就会得到一个幻象循环:三个无关客户端各失败一次看起来与一个客户端失败三次完全相同。
面向 session 的传输提供一个真实的 session ID。Stdio 不提供——进程生命周期内恰好有一个连接。解决顺序:一个真实的 session ID 总是赢的并永久标记服务器为 session 感知的。一旦一个服务器被观察到分配出一个,一个后来没有的调用会被跳过而不是合并。一个生成的回退仅在传输在结构上被确认为单连接时使用,或者当你通过 assumeSingleSession 显式选择加入时。
否则检测会无声地被跳过而不是猜测。跳过产生缺失的数据;猜测产生错误的数据。错误更糟。
与 v0.5.0 中的预算标志相同的原则。检测循环设置属性并发出指标。它不取消请求、中断连接或拒绝下一个调用。
一个能中断 agent 执行的仪表化库是一个能以没人预见的方式破坏生产的库。循环破坏属于 agent 框架或 AI 网关,在那里它是请求路径的一个有意的部分,可以在不重新部署你的 MCP 服务器的情况下禁用。
如果你只想知道现在是否有任何东西在 thrash,有一个根本不涉及 OpenTelemetry 的进程内访问器:
console.log(server.getThrashSummary());
// {
// activeLoops: 1,
// totalLoopsDetected: 4,
// totalWastedCostUsd: 0.09,
// totalWastedTokensIn: 3600,
// totalWastedTokensOut: 900,
// topOffenders: [
// { toolName: 'lookup_customer', fingerprint: 'a3f4c8e2b1d09f77',
// loops: 3, wastedCostUsd: 0.03 }
// ]
// }
没有任何东西被发送到任何地方。从健康检查处理器调用是安全的,在你还没有建立任何后端之前它就有效。
一个值得说明的警告:activeLoops 和 topOffenders 仅反映当前在有界存储中的内容,所以一个被驱逐或过期的循环不会出现,即使它真的发生过。累积总数在两者中都存活并回答"自启动以来这个进程浪费了多少"。读总数来做会计,而不是读违规者列表。
检测是哈希映射查找和计数器增量,仅在失败路径上。成功的调用做一个单一清除操作。无网络调用,无异步工作。
不同的原因产生不同的指纹,所以它们不会分组,没有循环会触发。这是预期的行为——一个工具以三种不同的方式失败是一个与工具以相同方式失败三次不同的问题。
不能。检测键基于指纹,所以使用 fingerprinting: false 它无声地永远不会触发。两者默认都是打开的。
这些通过 JSON-RPC 错误通道而不是作为 isError: true 到达,所以它们是一个单独的检测路径,这里没有覆盖。值得知道,因为"agent 调用工具错误"是论点上最可能循环的失败模式。它在列表中。
在阈值处发出一次,然后每隔 reEmitAfter 个失败再发一次——所以一个 12 调用循环在 3、6、9 和 12 处发出,而不是九次。浪费成本的数字是增量计算的,所以重新发出不会双重计数。
值得明确说明,因为这是库中现在最有趣的未解决问题。
如果观察路径本身破坏了——没有提供商注册、收集器无法到达、导出器无声地丢弃——那么工具失败和干净运行产生相同的输出。零。"什么都没有失败"和"什么都没有被观察"折叠成相同的状态。
这来自一个外部读者对 v0.3.0 发布帖子的评论,他是对的。活跃信号不能通过其活跃性有问题的通道传递,所以这必须在进程内路径上显现而不是通过 span。
提议的约定是三个状态而不是两个:OBSERVED_CLEAN、OBSERVED_FAILING、OBSERVATION_UNAVAILABLE。它目前在回购中作为一个跳过的规范测试存在,推理已被记录,而不是被无声地忽视。不与不稳定的 SDK 内部耦合就检测到无界的观察路径是我还没有答案的部分。
任何追踪过一个看起来完全健康但没有绑定任何端口的收集器的人都会认识这个形状。
npm install opentel-mcp
npm: https://www.npmjs.com/package/opentel-mcp
GitHub: https://github.com/Thirumalaiboobathi/opentel-mcp
Docs: https://opentel-mcp-site.pages.dev/
如果你在 AWS 上运行 MCP 服务器,值得今天检查的是你的工具失败是否真的到达了你的 trace。我的没有——我想知道你的重试模式是什么样的,因为人们报告的边界情况是塑造下一个版本的东西。