先说结论

写LLM应用的人多,但认真给Agent写测试的人少。为什么?因为Agent测试比传统单元测试难一个量级——你要测的不只是"输入对不对、输出对不对",还要测"Agent选对工具了吗?传的参数对吗?调了几个工具?调用顺序对不对?多轮对话有没有记住上文?"

今天手写了4个Step、9个Java文件、106项断言,从零搭了一套Agent测试体系。0个Token、0次网络调用、毫秒级跑完。这篇文章把整个过程拆给你看,包括Mock方案、ReAct循环实现、多轮对话测试、决策路径断言,最后对比真实环境Function Calling的区别。

Agent测试为什么比RAG测试难?

先说清楚问题在哪。

传统单元测试:assertEquals(add(1,1), 2),确定性,同输入永远同输出。

RAG测试:输入"什么是RAG" → 检索文档 → 生成回答 → 检查幻觉率。虽然输出非确定,但流程是线性的——输入到输出一条路走到底。

Agent测试:输入"北京和上海温差多少" →

  • Agent决策:该调哪个工具?❓
  • Agent生成参数:传"北京"还是"Beijing"?❓
  • 工具执行:WeatherTool(“北京”) → “晴,32°C” ✅
  • Agent再决策:结果够了吗?要不要再查?❓
  • Agent再调:WeatherTool(“上海”) → “多云,28°C”
  • Agent再决策:够了,该算温差了 ❓
  • Agent调计算器:CalculatorTool(“32-28”) → “4.0”
  • Agent生成最终回复

三个不确定点:

  1. 工具选择——Agent选对工具了吗?该查天气时没调计算器吧?
  2. 参数生成——用户说"北京",工具收到的是"北京"不是"Beijing"吧?
  3. 调用顺序——先查再算,还是先算再查?顺序错了结果就错了。

RAG是直线,Agent有分支和循环。这就是测试难度跳了一个量级的原因。

测试策略:控制大脑,验证手脚

核心思路就一句话:用Mock控制LLM的"决策",用Recorder记录Agent的"行为",用断言验证两者是否一致。

你控制不了真实LLM每次返回什么,但在测试环境里,你可以预设LLM的决策序列。然后看Agent有没有正确执行这些决策。

MockChatModel预设决策 → Agent执行 → Recorder记录 → 断言验证
     "大脑"              "手脚"         "摄像头"

这个思路和Spring Boot测试中Mock Service层是一脉相承的——你Mock掉外部依赖(LLM),测的是你的业务逻辑(Agent决策执行逻辑),不是模型智商。

Step 1:搭建可录制的工具体系

AgentTool接口

先定义工具接口。Agent为什么需要工具?因为LLM本身只会生成文本,不能查数据库、调API、执行计算。工具就是LLM的"手和脚"。

public interface AgentTool {
    String getName();
    String getDescription();
    String execute(String input);
}

对比Spring的@Service——工具就是一个可被Agent调用的Service。区别在于:传统代码里你在Controller里写死调哪个Service,Agent里是LLM动态决定的。

ToolCallRecorder:工具调用的摄像头

这是整个测试体系的核心组件。包装Agent工具,记录每次调用的工具名、输入参数、输出结果、调用顺序。

public class ToolCallRecorder {

    public static class ToolCallRecord {
        public final String toolName;
        public final String input;
        public final String output;
        public final int callIndex;
    }

    // 5种断言方法
    public boolean assertCallCount(String toolName, int expectedCount);
    public boolean assertTotalCalls(int expected);
    public boolean assertToolCalledWith(String toolName, String expectedInput);
    public boolean assertCallOrder(String... expectedOrder);
    public boolean assertToolCalled(String toolName);
}

设计模式是装饰器模式——RecordedTool包装原始工具,不改变行为只增加录制。和Spring AOP的@Around切面记录方法调用,思路一模一样。

三个具体工具

天气查询、计算器、代码分析。每个工具都是确定性的——同一个输入永远同一个输出。不确定性来自Agent的决策,不是工具本身。

跑4个场景:单工具调用、计算器调用、代码分析、多工具串联("北京和上海温差多少"需要2次天气+1次计算)。5项断言全通过。

Step 2:ReAct决策循环 + 工具调用链验证

Step 1是手动调工具,Step 2让Agent自动决策。

ToolCallAgent:最简ReAct实现

ReAct模式的核心:Think(LLM决策)→ Act(执行工具)→ Observe(看结果)→ Think…

public String execute(String userInput) {
    String currentInput = userInput;
    while (iteration < maxIterations) {
        String llmDecision = llm.chat(currentInput);

        if (llmDecision.startsWith("CALL:")) {
            // 解析工具名和参数,执行工具
            String[] parts = llmDecision.split(":", 3);
            AgentTool tool = tools.get(parts[1].trim());
            String output = tool.execute(parts[2].trim());
            currentInput = "工具返回: " + output;
        } else if (llmDecision.startsWith("DONE:")) {
            return llmDecision.substring(5);
        } else if (llmDecision.startsWith("DIRECT:")) {
            return llmDecision.substring(7);
        }
    }
}

决策协议三种指令:

  • CALL:工具名:参数——调用工具
  • DONE:最终回复——结束循环
  • DIRECT:回复——不调工具,直接回复

4个场景测试:

  • 场景A:单工具调用(天气)——5/5断言通过
  • 场景B:不调工具直接回复(闲聊)——3/3通过
  • 场景C:多工具串联(温差=2次天气+1次计算)——9/9通过
  • 场景D:故意选错工具——2个断言失败(验证测试本身能抓到bug)

场景D是反向测试。用户问天气,Mock故意让Agent调CalculatorTool。断言"应该调WeatherTool"失败。这验证了测试本身是有效的——如果Agent真的选错工具,测试能抓到。

Step 3:多轮对话测试——指代消解+上下文累积

Step 2的Agent是一次性的,每次execute独立。Step 3加入对话历史。

多轮对话三个核心能力

  1. 指代消解——"那上海呢?"要理解为查上海天气
  2. 上下文累积——"温差多少"要从历史拿北京32°C和上海28°C
  3. 话题切换——"算了,帮我算1+1"要切到计算器,不追问天气

MultiTurnAgent:带记忆的Agent

private String buildPromptWithContext(String userInput) {
    if (history.isEmpty()) return userInput;

    StringBuilder sb = new StringBuilder();
    sb.append("[对话历史]\n");
    for (TurnRecord tr : history) {
        sb.append(String.format(
            "Turn %d: 用户=\"%s\" | 工具=%s | 回复=\"%s\"\n",
            tr.turn, tr.userInput, tr.toolCalls, tr.agentReply));
    }
    sb.append("[当前输入] ").append(userInput);
    return sb.toString();
}

每轮开始时,把历史拼进prompt给LLM。真实LLM用messages列表,这里简化为文本拼接。

三轮天气对话测试

Turn 1:用户问"北京天气怎么样" → Agent调WeatherTool(“北京”) → “北京32°C”
Turn 2:用户问"那上海呢" → Agent调WeatherTool(“上海”)(指代消解!Mock预设的决策"考虑"了历史) → “上海28°C”
Turn 3:用户问"温差多少" → Agent调CalculatorTool(“32-28”)(上下文累积!没调WeatherTool,因为数据在历史里) → “温差4°C”

35项断言全通过,包括:

  • 调用顺序验证:WeatherTool → WeatherTool → CalculatorTool
  • 参数验证:第一次"北京"、第二次"上海"(指代消解)、第三次"32-28"(上下文累积)
  • 历史传递验证:检查Mock的callLog,确认Turn 2的prompt包含Turn 1的历史

这个"检查prompt包含历史"的断言最有价值——它直接验证了上下文管理逻辑的正确性,不只是看结果对不对。

Step 4:决策路径断言——“结果对"不等于"路径对”

这是今天最深的一层。

Agent可能通过错误路径碰巧得到正确结果。比如用户问天气,Agent调了CalculatorTool(“北京”),CalculatorTool返回"格式错误",Agent换个工具再试,最后碰巧查到了天气。结果对了,但路径错了——如果测试只看结果,这个bug就漏了。

DecisionPathTracker:路径记录器

记录Agent执行过程中走了哪些节点,形成一条轨迹:

[1] 📥输入 北京天气怎么样
[2] 🧠决策 迭代#1
[3] 🔧工具 WeatherTool("北京")
[4] 🧠决策 迭代#2
[5] ✅完成 北京今天晴,32°C。

路径签名:INPUT_RECEIVED->LLM_DECISION->TOOL_CALL->LLM_DECISION->DONE

不同输入走不同路径:

  • 天气查询:INPUT->LLM->TOOL->LLM->DONE
  • 闲聊:INPUT->LLM->DIRECT_REPLY
  • 错误:INPUT->LLM->ERROR

路径签名的价值:用于回归测试。改了代码后,如果路径签名变了=路径变了=可能有bug。不需要理解断言细节,看签名一眼就知道。

5个场景45项断言全通过,包括:

  • 单工具路径:验证5步路径顺序正确
  • 直接回复路径:验证3步路径,不包含TOOL_CALL
  • 多工具串联路径:验证9步路径,3次TOOL_CALL
  • 错误路径:验证3步路径,包含ERROR节点
  • 路径签名对比:三种输入走三条不同的路

Mock版 vs 真实环境:区别在哪?

这是最容易混淆的地方。

我们Mock的(教学版)

LLM返回文本字符串 "CALL:WeatherTool:北京",我们自己写split解析、自己查Map找工具、自己调工具、自己写循环。

真实环境(Function Calling API)

LLM返回结构化JSON:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": {
      "name": "WeatherTool",
      "arguments": "{\"city\": \"北京\"}"
    }
  }]
}

LLM自己判断"该调工具了",返回tool_calls数组,指定工具名+JSON参数。

LangChain4j帮你封装了什么

真实写法:

class WeatherTools {
    @Tool("查询指定城市的天气")
    String getWeather(@P("城市名") String city) {
        return "晴,32°C";
    }
}

AiServices<MyAgent> agent = AiServices.builder(MyAgent.class)
    .chatModel(realModel)
    .tools(new WeatherTools())
    .build();

String reply = agent.chat("北京天气怎么样");

@Tool注解自动生成工具描述给LLM,框架自动处理"LLM返回tool_calls → 解析JSON → 反射调用方法 → 把结果拼回messages → 再发给LLM"整个循环。你写的ToolCallAgent那个while循环,框架帮你做了。

那Mock的价值是什么?

  1. 理解Agent决策循环的本质——你手写了Think→Act→Observe→Think,知道框架内部在干什么,不是黑盒
  2. 测试管道逻辑——当LLM决定调WeatherTool时,你的代码有没有正确执行,这个逻辑Mock和真实都一样
  3. 0成本0网络——真实API每次调用烧Token,Mock跑1000次不花钱

真实环境的三种测试方案

方案 思路 适用场景
Mock HTTP层 拦截OpenAI API的HTTP返回,模拟tool_calls JSON 单元测试
本地小模型 Ollama跑Qwen3-8B,真实走Function Calling 集成测试
记录+回放 真实调用一次录制响应,测试时回放 回归测试

可能遇到的面试题

Q1:Agent测试和传统单元测试有什么区别?

传统单元测试测的是确定性——同输入永远同输出。Agent测试有三个不确定点:工具选择、参数生成、调用顺序。所以Agent测试的核心策略是Mock LLM的决策,验证Agent的执行——控制大脑,验证手脚。

Q2:为什么Mock LLM而不是直接调真实API?

三个原因:速度(Mock毫秒级,真实API几秒)、成本(Mock免费,真实API烧Token)、稳定性(Mock确定性,真实LLM每次返回不同)。测试要快、要稳、要可重复。

Q3:Agent测试的三个层次是什么?

工具选择正确性(选对工具了吗)、参数生成正确性(传对参数了吗)、调用顺序正确性(先查再算还是先算再查)。对应ToolCallRecorder的assertToolCalled、assertToolCalledWith、assertCallOrder三个断言。

Q4:多轮对话测试怎么验证指代消解?

用户Turn 2说"那上海呢",断言WeatherTool的输入参数是"上海"。如果Agent没有正确理解指代,参数可能是null或"那上海呢"原样传入。同时检查Mock的callLog,确认Turn 2的prompt包含了Turn 1的历史。

Q5:什么是路径签名?有什么用?

把Agent的执行路径转成节点类型序列字符串,如INPUT->LLM->TOOL->LLM->DONE。用于回归测试——改了代码后如果签名变了,说明Agent走的路径变了,可能有bug。不需要理解每条断言的细节,看签名一眼就知道路径有没有变。

Q6:Mock版和真实Function Calling的区别?

Mock版LLM返回文本"CALL:Tool:param",自己解析。真实版LLM返回结构化tool_calls JSON,框架自动解析。Mock的价值在于理解Agent决策循环的本质——你手写了框架内部做的事,不是黑盒。

Q7:决策路径测试和工具调用记录有什么区别?

ToolCallRecorder只记录工具调用——调了什么、参数是什么、顺序对不对。DecisionPathTracker记录整个执行过程——包括LLM决策节点、错误节点、直接回复节点。“结果对不等于路径对”——Agent可能通过错误路径碰巧得到正确结果,路径断言能抓到这类问题。

全部代码文件一览

文件 Step 用途
AgentTool.java 1 Agent工具接口
ToolCallRecorder.java 1 工具调用记录器(5种断言)
AgentTestingConceptDemo.java 1 概念演示(4场景5断言)
ToolCallAgent.java 2 最简ReAct实现
AgentToolCallTestDemo.java 2 工具调用链验证(4场景17断言)
MultiTurnAgent.java 3 带对话历史的Agent
MultiTurnConversationTestDemo.java 3 多轮对话测试(3场景35断言)
DecisionPathTracker.java 4 决策路径追踪器
DecisionPathTestDemo.java 4 决策路径断言(5场景45断言)

三个核心设计模式贯穿始终:装饰器模式(RecordedTool包装工具)、责任链模式(Agent决策循环)、策略模式(不同输入走不同路径)。

总结

Agent测试的精髓:控制大脑,验证手脚,回放路径

  • 控制大脑:MockChatModel预设LLM的决策序列
  • 验证手脚:ToolCallRecorder记录并断言Agent的实际行为
  • 回放路径:DecisionPathTracker生成路径签名,用于回归测试

106项断言,0个Token,0次网络调用,毫秒级跑完。这就是Mock测试的价值——不花一分钱,测到Agent每一条逻辑分支。

下一篇我们聊LLM-as-a-Judge——用强模型评估弱模型输出。这是Agent测试体系从"Mock版"走向"真实版"的关键一步。


欢迎关注,下周继续更新LLM应用开发学习笔记。有问题评论区聊。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐