不调真实模型,怎么测一个 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}"
    );
  1. 剧本两段:第一段让"模型"调 exec_command,命令往只读沙箱外写文件并往 stderr 打一个哨兵字符串;第二段让模型说 "done"。
  2. 用只读权限提交一轮。agent 真的去执行命令,沙箱真的拒绝。
  3. 从请求记录里取 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.rsHTTP 429 / 503 带 Retry-After: 1;流里返回限流错误;流里返回 overloadHTTP 429(由采样循环重试,不是请求层):重试前至少等够 1 秒,最终成功,共 2 个请求。流里的限流错误:消息里写了 "try again in 1s" 就按它等;只有响应头带 Retry-After 时,用本地退避(首次约 200 ms),不按头等(标着 TODO(anp) respect Retry-After,是计划要改的现状)。流里的 overload:直接终止,只有 1 个请求
websocket_fallback.rsWebSocket 握手返回 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 的实测行为模拟。

注入故障
故障出现
被测 agent
服务器收到的请求
模拟时间
最近一个事件
agent 状态
流层重试 / SDK 重试
测试断言
假模型服务器发出的内容(红 = 注入的故障)

SDK 累积出的消息(get_final_message 会返回的东西)

被测 agent 的日志

测试断言(跑完才判)

✎练习

题 1(测什么):给 agent loop 写"工具结果回传"的集成测试,最该断言的是哪个?
A 断言的是假模型的剧本,剧本是你自己写的,永远对。B 必要但不够:结果填错 id、内容被截断,状态照样是 done。C 才是"agent 有没有做对"的证据,Codex 的 function_call_output_text(call_id) 就是干这个的。
题 2(SDK 行为):用 anthropic 1.11.0 的 client.messages.stream(),第 1 轮服务器发完 message_delta 后正常关闭了连接,没发 message_stop。get_final_message() 会怎样?
本地实测:不报错,返回累积到的消息(这一例里 stop_reason 已经是 tool_use)。这次内容碰巧是完整的,但客户端分不清"只丢了最后一个事件"和"丢了一半",所以要自己检查 message_stop,统一当成没说完。Codex 对 response.completed 也是这么处理的。
题 3(算):SDK 的 max_retries=2,服务器对每个请求都回 429、retry-after: 3。从第 1 个请求发出到 SDK 抛出 RateLimitError,至少要等多少秒(忽略网络耗时)?
秒
共 1 + 2 = 3 个请求,中间等 2 次,每次按 retry-after 等 3 秒:2 × 3 = 6 秒。SDK 1.11.0 拿到正的 retry-after 就照用(_base_client.py 的 _calculate_retry_timeout),没有的话才用 0.5 秒起、翻倍、上限 8 秒、带抖动的指数退避。可以在上面的演示里选"429 + 每次都出现"、Retry-After 拉到 3 验证。
题 4(读源码):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) 不成立。这正是两层重试分开配置的好处:测试能精确地只打开一条路径。
题 5(CI):blocking-ci.yml 里汇总用的 "CI required" job 为什么要写 if: ${{ always() }}?
这是 yml 里的原注释说的。配套的 check_ci_results.py 只认 success。测开平时做质量门禁也会遇到同一类问题:"没跑"不能被当成"通过"。

7面试要点与代码

一句话讲清楚

测 agent 不调真实模型:起一个假的模型服务器,按剧本返回 SSE 事件流,把断开、没说完、429、畸形数据都写进剧本;断言的是 agent 发给模型的下一个请求(工具结果按 id 回填、重试时请求体不变、限流后等够了 Retry-After),以及请求次数。Codex 的集成测试就是这么写的,再加上 deny unwrap 的 lint、请求快照和一个 always() 的汇总门禁。

追问准备

  1. mock 和真实 API 行为不一致怎么办?事件格式照官方文档的流式示例写,并用官方 SDK 当客户端去解析:格式错了 SDK 会直接报错。另外保留少量真实 API 的冒烟测试,定期跑,不进 PR 门禁。
  2. 流断了,重发时要不要带上已经收到的半截内容?官方流式文档的 Error recovery 一节建议保存半截内容续写:Claude 4.5 及更早放进 assistant 消息开头,4.6 及之后改成在 user 消息里带上半截回复、让模型接着写。我们选择整轮重发、历史里不留半截消息,理由有二:同一节说 tool_use / thinking 块不能部分恢复,而断点常落在 tool_use 里;整轮重发时请求体和第一次逐字相同,测试一条等式就能断言。代价是已生成的部分要重新生成。
  3. 两层重试为什么要分开?流层(读到一半出问题)SDK 不管,只能 agent 自己重试;HTTP 状态错误(429 / 5xx)我们交给 SDK,它认 retry-after。两层次数分开配,测试才能只打开其中一条路径。这个分工是我们自己的设计,不是"和 Codex 一样":Codex 的请求层不重试 429(交给采样循环),5xx 两层都重试、次数相乘。我们这边 429 / 5xx 不相乘,但拿到响应前的超时会被 SDK 和流层各重试一遍(实测 3 × 3 = 9 个连接)。
  4. 怎么证明这些测试有用?做变异:故意改坏被测代码(去掉某个检查),看对应的测试会不会红。三处变异各让 1 个测试变红,说明每个测试都守着一条具体的路径。

常见错误说法

❌ "用真实模型跑几遍没出错,重试逻辑就没问题":真实 API 很少在你跑测试时断流,这些路径根本没被走到。
❌ "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 周"长任务可靠性"会直接复用这个假服务器做故障注入。