跳到正文
深圳 · 大湾区 · 地球

MCP 服务端设计:收自然语言指令的工具,写不出回归测试

我最早交付的 MCP server 只收一个 instruction 字符串,演示一次就过,两个月后三个缺陷触达了人。把解释权还给模型、把 schema 放在边界上,回归测试才有地方落脚。

7 分钟阅读1,438
Model Context ProtocolAI Systems暂无译文。

一个只收 instruction 字符串的工具,为什么一定出事

MCP 服务端设计的约束最后只剩一条:工具接收带类型的参数、返回带类型的结果。做不到,它就没有可断言的边界,因为它的输入是模型分布里的一次采样,而断言没法写在一个分布上。

接受自然语言指令的 MCP 工具无法被测试;只有接收带类型参数、返回带类型结果的工具,才配得上一个回归集。

我最早交付的那台 server 就栽在最普通的地方。它暴露了一个 run_report,schema 只有一个字段:{ instruction: string }。handler 拿到这串话开始猜:正则抠日期范围、查表对上三十几个指标名、匹配不上就回落到"按月收入"。演示跑得很好,这才是陷阱——演示是一个采样,任何东西在单个采样上都能跑通。

上线两个月,它产出了三个触达人的缺陷:季度边界少算一周;欧元账户被按美元汇总,因为默认币种写了 USD;一张表在 500 行处被 LIMIT 静默截断。三个都复现不了,因为改一次 agent 的 prompt,进到工具里的东西就换了一批。git 里 handler 一个字没动,行为上它已经是另一个程序。

有类型的工具契约里到底放什么

三个对象:input schema、output schema、handler。模型决定调用之前,会先在 tools/list 里读前两个。

export const LineItem = z.object({
  sku: z.string().regex(/^[A-Z]{3}-\d{4}$/),
  quantity: z.number().int().positive().max(10_000),
  unitPriceMinor: z.number().int().nonnegative(),
});

export const QuoteRequest = z.object({
  accountId: z.string().uuid(),
  currency: z.enum(["USD", "EUR", "CNY"]),
  lines: z.array(LineItem).min(1).max(200),
});

export const QuoteResult = z.object({
  quoteId: z.string().uuid(),
  totalMinor: z.number().int().nonnegative(),
  flags: z.array(z.enum(["credit_hold", "below_floor", "manual_review"])),
});

这几行各自对得上一个我见过的缺陷。金额用最小单位的整数,因为 JSON 没有小数类型:钱一旦是浮点,服务端算出的和你账上记的会差一分,还只在部分账户上差。币种是 enum 而不是 string,因为 usdUSD 对人是同一件事,对代码是两条分支。明细数组封顶 200 条,否则某天会有四千条进来,在请求线程上解析九秒。

sku 的正则比我写的任何说明都值钱。模型发来 ab-1,会收到点名到路径的校验错误,下一次基本能改对;读到"三位大写字母加连字符加四位数字"这段散文时,它大多数时候能对——这是最坏的结局:对得足够多,能过演示;错得足够频繁,能触达人。

handler 是从哪里开始的

handler 是普通函数:拿到校验过的对象,返回约定的结果形状。它不 import MCP,也不知道自己正被 agent 调用。

export function createQuoteHandler(repo: QuoteRepo) {
  return async ({ accountId, currency, lines }: QuoteRequestInput): Promise<ToolResult<QuoteResultOutput>> => {
    const account = await repo.findAccount(accountId);
    if (account === null) return { isError: true, content: [{ type: "text", text: "unknown_account" }] };
    const draft = await repo.createQuote({ accountId, currency, lines });
    const structuredContent = QuoteResult.parse(draft);
    return { content: [{ type: "text", text: `quote ${draft.quoteId} created` }], structuredContent };
  };
}

export function registerQuoteTool(server: McpServer, repo: QuoteRepo): void {
  server.registerTool("create_quote", {
    description: "Create a quote from validated line items. Amounts are integer minor units.",
    inputSchema: QuoteRequest.shape,
    outputSchema: QuoteResult.shape,
  }, createQuoteHandler(repo));
}

只有 registerQuoteTool 这一处 import 了 SDK,这个隔离就是全部意义:测试不需要 transport、不需要 server 进程、不需要模型。

这里有两类失败,混在一起本身就是缺陷。schema 违规是调用方的问题,正确回应是告诉它出错的路径,让下一次尝试能对。领域拒绝是业务的问题——账户不存在、超出授信、低于底价——重试只会花掉 token 换来同一个答案。我的 server 前者当校验错误返回,后者返回 isError: true 加一个短错误码,agent 循环的处理不同:一个重试一次,另一个停下来上报。

调用方是模型,回归集还能怎么写

三层,每层接住别的层看不见的缺陷。第一层是 schema 测试:喂进各种形状的输入,断言校验在 handler 跑起来之前就拦住,不需要模型也不需要网络。第二层是 handler 测试,用固定 fixture 钉住行为;测试里的 repo 是内存实现并数写入次数,这才让第一条断言有意义——它能证明校验发生在任何副作用之前,而一个插库之后才跑的 schema 只是装饰。

第三层最常被跳过:从真实流量里按 tool call 记录 fixture——决策前的对话、模型选中的工具、它填的参数——每周给工具选择打分:选对工具且每个必填参数完全匹配的比例。四十条这样的三元组就够抓出一次改坏账户解析的 prompt 编辑。打分只看工具名和参数,它们能逐字比较。

失败在哪一层被接住能否重试调用方收到什么
该给数量的地方收到字符串handler 执行前的 schema 校验可以,一次,带出错路径lines.0.quantity: expected number
账户在系统里根本不存在handler 查库,领域拒绝不可以unknown_account
币种小写成 usdenum 校验可以currency: invalid enum value
一次传进四千条明细schema 里的数组上限可以,并告知上限lines: too big, max 200
上游定价接口 503handler 捕获的传输错误可以,有限次数upstream_unavailable
工具选对了账户选错了只看工具选择的 fixture 打分不可以,这是 prompt 缺陷什么都没有,这是你自己的 bug
类型全对金额算错拿真实发票做的领域测试不可以什么都没有,schema 看不见真值

最后两行是有类型契约必要但不充分的原因:类型挡得住"边界是语言"带来的失败,挡不住选错工具,也不会让算术正确。字段级约束怎么定、handler 怎么抽干净,都在能扛住生产流量的 MCP 服务端设计里。

有类型的边界值多少 token、多少工时

schema 不是免费的:工具定义每个请求都要发一遍,一段话的描述就是每个回合都在付钱。按示例单价算一道,换成你供应商的现价,结构不变:14 个工具、描述平均 90 token,等于每请求 1,260 token 的工具定义;每月 40,000 次请求就是 5,040 万 token,按每百万 $2.50 算是约 $126。同样的 14 个工具若把描述写成段落并附上重试建议,平均 190 token,就是 1.064 亿 token、约 $266。

工具定义风格每次请求增加的 token月成本,未缓存同口径,开 prompt 缓存
14 个工具,一句话描述,约束写在 schema 里1,260约 $126约 $12.6
14 个工具,段落描述加重试建议2,660约 $266约 $26.6

结论不是把工具砍少,而是把约束放进 schema:短,而且由机器执行。另一半是工时,这是我自己计的数,不是研究结论:逻辑本来就在一个函数里时,补 schema、抽 handler、写五条测试大约 2 到 4 小时;定价逻辑和传输处理缠在一起时是 2 到 4 天,真正的工作是那次剥离。

什么情况下不该给工具套 schema

输出本来就是开放的时候(总结客服会话、起草回复),别为正文硬造一个很大的对象:把边界类型化,让散文留在里面——入参 { threadId, maxWords, format },出参 { summary, sourceCount }。九字段的输出对象里四个是空字符串,这种情况我见过不少,那是 schema 在花钱编码"没有内容"。

代码解释器或 SQL runner 是另一种:灵活性就是产品,查询语句没法类型化,契约只能移到沙箱边界上——能读哪几张表、行数上限、语句超时、结果形状。给一个人用一周的原型也不必写:第二个调用方出现时账就要重算,边界从此有了读不到你代码的消费者。

最后一种错误更隐蔽:四十个值的 enum 跟着数据库表同步,会让加一条产品线变成一次发版,换成 pattern 加服务端查表即可。

下一个工具上线之前先做什么

挑出你那台 server 上唯一还收自由文本参数的工具,写出它的 schema,把 handler 抽成普通函数,补五条测试:三条给 schema,两条给领域逻辑。上线前后各量一周重试率——在我迁移过的 server 上它是降的,因为点名路径的校验错误,比同一段含糊的话加更多字更可能改对。先迁移碰钱的那几个工具,它们的错误答案带着一个数字和一个证人。回归集不是设计做完之后补的质量文档,它是别人不必先问你就能改这套系统的原因。

继续阅读

更多「AI 系统架构」

准备构建一套系统?[ 预约会议 ]