恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Agent Harness工程测试指南:白盒测试让AI Agent稳定落地
首页
资讯中心
/
Agent Harness工程测试指南:白盒测试让AI Agent稳定落地
Agent Harness工程测试指南:白盒测试让AI Agent稳定落地
发布时间:2026/9/13 2:31:03
1. 为什么Agent Harness测试不能靠黑盒做AI Agent项目也有段时间了最深的感触是Agent的入场门槛是真低但把它做稳做可靠的难度全在后半程。尤其是Agent Harness Engineering——这套承载Agent运行的工作台、执行循环、工具机制、记忆机制的工程骨架——很多时候决定了整个系统的上限。模型是Agent的“大脑”Harness就是承载大脑的“驾驶舱”。可我观察下来很多人对“驾驶舱”到底靠不靠谱几乎没有系统性的验证手段。所谓Harness直译是“马具、挽具”在Agent工程里被引申为“让Agent稳定跑起来的整套框架”。它不负责输出智能但负责让智能稳定地工作。工具调用循环、上下文管理、记忆读写、结果校验、异常恢复这些都属于Harness的范畴。问题是大多数团队测试Agent的方式还是端到端黑盒给定一句话看回复像不像样。这种测法不是没用而是太粗。模型输出千变万化你很难判断一次失败到底是模型能力问题还是Harness处理逻辑的问题。我自己的答案是把测试下沉到Harness内部去做白盒测试从单元测试到集成测试把每个分支、每个状态转换、每个调用参数都变成可验证的断言。这篇文章就是我实践的完整方案适合正在做Agent开发、想搭建测试体系或者准备Agent相关工程面试的朋友。你看完可以直接套用里面的框架和用例设计思路不用再从零踩坑。1.1 先认清Agent Harness的“身体结构”要测一个东西首先得知道它由什么组成。我接手过的Agent项目里Harness的代码结构无论怎么包装最终都逃不开下面五层接口与协议层对外暴露的API、消息格式转换、鉴权逻辑。编排与控制层Agent的主循环。模型返回之后下一步该做什么是调工具、结束、还是重试。工具与动作层注册了哪些工具、工具参数怎么校验、执行结果如何返回给模型。状态与记忆层多轮对话上下文、内置记忆、任务进度状态。模型调用层请求组装、token预算控制、超时与重试。这五层加在一起就是Agent Harness。模型本身是黑盒你没法预测它下一句生成什么但Harness不是黑盒它每一行都是你写的代码走哪些分支、什么时候返回、什么条件抛异常理论上全部可判断、可验证。有意思的是很多团队的测试恰恰避开了这些可控代码只盯着不可控的模型输出。测试用例全是“让Agent写一首诗然后判断输出像不像诗”。这类用例一次能过一百次可能挂三十次因为模型输出漂移了。真正该测的——工具参数有没有正确传给执行器、循环会不会卡死、上下文会不会被截断——反而没人管。白盒测试的核心思路是看到内部结构为内部结构设计用例。对Agent来说内部结构就是上面这五层。你能在代码级别看清楚每个模块的输入输出契约测试才有落脚点。1.2 白盒测试的边界把非确定性“关起来”这里有一个绕不开的问题LLM输出是非确定性的。你没法保证模型每次返回的tool_call JSON都合法也不能断言它生成的文本一定等于某个字符串。如果直接拿真实模型当测试输入那测试本身也会变得不稳定。解决思路是把非确定性“隔离”在边界之外。具体来说在单元测试阶段用假模型客户端替换真实模型把模型输出当作可控制的测试输入。这样做有一个本质转变测试对象从“模型”变成了“Harness”。模型不是我们要测的东西Harness才是。Harness面对模型输出时的各种处理逻辑恰好是白盒测试最擅长覆盖的场景模型返回了一段非法JSON解析层是否报错错误信息是否可理解模型返回的tool_call里缺参数工具层是拒绝执行还是尝试补默认值模型反复请求同一个工具最大步数限制有没有生效上下文超长时截断策略是否保住了system prompt和当前用户输入这些用例的共同特点是给特定输入验证特定分支得到精确结论。这正是白盒测试能提供“确定性答案”的地方也是黑盒测试永远给不了的东西。我曾在生产环境遇到过一个很隐蔽的bug模型返回的tool_call多嵌套了一层object参数校验时抛了TypeError重试逻辑把它当成模型异常重试了三次三次之后整个请求失败。这个bug用黑盒端到端测试极难复现但白盒单测只需要mock一个嵌套结构的返回五分钟就能让它现形。1.3 一个可落地的分层测试策略把Harness拆开之后整个测试策略可以分层设计每一层负责不同的深度和成本测试层级测试对象LLM状态重点验证内容单元测试工具层、状态层、提示词层、循环控制完全mock分支覆盖、参数校验、错误处理半集成测试Harness真实或假的单侧组件真实LLM或实时mock编排逻辑、解析路由、副作用端到端测试完整Harness真实依赖真实LLM或录制回放完整生命周期、外部依赖协作单元测试追求快和准跑完整套不能超过几秒半集成测试追求场景真实性允许慢一点也允许用录制回放保证稳定端到端测试数量要少只覆盖最核心的两三条用户链路。这个金字塔和普通后端测试最大的区别是中间两层被放到了非常高的位置。为什么因为对Agent来说“编排逻辑”本身就是核心业务是连接模型智能和外部动作的桥梁。这一层如果只靠端到端去碰运气那上线之后出问题几乎是必然的。2. 单元测试把大模型mock掉专测Harness骨架单元测试是白盒的主力军但很多人一上来就被异步、mock、fixture搞得头大。别急先解决一个前置问题你的Harness代码能不能被测试我见过大量Agent项目没法做白盒测试根源不是测试不会写而是类内部把模型客户端、registry、memory全部new死在构造函数里外部根本没有注入点。所以第一步是把边界做干净。2.1 先搭一个可测试的Harness骨架下面这个例子我做了大量简化但保留了真实Harness的核心执行逻辑。你可以在它基础上扩展prompt缓存、流式输出、事件回调结构不变。# harness.py class ToolSpec: def __init__(self, name: str, handler, parameters: dict | None None): self.name name self.handler handler self.parameters parameters or {} class AgentLoopLimitError(Exception): pass class AgentHarness: def __init__(self, model_client, tool_registry, memory, max_steps5): self.model model_client self.tools tool_registry self.memory memory self.max_steps max_steps async def run(self, user_input: str) - str: messages await self.memory.load() messages.append({role: user, content: user_input}) for step in range(self.max_steps): response await self.model.chat(messages) action self._parse_action(response) if action[type] final: return action[content] spec self.tools.get(action[name]) if spec is None: messages.append({ role: system, content: f工具 {action[name]} 不存在请更换工具 }) continue try: result await spec.handler(**action[arguments]) except Exception as exc: result f工具执行失败: {exc} messages.append({ role: tool, name: spec.name, content: str(result) }) raise AgentLoopLimitError(f超过最大步数 {self.max_steps}) staticmethod def _parse_action(response: dict) - dict: if response.get(type) final: return {type: final, content: response[content]} if response.get(type) tool_call: name response.get(name) args response.get(arguments) or {} return {type: tool_call, name: name, arguments: args} raise ValueError(f无法识别的响应: {response})这套骨架里包含三个对“可测试性”至关重要的设计依赖注入。model_client、tool_registry、memory全部通过构造函数传入没有在内部直接new。单测时把真实对象替换成假对象不需要改任何业务代码。纯逻辑与IO分离。_parse_action是纯函数输入一个dict输出一个结构化的action可以单测直接调用。model.chat和handler是IO边界只在集成测试阶段用真实实现。显式循环上限。max_steps是配置参数而不是魔法数字测试时可以传一个很小的值快速触发AgentLoopLimitError。2.2 工具注册与参数校验的测试工具层是Agent最容易出问题的地方。模型说“我要查北京天气”Harness要把这个意图精确翻译成get_weather(city北京)的调用中间错一步整个对话就断了。工具层的单元测试通常覆盖这几类场景工具注册同名工具重复注册会不会冲突注册后能否正确获取。参数校验参数缺一个、类型不对、多传未声明参数分别怎么处理。执行器工具正常返回、抛异常、超时Harness如何把结果回传给模型。结果归一化工具返回dict、字符串、空值消息格式会不会被破坏。先准备两个基础的假对象class FakeModel: def __init__(self, responses): self.responses list(responses) self.request_log [] async def chat(self, messages): self.request_log.append(messages) return self.responses.pop(0) class FakeMemory: def __init__(self): self.messages [] async def load(self): return list(self.messages) async def append(self, message): self.messages.append(message)然后写一个最关键的用例模型调用了一个不存在的工具Harness应该把纠正信息回传给模型而不是直接崩溃。import pytest async def test_unknown_tool_returns_system_feedback(): fake_model FakeModel([ {type: tool_call, name: not_exists, arguments: {}}, {type: final, content: 好的那我换个方式}, ]) harness AgentHarness( model_clientfake_model, tool_registry{}, memoryFakeMemory(), max_steps3, ) result await harness.run(帮我处理一下) assert result 好的那我换个方式 assert fake_model.request_log[1][-1][role] system assert 不存在 in fake_model.request_log[1][-1][content]注意最后的断言我们检查了第二次发给模型的消息列表里最后一条是system角色且包含“不存在”。这就是典型的白盒断言——不仅验证最终答案还验证Harness内部状态变化是否合理。这种断言是黑盒测试写不出来的。再看一个工具参数传递的用例async def test_tool_call_receives_parsed_arguments(): collected {} def get_weather(city, unitcelsius): collected[city] city collected[unit] unit return 晴26度 registry { get_weather: ToolSpec(nameget_weather, handlerget_weather) } fake_model FakeModel([ {type: tool_call, name: get_weather, arguments: {city: 北京}}, {type: final, content: 北京晴天26度}, ]) harness AgentHarness(fake_model, registry, FakeMemory(), max_steps3) result await harness.run(北京天气怎么样) assert result 北京晴天26度 assert collected[city] 北京 assert collected[unit] celsius这里的关键是验证arguments里的JSON字段被正确展开成了Python函数的关键字参数并且默认参数生效了。我见过很多线上事故就是在这一步出问题模型传了cityHarness却把city塞给了别的参数最后调用了一个风马牛不相及的工具。2.3 提示词组装与上下文窗口的测试提示词组装往往被当成“字符串拼接”看待but它的质量直接影响模型输出和token消耗。白盒测试在这一层能验证的东西非常多模板变量是否正确替换缺变量时有没有静默失败。system prompt、工具描述、历史消息、用户输入在消息数组里的顺序是否稳定。估算token超长时截断策略是否生效截断的是历史消息而不是system prompt和当前输入。不同模型对消息格式的兼容性比如有些模型不认tool角色需要做格式转换。一个可测试的PromptBuilder大概是这样的class PromptBuilder: def __init__(self, system_template: str, max_tokens: int 2000): self.system_template system_template self.max_tokens max_tokens def build(self, history, user_input, tools_desc): messages [ {role: system, content: self.system_template.replace({{tools}}, tools_desc)} ] budget self.max_tokens - estimate_tokens(user_input) for msg in reversed(history): cost estimate_tokens(msg[content]) if budget - cost 0: break messages.insert(1, msg) budget - cost messages.append({role: user, content: user_input}) return messages对应的单元测试可以这样写def test_prompt_builder_truncates_history_not_system(): builder PromptBuilder( system_template你是助手可用工具{{tools}}, max_tokens120, ) history [ {role: user, content: x * 50}, {role: assistant, content: y * 50}, ] messages builder.build(history, 今天天气, get_weather) assert messages[0][role] system assert messages[-1][content] 今天天气 assert {{tools}} not in messages[0][content] assert total_tokens(messages) 120这类测试的价值在于它能保证你的prompt工程不是“拍脑袋调参”每一次改动都有回归保护。我见过一个团队优化prompt时不小心把system prompt里的工具描述删掉了结果Agent忽然不会调用工具了排查了大半天才发现是prompt模板问题。如果有一个像上面这样的测试这个问题在发布前就会被拦住。2.4 循环与状态转换的测试Harness循环是整个Agent运行的心脏模型返回一个actionHarness判断是继续还是结束。循环控制有几个必测的场景正常链条工具调用 - 结果回传 - 再次调用模型 - final - 返回结果。工具不存在模型调用了未注册工具Harness回传提示让模型纠正。循环不退出模型反复调用工具不返回finalmax_steps触发后抛异常。工具抛异常异常被捕获后转成给模型的提示信息而不是让整个请求崩掉。上面已经提过工具不存在的情况这里补一个max_steps的用例async def test_max_steps_limit_raises(): fake_model FakeModel( [{type: tool_call, name: loop, arguments: {}}] * 5 ) registry { loop: ToolSpec(nameloop, handlerlambda: again) } harness AgentHarness(fake_model, registry, FakeMemory(), max_steps3) with pytest.raises(AgentLoopLimitError): await harness.run(开始循环)这个用例虽然只有几行但它用一个很小的max_steps快速验证了Agent不会无限循环。很多线上故障——比如Agent自己和自己对话直到token耗尽——就是败在这一行逻辑上。我在项目里把这几个核心用例全部堆到上百个覆盖工具层、循环控制、上下文管理跑一次几秒钟。这块后来成了整个Agent系统里最稳固的部分后续迭代代码时的信心完全是被这些用例托住的。3. 集成测试让真实组件开始“碰头”单元测试把每个组件都隔离测了一遍但组件之间一碰面往往又出新问题。集成测试就是要回答“它们协作时契约是否正确”。3.1 先想清楚集成测试里哪些用真的哪些用假的集成测试最容易犯的错是把所有组件全换成真的然后跑端到端。这样既慢又不稳定出了问题还很难定位。我的做法是分层替换每次只放开一个“真实变量”模型工具场景验证重点MockMock单元测试Harness逻辑Mock真半集成A工具副作用、重试、幂等真Mock半集成B真实模型输出到工具路由的解析链路真真端到端完整生命周期、外部依赖为什么中间两档很重要因为真实模型和真实工具各自都会带来不确定性如果一次全放开出问题你很难判断是模型理解错了、工具执行错了还是Harness编排错了。一次只换一个变量问题定位会清晰得多。3.2 真LLM假工具验证解析与路由的稳定性这一层的核心价值是用真实模型输出暴露出mock永远发现不了的问题。比如模型对工具名称的表达方式千奇百怪可能叫“get_weather”也可能在参数里塞进一个文档里根本没写的额外字段也可能把枚举值理解错了。这些只有真实模型跑一遍才能看到。具体做法是Harness用真实模型工具用stubstub负责记录收到的参数并返回固定结果。然后跑一个查询类任务验证模型是否成功把意图路由到了正确的工具。async def test_real_model_routes_weather_query_to_tool(): model OpenAIModel(model_namegpt-4o-mini) got_city [] async def fake_weather(city: str): got_city.append(city) return 晴26度 registry { get_weather: ToolSpec( nameget_weather, handlerfake_weather, parameters{ type: object, properties: {city: {type: string}}, }, ) } harness AgentHarness(model, registry, FileMemory(), max_steps5) result await harness.run(北京适合出门吗) assert got_city, 真实模型应该成功调用天气工具 assert 26 in result or 晴 in result注意这层测试的断言要尽量“宽”。不要去精确断言最终回复的文本因为模型换个说法就会挂。更合理的做法是断言关键工具确实被调用了、关键信息确实出现在回复里。还有一个实操细节这层测试强烈建议加一次自动重试。因为真实模型偶尔会抽风一次没调用工具不代表代码有问题可能是采样概率导致的。加一次重试能显著降低测试的偶发失败率又不影响它对“路由逻辑”的验证。3.3 假LLM真工具验证副作用与故障恢复另一种半集成是反过来模型是假的但工具是真的。这样我们精确控制模型“接下来要做哪一步”同时验证工具执行的真实副作用。这个场景特别适合测三类问题第一真实副作用。比如一个预订类工具真的往staging数据库插了一条记录。我们可以用假模型连续给出两个tool_call第二次调用基于第一次的真实返回值然后断言数据库里确实多了一条订单参数完全正确。第二失败重试。工具第一次抛异常Harness把异常信息回传给模型假模型第二次输出正确的参数工具调用成功。这验证的是“Harness能否把工具异常转化为对模型友好的错误信息”。async def test_tool_exception_is_passed_back_to_model(): calls [] async def flaky_api(param: str): calls.append(param) if len(calls) 1: raise RuntimeError(上游超时) return ok-200 fake_model FakeModel([ {type: tool_call, name: call_api, arguments: {param: A}}, {type: tool_call, name: call_api, arguments: {param: A}}, {type: final, content: 已重试成功}, ]) harness AgentHarness( fake_model, {call_api: ToolSpec(namecall_api, handlerflaky_api)}, FakeMemory(), max_steps5, ) result await harness.run(执行) assert result 已重试成功 assert len(calls) 2 assert 超时 in fake_model.request_log[1][-1][content]这个用例的最后一行的价值极高。它验证了Harness把异常包装成消息回传给模型之后模型能看到具体的错误原因。如果这里实现有误比如错误信息没被附加到消息列表里那模型永远只能看到“工具调用失败”这种模糊提示纠错能力会大打折扣。第三幂等性。用同一个参数调用工具两次断言结果一致且没有额外的副作用比如重复扣费、重复插记录。这类问题在Agent场景特别隐蔽因为模型可能会因为一次网络抖动就重复发起同一个工具调用。3.4 端到端集成完整生命周期的确认端到端测试数量不用多两三条核心链路即可。我的建议是覆盖“查询追问”“多工具协作”“上下文超长后的恢复”这三类代表性场景。端到端最大的问题是稳定性。真实模型加真实外部API跑一次不仅慢还可能因为上游故障、限额、网络波动而挂掉。这里推荐用VCR录制回放方案第一次跑测试时把真实模型响应和外部API响应录制下来存成cassette文件之后测试回放录制数据不再发真实请求。vcr.use_cassette(cassettes/weather_agent.yaml) async def test_e2e_weather_agent(): model OpenAIModel(model_namegpt-4o-mini) registry build_real_registry() # 接入真实天气API harness AgentHarness(model, registry, FileMemory(), max_steps5) result await harness.run(上海明天会下雨吗) assert 雨 in result or 晴 in result第一次跑这个用例时vcrpy会自动录下所有HTTP请求和响应之后跑就纯本地回放。这样既保留了端到端的“真实脚本”又拿到了单元测试级别的稳定性。端到端还有个不可忽略的前提独立的测试环境。独立API key配额、独立数据库、独立存储目录绝不能拿生产数据来测。这个原则我踩过坑后才真正刻进脑子。4. 落地过程中的常见问题与避坑实录方案说起来一套一套真正落地时到处是坑。这一节我把踩过得比较多的几个问题集中说下基本可以当速查表用。4.1 断言太严格、太脆弱刚做Agent测试时我犯过最大的错是把黑盒时代的习惯带进来总觉得“这轮对话应该回复什么”。结果模型换了个表达方式测试就挂五个用例挂三个最后整个测试集形同虚设。后来总结出一个原则对LLM输出做语义级断言对Harness内部数据做精确断言。工具调用参数、消息列表结构、状态字段、错误信息这些内部数据完全可以用等于、包含、类型检查去精确断言而模型生成的最终文本只做关键词包含、语义相似度判断或者直接用LLM-as-judge打分。特别是“模型最终回复”这种断言不要写死“必须是某个字符串”改成“必须包含工具返回的关键信息”就稳得多。4.2 mock太厚测了个寂寞mock的粒度是个大学问。很多初学者喜欢把model、tools、memory全部mock掉甚至把自己写的prompt builder也mock掉最后跑完测试发现真正被测试的代码只有几行if-else覆盖率低得可怜。我的原则是只mock边界不mock逻辑。什么是边界模型客户端、外部API、数据库、文件系统、时间函数。什么是逻辑工具注册表、参数解析、循环控制、消息组装、状态维护。逻辑代码必须用真实实现跑到mock了就不再是白盒测试而是自欺欺人。一个简单的判断标准如果某个测试在改动一行核心Harness代码后不会失败说明它mock肉太厚了根本没测到改动逻辑。4.3 并发测试的环境隔离Agent测试逻辑复杂、用例多天然想并发跑。但共享环境会带来灾难。最常见的坑是多个测试共用一个工具注册表而注册表是全局单例并行执行时相互覆盖注册信息。还有数据库测试互相污染数据导致断言随机失败。解法其实很常规注册表做成实例级fixture里每个测试重建一个涉及数据库的测试用独立schema或者testcontainer结束统一清理用pytest-xdist时给每个worker分配独立数据目录。还有一个隐蔽问题对象内部的静态缓存。比如token估算函数有缓存并发时可能读到半初始化状态。这类问题很难查但确实会偶发失败。建议对缓存类代码专项排查在测试fixture里统一清理干净。4.4 覆盖率不应该是“硬指标”要看落点白盒测试绕不开覆盖率。我的观点是覆盖率要有但不能盲目追求数字。Agent项目里模型调用层的覆盖率没有意义——你把一堆mock算进去数字可能很漂亮但测试的并不是真实逻辑。更合理的做法只统计Harness内部确定性模块的覆盖率模型调用层和外部工具执行层在配置文件里直接排除。优先看分支覆盖率其次才是语句覆盖率。Harness里最怕的不是某行没执行而是分支没覆盖循环没退出、工具找不到、参数解析失败、重试次数耗尽这些全是分支场景。给关键模块设置护栏值工具层、循环控制、提示词组装的覆盖率建议85%以上其他辅助模块可以放宽。我见过不少项目“行覆盖100%但分支覆盖只有30%”测试看起来漂亮实际最危险的错误处理分支完全没测到。所以看覆盖率时一定要单独拉分支覆盖率报告。5. 工具链选型与落地经验方案讲完说说工具和推进节奏。工具不在多顺手最重要。5.1 一套趁手的测试工具链以Python生态为例我目前的主力组合是这些工具用途适用阶段pytest pytest-asyncio异步测试框架全阶段respx / aioresponses模拟HTTP客户端调用半集成vcrpy录制回放真实模型请求端到端testcontainers容器化数据库和中间件集成测试jsonschema结构化校验工具参数单元测试pytest-cov覆盖率统计全阶段如果项目用的是Java或TypeScript对应生态里也有等价物核心思路完全一致异步测试支持、HTTP mock、录制回放、容器化中间件、覆盖率工具。有一个容易被忽略的点jsonschema可以同时用在业务代码和测试代码里。工具注册时做一次参数校验测试里再对模型返回的arguments做一次校验双重保险。5.2 从0到1推进测试的路线如果现在你接手的是一个几乎没有测试的Agent项目不要想着一夜之间补齐所有测试。我建议按这个顺序推进先做依赖注入改造。把model client、tool registry、memory从内部new改成构造参数注入。这步不动业务逻辑但为后续所有测试打开空间。给工具层补单元测试。性价比最高因为工具层最稳定、最容易断言也最容易出泄漏类的bug。给循环控制补测试。覆盖final、tool_call、not found、max_steps这四条主分支。搭两个半集成用例。真LLM加假工具一个假LLM加真工具一个打通集成测试框架。最后加端到端和vcr回放验证整体流程再用覆盖率报告查漏补缺。每完成一步提测前的回归成本就会明显降一截。我自己的感受是做完前三步之后线上Agent因为逻辑bug导致的故障率至少下降了一半以上。5.3 一个额外的小经验把测试用例当工具规格说明书最后分享一个很小的实操习惯。我写工具层测试时喜欢把用例描述写成“给谁什么期望什么”的句式比如“给get_weather传入city北京期望返回晴26度并记录城市”。时间一长这些测试用例本身就成了工具行为的活文档。后端开发、新来的同事、甚至产品经理都能通过读测试用例快速理解每个工具的行为边界。团队协作时这份“活的规格说明书”比任何设计文档都好用因为它会随着代码变更自动失效逼着团队保持同步而不是文档写一套、代码跑另一套。这是我个人很受益的一点也算是白盒测试带来的额外价值测试不只是质量保障更是把系统内部结构“讲清楚”的过程。