在没有网络的情况下测试
使用脚本模型、确定性辅助工具、协议 cassette 与评估门槛。
测试分层
使用精简的测试金字塔:
- 策略、Schema 与工作流条件的纯单元测试;
- 使用脚本模型和工具的确定性 Agent 测试;
- 使用脱敏 Cassette 的协议契约测试;
- 少量真实服务商在线冒烟;
- 版本化质量评测。
大部分应用正确性不应该依赖真实模型或网络。
脚本模型
添加 runifold-testkit 开发依赖,使用 ScriptedModel 返回已知模型响应序列。断言最终
结果、Transcript、工具调用、事件与用量。
[dev-dependencies]
anyhow = "1"
runifold = "=0.9.0"
runifold-testkit = "=0.9.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }下面的测试不会访问网络,也不需要 API Key:
use std::{collections::BTreeMap, sync::Arc};
use runifold::{
Agent, ContentPart, FinishReason, ModelRef, ModelStreamEvent,
};
use runifold_testkit::ScriptedModel;
#[tokio::test]
async fn agent_returns_the_scripted_answer() -> anyhow::Result<()> {
let model = ScriptedModel::new();
model.enqueue([
ModelStreamEvent::ResponseStarted {
id: Some("response-1".into()),
model: ModelRef::new("test", "scripted"),
},
ModelStreamEvent::ContentPartCompleted {
index: 0,
part: ContentPart::text("approved"),
},
ModelStreamEvent::ResponseCompleted {
finish_reason: FinishReason::Stop,
provider_metadata: BTreeMap::new(),
},
]);
let observed = model.clone();
let agent = Agent::builder(
"reviewer",
Arc::new(model),
ModelRef::new("test", "scripted"),
)
.system("Return one review decision.")
.build()?;
let answer = agent.prompt_text("Review order 42").await?;
assert_eq!(answer, "approved");
assert_eq!(observed.recorded_requests().len(), 1);
Ok(())
}用 cargo test --test agent 只运行这项集成测试。如果需要在执行后检查模型收到的
Request 或调用上下文,应先 Clone ScriptedModel,再把另一个实例交给 Agent。
除正常路径外,还要覆盖工具参数损坏、权限拒绝、预算耗尽、取消、服务商拒绝与最大 轮次。
服务商 Cassette
Cassette 可以验证 HTTP 编解码,而不让 CI 依赖服务商。提交前必须移除认证信息、 Cookie、请求 ID 与用户正文。
把 Cassette 格式与端点版本视为测试 Fixture。协议行为变化时显式刷新,不能在测试 中自动更新。
质量评测
模型行为具有概率性,应评估可观察的产品属性:分类正确性、引用依据、拒答行为、工具 选择、延迟与成本。
Prompt、数据集、Grader、模型标识和验收阈值应一起版本化。需要查看失败案例,不能 只相信一个总分。
CI 门禁
每次修改运行格式化、Clippy、单元测试、文档示例、协议契约与离线评测。在线冒烟应 在受保护环境中运行,并设置严格预算。
必要能力变为 Unknown,或已验证服务商组合失去证据时,应阻止发布。
每个边界应该断言什么
| 边界 | 应断言 | 不应依赖 |
|---|---|---|
| Agent | 最终 Outcome、Request 形状、轮次 | 除非措辞就是契约,否则不要逐字匹配 |
| Tool | 参数解析、Capability 检查、结构化结果 | 让模型决定写操作是否安全 |
| Stream | 事件顺序且只有一个终止事件 | 拼接可见 Delta 作为最终答案 |
| Workflow | 稳定 Step 输出、Usage、失败策略 | 分支完成的实际时钟顺序 |
| Provider Adapter | 规范请求与响应映射 | 每个 PR 都访问真实端点 |
| Evaluation | 单 Case 失败和阈值 | 不检查失败样本的单一平均分 |
复现故障与恢复
runifold-testkit 0.9 提供可复用的断连、具名 Tool 失败、Runtime 重建与标准化
Golden Trace 边界:
use runifold_testkit::{FaultScenario, RecoveryHarness};
let faults = FaultScenario::new()
.disconnect_after_tool_call()
.fail_tool_on_invocation("charge", 2, injected_error);
let model = faults.model(scripted_model);
let mut runtime = RecoveryHarness::new(runtime_factory, faults.clone());
runtime.restart();
faults.assert_tool_executed_exactly("charge", 1)?;对 0.9 Review Gate,要分别在 Review Ready、Review In-flight 与 Approved Plan Checkpoint 杀进程。断言 Ready Candidate 不会重新生成、已批准的 Tool Plan 只回放一次, 而不确定的 In-flight Review 在应用授予显式重试权限前会失败。Golden Trace 会去掉随机 ID 与时间戳,同时保留第一个因果差异。
测试失败排查
如果 ScriptedModel 提示没有排队的调用,先计算路径实际会调用模型几次:重试、
Tool 后续轮次和 Fallback 都会多消耗一个 Script。如果 Stream 无法完成,确认恰好包含
一个终止 ResponseCompleted 事件。如果在线测试不稳定,把协议正确性移入 Cassette
测试,只把在线测试保留为有严格预算的 Canary。