MCP Server 上线之后:怎么知道模型都在拿你的工具干什么
前言
上一篇聊 MCP Server 的测试,结尾留了个钩子:可观测性。这篇把它补上。
先描述一个上线后最常见的场景:用户反馈"AI 助手答非所问",你怀疑是模型乱调了工具,但手头没有任何记录——不知道那次会话里模型看到了哪些工具、发了什么参数、工具返回了什么。你唯一能做的是在自己电脑上连一遍,复现看看。"感觉修好了",但你其实说不清刚才那次的真实原因。
传统 Web 服务有成熟的可观测性方案,日志、指标、链路追踪三大件。MCP Server 大体也能套用,但有两个本质差异,直接决定了方案怎么做:
- 调用方是模型,参数是模型生成的。参数里可能带着用户的敏感数据(文件路径、查询语句、邮件内容),日志里全量打印参数,等于把用户数据抄了一份到日志文件——你自己制造了一个泄露面;
- 一次模型决策可能触发一串工具调用。链路的"根"不在 HTTP 入口,而在某轮对话里,传统按请求串 trace 的思路需要调整。
这篇文章讲我落地的做法:协议内日志、结构化调用日志、按工具分维度的指标、可回放的审计记录。代码仍是 TypeScript 官方 SDK。
一、先分清两条通道:协议内日志 vs 本地日志
MCP 协议自带 logging 能力,这是很多人没注意到的。Server 声明capabilities.logging之后,可以随时向客户端推日志消息:
const server = new Server( { name: "todo-server", version: "1.0.0" }, { capabilities: { tools: {}, logging: {} } }, ); // 工具执行中向客户端/用户报告进度和状态 await server.sendLoggingMessage({ level: "info", logger: "todo-server", data: "已扫描 120 个文件,开始匹配关键词", });客户端(如果实现了 logging)会把消息展示给用户。这适合放过程可见的信息:长任务做到哪一步、准备批量处理什么。对用户来说,工具不再是"按下回车后黑箱三分钟"。
但要清楚它的边界:协议内日志是给"这次会话的用户"看的,不可检索、不会持久化(取决于客户端)、你自己的排查也用不上。所以本地结构化日志同样要做,两者不互相替代:
- 协议内日志:面向用户,报进度、报状态;
- 本地日志:面向你自己,记录完整调用事实,供排查和审计。
还有一个 stdio 特有的约束,测试篇提过再强调一次:stdio 模式下 stdout 是协议通道,本地日志只能写 stderr 或文件。日志框架配置错目的地,是 stdio server 的头号自毁方式。
二、工具调用日志:记什么、不记什么
核心原则:一次 tools/call 记一行结构化日志,够排查问题,但不复制用户数据。
type ToolCallLog = { ts: string; // ISO 时间 sessionId: string; // 会话标识(自增即可) tool: string; // 工具名 argDigest: string; // 参数摘要,不是参数全文 outcome: "ok" | "tool_error" | "thrown"; durationMs: number; resultDigest?: string; // 结果摘要 };关键设计是argDigest(参数摘要):
function digestArgs(args: Record<string, unknown>): string { return Object.entries(args) .map(([k, v]) => { const s = String(v); const shown = s.length > 24 ? s.slice(0, 24) + `…(${s.length})` : s; return `${k}=${shown}`; }) .join("&"); }记参数的"形状"而不记内容:路径记到文件名、查询记前 24 个字符加长度。排查"模型传了空参数""参数类型不对"这类问题完全够用,同时避免把用户邮件正文、数据库记录写进日志。如果某个工具确实需要完整参数才能排查,做成开关配置 + 默认关闭,并在文档里写明这会记录用户内容。
用装饰器把记录逻辑统一包住所有工具,避免每个 handler 里手写:
const timed = <T>( name: string, fn: (args: Record<string, unknown>) => Promise<T> ) => async (args: Record<string, unknown>) => { const start = Date.now(); let outcome: ToolCallLog["outcome"] = "ok"; try { return await fn(args); } catch (e) { outcome = "thrown"; throw e; } finally { log.info({ tool: name, argDigest: digestArgs(args), outcome, durationMs: Date.now() - start, }); } };注意outcome只在catch里改标记、finally里统一落日志——isError形式的"业务失败"不抛异常,会在正常返回路径记录,想区分的话在返回前检查一次result.isError把它标成tool_error。
三、指标:按工具名分维度,别只看平均值
有了结构化日志,指标就是聚合问题。最少要这三个,全部按工具名分维度:
- 调用次数:哪个工具被用得多,直接指导优化优先级;
- 错误率:
tool_error + thrown / total。某个工具错误率突然抬升,往往是依赖的外部服务在抖动; - 耗时分布:P50 和 P99,不要只看平均值。
为什么 P99 特别重要?模型客户端普遍对工具调用有超时,慢工具会触发模型侧重试,重试又把负载打回去,形成恶性循环。平均值 200ms 的工具如果 P99 是 8 秒,用户看到的就是"经常转圈失败",而你的平均值监控一片绿。
实践里我给每个工具设一个软预算(比如 3 秒),接近预算就先推协议内日志("正在查询更多数据…"),让用户和模型都知道工具还活着。
四、审计日志:为"出事之后"留证据
《MCP 权限模型》那篇讲过:工具粒度的授权需要知道"谁在什么上下文里调了什么"。落到实现就是审计日志,和第二节的运行日志相比,它多了两个诉求:
- 身份:哪个客户端、哪个(如果有的话)终端用户。stdio 本地场景身份意义不大,HTTP/远程部署时这列是命根子;
- 不可变性:审计记录写独立的 append-only 文件(或直接进日志服务),和应用日志分开,避免"排查时顺手改两行"。
审计事件的最小集:时间、身份、工具名、参数摘要、结果状态、会话 ID。和运行日志几乎同构——所以我直接复用同一套 digest 逻辑,只是输出到不同 sink。每条记录加一行换行符分隔的 JSON(NDJSON),出安全事件时grep 工具名就能拉出完整时间线。
给一个务实的边界建议:单机个人工具不必上审计,运行日志足够;凡是"连了公司数据源、有多个使用者"的 server,审计从第一天就要有——它不能事后补。
五、一条端到端的排查路径
最后把工具串起来。收到"AI 回答不对"的反馈时:
- 拿会话时间点,在运行日志里按
sessionId过滤,看模型实际调了哪些工具、参数摘要是什么形状; - 参数明显异常(空参、类型不对)→ 回到工具 schema 和描述,八成是描述有歧义,模型在猜;
- 参数正常但结果
tool_error→ 工具自身问题,看错误率和下游依赖; - 调用都正常、答案仍错 → 才是模型侧的问题(上下文、prompt),此时你才需要动 prompt。
这条路径的价值在于把"玄学问题"变成分层排查:先工具层,再模型层,而不是对着聊天记录猜。
写在最后
清单版总结,给要上线的 MCP Server:
- 协议内 logging:长任务报进度,给用户看;
- 本地结构化日志:一行一次调用,参数记摘要不记全文,stdio 只写 stderr/文件;
- 指标按工具名分维度,盯错误率和 P99,不只看平均;
- 多人/连公司数据的 server,第一天就开 append-only 审计;
- 排查顺序:工具参数 → 工具结果 → 才轮到模型。
到这里,MCP Server 的工程化三篇(测试、可观测性,加上安全四篇)就齐了。下一篇可能聊聊部署形态的选择:stdio 子进程、本地 HTTP 和远程托管各自适合什么场景。