评测工具型 Agent 时,最常踩的坑不是模型答错,而是世界状态不可复现 :同一个 prompt 跑两次,第一次 mock 数据是新鲜的,第二次工具返回的是上次调用留下的脏数据;record/replay 只能重放录制过的路径,新模型走一条"合法但没录过"的路径就直接挂;直接打 live 服务又面临限流、成本、共享状态污染。核心矛盾其实只有一句:模型可以不确定,但模型操作的外部世界必须可复现、可隔离、可分叉、可比较 。这就是 MCP-State-Twin(augety121/MCP-State-Twin,Go 1.26 + MCP Streamable HTTP)给出的答案------一个在 MCP 工具背后提供确定性测试世界的环境层,工作流是 snapshot → fork → act → assert → diff:从同一不可变快照出发,让不同 Agent 在各自隔离的 branch 上执行不同的合法工具轨迹,最后比较世界终态,全程不写入生产服务。
为什么 mock / record-replay / live 都不够
| 方案 | 主要优势 | 用于 Agent 评测时的边界 |
|---|---|---|
| Record/replay | 精确重现已捕获的调用路径 | 新模型可能走从未录制过、但完全合法的路径,直接失配 |
| 静态 MCP mock | 隔离客户端、返回可控数据 | 跨调用状态、约束、幂等、失败语义往往不完整 |
| 手工 benchmark sandbox | 任务精心设计、结果可判 | 复用开发者自己的 tool surface 不是其核心抽象 |
| Live 测试/生产服务 | 真实行为、零建模成本 | 副作用、限流、成本、共享状态污染、起点不可复现 |
一个 issue-tracker Agent 的典型轨迹是:读 issue → 加 comment → 超时后重试 → 再读 → 状态符合预期才 close。每一次调用都会改变后续调用应该看到的世界------这正是静态 mock 建模不了的部分。MCP-State-Twin 的定位不是替代 mock,而是把"带状态的世界"本身变成一等公民:显式状态转换在可分叉的世界状态上执行,而不是在无状态 JSON 上做手脚。
双平面架构:测试控制不伪装成 MCP 工具
┌─ 被测 Agent ──────────────┐ ┌─ MCP State Twin Runtime ──────────────┐
│ Agent A ──MCP──▶ /mcp/run-a/ ──┐ │ │
│ Agent B ──MCP──▶ /mcp/run-b/ ──┼───▶│ Data Plane 127.0.0.1:8090 (MCP tools) │
└────────────────────────────┘ │ │ │ SQLite 原子状态转换 + 审计 │
│ │ Control Plane 127.0.0.1:8091 (私有) │
└─ Test Harness ──Bearer token──┘ │ snapshot / fork / reset / diff │
└───────────────────────────────────────────┘
两个 trust domain 刻意分离:
| Agent Data Plane | Simulation Control Plane | |
|---|---|---|
| 面向谁 | 被测 Agent | test harness / 操作者 |
| 默认地址 | 127.0.0.1:8090 |
127.0.0.1:8091 |
| 内容 | TwinSpec 声明的业务工具 | branch 状态、snapshot、fork、reset、diff |
| 鉴权 | 当前无(仅限 loopback) | 独立 bearer token |
两个关键设计决策:branch ID 是 MCP URL 的一部分 (/mcp/run-a/),而不是暴露给模型的额外 tool argument------不同 branch 保持相同的 tool input schema,Agent 侧零改动;评测控制能力不是 MCP tools ,不会出现在 tools/list 里------snapshot/fork/diff 属于独立 control plane,避免"用 prompt 约束 Agent 别调用测试工具"这种把 prompt 当授权边界的坏味道。
TwinSpec:把工具行为写成可执行契约
一个工具不只是 function(args) -> JSON,而是:
tool behavior = input contract
+ reads and preconditions
+ deterministic state effects
+ postconditions and global invariants
+ structured result or typed error
+ time and idempotency semantics
TwinSpec v1alpha1 用 YAML 声明这一切,片段:
apiVersion: statetwin.dev/v1alpha1
kind: Twin
metadata:
name: issue-tracker
upstream:
protocol: mcp
status: unbound # 未绑定任何真实上游
fidelity:
level: L1 # 显式声明保真度,不许虚报
status: unverified
clock:
mode: virtual # 虚拟时钟,评测内时间可重放
initial: "2026-08-01T00:00:00Z"
state:
entities:
repository:
key: [owner, name]
issue:
key: [repository, number]
tools:
- name: close_issue
description: Close an existing open issue in the isolated simulated repository.
preconditions:
- expr: "state.entities.issue[input.owner + '/' + input.repository + '#' + string(input.number)].state == 'open'"
code: CONFLICT
message: issue is already closed
effects:
- op: update
entity: issue
key: "input.owner + '/' + input.repository + '#' + string(input.number)"
merge: true
value: "{'state': 'closed', 'closedAt': clock}"
实现细节值得注意:precondition/effect 里的表达式用 cel-go 编译执行,有硬边界------source 最大 4096 UTF-8 字节、load 时编译、cost limit 10000、只接收 input/state/vars/item/clock/call_index 这些 JSON-shaped 变量,不注册 filesystem/process/network/reflection 或任意 Go 函数 ,杜绝表达式逃逸。tool input/output 按 JSON Schema Draft 2020-12 编译校验,声明了 successful output 却不符合 schema 时,状态转换直接 rollback 并返回 INTERNAL_TWIN_ERROR。
当前 effect 操作只有四个原语,语义干净:
| Operation | Semantics |
|---|---|
allocate |
对命名确定性序列自增,结果绑定到 vars |
insert |
插入 keyed entity;已存在则 conflict |
update |
replace 或 merge keyed entity;不存在则失败 |
delete |
删除 keyed entity;不存在则失败 |
确定性契约与事务语义
环境的确定性身份可以概念化为:
E = (runtime version,
TwinSpec digest,
snapshot digest,
scenario seed,
ordered tool calls)
execute(E) -> (ordered structured results, final state digest)
Spec、MCP tool surface、world state 都做 canonical SHA-256 digest;upstream binding 对 current / drifted / unknown 等不匹配状态 fail closed。普通 tool transition 在单个 SQLite transaction 内完成:
load branch head
-> validate input
-> evaluate preconditions
-> apply effects to isolated working state
-> evaluate query, postconditions, and global invariants
-> commit state + audit record 原子提交
失败的 domain outcome(如 precondition 不满足)保留之前的 state digest,但仍追加 tool-call audit。canonical 错误类共七种:INVALID_INPUT、PRECONDITION_FAILED、NOT_FOUND、CONFLICT、INVARIANT_VIOLATION、UNMODELED_BEHAVIOR、INTERNAL_TWIN_ERROR------错误本身也是可断言的对象。
Fidelity:Twin 不等于完美副本
项目对"仿真保真度"的态度值得抄进任何 mock 工程的 README:fidelity 必须声明、限定、并由证据支持,自动生成的行为不能自我晋级。
| Level | 含义 | 预期用途 |
|---|---|---|
L0 --- Cassette replay |
匹配已录制 interaction | exact-path smoke / regression |
L1 --- Stateful template |
显式 entity + 经审查的基础 transition | 开发与探索性 workflow 测试 |
L2 --- Contract-backed |
人工审查规则、invariant、differential tests、upstream fingerprint | 在声明覆盖范围内做 CI / evaluation |
L3 --- Native/reference |
共享或领域提供的 reference logic | 高保真领域模拟 |
当前 reference Twin(6 个工具:get_repository / list_issues / get_issue / create_issue / add_comment / close_issue)是 synthetic fixture,L1 + unverified + unbound------README 明确说"不要把它描述成 GitHub-equivalent environment"。这种诚实标注比"我们完整模拟了 GitHub"可信得多,也更好维护。
实践:跑一个可复现的评测
# 1. 校验 TwinSpec(结构 + CEL 编译,打印 spec digest)
go run ./cmd/statetwin validate --spec examples/issue-tracker/twin.yaml
# 2. 初始化世界并创建不可变 base snapshot
go run ./cmd/statetwin init --spec examples/issue-tracker/twin.yaml \
--fixture examples/issue-tracker/state.json --db demo.db \
--branch main --snapshot base
# 3. 从同一 snapshot fork 出两个隔离 branch
go run ./cmd/statetwin fork --db demo.db --snapshot base --branch run-a
go run ./cmd/statetwin fork --db demo.db --snapshot base --branch run-b
# 4. 两个 Agent 走不同轨迹(run-a 创建 issue,run-b 关闭 issue)
go run ./cmd/statetwin call --spec examples/issue-tracker/twin.yaml \
--db demo.db --branch run-a --tool create_issue \
--input '{"owner":"octo","repository":"demo","title":"Fork A","body":"Created only in A"}'
# 5. 比较终态(canonical diff,JSON Pointer path 稳定)
go run ./cmd/statetwin diff --db demo.db --before run-a --after run-b
踩坑记录(都写在 README 的 CAUTION 里,实测同样会遇到):
- data plane 当前没有鉴权和 TLS,两个服务默认只绑 loopback,绝不能暴露公网;control token 只是开发期保护措施。
- scenario report 会包含工具输入与结果,只允许用 synthetic fixture 跑,带 credential / production trace / 个人数据的报告不能提交。
- CLI 输出是结构化 JSON(除 server log 与 fatal diagnostic 外),接 CI 断言比解析文本稳。
- SQLite 文件带 State Twin application ID 和显式 schema version,snapshot 绑定 storage schema version;foreign database 和高于当前 runtime 的版本会被直接拒绝------迁移策略是 fail 而不是静默升级。
- 定位是 development preview(
0.1.0-dev),README 对"已实现"和"Roadmap"分得很清:deterministic fault injection、virtual-clock advancement、recorder/cassette replay、live ChatGPT/Claude smoke tests 都还没实现,别按文档脑补功能。
总结与进阶方向
MCP-State-Twin 的核心贡献不是又一个 mock server,而是把 Agent 评测环境抽象成一组可组合的能力:MCP 兼容的 agent-facing surface、显式状态转换契约、可分叉的确定性世界状态、严格的 control-plane 隔离、声明的 fidelity 与 differential validation。确定的是环境,不是语言模型------模型可以做出不同决策,但所有决策都发生在可复现、可比较的世界里,评测从同一 snapshot 出发、比较终态与不变量,而不是强迫轨迹一致。
进阶方向:virtual-time advancement 与确定性 scheduled fault(超时/部分生效/速率限制的注入)、upstream surface inspector 自动刷新、recorder → cassette replay 的 L0 模式、differential validation 把 Twin 晋级到 L2 的完整工作流。对做 Agent 测试平台的团队,这套"快照即起点、分支即用例、diff 即断言"的模型可以直接借鉴到自己的评测基建里。