在构建基于大语言的智能体系统时,工程师往往把重心放在提示词与编排逻辑上,却忽略了Agent最核心的不确定性来源:工具调用。一个具备搜索、数据库查询、代码执行能力的Agent,其输出不仅取决于模型本身,更受外部工具返回值直接影响。如果单元测试直接连接真实服务,不仅执行缓慢、结果浮动,还会因第三方限流导致流水线不稳定。因此,我们需要在测试环境中将工具调用替换为可控的模拟实现,并对Agent产生的调用行为与最终输出进行双重验证。

为什么Agent的工具调用必须被模拟
传统单元测试中,我们习惯把函数视作纯逻辑黑盒,给定输入即可得到固定输出。但Agent的运行过程是一个循环:模型生成一段包含工具名的指令,框架解析后执行对应函数,再将结果拼回上下文供下一轮推理。若这一步真的打向生产环境,比如调用付费翻译接口或写入测试库,不仅成本不可控,还可能因返回数据随机造成断言失败。更隐蔽的问题是,当工具偶发超时,Agent可能走降级分支输出完全不同答案,这类路径在真实网络中极难复现,却恰恰是最该被测试覆盖的。
从可观测性角度看,模拟工具调用能把非确定性的外部依赖变成确定性的测试桩。我们可以让同一个Agent在面对“天气API返回暴雨”和“天气API返回晴天”时,分别产生不同的行程建议,从而验证分支逻辑。如果不做模拟,这两种场景只能在真实气候下碰运气。此外,模拟还能让我们注入非法JSON、超时异常、空响应等极端情况,检验Agent的容错与重试机制是否生效。这些都是保障多步推理可靠性的基础。
另一个常被忽视的点是调用次数的断言。某些业务要求Agent最多只查一次库存,若模型幻觉导致重复查询,真实环境里不易察觉,但通过在模拟工具内部计数,测试可以精确捕获违规。由此可见,模拟并非单纯为了提速,更是为了把Agent和外部世界解耦,使其推理策略本身成为可检验的对象。
基于依赖注入与Mock的模拟实现方案
最常见的做法是利用依赖注入,将工具注册表与Agent运行时解耦。以Python生态为例,许多Agent框架允许在初始化时传入一个tools字典,键为工具名,值为可调用对象。测试时我们构造一个FakeWeather类,其方法返回预设字符串,再将其注入取代真实请求函数。这样Agent代码无需任何改动,仅在测试装配阶段替换依赖。该方式结构清晰,且模拟对象可以携带调用记录属性,方便后续断言。
如果框架不支持显式注入,则可使用unittest.mock或pytest的monkeypatch来替换模块级函数。例如真实工具定义在tools/weather.py的fetch方法,我们在测试前用patch将其指向本地lambda,返回固定JSON。这种方案侵入性低,适合遗留系统,但需注意patch路径必须指向被导入处而非定义处,否则不生效。下面给出一个pytest结合依赖注入的完整示例,展示如何伪造天气查询并验证Agent输出中包含合理建议。
import pytest
from agent_runner import Agent
class FakeWeather:
def __init__(self, result):
self.result = result
self.call_count = 0
def fetch(self, city):
self.call_count += 1
# 模拟网络返回,避免真实请求
return {"city": city, "condition": self.result}
def test_agent_rain_advice():
fake = FakeWeather("rain")
agent = Agent(tools={"weather": fake.fetch})
output = agent.run("北京今天适合户外跑步吗?")
# 断言调用了一次工具
assert fake.call_count == 1
# 断言输出文本提及雨天相关建议
assert "不建议" in output or "雨天" in output
def test_agent_sunny_advice():
fake = FakeWeather("sunny")
agent = Agent(tools={"weather": fake.fetch})
output = agent.run("上海今天适合户外跑步吗?")
assert fake.call_count == 1
assert "适合" in output or "晴天" in output
上述代码中,FakeWeather不仅提供了确定返回,还用call_count记录了被触达次数。测试函数分别构造雨天与晴天场景,验证Agent是否给出符合常识的答复。这种写法把工具契约显性化,当真实工具字段变更时,测试会立即暴露不一致。相比在测试里直接mock框架内部事件,依赖注入更容易维护,也更符合整洁测试的原则。
输出断言的策略:从文本到结构化信号
断言输出时,不少团队只检查Agent最终自然语言里是否包含某关键词,这固然简单,却容易漏掉中间错误。比如Agent可能调用了正确工具,但因提示词缺陷把返回数据误解,最终文本碰巧含有关键词而测试通过。更稳健的做法是分层断言:第一层校验工具是否被以正确参数调用,第二层校验注入的模拟返回是否进入上下文,第三层才是对用户可见文本的语义检查。三层结合可定位故障究竟在调用、解析还是生成阶段。
对于结构化输出,若Agent被要求返回JSON或特定格式计划,断言应直接解析该结构而非匹配字符串。例如期望输出含steps数组且第一步为check_weather,可用json.loads后断言键存在与顺序。若框架支持回调或钩子,还可断言on_tool_start事件携带的参数city等于"北京"。下表对比两种常见断言风格的适用场景:
| 断言方式 | 优点 | 缺点 | 适用情况 |
|---|---|---|---|
| 文本关键词匹配 | 编写快,贴近用户视角 | 误报率高,难定位逻辑层错误 | 冒烟测试、Demo验证 |
| 调用链与结构断言 | 精准捕获参数与分支错误 | 测试代码较长,需理解内部协议 | 核心业务流水线 |
实践中建议以结构化断言为主,文本断言为辅。当模型版本升级导致措辞变化时,只要工具调用与中间结构稳定,测试不应脆弱失效;而文本层仅做宽松包含判断,可容忍表达差异。对于涉及多工具串联的复杂Agent,还可以利用模拟工具抛出预设异常,断言Agent是否按设计进入兜底回复,从而把容灾逻辑也纳入单元测试范畴。如此,模拟与断言共同构成了Agent质量防线,使其迭代不再依赖手工试错。
Agentunit_testtool_calling修改时间:2026-08-18 05:08:33