先说结论
写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生成最终回复
三个不确定点:
- 工具选择——Agent选对工具了吗?该查天气时没调计算器吧?
- 参数生成——用户说"北京",工具收到的是"北京"不是"Beijing"吧?
- 调用顺序——先查再算,还是先算再查?顺序错了结果就错了。
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的"手和脚"。
publicinterfaceAgentTool{StringgetName();StringgetDescription();Stringexecute(Stringinput);}对比Spring的@Service——工具就是一个可被Agent调用的Service。区别在于:传统代码里你在Controller里写死调哪个Service,Agent里是LLM动态决定的。
ToolCallRecorder:工具调用的摄像头
这是整个测试体系的核心组件。包装Agent工具,记录每次调用的工具名、输入参数、输出结果、调用顺序。
publicclassToolCallRecorder{publicstaticclassToolCallRecord{publicfinalStringtoolName;publicfinalStringinput;publicfinalStringoutput;publicfinalintcallIndex;}// 5种断言方法publicbooleanassertCallCount(StringtoolName,intexpectedCount);publicbooleanassertTotalCalls(intexpected);publicbooleanassertToolCalledWith(StringtoolName,StringexpectedInput);publicbooleanassertCallOrder(String...expectedOrder);publicbooleanassertToolCalled(StringtoolName);}设计模式是装饰器模式——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…
publicStringexecute(StringuserInput){StringcurrentInput=userInput;while(iteration<maxIterations){StringllmDecision=llm.chat(currentInput);if(llmDecision.startsWith("CALL:")){// 解析工具名和参数,执行工具String[]parts=llmDecision.split(":",3);AgentTooltool=tools.get(parts[1].trim());Stringoutput=tool.execute(parts[2].trim());currentInput="工具返回: "+output;}elseif(llmDecision.startsWith("DONE:")){returnllmDecision.substring(5);}elseif(llmDecision.startsWith("DIRECT:")){returnllmDecision.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加入对话历史。
多轮对话三个核心能力
- 指代消解——"那上海呢?"要理解为查上海天气
- 上下文累积——"温差多少"要从历史拿北京32°C和上海28°C
- 话题切换——"算了,帮我算1+1"要切到计算器,不追问天气
MultiTurnAgent:带记忆的Agent
privateStringbuildPromptWithContext(StringuserInput){if(history.isEmpty())returnuserInput;StringBuildersb=newStringBuilder();sb.append("[对话历史]\n");for(TurnRecordtr:history){sb.append(String.format("Turn %d: 用户=\"%s\" | 工具=%s | 回复=\"%s\"\n",tr.turn,tr.userInput,tr.toolCalls,tr.agentReply));}sb.append("[当前输入] ").append(userInput);returnsb.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帮你封装了什么
真实写法:
classWeatherTools{@Tool("查询指定城市的天气")StringgetWeather(@P("城市名")Stringcity){return"晴,32°C";}}AiServices<MyAgent>agent=AiServices.builder(MyAgent.class).chatModel(realModel).tools(newWeatherTools()).build();Stringreply=agent.chat("北京天气怎么样");@Tool注解自动生成工具描述给LLM,框架自动处理"LLM返回tool_calls → 解析JSON → 反射调用方法 → 把结果拼回messages → 再发给LLM"整个循环。你写的ToolCallAgent那个while循环,框架帮你做了。
那Mock的价值是什么?
- 理解Agent决策循环的本质——你手写了Think→Act→Observe→Think,知道框架内部在干什么,不是黑盒
- 测试管道逻辑——当LLM决定调WeatherTool时,你的代码有没有正确执行,这个逻辑Mock和真实都一样
- 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_callsJSON,框架自动解析。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应用开发学习笔记。有问题评论区聊。