不调真实模型,怎么测一个 Agent?
把模型当成一个可以编排的 HTTP 依赖:起一个假的模型服务器,按剧本吐 SSE 事件流,故障也写进剧本;然后断言 agent 发给模型的下一次请求,而不是断言模型说了什么。
这是 Codex 集成测试的核心套路(commit 7993248:core/tests/suite/ 下 218 个 .rs 文件,207 个用到了假模型服务器)。这一讲读它的 mock 工具、三个故障注入测试、快照、lint 和 CI 门禁,最后用 Python 标准库复刻一个,给第 2 周的 agent loop 写测试。
1为什么断言请求,而不是断言回答
用真实模型测 agent,有三个绕不开的问题:输出不确定(同一个输入两次回答不一样),花钱,以及故障没法按需复现,你没办法让线上 API"在第 5 个事件断开"。
换个角度:agent loop 是你的代码,模型是它的外部依赖。测你的代码,就把依赖换成假的,固定住它的行为,再看你的代码对每一种行为反应对不对。agent 的"反应"体现在它发出的下一次请求里:工具结果有没有按 id 回填、重试时有没有把半截消息塞进历史、限流后隔了多久才重发。这些都是确定的,可以逐字断言。
| 断言对象 | 能测什么 | 稳定吗 |
|---|---|---|
| 模型的回答 | 模型质量(属于评测,不属于这里) | 不稳定,要统计(第 1 周的方法) |
| agent 发出的请求 | 工具结果回传、历史拼接、重试次数、退避时间、请求头 | 确定,一次就能判对错 |
| agent 的最终状态和事件 | 停止条件、错误分类、是否误报成功 | 确定 |
2Codex 的假模型服务器:三件工具
测试支持代码在 codex-rs/core/tests/common/responses.rs,底层是 Rust 的 HTTP mock 库 wiremock 0.6(codex-rs/Cargo.toml:555)。读懂三件就够了:
// core/tests/common/responses.rs:752-766
/// Build an SSE stream body from a list of JSON events.
pub fn sse(events: Vec<Value>) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for ev in events {
let kind = ev.get("type").and_then(|v| v.as_str()).unwrap();
writeln!(&mut out, "event: {kind}").unwrap();
if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) {
write!(&mut out, "data: {ev}\n\n").unwrap();
} else {
out.push('\n');
}
}
out
}
① sse(events):把一串 JSON 事件拼成 SSE 文本。每个事件取 type 字段写成 event: 行,再把整个 JSON 写成 data: 行,空行分隔。注意 if:事件只有 type 一个字段时不写 data 行。故障注入测试正是利用了这一点造出"半个事件"。
// core/tests/common/responses.rs:1469-1506(节选)
pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec<String>) -> ResponseMock {
// ...
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst);
let missing_response_message = format!("no response for {call_num}");
let body = self
.responses
.get(call_num)
.expect(&missing_response_message);
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body.clone())
}
// ...
mock.respond_with(responder)
.up_to_n_times(num_calls as u64)
.expect(num_calls as u64)
.mount(server)
.await;
② mount_sse_sequence:按调用顺序发剧本。第 n 次 POST 拿第 n 段 SSE(fetch_add 是原子计数器,并发安全)。.up_to_n_times(N) 让 mock 最多匹配 N 次:第 N+1 个请求不再匹配,wiremock 回默认的 404,走不到 respond,所以 .expect("no response for n") 实际碰不到(函数注释说会 panic,和实际行为不符)。.expect(N) 保证请求少了一定红;多了红不红,取决于 agent 怎么处理那个 404。我们的 Python 版把多出来的请求记进 unexpected,fixture 收尾时断言它为空,才做到多一个也一定红。
// core/tests/common/responses.rs:738-750
impl Match for ResponseMock {
fn matches(&self, request: &wiremock::Request) -> bool {
self.requests
.lock()
.unwrap()
.push(ResponsesRequest(request.clone()));
// Enforce invariant checks on every request body captured by the mock.
// Panic on orphan tool outputs or calls to catch regressions early.
validate_request_body_invariants(request);
true
}
}
③ ResponseMock:录下每一个请求,顺便查不变量。它挂在 wiremock 的匹配器上,每来一个请求就存一份,测试结束后用 function_call_output_text(call_id) 之类的方法取出"agent 回给模型的工具输出"。validate_request_body_invariants(:1558 起)检查每个请求里工具调用和工具输出是否成对,孤儿输出直接 panic。也就是说,任何一个用了这套 mock 的测试,都顺带测了配对规则。
另有 start_mock_server()(:1203-1212)启动时先挂一个空的 /models 响应,保证测试完全离线。
3一个完整的测试:工具输出有没有原样回到模型
// core/tests/suite/tools.rs:704-726, 745-748(节选)
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
fixture
.submit_turn_with_permission_profile(
"run a command that should be denied by the read-only sandbox",
PermissionProfile::read_only(),
)
.await?;
let output_text = mock
.function_call_output_text(call_id)
.context("shell output present")?;
// ...
assert!(
body.contains(sentinel),
"expected sentinel output from command to reach the model: {body}"
);
- 剧本两段:第一段让"模型"调
exec_command,命令往只读沙箱外写文件并往 stderr 打一个哨兵字符串;第二段让模型说 "done"。 - 用只读权限提交一轮。agent 真的去执行命令,沙箱真的拒绝。
- 从请求记录里取 agent 回给模型的工具输出,断言:带有权限拒绝的字样、哨兵字符串到了模型那里、提到了被拒的路径、不是兜底文案、退出码非 0(
:736-763)。
和第 2 周的 loop 对照:这就是"tool_result 有没有按 id 回填、内容对不对"的测试,只是 Codex 断言的是 OpenAI Responses API 的 function_call_output,我们换成 Claude 的 tool_result。
4故障注入:三个测试文件各测什么
| 文件 | 注入的故障 | 断言 |
|---|---|---|
stream_no_completed.rs | 第一段流只有一个 response.output_item.done(而且没有 data 行),然后正常结束,没有 response.completed | 允许 1 次流重试(stream_max_retries: Some(1))时,turn 最终完成,服务器恰好收到 2 个请求 |
retry_after.rs | HTTP 429 / 503 带 Retry-After: 1;流里返回限流错误;流里返回 overload | HTTP 429(由采样循环重试,不是请求层):重试前至少等够 1 秒,最终成功,共 2 个请求。流里的限流错误:消息里写了 "try again in 1s" 就按它等;只有响应头带 Retry-After 时,用本地退避(首次约 200 ms),不按头等(标着 TODO(anp) respect Retry-After,是计划要改的现状)。流里的 overload:直接终止,只有 1 个请求 |
websocket_fallback.rs | WebSocket 握手返回 426,或者握手一直失败 | 426:只试 1 次 WebSocket 就切 HTTP;一直失败:WebSocket 共 4 次(启动预热 1 次 + 首轮 1 次 + 重试 2 次)后切 HTTP,且之后的 turn 一直走 HTTP("sticky") |
// core/tests/suite/stream_no_completed.rs:20-24
fn sse_incomplete() -> String {
responses::sse(vec![serde_json::json!({
"type": "response.output_item.done",
})])
}
// :65-68
// exercise retry path: first attempt yields incomplete stream, so allow 1 retry
request_max_retries: Some(0),
stream_max_retries: Some(1),
stream_idle_timeout_ms: Some(2000),
// :95-100
let requests = server.requests().await;
assert_eq!(
requests.len(),
2,
"expected retry after incomplete SSE stream"
);
被测的生产代码在 codex-api/src/sse/responses.rs:流读到头还没见到 completed,就报 "stream closed before response.completed"(:558-563);某个事件的 data 解析不了,记一条 debug 日志,跳过这个事件继续读(:575-586);两次事件之间超过空闲超时(默认 300 秒)也报错。重试分两层:request_max_retries 默认 4,管 HTTP 层的 5xx、超时、网络错误,不含 429(retry_429: false,lib.rs:447-453);stream_max_retries 默认 5,管流中途的错误和带 Retry-After 的 429(model-provider-info/src/lib.rs:62-64)。5xx 两层都会重试,次数相乘。测试里把一层设 0、另一层设 1,就能精确控制走哪条路径。
5快照、lint、CI 门禁、评审规范
insta 快照
Codex 用 insta 1.46(全仓 1,488 个 .snap,其中 TUI 1,373 个,core 84 个)。core 里的快照多半拍的是发给模型的请求长什么样:
// core/tests/suite/additional_context.rs:89-95
insta::assert_snapshot!(
"additional_context_simple_input",
context_snapshot::format_labeled_requests_snapshot(
"additional context is inserted before the user turn input.",
&[("Request", &request)],
&ContextSnapshotOptions::default().rewrite_known_segments(),
)
);
先把请求格式化成人能读的文本(rewrite_known_segments 把权限说明之类的大段固定文本换成 <PERMISSIONS_INSTRUCTIONS> 占位),再和 snapshots/ 下的文件逐字比较。上下文拼接顺序一变,快照就红,评审时看 diff 就知道请求变了什么。
clippy:禁止 unwrap / expect
codex-rs/Cargo.toml:561-597 的 [workspace.lints.clippy] 把 36 条 lint 设成 deny,包括 unwrap_used、expect_used、await_holding_lock、redundant_clone、uninlined_format_args。codex-rs/clippy.toml 前两行又放开了测试:allow-expect-in-tests = true、allow-unwrap-in-tests = true。
为什么:Rust 的 unwrap() 相当于 Python 里"假设一定成功,失败就让进程崩"。在长时间运行的 agent 里,一个没想到的 None 会让整个会话崩掉,而正确做法是把错误往上返回,由 loop 决定是重试、回给模型还是终止。测试里崩了就是测试失败,正是想要的,所以放开。
CI 门禁
# .github/workflows/blocking-ci.yml:48-72(节选)
required:
name: CI required
# Without `always()`, GitHub skips this job after a failed dependency and a
# required check can appear successful instead of reporting the failure.
if: ${{ always() }}
needs:
- bazel
# ...
- sdk
# ...
- name: Require successful dependencies
env:
NEEDS: ${{ toJSON(needs) }}
run: python3 .github/scripts/check_ci_results.py
仓库的设计是让 PR 合并只依赖一个汇总检查 "CI required"(yml 开头注释说 main 分支的规则集应当要求它;GitHub 上实际配了哪些必需检查,从仓库文件里看不到,未核实)。它依赖 7 个子工作流(Bazel 测试与 clippy、cargo-deny、codespell、repo-checks、rust-ci 等),用 if: always() 保证依赖失败时它照样运行,再由脚本检查:只有 success 算过,skipped、cancelled 也算失败。全平台 cargo clippy -D warnings 和分片 nextest 放在合并后的 postmerge-ci.yml 里跑。nextest 配置(codex-rs/.config/nextest.toml):超过 30 秒算慢、2 个周期后终止,失败自动重试 1 次。
PR 评审规范
.github/codex/labels/codex-rust-review.md 是给 Codex 自己审 PR 用的 prompt。和测试相关的几条:PR 描述要说清动机;一个 PR 只做一件事,重构和功能分开;测试里对整个对象做一次 assert_eq!,不要逐字段比较,因为单元测试也是"可执行的文档";不用 unsafe,不用 std::env::set_var(曾多次导致竞态)。这份文件也有过时的地方:它建议把共享逻辑放进 codex-rs/common,而这个 crate 在当前 commit 里已经不存在。
6迁移到我们自己的 agent:Python 版
本地代码在 week03_Codex源码/code/mock_model_server/:假服务器只用标准库(http.server),被测的是第 2 周的 loop 改成流式之后的版本,用官方 SDK anthropic 1.11.0 指向假服务器。6 个测试在本机真实跑通:
test_agent_loop.py::test_tool_result_is_sent_back PASSED [ 16%]
test_agent_loop.py::test_retries_after_midstream_disconnect PASSED [ 33%]
test_agent_loop.py::test_missing_message_stop_is_retried PASSED [ 50%]
test_agent_loop.py::test_429_respects_retry_after PASSED [ 66%]
test_agent_loop.py::test_429_without_http_retries_fails_fast PASSED [ 83%]
test_agent_loop.py::test_malformed_json_gives_up_after_bounded_retries PASSED [100%]
============================== 6 passed in 4.37s ===============================
跑测试的过程中发现了 SDK(1.11.0)的四个行为,正是这些测试要防的坑:
| 注入的故障 | SDK 的表现 | agent 要自己做什么 |
|---|---|---|
| 流读到一半 TCP 断开 | 抛 httpx2.RemoteProtocolError,不是 anthropic.APIConnectionError | 重试时连传输层异常一起捕获 |
正常结束但没有 message_stop | 不报错,get_final_message() 照样返回半截消息 | 自己记下有没有见到 message_stop |
| data 不是合法 JSON | 抛 json.JSONDecodeError(Codex 是跳过这个事件) | 归入流层重试,次数有上限 |
429 + retry-after: 1 | 默认 max_retries=2,按 retry-after 等待后重发,请求头 x-stainless-retry-count 从 0 变 1 | 别把 max_retries 关掉;自己的重试别再叠一层 |
再做三次"变异"验证测试真能抓 bug:把断开异常的捕获去掉、把 message_stop 检查去掉、把工具结果截成 10 个字符,每次都恰好有 1 个测试变红(详见博客正文)。
▶互动演示:SSE 剧本编辑器
左边是假模型服务器发出的事件流,右边是 SDK 累积出的消息、被测 agent 的日志。任务和测试一样:模型第 1 轮调 read_file("README.md"),第 2 轮说完。故障只注入在第 1 轮。时间是模拟的:每个事件 0.05 秒,执行工具 0.1 秒,流层退避 0.2、0.4 秒,429 按 Retry-After 等。agent 的行为按本地代码和 SDK 1.11.0 的实测行为模拟。
SDK 累积出的消息(get_final_message 会返回的东西)
被测 agent 的日志
测试断言(跑完才判)
✎练习
function_call_output_text(call_id) 就是干这个的。anthropic 1.11.0 的 client.messages.stream(),第 1 轮服务器发完 message_delta 后正常关闭了连接,没发 message_stop。get_final_message() 会怎样?message_stop,统一当成没说完。Codex 对 response.completed 也是这么处理的。max_retries=2,服务器对每个请求都回 429、retry-after: 3。从第 1 个请求发出到 SDK 抛出 RateLimitError,至少要等多少秒(忽略网络耗时)?_base_client.py 的 _calculate_retry_timeout),没有的话才用 0.5 秒起、翻倍、上限 8 秒、带抖动的指数退避。可以在上面的演示里选"429 + 每次都出现"、Retry-After 拉到 3 验证。stream_no_completed.rs 把 stream_max_retries 设成 1,断言服务器收到 2 个请求。如果改成 0,会发生什么?stream_max_retries 管;测试里 request_max_retries 是 0,而且它管的是 HTTP 请求本身失败。改成 0 就不重试,只有 1 个请求,assert_eq!(requests.len(), 2) 不成立。这正是两层重试分开配置的好处:测试能精确地只打开一条路径。blocking-ci.yml 里汇总用的 "CI required" job 为什么要写 if: ${{ always() }}?check_ci_results.py 只认 success。测开平时做质量门禁也会遇到同一类问题:"没跑"不能被当成"通过"。7面试要点与代码
一句话讲清楚
测 agent 不调真实模型:起一个假的模型服务器,按剧本返回 SSE 事件流,把断开、没说完、429、畸形数据都写进剧本;断言的是 agent 发给模型的下一个请求(工具结果按 id 回填、重试时请求体不变、限流后等够了 Retry-After),以及请求次数。Codex 的集成测试就是这么写的,再加上 deny unwrap 的 lint、请求快照和一个 always() 的汇总门禁。
追问准备
- mock 和真实 API 行为不一致怎么办?事件格式照官方文档的流式示例写,并用官方 SDK 当客户端去解析:格式错了 SDK 会直接报错。另外保留少量真实 API 的冒烟测试,定期跑,不进 PR 门禁。
- 流断了,重发时要不要带上已经收到的半截内容?官方流式文档的 Error recovery 一节建议保存半截内容续写:Claude 4.5 及更早放进 assistant 消息开头,4.6 及之后改成在 user 消息里带上半截回复、让模型接着写。我们选择整轮重发、历史里不留半截消息,理由有二:同一节说 tool_use / thinking 块不能部分恢复,而断点常落在 tool_use 里;整轮重发时请求体和第一次逐字相同,测试一条等式就能断言。代价是已生成的部分要重新生成。
- 两层重试为什么要分开?流层(读到一半出问题)SDK 不管,只能 agent 自己重试;HTTP 状态错误(429 / 5xx)我们交给 SDK,它认 retry-after。两层次数分开配,测试才能只打开其中一条路径。这个分工是我们自己的设计,不是"和 Codex 一样":Codex 的请求层不重试 429(交给采样循环),5xx 两层都重试、次数相乘。我们这边 429 / 5xx 不相乘,但拿到响应前的超时会被 SDK 和流层各重试一遍(实测 3 × 3 = 9 个连接)。
- 怎么证明这些测试有用?做变异:故意改坏被测代码(去掉某个检查),看对应的测试会不会红。三处变异各让 1 个测试变红,说明每个测试都守着一条具体的路径。
常见错误说法
❌ "SDK 有重试,流式调用就不用管了":SDK 的重试只管拿到响应之前;流读到一半断开、没收到 message_stop,都要 agent 自己处理。
❌ "捕获
anthropic.APIConnectionError 就能兜住断流":SDK 1.11.0 里流读到一半断开抛的是 httpx2.RemoteProtocolError。❌ "测试要断言模型回答对不对":那是评测;集成测试断言的是 agent 发出去的请求和最终状态。
❌ "Codex 禁止 unwrap,所以测试里也不能用":
clippy.toml 明确允许测试里用 unwrap / expect。❌ "Codex 的 HTTP 请求层会按 Retry-After 重试 429":请求层
retry_429: false,429 由采样循环重试,测试里重试事件的 layer 是 "stream"。
最小可运行代码
完整代码在本地 week03_Codex源码/code/mock_model_server/(mock_model_server.py、agent_loop.py、test_agent_loop.py、conftest.py),运行:python3.13 -m venv .venv && .venv/bin/pip install -r requirements.txt && .venv/bin/python -m pytest -v。博客正文贴了关键部分。
本周下一步:第 5 周"长任务可靠性"会直接复用这个假服务器做故障注入。