ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Mock LLM 实战:让 Agent 测试从碰运气变为可预期的工程实践

Mock LLM 实战:让 Agent 测试从碰运气变为可预期的工程实践 做 agent 开发的这两年我最大的感受是写一个能跑的 agent 不难难的是让它在改动之后还能“确定地”不出问题。刚起步那会儿我习惯让真实 LLM 直接跑测试case 就是一段 prompt跑完看结果对不对。表面看没什么问题但很快我就被折磨得够呛——同一个 case上午跑是成功的下午跑就失败团队成员也复现不了我的结果CI 里只要连着一个 agent 测试一次构建就要等十几分钟。后来我才想明白问题并不出在 agent 逻辑而在于我把一个天然随机的依赖LLM当成了确定性组件在用。于是我开始在项目里引入 Mock LLM——用一段完全可控的代码去代替真实的大模型服务把 agent 测试从“碰运气”变成“讲逻辑”。这篇文章想聊的就是Mock LLM 是什么为什么测试 agent 的时候非常需要它以及怎么在项目里把它真正落地。1. 为什么测试 agent 不能总依赖真实 LLM如果只是写一个“一问一答”的简单应用你其实不一定需要 Mock。真实模型的表现就是产品行为用真模型测反而最直接。但 agent 这类多步骤、会调用工具、自己规划执行路径的应用情况完全不同。真实 LLM 会给测试带来三座大山不可复现、成本时延不受控、异常场景覆盖不了。1.1 不可复现同一个 case两次运行两种结果大模型本身在温度大于零的情况下输出是有随机性的。同样的 prompt每次取样的 token 概率不同返回内容自然不同。这个随机性放在单次问答里还好但放在 agent 的循环里就会被指数级放大agent 根据每次 LLM 输出决定下一步动作第一步的 action 不一样后面整个执行链就全变了。我举一个最常见的例子。假设你有一个查询天气的 agent第一次运行它先调用 get_weather(“Hangzhou”)拿到结构化数据后返回“杭州今天晴”。第二次运行同一句“杭州今天天气怎么样”模型可能觉得“这种简单问题不需要工具”直接根据训练记忆告诉你“杭州大概率是晴天”。整个执行路径完全不同但单看最终答案你很难说它“错了”。问题是测试怎么断言怎么复现如果你把这条 case 挂到 CI它就成了一个随机的绿灯红灯大概率时间都花在“重新跑一遍试试”上面。更麻烦的是agent 一旦出错你根本无法判断是代码引入的 bug还是模型这轮的输出把你带偏了。我最早排查一个 agent 问题时连续跑五次五次路径都不一样最后不得不给 prompt 加上“请严格按工具调用格式输出”才勉强稳定下来。可这个“勉强稳定”恰恰意味着我的测试几乎没有防御力。所以做 agent 测试的第一条原则就是不要在测试里依赖真实 LLM 的随机输出。1.2 成本与时延开着真实模型跑测试跑不起也跑不动很多团队一开始不 mock不是因为不想而是没算过账。一个 agent 任务不是一次 LLM 调用而是多轮循环常见的一个 case 会有 5 到 15 次调用。我们来粗算一笔账假设一个小模型按 1 美元/M input、2 美元/M output 计价每次 agent 调用平均 2000 input token、200 output token那么单次调用成本大概是 0.002 0.0004 0.0024 美元。一个 case 算 10 次调用就是 0.024 美元。100 个 case 一轮全量测试2.4 美元。如果 CI 每天跑 20 次一天就是 48 美元一个月下来超过一千美元。注意这里还是按“便宜的小模型”算的如果用更强更贵的模型数字还要翻好几倍。再算时间。单次 LLM 调用通常要 1 到 2 秒一个 10 轮调用的 agent case加上工具执行和网络开销跑完可能要 20 秒以上。50 个 case就是 15 分钟以上。这种速度放本地开发还能忍放到 CI 里每次 push 都等十五分钟团队迭代节奏直接垮掉。我也试过只挑几个核心 case 跑真实模型剩下的不测但这等于把大部分逻辑裸奔。Mock LLM 不是可选优化而是让 agent 测试“跑得起、跑得快”的硬条件。1.3 异常场景你想模拟的错误真实模型不配合真实模型不会因为你测试需要就专门返回一个非法 JSON、突然断连、或者输出一个不存在的工具名。但 agent 代码里恰恰要处理这些情况LLM 返回格式错误agent 会不会重试返回的 action 名称非法agent 能不能纠正请求超时agent 会不会优雅降级。这些分支如果没被测试覆盖上线后遇到一次就是事故。Mock LLM 的价值就在于它可以“点菜”——你让 mock 这轮返回什么它就返回什么。你可以把故障注入做成参数化测试用同一套 agent 逻辑跑几十种不同响应场景把所有防御性分支都撑起来。这一步在真实 LLM 上几乎不可能稳定复现所以对 agent 这类依赖模型输出的系统来说mock 不是退而求其次而是唯一能精确控制输入的测试手段。2. Mock LLM 到底在“模拟”什么很多人听到 Mock LLM第一反应是“哦就是伪造一个大模型接口”。这么理解没错但如果只停留在“伪造”层面你很快会写出一堆没用还误导人的 mock。我觉得更准确的定义是Mock LLM 是一个“行为可控的响应源”它对外表现得和真实 LLM 一样但内部完全由测试脚本决定返回什么。2.1 一句话定义把不可控的 LLM 变成可控的响应源真实 LLM 对测试就好像一个不听使唤的演员你希望它今天演“调用天气工具后返回结果”它可能临时加戏直接给你报个即时比分。Mock LLM 则是一个拿着剧本的演员你说什么就是什么。它需要满足三个基本能力我称之为“三可”可重复、可控、可观测。可重复是指相同请求永远给相同响应这保证了测试结果的稳定性也是可以挂在 CI 上的前提。可控是指测试者能决定它返回什么内容、以什么顺序返回、什么时候返回甚至返回错误、超时、空内容。可观测则容易被忽略mock 要完整记录收到的每一次 prompt 和元信息供测试结束后断言。很多失败的 mock 设计就是只做到了“能返回固定内容”却把“prompt 是否合理”这个最重要的断言维度丢掉了。实际上agent 测试很多时候不只要测最终输出还要测中间每一轮有没有把正确的信息传给模型。2.2 三种常用模式固定响应、序列响应、录制回放我见过也有人把 Mock LLM 分得更细但工作中真正常用的其实就是三种模式。固定响应或者叫字典匹配是入门款把 prompt 里的某个关键词映射到一段固定回复。适合“一问一答”的简单场景比如 agent 只是一个单轮调用或者某个工具的封装。序列响应更适合 agent 的多轮循环mock 内部维护一个响应列表第一次调用返回列表的第一项第二次返回第二项以此类推。ReAct 类 agent 最常见的测试套路就是“第一轮返回工具调用指令第二轮返回最终答案”。如果 agent 实际调用次数比列表长mock 直接抛错这本身也是一个有效的失败信号——说明 agent 可能陷入了循环或者多执行了不必要的步骤。录制回放record and replay是把真实 LLM 跑一次的 prompt-response 对保存下来测试时原样重放。这种方式的好处是响应内容真实自然适合做业务链路的“黄金回放”坏处是回放数据一旦录下来就固定了无法覆盖新变化换模型或者改 prompt 后数据就不可用。我的经验是录制回放适合做冒烟回归不适合做逻辑断言它和序列响应是互补关系。2.3 一个合格的 mock要记住自己“不是模型”这个点我认为必须单独讲。Mock LLM 的能力边界要清楚它替代的是“调用 LLM 这个外部依赖”而不是替代“模型的知识和语言能力”。所以 mock 里不应该塞任何业务规则判断比如“如果 prompt 里提到下雨就返回下雨”这会把你对业务的理解硬编码进测试替身表面上是完成了任务实际上是把你预期的逻辑复制了一份测试反而失去了独立验证的意义。正确的做法是mock 只负责按约定返回响应agent 是否把响应转成正确的工具参数、是否正确处理结果这是 agent 自己的逻辑测试断言的应是后者。换句话说mock 是“哑巴演员”它的任务是服从不是即兴发挥。理解这一点能避免写出大量“自己测试自己”的无效 case。3. 从零撸一个 Mock LLM设计思路与踩坑记录这部分我直接给出代码。为了让思路更通用我用纯 Python 实现不绑定具体框架。你可以把这里的类当成一个内核换成任何语言、任何框架都很容易。3.1 第一步字典匹配的 Mock LLM最简单的 Mock LLM 长这样。它接收一个 response_map键是 prompt 中的关键词值是预设响应。generate 方法把每次请求丢进历史列表再根据关键词匹配返回内容。class DictMockLLM: def __init__(self, response_map, default_response): self.response_map response_map self.default_response default_response self.request_history [] def generate(self, prompt: str) - str: self.request_history.append(prompt) for keyword, response in self.response_map.items(): if keyword in prompt: return response return self.default_response为什么用“包含匹配”而不是“完全相等”因为 agent 的 prompt 往往带有时间戳、用户 ID、工具返回结果等动态内容你不可能每次都知道完整字符串。包含匹配足够稳定也容易读。但这个设计里有个隐藏风险如果 response_map 里没有任何关键词命中它会静默返回空字符串。这在调试时会很困惑我建议至少把未命中的请求打一条 warning或者直接抛异常否则你很容易在“mock 一直返回默认值”的情况下还觉得测试是自己想要的。配合 pytest 的用例如下。目标是验证 agent 遇到天气问题时确实把城市名放进了请求里并且最终答案是预设的。def test_agent_calls_weather_tool_with_city(): mock_llm DictMockLLM( { weather: get_weather(tool_input{city: Hangzhou}), } ) agent build_agent(llmmock_llm) result agent.run(杭州今天天气怎么样) assert result get_weather(tool_input{city: Hangzhou}) assert any(Hangzhou in prompt for prompt in mock_llm.request_history)这个例子里第一个断言验证最终输出第二个断言验证中间 prompt 里确实出现了城市名。如果你把“城市名是否传对”这一点忘掉只测最终输出那 mock 再多也只是在测“字符串终于回到了起点”。3.2 第二步序列式 Mock处理 agent 的多轮循环agent 真正的复杂性在于循环所以 Mock LLM 至少要能按顺序返回多段响应。核心实现如下。class SeqMockLLM: def __init__(self, responses): self.responses responses self.call_times 0 self.request_history [] def generate(self, prompt: str) - str: self.request_history.append(prompt) if self.call_times len(self.responses): raise RuntimeError( Mock LLM response exhausted: fexpected {len(self.responses)} calls, got more ) response self.responses[self.call_times] self.call_times 1 return response这里我特意做了“调用次数超出序列长度就抛错”的设计。它的价值在于如果 agent 因为 prompt 写得太模糊而多执行了一轮工具调用mock 会立刻把问题暴露出来。你可以把超出的那一次请求从 request_history 里捞出来看看 agent 那一轮在想什么往往很快就能定位到是模型被误导还是逻辑分支错了。在 ReAct 类的 agent 上一个典型用例是mock_llm SeqMockLLM( [ {thought: need to query weather, action: get_weather, action_input: Hangzhou}, {thought: got weather, final answer: Hangzhou is sunny today}, ] ) runner build_react_agent(llmmock_llm) result runner.run(杭州天气怎么样) assert result Hangzhou is sunny today assert mock_llm.call_times 2 assert get_weather in mock_llm.request_history[0]注意这里的响应格式必须和 agent 框架实际使用的格式一致比如 ReAct 用的是 “Thought / Action / Action Input” 文本OpenAI function calling 用的是结构化 JSONMoA 又不一样。mock 永远只模拟协议不模拟语义。确定协议最简单的方式是先把真实 LLM 跑一次把原始响应打出来照着它的结构 mock。3.3 第三步拦截 HTTP 层假装自己是 OpenAI有些情况下你不好改 agent 内部代码或者 agent 依赖的第三方库直接创建了 OpenAI SDK 客户端没法注入。这时候可以在 HTTP 层拦截请求。Python 生态里 respx pytest 是常用组合下面是一个拦截 Chat Completions 接口的例子。import httpx import respx respx.mock def test_agent_against_mock_openai(): respx.post(https://api.openai.com/v1/chat/completions).mock( return_valuehttpx.Response( 200, json{ id: chatcmpl-mock-001, object: chat.completion, model: gpt-4o-mini, choices: [ { index: 0, message: {role: assistant, content: 杭州今天晴23度}, finish_reason: stop, } ], }, ) ) agent build_agent_with_real_openai_client(api_keytest-key) result agent.run(杭州今天天气怎么样) assert 晴 in result这个方案的好处是不用动业务代码连 agent 内部的 SDK 客户端都不换网络层就被 mock 掉了。坏处是它只能拦截 HTTP无法在 prompt 级做很细的断言而且很多 SDK 请求走的是流式接口需要额外处理。一个经验是如果 agent 内部结构可控优先依赖注入如果是要测一个第三方 agent 框架或 SDK才用 HTTP 拦截。3.4 设计 Mock LLM 时容易踩的坑第一个坑是 mock 匹配太宽松。我见过有人把所有请求都返回同一个 weather 响应结果 agent 无论用户问什么都走同一条工具调用路径测试永远绿。这个问题的本质是“match 没有覆盖参数变化”解决办法是断言请求里的关键参数而不是只断言返回结果。第二个坑是 mock 只实现了非流式。OpenAI 新版 SDK 默认可能用流式响应agent 框架监听的是 delta 字段如果你 mock 返回的是 message.contentagent 拿到的可能是空内容。写 mock 之前先确认框架用的是哪种 consume 方式流式就按流式协议 mock。第三个坑是序列响应和实际调用轮数对不上。测试报错后第一反应不要是“把序列加长”要先看多出来的那次请求内容是什么判断 agent 是不是在正常完成任务后又多调用了一轮。4. 在真实 agent 项目中接入 Mock LLM上面的代码单独看没问题但接进真实 agent 项目时还需要解决“怎么把 mock 塞进去”的问题。这一章讲依赖注入、框架自带的 fake 类以及 CI 里的分层策略。4.1 用依赖注入替换内部的 LLM 客户端最常见的反模式是 agent 内部直接OpenAI()一把梭测试时想换成 mock 根本无从下手。正确做法是让 agent 只依赖一个抽象接口生产环境传入真实客户端测试环境传入 mock。Python 里可以用 Protocol 定义接口。from typing import Protocol class LLMClient(Protocol): def generate(self, prompt: str) - str: ... class OpenAIClient: def __init__(self, api_key: str): self._client OpenAI(api_keyapi_key) def generate(self, prompt: str) - str: resp self._client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], ) return resp.choices[0].message.content class MyAgent: def __init__(self, llm: LLMClient): self._llm llm def run(self, user_input: str) - str: # agent 内部所有对模型调用都走 self._llm.generate(...) ...这样测试时的组装方式就变成def test_my_agent(): agent MyAgent(llmSeqMockLLM([...])) result agent.run(...) ...依赖注入看似多写了一层接口实际换来的东西极其重要agent 的所有逻辑都变成可单测的。你不必再为“测试环境能不能访问外网 API”发愁也不必担心测试数据被真实模型污染。这个模式不只在 Python 里成立在 TypeScript、Java、Go 里都一样核心就是“面向接口编程”。4.2 结合 LangChain/LangGraph 的现成方案如果你的 agent 用 LangChain/LangGraph 搭建可以不手写 mock。LangChain 官方提供了 FakeListLLM、FakeStreamingListLLM、FakeMessagesListChatModel 等测试替身它们接入的是框架标准接口适合直接替换。from langchain_core.language_models.fake import FakeListLLM llm FakeListLLM(responses[ 我需要先查询天气, Final Answer: 杭州今天晴23度, ])用的时候把它当作普通模型传给链或图即可。FakeListLLM 的默认行为是每次调用从 responses 里按顺序取一条取完再调则报错这和前面手写 SeqMockLLM 的逻辑一致。要注意的是FakeListLLM 的行为并不完全等价于一个 ChatModel如果你的 agent 用了 ChatModel 的特有方法比如 bind_tools、tool_calls就得用 FakeMessagesListChatModel 或自定义一个假 ChatModel。判断方法很简单造一个最小用例把 fake 传进去打印每一次调用的入参和出参确认协议一致。4.3 落到 CI 流水线分层测试的实践方式Mock LLM 接入后CI 里应该形成一套分层策略而不是“全部 mock”或“全部真模型”。第一层是单元测试全 mock覆盖 agent 的纯逻辑比如工具调用参数是否正确、对工具结果的解析是否正确、分支判断是否合理。这一层跑得快适合每个 commit 都跑。第二层是集成测试mock 掉 LLM 和外部服务只保留 agent 和工具之间的真实互动覆盖工具链路的拼装。第三层是少量端到端冒烟测试用真实模型但只跑几条关键路径比如注册流程、下单流程数量控制在个位数保证“模型能力没有明显退化”即可。如果项目里还有“黄金数据”或录制回放机制可以把真实模型跑出来的若干条历史上线记录保存下来在集成层做回放回归。这样既能保证速度快又能拿到接近真实模型的响应分布。实践下来我认为测试时间可控在 1 分钟以内才算真正把 agent 的测试落地不然开发同学还是会偷懒不跑。5. 常见问题与排查技巧实录最后把我踩过的一些坑整理一下。如果你在接入 Mock LLM 时遇到类似问题可以直接按表格对标定位。5.1 典型问题排查速查表现象可能原因解决方法测试一直通过但 agent 上生产后总出问题mock 匹配太宽松覆盖不到真实分支减少默认响应强制对关键 prompt 做断言序列 mock 报“sequence exhausted”agent 比预期多调了模型打印 request_history 最后一轮定位多出来的调用mock 没有生效请求发到了真实 API依赖注入没做或 HTTP 拦截路径不对优先改依赖注入HTTP 拦截确认 base_url 完全一致断言 prompt 匹配不上prompt 里有时间戳、随机 ID 等动态内容匹配逻辑用子串或正则不要全等流式接口拿到空内容mock 返回了非流式结构按 SDK 的流式协议 mock delta 字段换模型后 mock 崩了mock 与模型输出格式强绑定mock 只模拟协议不模拟具体内容这张表里我想特别强调第一行mock 太宽松反而有害。测试的意义在于给你信心如果一个 mock 让所有 case 都绿但换真实模型就翻车那这个测试不仅没用还会误导团队让大家觉得 agent 很安全。5.2 三个我踩过的坑第一个坑只在最终答案上做断言。早期我写 agent 测试只检查“最后返回的字符串对不对”结果一个工具参数从 city 传成了 country测试照样过。后来我把 request_history 的断言补上才真正把“模型有没有给对信息”这个环节纳入测试范围。第二个坑mock 数据全部手写。有一段时间我的 mock 响应全是手编的漂亮文本agent 解析逻辑根本不经过真实场景。后来我改成“录制一段真实模型调用作为基础再叠加手写边界”把 context 里的真实 prompt 保到 fixture 里测试可信度高了很多。手写 mock 一定要和真实输出结构对齐否则就是测了个和自己幻想出来的模型对话。第三个坑忘了模拟超时。agent 有重试逻辑但我的 mock 从来不返回超时错误导致重试代码长期裸奔。后来加了一个失败注入模式mock 在前两次调用直接 raise TimeoutError第三次才返回正常结果agent 的容错逻辑才被真正测到。凡是 agent 代码里写了“如果出错了怎么办”你就应该在 mock 里把“出错”当成一等公民。5.3 Mock LLM 替代不了的事情必须说清楚Mock LLM 解决的是“逻辑正确性”解决不了“模型好不好用”。它无法评估模型的知识水平、指令跟随能力、推理能力也测不出 prompt 写得模糊导致的随机失败。我见过有人把所有测试都 mock 掉之后上线半天被用户投诉回答质量差原因就是 prompt 模型跟不上了而 mock 永远会返回预设的正确答案。所以我的实践原则是用 mock 保住工程确定性用真实模型的端到端冒烟保住模型效果底线两者互补不能互相替代。agent 测试没有银弹但这套组合拳是目前成本最低、效果最稳的方案。最后再分享一个我觉得特别值的小技巧Mock LLM 不只是测试工具还可以用来做 agent 的确定性调试。生产上遇到一个诡异失败你把真实模型调用序列录下来本地用 SeqMockLLM 重放就能一遍又一遍地复现同一条执行路径慢慢把问题钉死在某个环节。这个能力真实模型给不了你。
返回列表