“你的单元测试还是全绿,因为它们是直接调用工具。坏掉的那部分,从来没人测:模型到底有没有选对工具?”

几周前我搓了个小玩意儿叫routeproof,专治MCP服务器里一件又窄又烦人的事:AI主控端决定调用哪个工具时,模型能看到的只有每个工具的名字、描述和输入schema,根本看不见你的代码。要是两个描述有重叠,或者某个写得太模糊,模型就会悄悄调错工具——甚至干脆不调。你写的单元测试全绿,因为它们绕过了模型直接调工具。真正崩掉的那一环,从来没人验证。

之前所有示例跑的都是我自己写的MCP服务器,很容易被一笑了之——你自己埋的雷当然一踩一个准。所以这周我把它指向了一个我一行代码都没写过的服务器,而且还是个口碑炸裂的标杆:@modelcontextprotocol/server-filesystem 参考实现。十四个工具,读文件、列目录、搜索、移动、生成目录树……干净、文档齐全,拿来做范例都挑不出毛病。

接下来就拆开这场路由翻车现场,看看一个老老实实的工具怎么偷偷吞掉两个完全不相干的请求。

1. 测试意图:六个日常指令,完全模拟真人说话

我先设了六个意图,全都是用户张嘴就来的大白话,每个都明确锚定一个工具。比如“帮我把config.json的内容读出来”对应read_text_file,“把这文件夹里的文件都列出来,还要每个多大”对应list_directory_with_sizes,“把整个项目结构用递归树画给我看”对应directory_tree,“找出所有 *.log 文件,不管在哪一层”对应search_files,“把draft.txt 重命名成final.txt”对应move_file,“打开screenshot.png,让我真能看到图”对应read_media_file。没有一句是文档腔,全是业务方会脱口而出的原话。

2. 路由不是掷骰子——但两次跑分能差出一题

我用Haiku模型跑了两轮,每个意图采样三次。第一轮6题对4题,第二轮6题对3题。同一台服务器、同一套描述、同一个模型、同一组问题,得分不一样。路由压根儿不是确定性的,单次跑分就是个骗子。正因为这样,routeproof才给每个意图做多次采样,最后吐出一个置信度,而不是绿勾勾。跑一次就打印“pass”的测试,等于只记录了一次掷骰子的结果。

3. 唯一的“惯犯”:list_allowed_directories 越界抢活

在跑分上下波动的噪声底下,有一样东西稳得像铁板一块——同一个工具疯狂越权。请求“读取config.json的内容”两次都被路由到了list_allowed_directories,而不是预期的read_text_file。请求“递归树查看整个项目”同样被这个工具截胡,第二轮里三个采样全都是它。list_allowed_directories本来只是个无害的小工具,只返回服务器被授权碰哪些目录。可它吞掉了一个文件读取和一个目录树视图,两码事,毫无关联。一个工具悄无声息干了另外两个工具的活儿,而且还不是偶尔,是每次。

4. 根源就藏在描述里:关键字碰瓷

为什么会这样?list_allowed_directories的描述里躺着“allowed”这个词,而read_text_file和directory_tree的描述里,要么用了“read”,要么用了“directory”,偏偏都不够干净,撞上了这个关键字的辐射范围。模型在几毫秒内做名词联想匹配,根本没有语义理解。谁的关键字更宽、更模糊,谁就更容易把流量吸走。

5. routeproof 的解法:拿真实措辞反复抽验,再给出修正方向

routeproof的检查逻辑很简单:只把模型能看见的东西塞给一个全新的模型,用真实用户说法反复跑,看哪些请求被错误路由,并给出具体的描述修改建议。它不是测你的代码,它测的是模型手里那张“工具菜单”的清晰度。当两个条目在文本空间里离得太近,你就必须改写描述,人为拉开距离。否则就算代码写得再漂亮,模型仍然会在你眼皮底下反复迷路。

这次无意间选中的标杆服务器,恰好撕开了一个所有直面模型路由的开发者都躲不开的坑:你永远不知道哪个小透明工具正在用一行描述偷走你的请求,直到你拿真实口语反复摇它。