
说明本文讨论的是给 Agent 应用写测试的工程方法属于 Agent 工程落地话题不涉及具体模型版本与价格。AI 领域版本迭代极快凡涉及版本号、价格、可用性请以你阅读时的官方页面为准。文中代码为结构示意请按自己的技术栈调整后再上生产。一、常规测试金字塔在这里不成立测试金字塔的常见形态是三层单元测试最多、集成测试居中、端到端最少。这个配比在业务服务上稳定了许多年因为它踩在一个前提上——底层逻辑是纯函数上层是它的组合。1.1 金字塔的三个隐含假设把金字塔拆开它依赖三个假设同一输入必然得到同一输出。单元测试的写法是给定输入、断言输出这个写法要求被测函数是确定的。依赖可以廉价替换。集成测试里的数据库、下游接口都能用内存实现或容器起一个替身替换之后行为大体一致。跑一次的成本可以忽略。所以可以每次提交跑全量失败了重跑一遍确认。Agent 项目对这三条一条都不成立。1.2 三重外部依赖把边界推到了进程外Agent 的运行链路上有三处依赖必须跨出进程它们的性质各不相同依赖传统服务里的对应物Agent 项目里的形态测试里能不能替换模型下游 API、业务库一次或多次生成调用返回自然语言或结构化字段能替换返回不能替换质量工具内部 RPC、外部接口检索、代码执行、第三方接口返回格式各异能替换替身数据要持续同步成本没有对应物每次调用按用量计费跑测试即产生开销不能替换只能设限额模型调用可以替换但替换掉的只是「返回什么」替换不掉「返回得好不好」。工具调用也可以替换代价是替身数据必须跟真实返回同步演进。成本这一行在传统服务里找不到对应物——测试跑得越多账单越大这一点直接改变了回归策略。还有一处依赖容易被漏算时间预算。传统集成测试可以容忍一个用例跑两秒因为本地资源免费且可并行。Agent 用例一旦走真实链路单次等待就是秒级起步串起来之后本地开发循环被拉长到无法接受。这不是速度问题是它会改变人的行为——写代码的人会开始跳过测试而不是优化测试。1.3 不确定、慢、贵是连在一起的三件事输出不确定带来一个直接后果断言不能写成相等。慢和贵带来另一个后果不能靠「多跑几次取平均」绕过不确定性因为每一次都要真实等待、真实付费。「慢」在实践中不是均匀的慢它的形态有三种一是单次调用本身慢二是重试把一次调用放大成三次四次三是端到端用例里工具调用逐次串行累加。第三种最难忍因为它把前面两层的开销乘了起来。这也解释了为什么端到端用例不能按功能点铺开——铺开的代价不是线性增长是乘积级增长。结论先行Agent 项目的测试不是把金字塔做厚而是重新划一条线——把系统切成确定性的外壳和不确定的内核外壳按传统方式狠测内核按统计方式抽测。这条线画在哪里是后面几章要回答的问题。画错的典型症状是用了大量替身覆盖率数字很好看线上仍然频繁翻车——因为替身覆盖的恰好是确定的那一半。二、把系统切成确定性外壳与不确定内核切分按「输出是否确定」来做不按代码目录来做。同一次生成的上下游常常一个确定、一个不确定按目录切会把它们分到同一个篮子里。2.1 四层与各自的可测性解析与渲染层把模型返回的文本解析成结构、把结构渲染成界面或请求体。纯函数进出都是数据。编排层状态机、分支、重试、工具编排顺序。输入是解析后的结构输出是下一步动作。模型层请求构造、参数拼装、流式分片、错误映射。它决定发出去的是什么决定不了返回的是什么。端到端真实链路包含以上全部再加真实模型与真实工具。按确定性切有个好处它和「这次改动动了什么」是对齐的。改解析层确定的部分没变跑得快、判得准改提示词确定的部分没动只需要在编排层和语义断言上加采样。按目录切就没有这个对齐关系——一个agent/目录里往往同时装着纯函数和一次真实调用。2.2 分层对照表层输出确定性适合的测试不该在这里测建议替身解析与渲染完全确定用例级断言、逐字段比对、边界输入内容质量不需要替身编排确定分支覆盖、重试与超时、工具调用顺序模型输出质量假模型 假工具模型层半确定请求确定、响应不确定请求体字段、参数上限、流式拼接、错误映射端到端效果录制回放夹具端到端不确定少量冒烟、真实链路可用性大范围逐条回归无看清这张表就知道回归怎么放前三层的用例数量可以放开端到端只留冒烟。2.3 边界要显式否则切不开切分成立的前提是边界在代码里看得见。通行做法是把「取模型返回」收成一个接口编排层只依赖这个接口不直接构造请求。⚠️ 代码待验证fromtypingimportProtocolclassModelReply(Protocol):text:strtool_calls:list[dict]finish_reason:strclassModelClient(Protocol):defcomplete(self,req:dict)-ModelReply:...# Orchestration depends only on the protocol above.# It must not import a concrete SDK, so tests can inject a fake here.defrun_step(state:dict,client:ModelClient)-dict:replyclient.complete(build_request(state))parsedparse_reply(reply)# pure function, unit-testablereturnnext_state(state,parsed)# state machine, unit-testable判据哪一行代码决定用哪个模型、哪个参数那一行就是边界。如果它散落在好几个文件里测试就只能从最外层进剩下能测的只有端到端。三、mock 模型调用的三种做法mock 在这里有三种粒度不同的做法。它们不是可互换的替代关系混用会互相抵消——用假实现测出来的绿灯证明不了录制夹具那条路径也成立。3.1 接口级假实现实现同一个接口返回一段写死的文本或一段拼好的结构化字段。写得最快也最容易被误用成万能方案。它只验证「拿到这个返回时下游处理对不对」完全不验证这个返回是否可能来自真实模型。用久了会出现假数据里有的字段真实返回里没有解析层照常通过上线当天才报错。3.2 录制回放夹具把真实调用的一次请求与响应成对存下来测试时按请求匹配回放。它保住了真实响应的形状与字段。录制的天然局限是它只代表那一次。同一个请求再跑一次会得到不同措辞夹具锁死后测试通过只说明「这一份录制数据能过」。另外录制会带进当时接口的全部字段接口一升级整批夹具同时失真。3.3 规则化桩与三者分工规则化桩按规则生成响应比如「输入包含关键词 A 就返回工具调用 B」用来覆盖编排分支。规则是人写的写规则时人已经把自己对模型行为的假设嵌了进去。分支覆盖率上去了假设错了照样通过。一个可用的缓解办法是让规则桩的输入来自真实夹具规则只负责决定「走到哪个分支」分支里的内容用录制数据填充这样至少内容不是编的。做法保住了什么适合测什么会漏掉什么维护触发点接口级假实现调用方接口下游解析、错误分支真实响应的字段与形状接口变更录制回放夹具真实响应的形状与字段解析层、流式拼接、多模态字段响应多样性、录制后的接口漂移接口升级、模型换版规则化桩分支可控性编排分支、工具编排、重试路径规则背后的行为假设规则与真实行为分叉做法编排分支用规则化桩解析与流式用录制夹具错误注入用接口级假实现。三种各测一段不要指望一种覆盖全部。四、夹具的设计与腐化夹具是这套测试里最容易烂掉的资产。烂掉的方式不是文件丢失而是文件还在、测试还绿、内容已经不代表真实返回。4.1 录制之后要做的四件事去噪请求标识、时间戳、服务端追踪字段、耗时——每次调用都不同留在夹具里会让比对永远失败。裁剪只留被测代码读得到的字段。整份响应存下来看着省事实际会让夹具随接口字段增删反复失真。脱敏录制里的用户输入、真实业务数据替换成构造值。不做这一步夹具目录迟早变成一个没人管的明文样本库。版本化夹具进版本库与产生它的接口版本绑定文件名和目录里带接口版本标识。4.2 一个 JSON 夹具长什么样⚠️ 代码待验证{meta:{interface_version:chat.v3,recorded_at:2026-09-12,source:staging,note:trimmed to fields read by parse_reply},request:{messages:[{role:user,content:统计这三个月的退款原因}],tool_choice:auto},response:{finish_reason:tool_calls,usage:{input_tokens:0,output_tokens:0},tool_calls:[{name:query_refunds,arguments:{from:2026-07,to:2026-09}}],content:null}}request只留匹配用得到的字段response只留解析层会读的字段meta记来源与录制时间供巡检用用量字段归一成固定值。字段处理按下面这张表逐类过一遍字段类别处理方式理由追踪标识、请求标识删除或替换为固定值每次调用都变留着必然失败时间戳、耗时删除无法复现也不参与解析用量字段保留但归一为固定值解析层可能读数值不参与断言用户输入原文替换为构造值避免真实数据进版本库业务结构化字段原样保留这是被测代码真正读的部分新增的未知字段保留提前暴露解析层的字段耦合4.3 漂移是怎么发生的夹具与真实返回的漂移有两个来源接口侧和模型侧。接口侧是字段增删、嵌套层级调整。这一侧可以在 CI 里用抽样校验拦住定期拿少量真实请求跑一遍把真实返回的字段集合与夹具里的字段集合做差差集非空就报出来。校验方向要双向做——真实有而夹具没有说明解析层可能读到空值夹具有而真实没有说明夹具在喂不存在的数据。模型侧是同一请求的输出分布改变。它没有这么直接的判据因为「分布变了」本身不影响字段集合。这一侧的发现依赖巡检节奏放到第 7 章讲。判断两侧漂移优先级有个简单规则接口侧漂移会让一批用例同时失败模型侧漂移只会让语义类用例的成功率缓慢下移。前者是突变、后者是趋势处置动作也不同——突变改夹具趋势改阈值或改断言。五、断言不确定输出断言这一层要接受一个事实你不能断言模型返回了什么只能断言返回满足什么条件。5.1 五类断言结构断言能否解析成目标结构必填字段是否齐、类型是否对、枚举是否落在集合内。包含断言是否包含某些必须出现的片段比如关键实体、引用的来源标识。集合断言从输出里抽出一个集合断言集合之间的关系比如「抽到的实体集合应包含输入里的全部实体」。判分器断言用另一个模型或一段规则给输出打分超过阈值算通过。人工抽样定期抽一小批人工判定只用来校准前四类。5.2 各自的失效条件断言方式能抓住什么失效条件主要误报来源结构断言结构崩掉、字段缺失、类型错误结构对但内容错误报低包含断言关键实体缺失、引用被编造换个说法绕开、同义替换关键词表不全集合断言漏抽、错抽抽取本身也是模型做的抽取先偏抽取环节的波动判分器断言语义层面的整体质量判分器与被测者同源偏差同向判分器自身漂移人工抽样校准前四类是否还成立样本太小、抽样不随机判定标准不统一三条硬约束判分器不要与被测者同源。同一个模型既生成又打分会系统性偏向自己的输出风格。换一个来源的模型或者至少换一套提示词。阈值要留白。阈值贴着当前分布定第一次波动就会红。先测分布再取偏低的一侧。人工抽样只做校准。把它接进门禁等于每次发布都排人力一周之内就没人执行了。5.3 一个结构加集合的断言函数⚠️ 代码待验证REQUIRED_KEYS{summary,items,citations}defassert_report_shape(payload:dict,expected_entities:set)-None:# Hard gate: structure must hold. No sampling here, fail fast.missingREQUIRED_KEYS-payload.keys()ifmissing:raiseAssertionError(missing keys: %s%sorted(missing))ifnotisinstance(payload[items],list):raiseAssertionError(items must be a list)forcinpayload[citations]:ifnotc.get(source_id):raiseAssertionError(citation without source_id)# Soft signal: content drift is recorded here, blocked elsewhere.got{i.get(entity)foriinpayload[items]ifisinstance(i,dict)}recalllen(expected_entitiesgot)/max(len(expected_entities),1)assertrecall0.8,entity recall too low: %.2f%recall这个函数体现了取舍结构不合格直接失败内容层面的偏差只记不拦。内容判断交给上一层更贵的断言去做不要在一个函数里既当门禁又当评分器。完整版资料清单本文用到的分层对照表、夹具字段处理清单与断言函数示例都整理在里面了扫码即可获取六、防 flaky 与 CI 分层flaky 在 Agent 测试里不是偶发现象是默认状态。把不确定性当噪声消灭做不到能做的是把波动写进用例。6.1 重复采样与容差一条用例跑一次的结果不能当判断依据。做法是重复采样加容差结构类断言不采样跑一次就够它本来就不该随机。语义类断言采样若干次本文按 5 次示意通过次数达到下限算通过。容差写进用例本身与断言一起维护不要在 CI 配置里另存一份。判据一条用例在同一份代码上连续跑两次结果不同它要么补采样要么降级成只观测。把它留在门禁里团队很快学会忽略它的红。6.2 把波动写进用例不要藏起来常见错误做法是用重试掩盖失败了自动重跑重跑过了就报绿。这会把一个真问题转成一次噪声代价是没有人再去区分「这次真坏了」和「这次只是采样到了尾部」。替代做法是把波动显式建模用例声明自己的采样次数与通过下限结果里同时记录通过次数与采样次数便于观察分布变化只有全部采样都失败的用例才立即拦发布其余进入观察队列。6.3 CI 分层与预算改动类型必跑层可跳过层理由解析、渲染代码解析层全量 编排层端到端纯函数回归快风险集中在解析编排与提示词编排层 采样后的语义断言端到端大范围分支变化多开销集中在语义断言模型接口或参数模型层请求构造 小样本端到端解析层全量请求体变了解析逻辑没动依赖升级全量 端到端冒烟无影响面不确定夹具或录制更新引用该夹具的用例其余改动只影响引用方⚠️ 代码待验证# CI layering: each stage declares its own sampling budget.stages:-name:unitruns_on:every_committarget:shell_layers# parser, renderer, state machinesample:1cache:[pip,fixture_dir]-name:orchestrationruns_on:every_committarget:orchestrationsample:1mock:rule_based-name:semanticruns_on:pull_requesttarget:semantic_assertionssample:5# repeat sampling, pass floor belowpass_floor:4mock:replay_fixturecache:[fixture_dir]-name:e2e_smokeruns_on:merge_to_maintarget:end_to_endsample:1live_calls:truetimeout_seconds:900# placeholder, set from your own baseline分层的目的不是省钱是让每次提交拿到的是「与本次改动相关的判断」。全量端到端挂在每次提交上结果是没人看它的报告——这也是 CI 预算真正要防的东西预算被无效用例吃掉有效用例反而没跑。缓存在这套分层里有两个位置依赖安装缓存和夹具目录缓存。前者省下的时间固定且无风险后者要小心夹具更新后如果缓存没失效跑的是上一版夹具绿灯就成了假绿。做法是把夹具目录的校验值写进缓存键夹具一变键就变不依赖时间戳判断。七、维护测试写完那一刻是它最好的状态之后就一路往下滑。最后一章讲怎么让它滑得慢一些以及滑了怎么发现。7.1 模型换版后测试怎么更新换版之后不要第一时间改断言。顺序是先跑一遍基线用现有夹具与断言跑一遍记录失败清单和失败方式。把失败分成三类结构变了改夹具或改解析、内容分布变了改阈值、真坏了改实现。只在前两类上动断言第三类不许动断言。判据如果同一次换版让多数语义断言同时从通过变成不通过先怀疑断言而不是模型——多半是断言依赖了上一版输出的具体措辞。7.2 断言失效的信号一条断言长期全部通过它已经不区分好坏退化成形式检查。一条断言长期贴着阈值抖动要么采样次数不够要么阈值贴得太近。断言失败后改实现与改断言的比例失衡说明断言在描述旧行为而不是在定义必要条件。三种信号都不是靠看单次结果发现的要把断言的历史通过率按周画出来才看得见。7.3 夹具腐化的巡检⚠️ 代码待验证# Fixture rot patrol: list fixtures by last modified date, flag stale ones.findfixtures-name*.json-printf%TY-%Tm-%Td %p\n|sort|head-30# Compare fixture field set with fresh live responses (run manually, weekly).python tools/fixture_drift.py--fixturefixtures/chat.v3/refund.json\--live-sample3--reportdrift_report.txt# Check code references before deleting anything.grep-rn--include*.pyrefund.jsontests/||echono reference, review first巡检节奏按周每周抽一批夹具与真实返回做字段集合比对把差集记进报告每季度清理一次引用计数为零的夹具。清理前必须先查代码引用不能凭文件名猜——把仍在用的夹具删掉比留着一批陈旧的更贵。完整版资料清单本文用到的夹具巡检脚本、断言失效信号清单与换版处理流程都整理在里面了扫码即可获取附表 A关键取舍一览取舍本文结论判断依据位置测试金字塔能不能照搬不能要按输出确定性重画三重外部依赖加不确定输出第一章切分按什么维度按输出是否确定不按目录同一次生成的上下游确定性常不同第二章边界要不要在代码里显式要模型调用收成接口边界不可见就只能测端到端第二章用一种 mock 覆盖全部不成立三种做法保住的属性各不相同第三章编排分支用什么替身规则化桩分支需要可控规则可读第三章夹具里的追踪标识与时间戳删除或固定化每次都变留着比对必然失败第四章夹具存整份响应还是裁剪裁剪到解析层读到的字段整份存会随接口字段增删反复失真第四章内容层面的偏差要不要拦发布不拦只记拦了会误伤且归因不清第五章判分器能否与被测者同源不能同源会偏好自己的输出风格第五章人工抽样进不进发布门禁不进只做校准排人力不可持续第五章用例失败要不要自动重试掩盖不要会掩盖真问题并训练团队忽略红灯第六章端到端挂每次提交不挂只在合并到主干跑开销大且报告无人看第六章换版后先改断言还是先跑基线先跑基线不分类就会把真问题当断言问题改掉第七章夹具清理依据代码引用计数不凭文件名误删在用夹具的代价更高第七章附表 B术语速查表术语含义确定性外壳输入输出都是数据、结果可复现的代码部分如解析与渲染层不确定内核输出依赖模型、无法保证复现的部分如生成与语义判断解析层把模型返回的文本转成结构化数据的纯函数部分编排层负责状态流转、分支、重试与工具调用顺序的代码部分模型层负责请求构造、参数拼装、流式分片与错误映射的部分端到端测试走真实模型与真实工具、覆盖完整链路的测试接口级假实现实现同一接口并返回写死数据的替身录制回放夹具把一次真实请求与响应成对保存、测试时按请求回放的替身规则化桩按预设规则生成响应的替身用于覆盖编排分支夹具保存下来的请求与响应样本测试时作为输入使用去噪从录制数据中移除每次调用都变化的字段字段裁剪只保留被测代码实际读取的字段缩小夹具体积与失真面夹具漂移夹具与真实返回之间的差异分接口侧与模型侧两类结构断言断言输出可被解析成目标结构且字段类型正确包含断言断言输出里出现了某些必须存在的片段集合断言从输出抽取集合并断言集合间的包含或等价关系判分器断言用另一个模型或规则给输出打分并按阈值判定的断言人工抽样定期抽取小批样本人工判定用于校准自动断言重复采样同一用例跑多次按通过次数下限判定结果容差用例声明的采样次数与通过下限与断言一起维护flaky 用例同一份代码上重复运行结果不一致的用例CI 分层按改动类型与开销决定各阶段跑哪些用例的编排方式写在最后这篇用到的资料写这篇文章时我把几个模型的官方文档、参数表和实测记录都对了一遍顺手整理成几份配套的东西大模型学习路线图从 LLM 基础到 Agent 开发各阶段该学什么、用什么资料大模型全套教程按主题分好的视频与文档清单大模型实战好书24 本附每本适合的阶段资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「大模型」优先通过。拿到之后建议先看学习路线图那一份先定位自己在哪个阶段再决定学什么比一上来就啃框架效率高得多。