进程内 mock vs 真 server 等价回归
B6 一路把 AML 工具从「进程内函数」迁到「网络 server」(Day 56 上网、Day 57 客户端、Day 58 零信任校验)。迁移最隐蔽的风险不是功能缺失,而是行为漂移:同一批请求经两条路径(进程内 handle() vs HTTP server)输出悄悄不一致,上线即回归。今天用 differential testing(对拍) 把这个风险钉死。这是 B6 从「能跑」收口到「可上线
阶段: B6 · 真 MCP server (2026-07-28 spec)(Day 51-60) 标签: #differential-testing #regression #json-rpc #migration
今日导引(由浅入深)
B6 一路把 AML 工具从「进程内函数」迁到「网络 server」(Day 56 上网、Day 57 客户端、Day 58 零信任校验)。迁移最隐蔽的风险不是功能缺失,而是行为漂移:同一批请求经两条路径(进程内 handle() vs HTTP server)输出悄悄不一致,上线即回归。今天用 differential testing(对拍) 把这个风险钉死。这是 B6 从「能跑」收口到「可上线」的最后一道工程闸门,紧接 Day 58 的安全闸门。承接全 B6 的 server 实现;明天(Day 60)做 Inspector 端到端验收并归档里程碑。今日「最小可判定产出」:20 例对拍两侧输出一致(目标 20/20),且全量测试维持绿。
一句话定位:前面几天是「把能力搬上网」,今天是「证明搬上网没搬坏」——能力曲线上从「实现」到「可信迁移」的关键一步,也是把质量纪律从手工验证升级成可自动回归的起点(直通 B7 的 CI gate)。
1. 机理精读
迁移上网的最大风险是行为漂移。把一段逻辑从「进程内函数调用」搬到「HTTP server」,中间经过序列化、传输、反序列化、错误码映射等多个环节,任何一环的细微差异都可能让输出不一致——尤其是错误路径和边界值,恰恰是 happy-path 测试覆盖不到的地方。
differential testing(对拍)是验证迁移一致性的最稳手段。它的逻辑朴素到无可辩驳:同一批输入,分别喂给「参照实现」(进程内 toolRegistry.ts 的 handle(),B6 全程当对照基线)和「被测实现」(HTTP server),逐字节/逐结构 diff 两侧的 result / error。一致则迁移无漂移,不一致则定位到具体输入。对拍不需要预先写「期望输出」——参照实现自己就是 oracle(判定标准),这正是它比传统断言式测试更省力、覆盖更全的原因。
对拍的价值集中在错误码和边界,而非 happy-path。SOTA 检查的硬告警就是「只测 happy-path」的反模式:两条路径在「合法输入返回正确结果」上往往天然一致,真正容易漂移的是——非法入参时进程内抛 McpCallError(-32602)、HTTP 侧是否也精确返回 -32602?缺字段时两侧的 error.message/error.data 是否一致?边界值(恰好等于 minimum、空数组、超长字符串)两侧处理是否一致?所以 20 例必须覆盖合法 / 非法 / 边界三类,而非 20 个 happy-path。
为什么进程内 handle() 能当 oracle? 因为它是 B6 的「真相源」——toolRegistry.ts 头注(第 12-16 行)明确它实现的是 MCP 2026-07-28 stateless 语义的协议形状(JSON-RPC 信封 + tools/list + tools/call + schema 校验),且全确定性(第 22 行「无时钟/随机/网络」)。HTTP server 应当是它「加了 transport 层」的等价物——业务语义必须逐字一致,只多了网络外壳。对拍就是把这个「应当等价」变成可执行断言。
迁移漂移的常见来源,逐个对号入座(解释「为什么非要对拍」):
- 序列化往返:进程内传 JS 对象,HTTP 传 JSON。
undefined字段在 JSON 里会消失、NaN/Infinity不是合法 JSON、Map/Set不能直接序列化——这些在进程内无碍,过 HTTP 就变形。 - 错误码映射:进程内抛
McpCallError(code),HTTP 侧要手工映射到 JSON-RPCerror.code;漏映射一个错误类型就漂移。 - 字段顺序/缺省:
toEqual深比较不在意 key 顺序,但若 HTTP 侧补了进程内没有的默认字段(如message: ''),就会不一致。 - 排序不稳定:
tools/list若两侧排序逻辑不同(一侧localeCompare、一侧默认),工具顺序漂移。
对拍的 20 例正是用来把这些隐性差异在上线前逼出来。
对拍 vs 传统断言测试的区别,值得讲清:
- 传统断言测试:人工写「输入 X → 期望输出 Y」,Y 由人脑算出,写 20 例要算 20 个 Y,且人脑算错则测试本身就错。
- 对拍:只需写「输入 X」,期望输出由参照实现(
handle())自动生成。人不需要预知 Y,只需保证参照实现可信。 - 代价:对拍依赖「参照实现是正确的」这一前提。若参照实现本身有 bug,对拍只能保证「被测实现和参照一样错」,逮不到共性 bug。所以对拍验证的是一致性(迁移无漂移),不是绝对正确性——后者仍需 Day 58 那种带
reference的断言测试兜底。
与相邻概念的边界:今天测「两条路径输出一致」,不测模型能力(Day 57)、不测安全拦截率本身(Day 58,但对拍会顺带验证非法输入两侧都返回 -32602)、不做端到端 Inspector 验收(Day 60)。
2. 推导 / 手算 / 代码走读
Day 59 seed 要求「仿 src/agent/eval/tasks.ts 风格写对拍测试」。逐条走读(Read 验证):
src/agent/eval/tasks.ts(第 1-23 行头注)是 P1 的 29-task eval 套件范式:每个CategorizedTask含id/category/prompt/reference/codeCheck。第 42 行const has = (re) => (out) => ({ pass: re.test(out) })是轻量代码型检查工厂。对拍测试借用它「结构化用例 + 确定性判定」的写法,但判定标准换成「两侧 diff 一致」而非「正则匹配」。- 参照实现:
McpToolRegistry.handle(req: JsonRpcRequest): JsonRpcResponse(toolRegistry.ts第 199 行)。它对tools/list返回{tools: this.list()}(第 202 行),对tools/call成功返回{content: result}(第 209 行),失败折成error(第 211-214 行),永不抛错(第 197 行注释)。这是对拍的 oracle。 - 错误一致性锚点:缺
params.name时返回-32602(第 206 行);McpCallError的code/message/data原样进error(第 212 行)。HTTP server 必须在这些点上逐字一致——这是 20 例里「非法」用例的重点。 - 稳定排序锚点:
list()(第 169 行)按namelocaleCompare稳定排序(第 170 行)。对拍tools/list时,两侧工具顺序必须一致,否则 diff 误判(呼应 Day 54 的「排序稳定是契约」)。 - 确定性保证:
validate(第 112 行)、call(第 183 行)、handle(第 199 行)全是纯逻辑,无随机/时钟/网络,所以同一输入两次运行结果恒等——对拍才有意义(不确定的实现无法对拍)。 - 测试接入:仿
tasks.ts把 20 例构造成数组,每例{ req, label },分别走registry.handle(req)与 HTTP server,expect(httpResult).toEqual(handleResult),接入pnpm test。
20 例的覆盖矩阵(合法 / 非法 / 边界三类,错误码是漂移高发区):
| # | 类别 | 输入要点 | 两侧应一致的关键字段 |
|---|---|---|---|
| 1-3 | 合法 | 3 工具各一次正常 tools/call | result.content 结构 |
| 4 | 合法 | tools/list | result.tools 顺序(稳定排序) |
| 5-7 | 非法·缺必填 | 3 工具各删 required 字段 | error.code=-32602 + error.data |
| 8-10 | 非法·错误类型 | string 位填 number/array | error.code=-32602 |
| 11-13 | 非法·越界值 | 超 maximum / 不在 enum | error.code=-32602 |
| 14-15 | 边界 | 恰好 =minimum / =maximum | result(应通过,不报错) |
| 16-17 | 边界 | 空数组 / 空对象入参 | result 或 error 一致 |
| 18 | 边界 | 未知工具名 | error.code=-32001(TOOL_NOT_FOUND) |
| 19 | 边界 | 缺 params.name | error.code=-32602(handle 第 206 行) |
| 20 | 边界 | 未知 method | error.code=-32601(METHOD_NOT_FOUND) |
第 14-15 行(恰好等于边界值)最易漂移:进程内 validate 第 127-128 行用 </>(含等于边界为合法),HTTP 侧若误用 <=/>= 就会两侧不一致——对拍正是要逮这种细节。
3. 今日实战
- 仿
src/agent/eval/tasks.ts写对拍测试文件:构造 20 例JsonRpcRequest,覆盖三类——- 合法:3 工具各若干正常
tools/call+tools/list。 - 非法:缺必填、错误类型、越界值(复用 Day 58 的恶意输入,断言两侧都
-32602)。 - 边界:恰好等于
minimum/maximum、空数组、未知工具名(断言两侧TOOL_NOT_FOUND = -32001)。
- 合法:3 工具各若干正常
- 每例分别走
toolRegistry.ts的handle()(参照)与 HTTP server(被测)。 diff两侧result/error,用toEqual深比较。- 接入
pnpm test,确保新增对拍后全量测试维持绿。
对拍核心循环的代码形状(伪代码,落地到 Vitest):
for (const { req, label } of CASES /* 20 例 */) {
const viaProc = registry.handle(req) // 参照实现(oracle)
const viaHttp = await httpServer.dispatch(req) // 被测实现
expect(viaHttp).toEqual(viaProc) // 逐结构深比较
}
落地前的自检清单(避免对拍本身失真):
- 两侧都关闭了非确定性来源(无时间戳/随机 id 进 result)。
- HTTP 侧的 JSON 反序列化没丢字段、没把
undefined变null。 -
tools/list两侧排序都走localeCompare(稳定)。 - 20 例里非法 + 边界 ≥ 合法(错误路径是漂移高发区,要重点压)。
4. 今日实测 / 产出
- 状态:待建——目标:20/20 输出一致的对拍测试。
- 当前仓库全量 378 tests 绿、tsc clean,本日新增对拍后须维持全绿。
- 纯确定性逻辑,无需 key。
(按诚信纪律:「待建」与「378 tests 绿 / tsc clean」逐字保留;「20/20」是目标,未臆造为已测得。)
对拍如何接入 CI(为 B7 的 CI gate 铺垫):对拍测试是确定性的(无 key、无网络、无随机),天生适合做 CI 必过项——它可以直接挂进 pnpm test,让每次 server 改动都自动验证「和进程内参照仍等价」。这正是 B7「CI gate」的雏形:把「20/20 对拍 + 9/9 拦截 + 378 全绿」固化为 merge 前的硬门槛,任何引入漂移的改动都会在 CI 红灯,进不了主干。本日只建对拍用例,把它升级成 gate 是 B7 的事。
5. 常见误区 / 陷阱
- 只测 happy-path:SOTA 检查明确反模式。对拍价值正在错误码/边界的一致性上,合法输入两侧天然容易一致。
- 不确定的实现拿来对拍:若 server 输出含时间戳/随机 id/非确定排序,对拍会误判。必须先确保两侧确定性(
list()的稳定排序就是为此)。 - 只比
result不比error:迁移漂移最常发生在错误路径——缺字段、越界、未知工具时两侧的code/message/data必须逐字一致。 - 对拍通过就放松全量回归:对拍只覆盖这 20 例,仍须维持 378 tests 全绿 + tsc clean,二者互补不互替。
- 误把参照实现的 bug 当「正确」:对拍只保证两侧一致,不保证两侧都对;共性 bug 逮不到,仍需 Day 58 那类带
reference的断言测试兜底。 - 边界用
<=替<导致两侧不一致:validate第 127-128 行边界判定用严格</>(等于边界为合法),HTTP 侧若手滑写成<=/>=,恰好等于边界的输入就会两侧分叉——这正是第 14-15 例要逮的漂移。
6. 学习资源(每条带 YYYY-MM)
- 仓库
src/agent/eval/tasks.ts(P1 产物,2026)—— 29-task eval 套件,对拍测试借鉴其「结构化用例 + 确定性判定」写法。 - 仓库
src/agent/mcp/toolRegistry.ts(2026)——handle()/list()/call()/validate()进程内参照实现(对拍 oracle)。 - differential testing 方法论(McKeeman, 1998;长期有效的软件工程技术)—— 用参照实现作 oracle 的对拍原理。
- MCP 规范(2026-07-28,硬复查点 07-28)——
tools/list/tools/call的 result/error 结构契约(对拍的比较基准)。 - JSON-RPC 2.0 规范(2010-03)——
-32602/-32601/-32001错误码语义(对拍非法/边界用例的断言依据)。 - Vitest 文档(2026)——
expect().toEqual()深比较语义(对拍断言落地工具)。
SOTA检查 (2026-06 更新)
- 当前主流方案:differential testing 是迁移回归的标准做法,无时效问题(成熟工程技术,不会过时)。
- 是否仍 SOTA:是。用进程内确定性实现作 oracle 对拍 HTTP server,是验证「上网无漂移」的最稳路径。
- 过时黑名单 / AVOID:禁止只测 happy-path——对拍价值正在错误码/边界的一致性上。禁止对不确定实现做对拍(先消除随机/时钟/非稳定排序)。
- 下次复查点:07-28 MCP 规范定稿后,重验 result/error 字段是否有新增(如 content 结构变化),同步更新对拍比较基准;server 上网部分实现后补跑 20/20。
衔接
- 昨天:Day 58 — 工具输入零信任校验(9/9 恶意输入被
-32602拦截,目标待建)。 - 今天:对拍进程内
handle()vs HTTP server,验证迁移上网无行为漂移(目标 20/20,378 tests 须维持绿)。 - 明天:Day 60 — 收尾 + Inspector 端到端验收,整块复盘 stateless 收益与剩余缺口。