乐于分享
好东西不私藏

AI Agent 的工具调用,需要一套"模拟测试"框架

AI Agent 的工具调用,需要一套"模拟测试"框架

Agent 在生产环境中之所以让人不放心,很大一部分原因在于工具调用是个黑盒。你给 Agent 定义了一个搜索工具、一个数据库查询工具、一个发送邮件的工具,它在测试时可能表现得很好,但上线后面对真实数据和真实 API 响应,行为完全不一样。这个问题不是模型能力的问题,而是测试方法的问题——你没法在开发阶段用真实工具测,因为真实工具有副作用、有延迟、有费用,甚至在测试环境里根本不可用。

很多团队的做法是手动给几个测试用例,跑一遍看结果,觉得没问题就上线了。这种做法在传统软件工程里早就被淘汰了,但到了 Agent 这里,大家又回到了"手工测试"的阶段。原因不是不想测,而是不知道该怎么测——工具调用涉及外部系统,传统的 mock 方式在 LLM 的随机性面前不太管用,而且 Agent 的决策链路比普通函数调用复杂得多。

问题到底出在哪

写一个工具函数很简单,比如一个搜索工具,接收关键词返回结果列表。但 Agent 使用这个工具的过程是:先理解用户意图,然后决定要不要调用搜索工具,再从可用的工具列表里选对工具,填对参数,拿到结果后判断结果是否满意,不满意可能还要换关键词再搜一次。这个链条里每一步都可能出错。

更麻烦的是,工具调用错误不是简单的"函数抛异常"。Agent 可能选错了工具,可能参数填对了但结果理解错了,可能多步工具调用的顺序错了,甚至可能在一个循环里反复调用同一个工具不退出。这些错误模式在传统单元测试里很难覆盖,因为你没法用简单的 assert 去判断"Agent 在这个场景下应该调用搜索工具而不是数据库工具"。

另一个现实问题是:很多工具在测试环境里根本跑不起来。比如 Agent 要调用一个内部 CRM 的 API,测试环境没有真实数据,或者需要 VPN 和特殊权限才能访问。再比如工具调用会触发外部通知、扣费、写入生产数据——这些都是不能在测试中随便触发的。

模拟测试的核心思路

解决这个问题的思路其实不复杂:把工具调用从真实的外部系统解耦出来,用模拟工具来替代。模拟工具不是简单的"返回固定值",而是一个可以控制行为、注入故障、记录调用历史的中间层。

核心的做法是给每个工具定义一个"模拟模式"和"真实模式"。在测试和开发阶段,工具运行在模拟模式下,它的响应由测试用例控制;在生产环境里,工具切换到真实模式,连接真实的外部系统。这个切换对 Agent 来说是透明的——Agent 看到的工具定义、参数结构、返回格式完全一样,只是背后的实现不同。

模拟工具的价值在于,你可以精确控制它返回什么。比如你要测试 Agent 在搜索工具返回空结果时的行为,就让模拟工具在特定关键词下返回空列表。你要测试 API 超时场景,就让模拟工具在指定次数后抛出超时异常。你要测试工具连续调用时的状态依赖,就让模拟工具在第一次调用时返回 A,第二次调用时返回 B。这些场景在真实环境下几乎不可能稳定复现,但在模拟环境里就是几行配置的事。

具体怎么搭

一个可用的模拟测试框架至少需要三个部分:模拟工具注册中心、调用记录器、场景定义器。

模拟工具注册中心维护着所有工具的模拟实现。每个工具需要提供一个"模拟处理函数",接收和真实工具相同的参数,但返回由测试控制的模拟响应。这个注册中心在测试启动时初始化,在测试结束后清理,确保不同测试用例之间不会互相影响。

调用记录器负责记录 Agent 对工具的每一次调用:调了哪个工具、传了什么参数、返回了什么结果、花了多长时间。这些记录在测试结束后可以用来做断言——比如"Agent 应该调用搜索工具恰好一次"、"搜索工具的参数应该包含用户提到的关键词"、"在搜索工具返回空后,Agent 应该尝试另一个工具而不是反复搜索"。

场景定义器是让测试用例可以描述"在什么条件下返回什么结果"。最简单的形式是一组规则映射:当工具名称和参数匹配某个条件时,返回指定的结果。更复杂的场景支持状态机式的模拟——工具的行为随着调用次数变化,模拟多次调用之间的交互。

下面是一个简化版的模拟工具定义示例:

# 模拟工具注册
classMockToolRegistry:
def__init__(self):
        self._tools = {}
        self._call_history = []

defregister(self, tool_name, mock_fn):
        self._tools[tool_name] = mock_fn

asyncdefcall(self, tool_name, **kwargs):
        self._call_history.append({
"tool": tool_name,
"args": kwargs,
"timestamp": time.time()
        })
if tool_name notin self._tools:
raise ValueError(f"Unknown tool: {tool_name}")
returnawait self._tools[tool_name](**kwargs)

defassert_called_once(self, tool_name, **kwargs):
        calls = [c for c in self._call_history 
if c["tool"] == tool_name and
                 all(k in c["args"and c["args"][k] == v 
for k, v in kwargs.items())]
assert len(calls) == 1f"Expected 1 call to {tool_name}, got {len(calls)}"

这个注册中心可以和任何 Agent 框架集成。关键是在 Agent 初始化时,把模拟注册中心注入到工具调用接口里,而不是直接使用真实工具。对于 LangGraph、OpenAI Agents SDK、Anthropic 的 Tool Use API,都可以通过自定义工具执行器来实现这个注入。

场景设计的几个关键模式

基础场景:正常响应。 模拟工具返回最典型的成功响应。用来验证 Agent 在正常情况下的决策路径是否正确。比如搜索工具返回三篇相关文章,Agent 应该正确总结并回复用户。

边界场景:空结果和极限值。 搜索返回空列表、数据库查询返回 0 条记录、结果数量超过 Agent 的上下文窗口。这些场景在真实环境中出现频率不低,但很多 Agent 在测试时从未验证过空结果路径。

故障场景:工具调用失败。 超时、限流、返回格式异常、权限拒绝。每个工具都应该至少有一个故障场景的测试用例,验证 Agent 在工具失败时能否正确降级或重试。一个常见的陷阱是 Agent 在工具失败后进入了无限重试循环,这在模拟测试中很容易发现。

时序场景:多步调用的状态依赖。 有些工具调用依赖前一步的结果,比如先查询用户 ID,再用用户 ID 查询订单。模拟工具需要支持"第一次调用返回 X,第二次调用返回 Y"的序列模式。

选择场景:工具之间的竞争。 当 Agent 同时有多个工具可用时,测试它是否选择了正确的工具。比如用户问"今天天气怎么样",Agent 应该调用天气工具而不是搜索工具。这种场景的模拟测试需要同时注册多个模拟工具,然后验证 Agent 调用了预期的那个。

在哪一层做模拟

模拟测试可以放在不同的层次上,各有优劣。

在工具函数层做模拟是最直接的方式。把每个工具函数的实现替换成模拟版本,Agent 框架不需要任何改动。优点是侵入性最小,缺点是模拟粒度粗,没法模拟工具调用过程中的中间状态。

在工具调用执行器层做模拟是在 Agent 框架的工具调用调度器里做拦截。这种方式可以控制工具调用的整个生命周期——何时调用、是否并发、结果如何返回。缺点是需要对 Agent 框架有一定了解,不同的框架拦截点不同。

在 API 传输层做模拟是最彻底的模拟方式。直接拦截发送给 LLM 的请求和 LLM 返回的响应,完全控制 Agent 看到什么。这种方式的优点是可以在不修改 Agent 代码的情况下测试任何场景,缺点是实现复杂,而且需要模拟 LLM 的响应格式,可能会引入额外的维护成本。

大多数团队从工具函数层开始就足够了,随着测试需求增加再往上层迁移。不需要一开始就搭建一个完整的传输层模拟框架。

模拟工具本身也需要维护

模拟测试框架不是写一次就完事的。随着真实工具的增加和变更,模拟工具也需要同步更新。如果模拟工具返回的格式和真实工具不一致,模拟测试就会失去意义。

一个比较实用的做法是让模拟工具和真实工具共享同一个响应模型定义。真实工具把外部 API 响应映射成内部结构体,模拟工具直接返回这个结构体的实例。这样当真实工具的响应格式变化时,类型检查会提醒你更新模拟工具,不至于出现模拟测试全通过但上线就挂的情况。

另一个做法是定期录制真实工具的响应,拿来做模拟测试的"回放"数据。比如每周跑一次回归测试,用真实工具执行一批查询,记录响应,然后把这些响应作为下周模拟测试的基准数据。这种方式既能保证模拟测试的覆盖度,又能及时发现真实工具的响应变化。

把模拟测试放进 CI

模拟测试最大的价值在于可以自动化。如果每次修改 Agent 的提示词、工具定义或决策逻辑后,都要人工跑一遍测试用例,那这个测试流程迟早会被跳过。

关键是要把模拟测试作为 CI 流水线的一步。Agent 的代码变更触发 CI 构建,CI 运行模拟测试套件,验证所有工具调用场景的决策路径是否正确,然后才允许合并到主分支。这个过程和传统软件工程的单元测试没有本质区别,只是测试的对象从函数变成了 Agent 的决策行为。

这里有一个容易被忽略的点:模拟测试的失败不应该只是"抛异常",而是应该给出清晰的诊断信息。比如"Agent 本应调用搜索工具但调用了数据库工具"、"搜索工具被调用了 3 次但预期是 1 次"、"参数 start_date 的格式不正确"。这样才能让开发者快速定位问题,而不是在 Agent 的决策日志里大海捞针。

模拟测试解决不了的问题

需要承认,模拟测试不是万能的。它解决的是"Agent 在已知场景下是否做出正确决策"的问题,但解决不了"Agent 在未知场景下是否安全"的问题。真实世界的数据分布、用户输入的多样性、工具调用的不可预测性,这些是模拟测试覆盖不到的。

但这不是模拟测试的缺陷,而是所有测试方法共有的局限。传统软件工程里,单元测试也覆盖不了所有集成问题,但没人会因此否定单元测试的价值。模拟测试在 Agent 开发中的角色,就是单元测试在传统开发中的角色——它不保证系统完全正确,但保证系统在已知的、可预期的场景下行为正确。

对于 Agent 来说,这个价值尤其重要。因为 Agent 的决策空间比传统软件大得多,不确定性也大得多。如果没有模拟测试这个最低层的安全网,你根本没法判断一个 Agent 的改动是变好了还是变坏了。而有了模拟测试,至少你可以说:在测试过的这 200 个场景里,Agent 的决策路径和预期一致。

对于正在把 Agent 推向生产环境的团队来说,模拟测试不是一个可选项,而是基础设施的一部分。它和你选的模型、Agent 框架、部署方式无关——不管用什么技术栈,都需要一能够验证 Agent 决策行为的测试方法。从这个角度看,模拟测试可能比选哪个模型更能决定 Agent 项目能不能真正跑起来。

#AI工程 #Agent工程 #工程实践 #测试 #质量保障