ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MCP Server 之旅第 7 站:用 OpenTelemetry 打破 MCP 的“黑盒困境”

MCP Server 之旅第 7 站:用 OpenTelemetry 打破 MCP 的“黑盒困境” 1. 一次 MCP 工具调用为什么在 Serverless 里成了黑盒MCP Server 在本地跑的时候日志、断点、进程都在你眼皮底下出问题顶多翻翻终端。可一旦把它部署到 Serverless 平台情况就完全变了函数实例随请求创建、执行完就销毁冷启动时间飘忽不定SSE 长连接和 message 通道的建立过程藏在平台内部你只能看到最终返回的天气结果中间发生了什么全靠猜。这就是 MCP Server 在 Serverless 场景下最典型的可观测性缺口——链路追踪缺失。OpenTelemetry 正好补上这块。它是一套厂商中立的可观测性标准核心能力是用 Trace 把一次请求经过的每个环节串起来每个环节叫一个 SpanSpan 之间有父子关系能还原出完整的调用树。对 MCP Server 来说一次工具调用天然就是一条链路客户端发起请求 → 建立 SSE 连接 → 收到 message → 路由到具体工具 → 工具执行比如查天气→ 返回结果。如果每个环节都埋上 Span你就能在追踪面板里看到哪一段慢、哪一段报错。这篇适合两类人一是已经把 MCP Server 部署到 Serverless 平台、但排查问题只能靠日志拼凑的开发者二是想给自建 MCP Server 加上链路追踪、但不知道 Span 该怎么埋的人。我会用 OpenTelemetry Collector 做数据汇聚给出可复制的配置和埋点片段最后发起一次真实的工具调用在追踪面板里确认父子 Span 和耗时链路完整呈现。整个过程不依赖特定云厂商你在本地 Docker 里就能跑通。先说清楚一个概念MCP 的“黑盒困境”不是指代码不可见而是指运行时行为不可见。Serverless 把基础设施抽象掉了好处是不用管服务器代价是失去了对执行过程的直接观察。OpenTelemetry 的价值就在于它用标准协议把这段被抽象掉的执行过程重新暴露出来而且不绑定任何一家平台。2. OpenTelemetry Collector 接入 MCP Server 的前置准备在动手埋点之前得先把数据管道搭好。OpenTelemetry 的架构分三层应用侧用 SDK 产生 Span通过 OTLP 协议发给 CollectorCollector 再转发给后端的追踪系统比如 Jaeger、Tempo 或者云厂商的可观测平台。这一节把 Collector 和 MCP Server 的运行环境准备好。2.1 用 Docker Compose 起一个 Collector 和 Jaeger最省事的方式是用 Docker Compose 把 Collector 和 Jaeger 一起拉起来。Jaeger 负责存储和展示 TraceCollector 负责接收和转发。新建一个目录写一个docker-compose.ymlversion: 3.8 services: otel-collector: image: otel/opentelemetry-collector-contrib:0.96.0 command: [--config/etc/otel-collector-config.yaml] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: - 4317:4317 # OTLP gRPC - 4318:4318 # OTLP HTTP - 8889:8889 # Prometheus metrics depends_on: - jaeger jaeger: image: jaegertracing/all-in-one:1.54 environment: - COLLECTOR_OTLP_ENABLEDtrue ports: - 16686:16686 # Jaeger UI - 4317这里 Collector 暴露了 4317gRPC和 4318HTTP两个 OTLP 接收端口MCP Server 的 SDK 会往这两个端口发数据。Jaeger 的 UI 在 16686等会儿验证链路就靠它。2.2 Collector 配置接收 OTLP、批量转发Collector 的行为完全由配置文件决定。新建otel-collector-config.yamlreceivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s send_batch_size: 512 memory_limiter: check_interval: 1s limit_mib: 256 exporters: otlp: endpoint: jaeger:4317 tls: insecure: true debug: verbosity: detailed service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp, debug]几个关键点batch处理器把 Span 攒一批再发减少网络开销memory_limiter防止 Collector 自己吃爆内存debug导出器会把收到的 Span 打到标准输出调试阶段非常有用能看到数据到底有没有进来。生产环境可以把debug去掉。2.3 MCP Server 侧安装 OpenTelemetry SDKMCP Server 通常用 Python 或 Node.js 写。以 Python 为例装这几个包pip install opentelemetry-api \ opentelemetry-sdk \ opentelemetry-exporter-otlp \ opentelemetry-instrumentation如果你用的是官方 MCP Python SDK它内部基于 asyncio埋点时要特别注意异步上下文的传递后面会讲。Node.js 侧对应的是opentelemetry/api、opentelemetry/sdk-node和opentelemetry/exporter-trace-otlp-grpc思路一致。环境变量把导出地址指到 Collectorexport OTEL_EXPORTER_OTLP_ENDPOINThttp://localhost:4317 export OTEL_SERVICE_NAMEmcp-weather-server export OTEL_TRACES_EXPORTERotlp export OTEL_EXPORTER_OTLP_PROTOCOLgrpcOTEL_SERVICE_NAME很重要它决定了你在 Jaeger 里按服务名筛选时看到的名字。多个 MCP Server 要设不同的值否则链路会混在一起。2.4 关于模型调用的接入选择MCP Server 里如果还要调用大模型比如让模型决定调哪个工具这部分调用同样值得追踪。我实测下来用 TaoToken 的 API 接入比较顺手它的 Base URL 是https://taotoken.net/api兼容 OpenAI 风格的接口OpenTelemetry 的 HTTP instrumentation 能自动给它生成 Span。你可以在 TaoToken API Keys 页面 拿到 Key模型 ID 按文档里列出的填。这样模型调用的耗时、Token 消耗也能进同一条链路排查“是工具慢还是模型慢”时特别有用。前置准备到这里就齐了Collector 在跑Jaeger 在等数据SDK 装好了环境变量也设了。下一节开始写埋点代码。3. 可复制的 Span 埋点配置与 MCP 工具调用链路这一节是核心。我要把一次 MCP 工具调用拆成几个 Span让它们在追踪面板里形成父子关系。先讲清楚 Span 的层级设计再给可复制的代码。3.1 设计 Span 层级从请求入口到工具执行一次典型的 MCP 工具调用Span 树应该是这样的mcp.request (根 Span代表一次客户端请求) ├── mcp.sse.connect (SSE 连接建立) ├── mcp.message.receive (收到 message) ├── mcp.tool.invoke (工具调用总入口) │ ├── mcp.tool.weather_query (具体工具执行) │ └── llm.chat.completion (如果中间调了模型) └── mcp.response.send (返回结果)根 Span 用mcp.request子 Span 用mcp.前缀这样在 Jaeger 里一眼就能看出哪些是 MCP 相关的。工具执行那段单独拆出来方便对比不同工具的耗时。3.2 初始化 TracerProvider先写一个tracing.py负责初始化 SDKfrom opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource def init_tracing(service_name: str mcp-weather-server): resource Resource.create({ service.name: service_name, service.version: 1.0.0, deployment.environment: serverless, }) provider TracerProvider(resourceresource) exporter OTLPSpanExporter(endpointhttp://localhost:4317, insecureTrue) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) return trace.get_tracer(service_name)Resource里的属性会附加到每个 Span 上deployment.environment设成serverless方便区分环境。BatchSpanProcessor异步批量导出不会阻塞工具执行。3.3 在 MCP 工具处理函数里埋点假设你的 MCP Server 用官方 SDK工具处理函数大概长这样。我在关键位置插入 Spanimport time from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer trace.get_tracer(mcp-weather-server) async def handle_tool_call(tool_name: str, arguments: dict): # 根 Span一次工具调用 with tracer.start_as_current_span(mcp.tool.invoke) as span: span.set_attribute(mcp.tool.name, tool_name) span.set_attribute(mcp.tool.arguments, str(arguments)) try: # 子 Span具体工具执行 with tracer.start_as_current_span(fmcp.tool.{tool_name}) as tool_span: start time.time() result await execute_tool(tool_name, arguments) elapsed (time.time() - start) * 1000 tool_span.set_attribute(mcp.tool.duration_ms, elapsed) tool_span.set_attribute(mcp.tool.result_size, len(str(result))) tool_span.set_status(Status(StatusCode.OK)) return result except Exception as e: span.record_exception(e) span.set_status(Status(StatusCode.ERROR, str(e))) raisestart_as_current_span会自动处理父子关系在mcp.tool.invoke的with块里再开 Span新 Span 自动成为它的子节点。record_exception把异常堆栈记进 Span排查时能直接看到报错。3.4 处理 SSE 连接和 message 接收MCP 的传输层用 SSE连接建立和消息接收是独立的阶段值得单独埋点async def handle_sse_connection(request): with tracer.start_as_current_span(mcp.sse.connect) as span: span.set_attribute(mcp.transport, sse) span.set_attribute(mcp.client.ip, request.client.host) connection await establish_sse(request) span.set_attribute(mcp.sse.connected, True) return connection async def handle_message(message): with tracer.start_as_current_span(mcp.message.receive) as span: span.set_attribute(mcp.message.method, message.get(method)) span.set_attribute(mcp.message.id, str(message.get(id))) return await process_message(message)3.5 跨进程传递上下文W3C traceparentServerless 场景下MCP Client 和 MCP Server 往往是两个独立的函数实例Span 要串起来必须靠 W3C 标准的traceparentHeader 传递上下文。Client 侧注入from opentelemetry.propagate import inject headers {} inject(headers) # 自动写入 traceparent # 发请求时带上 headersServer 侧提取from opentelemetry.propagate import extract def handle_request(request): ctx extract(request.headers) with tracer.start_as_current_span(mcp.request, contextctx) as span: # 这里的 Span 会自动挂到 Client 的 Span 下面 ...这一步是打破黑盒的关键。没有它Client 和 Server 的 Trace 是两棵独立的树看不出上下游关系。3.6 采样策略平衡数据量和成本Serverless 按调用次数计费全量采样在高并发下成本不低。用ParentBased采样器正常情况按比例采出错时全采from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased sampler ParentBased(rootTraceIdRatioBased(0.1)) # 10% 采样 provider TracerProvider(resourceresource, samplersampler)ParentBased保证子 Span 跟随父 Span 的采样决定不会出现父 Span 采了、子 Span 没采的断裂情况。故障排查时临时把比例调到 1.0排查完再调回去。4. 验证请求在追踪面板确认父子 Span 与耗时链路配置写完了得实际跑一次确认数据真的进来了。这一节给出验证步骤和预期结果。4.1 启动环境并触发一次工具调用先把 Collector 和 Jaeger 拉起来docker compose up -d docker compose logs -f otel-collector日志里看到Everything is ready. Begin running and processing data.就说明 Collector 起来了。然后启动你的 MCP Server用 MCP Client 发起一次天气查询。如果你手头没有现成的 Client可以用一个简单的 Python 脚本模拟import httpx from opentelemetry.propagate import inject headers {Content-Type: application/json} inject(headers) # 注入 traceparent resp httpx.post( http://localhost:8000/mcp/tools/call, json{tool: weather_query, arguments: {city: 杭州}}, headersheaders, ) print(resp.json())4.2 在 Collector 日志里确认 Span 到达触发调用后回到 Collector 的日志应该能看到类似这样的输出Resource SchemaURL: Resource attributes: - service.name: Str(mcp-weather-server) - deployment.environment: Str(serverless) ScopeSpans #0 Span #0 Trace ID : 4bf92f3577b34da6a3ce929d0e0e4736 Parent ID : Name : mcp.tool.invoke Kind : Internal Start time : 2025-01-15 10:23:45.123 End time : 2025-01-15 10:23:45.456 Attributes: - mcp.tool.name: Str(weather_query) Span #1 Trace ID : 4bf92f3577b34da6a3ce929d0e0e4736 Parent ID : 00f067aa0ba902b7 Name : mcp.tool.weather_query注意Trace ID相同、Parent ID指向父 Span这就是父子关系成立的证据。如果Parent ID是空的说明上下文没传对回去检查extract那一步。4.3 在 Jaeger UI 里看完整链路打开http://localhost:16686Service 选mcp-weather-server点 Find Traces。你会看到刚才那次调用的 Trace点进去是一棵 Span 树mcp.request [] 320ms ├── mcp.sse.connect [] 45ms ├── mcp.message.receive [] 12ms ├── mcp.tool.invoke [] 210ms │ ├── mcp.tool.weather_query [] 180ms │ └── llm.chat.completion [] 30ms └── mcp.response.send [] 15ms每个 Span 的宽度代表耗时一眼就能看出weather_query是瓶颈。点开某个 Span能看到它的属性、事件和异常信息。如果mcp.tool.weather_query上报了异常Jaeger 会用红色标出来点进去直接看堆栈。4.4 对比冷启动和热启动的耗时差异Serverless 最头疼的冷启动在 Trace 里也能看出来。连续触发两次调用第一次是冷启动第二次复用实例。对比两次 Trace 的mcp.request总耗时差值就是冷启动开销。如果mcp.sse.connect在冷启动时特别长说明连接建立阶段受初始化影响大可以考虑预热或者把连接池初始化提前。我试过在同一个函数里连续调 10 次前 2 次明显比后面慢 200ms 左右这个数据在日志里是看不出来的只有 Trace 能直观呈现。4.5 验证模型调用 Span 的 Token 信息如果 MCP Server 中间调了模型llm.chat.completion这个 Span 上可以附加 Token 消耗span.set_attribute(llm.usage.prompt_tokens, response.usage.prompt_tokens) span.set_attribute(llm.usage.completion_tokens, response.usage.completion_tokens) span.set_attribute(llm.model, response.model)在 Jaeger 里点开这个 SpanAttributes 区域就能看到 Token 数。把模型调用和工具执行的耗时放一起对比能快速判断一次慢请求到底是模型慢还是工具慢。5. 本篇常见错排查401、local proxy failed 与 Span 断链埋点过程中最容易踩的坑集中在导出失败和上下文丢失两类。这一节按真实报错逐个拆。5.1 OTLP 导出报 401 Unauthorized如果你把 Collector 的 exporter 指向了需要鉴权的后端比如云厂商的可观测平台日志里会出现exporter export failed: rpc error: code Unauthenticated desc 401 Unauthorized原因通常是鉴权 Header 没配。在 Collector 配置的 exporter 段加上exporters: otlp: endpoint: your-backend:4317 headers: authorization: Bearer ${env:OTEL_API_TOKEN}${env:OTEL_API_TOKEN}从环境变量读别把 Token 硬编码进配置文件。如果用的是 TaoToken 这类兼容 OpenAI 的接口鉴权走Authorization: Bearer keyKey 在 API Keys 页面 生成注意别把模型调用的 Key 和 Collector 的 Token 搞混。5.2 local proxy failed 或 connection refusedSDK 侧报这个错说明连不上 CollectorFailed to export spans. The request could not be executed. Error: local proxy failed: connection refused排查顺序先确认 Collector 容器在跑docker ps再确认端口映射对4317 有没有映射出来最后确认 SDK 里的 endpoint 写的是http://localhost:4317而不是容器内部地址。如果你在容器里跑 MCP Serverlocalhost指向的是容器自己得改成 Collector 的服务名或者宿主机 IP。5.3 Span 断链子 Span 找不到父 SpanJaeger 里看到两个独立的 Trace而不是一棵树说明上下文没传过去。常见原因有三个一是 Client 侧忘了inject请求头里没有traceparent。用 curl 抓一下请求头确认。二是 Server 侧extract的 Header 名字不对。W3C 标准是traceparent有些框架会把它转成Traceparent或HTTP_TRACEPARENT得按框架的规范取。三是异步任务里上下文丢失。Python 的 asyncio 在create_task时不会自动传递 context需要手动传import asyncio from opentelemetry import context ctx context.get_current() task asyncio.create_task(coro(), contextctx)5.4 报错 reading choices 或响应解析失败如果 MCP Server 调模型时返回reading choices之类的解析错误通常是响应体不是预期的 OpenAI 格式。检查 Base URL 有没有写全https://taotoken.net/api后面要接/v1/chat/completions这类路径Model ID 有没有填对。这类错误不会自动进 Span建议在调用处包一层 try/except把原始响应记进 Span 属性span.set_attribute(llm.raw_response, resp.text[:500])排查完记得去掉避免敏感信息进 Trace。5.5 OAuth 或鉴权失败导致工具调用中断MCP Server 如果接了需要 OAuth 的外部服务鉴权失败时工具会直接抛异常。这类异常一定要record_exception否则 Trace 里只看到 Span 变红看不到原因。另外把鉴权相关的 Span 单独拆出来with tracer.start_as_current_span(mcp.auth.token_refresh) as span: span.set_attribute(mcp.auth.provider, oauth2) token await refresh_token() span.set_attribute(mcp.auth.success, token is not None)这样鉴权慢还是工具慢一目了然。5.6 三件套检查清单不管报什么错先核对这三样Base URL 是不是https://taotoken.net/api注意结尾不要多加斜杠、API Key 有没有过期、Model ID 是不是文档里列出的那个。这三件套任何一项不对都会表现为各种奇怪的报错。CC Switch、Cline MCP、Codex 的auth.json里如果配了自定义 endpoint也要同步检查别一处改了一处没改。6. 把链路追踪接进你的 MCP 工作流链路追踪搭好之后下一步是让它真正融入日常开发。几个实用做法把 Jaeger 的 Trace 链接加到告警通知里出错时点一下就能看到完整链路在 CI 里跑一次冒烟测试断言关键 Span 存在防止埋点被误删定期看采样率高流量时调低、排查时调高。如果你还在选模型接入方案可以先用 TaoToken 模型对话 快速验证接口通不通确认没问题再写进 MCP Server。长期跑编码类 Agent 的话Coding Plan 的额度更划算。接入细节和参数说明都在 接入文档 里配置时对着抄就行。最后留一个我踩过的坑Collector 的batch处理器默认 5 秒才发一次调试时觉得数据没进来其实是还没到发送时机。把timeout临时改成1s验证完再调回去。另外 Jaeger 的 all-in-one 镜像默认内存存储重启就丢数据生产环境记得换成持久化后端。
返回列表