Agent 测试工程 2026:trace replay 与 LLM mock
把 trace 录制、deterministic replay、LLM/tool mock 与 CI 门禁四件套压回可控回归;结构、语义、性能契约三层分开断言,让 Agent 系统从 demo 走到生产。
约 39 分钟阅读11,602 字30 次阅读博主

把 trace 录制、deterministic replay、LLM/tool mock 与 CI 门禁四件套压回可控回归;结构、语义、性能契约三层分开断言,让 Agent 系统从 demo 走到生产。

一句话摘要:当 Agent 从 demo 走到生产,"测试"不再是单条 function calling 的 assertable 单元,而是一段由模型、工具、检索、记忆、状态、副作用构成的分布式确定性难题;本文给出从 trace 录制、deterministic replay、LLM/tool mock,到 CI 门禁的工程闭环。
Agent 系统在 2026 年已经全面进入生产环境。根据对 LangGraph(40,438 ⭐,截至 2026-08-24)、CrewAI(57,606 ⭐,截至 2026-08-25)、AutoGen(60,627 ⭐,截至 2026-04-15)、OpenAI Agents SDK(31,458 ⭐,截至 2026-08-25)和 Anthropic Claude Agent SDK(3,850 ⭐,截至 2026-08-25)五个主流框架的代码观察,一个被反复复现的现象是:同一个 prompt + 同样的工具集 + 同样的环境,连续跑两次的 trace 仍然会分叉——分叉点可能是 LLM 的随机采样、tool 返回的非稳定排序、time-based context(如 datetime.now())、向量检索的近似最近邻、并发抢锁的顺序、HTTP 重试的回执时序。这些分叉点不是"bug",而是 Agent 系统本身的不确定性来源;它们的累积让"测试"从传统软件工程的 assertable 单元,走到了一个必须显式处理不确定性的领域。
更棘手的是,传统的单元测试哲学——"给定输入、跑函数、断言输出"——在 Agent 场景里无法直接套用。给定同样的 prompt,GPT-4o 可能生成略有不同的 tool 调用参数;给定同样的 search query,向量数据库可能返回稍有不同的 top-K;给定同样的 wall-clock,缓存命中与否会决定某一段 sub-call 是 50ms 还是 800ms。Agent 测试的核心矛盾因此被归纳为:
理解了这三个矛盾,下面的整套工程范式——deterministic replay、LLM/tool mock、CI 集成测试——才显得不是"为了测试而测试",而是"为了在不确定性之上重建工程可控性"。
我们把 Agent 测试抽象成四元组 (Trace, Recorder, Replayer, MockEngine),加上贯穿始终的 AssertEngine:
Event(eid, t, actor, kind, payload),actor ∈ {user, llm, tool, memory, system},kind ∈ {prompt, response, call, return, error, state_change}。Trace 必须包含时间戳、随机种子、所有外部 I/O 的输入/输出、所有内部状态转移——缺一项,replay 就一定分叉。围绕这四元组,我们定义 Agent 测试的三层契约:
三层契约的失效模式完全不同:结构契约破坏 = Agent 的代码逻辑出错;语义契约破坏 = Agent 的 prompt/工具/检索需要重新对齐;性能契约破坏 = Agent 进入了无限循环或工具调用爆炸。把三层契约分开断言,是 Agent 测试工程能够稳定运行的第一步。
Recorder 不是"把 log 写下来"那么简单。它的工程难点在于:录少了,replay 时分叉;录多了,存储和隐私都崩。我们采用如下的最小完备录制策略:
(messages, model, temperature, seed, response, latency_ms, tokens_in, tokens_out);所有 Tool 调用的 (tool_name, args, kwargs, return_value, error, latency_ms);所有外部 I/O(HTTP/SQL/Redis)的 (request, response, status, latency_ms);所有 wall-clock time(以 epoch ms 落盘);所有 random source(Python random 的 seed、numpy 的 seed、torch 的 seed、LLM 的 sampling seed)。(query, top_k, embedding_model, results);中间 prompt template 的渲染结果;token 预算/限流的命中日志;agent 内部状态机的转移日志。工程实现上,Recorder 通常以 OpenTelemetry(OTel)SDK 为底座,把每个 sub-call 包成一个 span,span 上挂 payload(注意 OTel attribute 有大小上限,raw response 必须落单独的 trace store,attribute 只放 id/hmac);同时在进程内开一个 RandomStateRecorder,拦截 random.seed / np.random.seed / torch.manual_seed,把 seed 落 trace。这样 Replayer 在 replay 时只要 reset 所有随机源到同样的 seed,就能拿到 bit-level 一致的 LLM 采样。
OpenTelemetry 已经成为 Agent trace 的事实标准:id=598(2026-08-23)已经覆盖了 OTel 标准化追踪的全部细节,包括 span naming convention、attribute 命名空间、sampling rate 的工程权衡、export to Jaeger/Tempo/Honeycomb 的代价对比。本文不重复 OTel 本身,重点是在 OTel 之上加一层 Agent-aware Recorder:把 trace 落到 S3 兼容的对象存储,按 (agent_id, trace_id, version) 三维索引,并用一个 content-addressable hash(SHA-256 of canonical JSON)做 trace 指纹——这就是后面 CI 门禁"trace diff"的基础。
最后,Recorder 必须支持双视图投影:同一个 trace 既能被"行为 replay 视图"消费(按时间轴展开所有 event),也能被"调用图视图"消费(按 actor+kind 折叠成有向图)。这两个视图对应不同的调试心智模型——前者是"看时间线"(出了什么、何时出、谁先谁后),后者是"看拓扑"(谁调了谁、循环在哪里、瓶颈在哪个分支)。工程上我们用 OpenTelemetry 的 span tree 作为调用图主干,再用 sidecar 的 event log 作为时间线源,两者通过 trace_id 关联。CI 失败时,failure classifier 会自动选择更合适的视图:循环超时问题用调用图更直观;偶发 race 用时间线更清晰。
一个常被低估的细节是:Recorder 必须支持"录制但跳过 LLM"的开关。原因是 CI 环境跑真实 LLM 既慢(每个 case 几十秒到几分钟)又贵(每次 PR 触发几百次就是几百美元),必须能切到"只录 tool 调用、不录 LLM"模式,让 Replayer 用 fixture 喂 LLM。生产环境则反过来——"录全"模式。两种模式共用同一份 Recorder 代码,靠 config 切换。
Replayer 是 Agent 测试工程里最难写对的一层。它的核心职责是:给定 Trace,重放一遍被测 Agent,得到第二条 Trace;然后让 AssertEngine 对比两条 Trace。但仅仅"重放一遍"是不够的,因为现实里有三类扰动源会污染 Replay:
datetime.now()、TTL 过期、cron 触发、ISO 日期解析都不同;针对三类扰动,Replayer 必须实现三类"归位":
time.time() / datetime.now() 调用都从 Trace 里读。所有 TTL 用 Trace 里的"原始 time + TTL delta"计算。所有 cron 触发器在 Replay 模式下被禁用。这是工程上最容易漏的一环:很多团队的 Replayer 只 mock 了 LLM 和 tool,忘了 mock 时间,结果"业务时间相关"的 assert 在不同时区跑测试时全分叉。asyncio.create_task / ThreadPoolExecutor.submit 的调度顺序都由 Trace 决定。Replayer 通过把 Trace 里的 eid 排序作为调度序列,强制让"应该先跑的协程先跑"。这一层的实现往往需要 monkey-patch asyncio 的 event loop,或用 pytest-asyncio 的 freeze_time 配合 manual scheduling。random.random() / np.random.rand() / torch.rand() 在 Replay 开始时被 reset 到 Trace 里记录的 seed。LLM 的采样随机性在 MockEngine 层处理(下一节)。更进一步,Replayer 还必须支持部分 Replay:有些 case 我们只想 replay LLM 决策部分、tool 部分用真实环境(因为我们要测的就是 tool 行为)。这就要求 Replayer 支持"hybrid mode"——MockEngine 决定哪些 sub-call 用 fixture、哪些用真实调用。这种混合 Replay 模式的工程实现非常微妙:真实 tool 调用的 latency 会被算入总耗时,但 AssertEngine 必须容忍这种 latency 漂移(用一个 latency_tolerance_ms 参数)。
LLM Mock 是 Agent 测试工程里最容易被低估的一层。简单版本:把 openai.ChatCompletion.create 替换成"读 fixture,返回固定 response"。这种实现只能应付最 trivial 的 case——一旦被测代码里有"基于上次 LLM 返回做条件分支",固定 fixture 就崩了。
工程上正确的 LLM Mock 必须支持按 call index 的 fixture 注入:
get_order(order_id)"refund_order(...)"这三段 response 必须按"第 N 次 LLM call" 顺序喂给被测代码,而不是按 prompt 哈希匹配——因为 prompt 可能因为微小的 whitespace 不同就哈希不上。Call-index based mock 比 content-based mock 稳得多。
更进一步,LLM Mock 必须支持模式切换:
refund_order 改成 cancel_order),然后观察 Agent 是否能 recover——这是测鲁棒性的关键模式。更工程化的设计是给 LLM Mock 加一层契约化包装(contract wrapper):每个 mock response 都要声明它依赖的前置条件(preconditions)和它保证的后置效果(postconditions)。例如 mock 一个"返回退款 ticket 号"的 response 时,precondition 是"order 存在且可退",postcondition 是"调用方后续能凭 ticket 号查询"。这种契约化让"在某个 call 点换 mock response"成为一类有类型的操作——如果新 mock 不满足前置条件,LLM Mock 会在 setup 阶段就报错,而不是让 assert 在几百毫秒后才失败。这把"negative test"从"经验驱动"提升为"契约驱动",对大规模 fixture 库的维护性是质的提升。
Tool Mock 的工程难度同样被低估。Tool Mock 必须支持:
refund_order(order_id) 调用一次后,order 状态就变了;Tool Mock 必须维护这个状态,否则 replay 第二次调用就拿到不同的状态、AssertEngine 误报 FAIL。refund_order 在第 3 次调用时返回 502,验证 Agent 能不能 fallback。这是测工具调用幂等性 + 重试 + 降级的标准手段,与 id=593(幂等性工程)和 id=517(超时熔断)形成测试侧的闭环。OpenTelemetry 标准的 semantic conventions 在 2026 年已经把 gen_ai.* attribute 命名空间稳定下来,LLM Mock 应该直接 emit OTel span,让 trace 看起来"和真实运行一致",便于后续接入 production monitoring。
把 Agent 测试分层是工程纪律。单测针对 agent.py 里的纯函数(如 _parse_tool_args、_select_next_action)—— Mock 一切外部依赖,断言返回值。集成测针对一个完整 agent loop(如 agent.run(prompt)),Mock LLM 但保留真实 tool/DB/检索—— 验证 Agent 在"工具真实、模型受控"下能正确完成业务目标。E2E 测试则用真实 LLM + 真实 tool + 真实环境,验证"产品在用户视角下能跑通"——但只跑少量 golden case(5-20 条),并且通常放在 nightly 而非 PR 触发的 CI。
三层之间的边界必须严格:
更进一步,三层之间应该有Promote 机制:单测里发现的问题 → 写一个 integration case 覆盖;integration 里发现的问题 → 提炼成 unit case 加快定位;E2E 里发现的问题 → 录成 trace 跑回 unit/integration 让回归测试自动保护**。这种 Promote 流程让测试套件像一个会进化的有机体,而不是"一次性写完就堆积"。
工程上一个常被忽视的细节是:单测与集成测必须能在同一进程里同时跑。很多团队的 CI 是"先跑单测、再跑集成测、再跑 E2E"——但 Agent 测试场景下,单测的 MockEngine 必须能跟集成测的 MockEngine 共存(同一进程里多个 agent 实例),否则测试套件要么串行要么互相污染。我们的工程实践是:每个测试 case 自带一个 MockEngine 实例(不是全局单例),跑完即销毁。
CI 集成是 Agent 测试工程的"最后一公里",也是最容易崩的一环。三个工程纪律必须同时遵守:
更进阶的工程实践包括:
工程上还有一类trace-aware 性能门禁值得专门投入:每个 agent 在 fixture 上的总耗时、token 消耗、tool 调用次数、LLM 调用次数必须落在 baseline × (1+ε) 之内。这个 baseline 是"上一个稳定版本的 50 分位 trace profile"。如果某次 PR 让 fixture 上的总耗时上涨 50%,即便最终业务结果正确,CI 也必须 FAIL——因为性能回归往往意味着 prompt 变 verbose 或 tool 误用 exponential backoff。配合 id=427(多租户成本护栏)+ id=596(语义缓存与成本感知路由),trace-aware 性能门禁形成"前置 + 中段 + 后置"三层成本控制:CI 层防止 PR 引入性能漂移,gateway 层防止运行时 token 失控,contract 层防止产品级 cost-out-of-control。
上述工程范式有几个已知的局限,必须诚实面对:
未来工作方向有几个值得投入的方向:
LLM-as-judge 在 fixture 维护中的作用也值得专门讨论:当 fixture 库从几百条涨到几万条时,靠人 review "这条 fixture 还有效吗" 是不可能的。我们让一个 LLM judge 定期扫 fixture 库,对每条 fixture 输出 (有效 / 过期 / 重复 / 异常) 四分类 + 置信度 + 修复建议(如"第 2 步 tool 返回值已与最新 OpenAI Agents SDK 不兼容,建议改用 new_schema")。这与 id=604(评测工程)的 LLM-as-judge 思路一脉相承,但 fixture 维护场景有它独有的难点:fixture judge 必须能"识别 prompt 漂移导致的 fixture 过期"——这比评测场景的 LLM-as-judge 更接近"对 prompt 变化的元认知"。工程上通常用"双 judge 一致性"作为可靠信号:当两个独立 LLM judge 同时把某条 fixture 标为过期,这条 fixture 自动进入"待重录"队列;当两者不一致,升级到人 review。
最后给 SRE / Agent 平台工程师 / 研究者一份可执行 checklist:
(messages, model, temperature, seed, response, latency, tokens)。(tool_name, args, kwargs, return, error, latency)。datetime.now() / time.time() 必须可被注入。把这十条压成一句话:Agent 测试的工程真相,是把"分布式不确定性"用 trace + mock + 归位 + 门禁四件套压回"可控回归"——这是 Agent 系统在 2026 年从 demo 走向生产的关键工程能力。对于刚启动 Agent 业务的团队,建议优先把前三项(LLM trace 录制 + call-index mock + 随机源 reset)落地,再补齐时间归位与并发归位,最后把 token budget gate 接入 CI——这个落地顺序在 2026 年已经被多个生产团队验证为投入产出比最优的路径。
Conversation
0 条