用 MCP 把 Agent 的工具调用做成可回放的实验
为什么工具调用总是"这次可以、下次不行"
做工具型 Agent 的时候,最难受的不是模型答错,而是同一个问题连着跑三次,行为三次都不一样,而你没有任何证据说明差异从哪来。日志里只有一行 tool_call: search_docs,参数被截断,返回被丢掉,重试次数没记,耗时没记。等你想复现,只能靠运气。
前一段时间我负责的一个内部知识库问答助手上线后,这类问题占了绝大部分线上工单。后来我们把每一次工具调用都记录成一个可回放的实验单元,"玄学"才变成可以归因的数据。下面这套做法和平台无关,只是在 MCP 上落地成本最低。
一、把埋点放在 MCP Server 侧,而不是 Agent 侧
最初我们在 Agent 框架里加日志,很快就乱了:框架换了、prompt 改了、工具换了三种语言实现,埋点逻辑要跟着改三遍。
MCP 的好处是它天然是一个边界——所有工具调用都从同一个 JSON-RPC 入口进出。只要在 server 的 dispatch 层包一层,就能拿到完整的 (tool, arguments, result, error, latency)。
一个最小的记录结构:
{
"span_id": "7f3c...",
"trace_id": "b21e...",
"tool": "search_docs",
"args_digest": "sha256:9a1c...",
"args_preview": {"q": "如何配置日志采集", "top_k": 5},
"status": "ok",
"latency_ms": 412,
"result_bytes": 18342,
"result_digest": "sha256:5bd0...",
"retry": 0,
"ts": "2026-11-13T01:12:44.318+08:00"
}
注意两个字段:digest 和 preview。参数和结果都可能很大,全量存会让日志系统先崩掉,所以正文进对象存储,索引里只留摘要和大小。后来正是 result_bytes 这个不起眼的字段帮我们定位了一个 bug:某次检索返回了 2.4MB,因为分页参数被模型写成了 top_k=500,后续 prompt 被截断,答案自然就"变傻"了。
二、录制而不是采样
可回放的前提是数据完整。很多团队默认开 5% 采样,结果出事的那一次永远在采样之外。
我们的做法是:正常流量只存摘要与 digest;一旦某个 trace 被标记为"不满意"(用户点踩、或评测集命中),就把这个 trace 关联的原始请求与响应完整落盘。这样存储可控,问题 trace 又 100% 可复现。
回放时 Agent 侧只做一件事:把 MCP client 的 transport 换成 fixture 传输层,按 (tool, args_digest) 命中录制结果直接返回。于是"模型换了一版还会不会犯同样的错"这个问题第一次变得可以回答——因为工具侧完全冻结,变量只剩模型。
三、只重跑规划,不重跑工具
调试工具型 Agent 最贵的是外部副作用:写库、发消息、下单。回放模式下必须把这些工具标记为只读,否则一次调试就是一次事故。
我们在 MCP server 的 tool 定义里补了注解:
readOnlyHint: true | false
idempotentHint: true | false
回放器见到 readOnlyHint=false 就直接抛 ReplayBlocked,并在 trace 里标注"该步骤被阻断",而不是伪造一个成功返回。伪造成功会掩盖真实的依赖关系,让结论偏向乐观——这是我们踩过的坑。
四、四个能直接看出问题的指标
有了 span 之后,日常只看四个数:
- 工具选择准确率:评测集里期望工具与首轮选择一致的比例。低于 85% 通常说明工具描述写得含糊。
-
- 参数合法率:第一次调用就通过 schema 校验的比例。它比"最终成功"更敏感,能提前发现模型在瞎猜字段。
-
-
时间戳统一用带时区的 RFC3339,别用本地时间。跨时区排查时你会感谢自己。
-
- 结果摘要不要截断在字符中间,UTF-8 多字节字符被切断会让下游解析器直接抛异常。
-
args_digest要把 key 排序后再算,否则{"a":1,"b":2}和{"b":2,"a":1}会被当成两次不同调用。- 工具调用从"不可解释"变成"可实验",中间隔的不是更聪明的模型,而是一层诚实的记录。
以上是我在自己项目里的实践,MCP 只是让这层记录更容易标准化。如果你们也在做类似的事,欢迎在评论区交流更细的指标口径。
-
- 点赞
- 收藏
- 关注作者
评论(0)