文章目录
- LangChain 单元测试怎么写:Fake LLM、Mock 与录制回放
- 一、测试策略:分三层
- 二、FakeListLLM:按顺序返回预设答案
- 三、FakeChatModel:模拟对话模型
- 四、Fake 的配套组件
- 4.1 FakeEmbeddings
- 4.2 FakeRetriever
- 4.3 GenericFakeChatModel:模拟流式
- 五、怎么验证"prompt 里有什么"
- 六、用 Mock 验证"调用了几次"
- 七、录制回放:一次性拿到真实响应
- 八、什么该真跑
- 九、一份 pytest 组织建议
- 十、常见坑
- 十一、小结
LangChain 单元测试怎么写:Fake LLM、Mock 与录制回放
LLM 应用难测试:输出不确定、调用要钱、还慢。
于是很多人干脆不写测试,改一行 prompt 全靠手动试。这篇讲怎么用Fake LLM把测试做成"秒级、免费、可重复",
以及哪些该 mock、哪些该真跑。
一、测试策略:分三层
| 层 | 测什么 | 用不用真模型 | 速度 |
|---|---|---|---|
| 单元测试 | 链的结构、格式、路由分支 | 不用(Fake LLM) | 毫秒 |
| 集成测试 | 真实调用能否跑通 | 用(少量) | 秒级 |
| 效果评测 | 答案质量 | 用(测试集) | 分钟级 |
90% 的测试应该在第一层——免费、快、稳定。
只有少量用例放第二三层。
二、FakeListLLM:按顺序返回预设答案
fromlangchain_community.llms.fakeimportFakeListLLMfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParser llm=FakeListLLM(responses=["答案A","答案B","答案C"])print(llm.invoke("第一个问题"))# 答案Aprint(llm.invoke("第二个问题"))# 答案Bprint(llm.invoke("第三个问题"))# 答案Cprint(llm.invoke("第四个问题"))# 答案A(循环)它不看输入,按顺序吐答案。
这正好适合测试"链的结构对不对":
deftest_chain_structure():llm=FakeListLLM(responses=["最终结果"])chain=(ChatPromptTemplate.from_template("问题:{q}")|llm|StrOutputParser())result=chain.invoke({"q":"任意问题"})assertresult=="最终结果"这个测试验证的是"链能跑通、输出能解析",
至于答案对不对,不是这一层要管的。
三、FakeChatModel:模拟对话模型
需要返回AIMessage(比如测试工具调用)时用它:
fromlangchain_community.chat_models.fakeimportFakeMessagesListChatModelfromlangchain_core.messagesimportAIMessage model=FakeMessagesListChatModel(responses=[AIMessage(content="你好"),AIMessage(content="",tool_calls=[{"name":"get_weather","args":{"city":"北京"},"id":"call_1"}],),])测试工具调用链路特别有用:
不用真调模型就能验证"我的循环能不能正确执行工具、把结果塞回去"。
deftest_tool_loop():model=FakeMessagesListChatModel(responses=[AIMessage(content="",tool_calls=[{"name":"search","args":{"q":"test"},"id":"c1"}]),AIMessage(content="最终答案"),])agent=build_agent(model,tools=[search_tool])result=agent.invoke({"messages":[("user","查一下test")]})assert"最终答案"inresult["messages"][-1].content四、Fake 的配套组件
除了 LLM,其他依赖也能 fake:
4.1 FakeEmbeddings
fromlangchain_community.embeddingsimportFakeEmbeddings emb=FakeEmbeddings(size=384)vectors=emb.embed_documents(["a","b"])print(len(vectors[0]))# 384它的向量是基于文本哈希生成的,
所以同样的文本永远得到同样的向量——
这让向量检索相关的测试变得可重复。
deftest_retrieval():vs=FAISS.from_documents(docs,FakeEmbeddings(size=128))results=vs.similarity_search("某个问题",k=3)assertlen(results)==34.2 FakeRetriever
fromlangchain_core.retrieversimportFakeRetrieverfromlangchain_core.documentsimportDocument retriever=FakeRetriever(documents=[Document(page_content="预设文档1",metadata={"source":"a.md"}),Document(page_content="预设文档2",metadata={"source":"b.md"}),])docs=retriever.invoke("任何问题")# 永远返回这两条测试 RAG 链的组装逻辑时非常好用——
你只想验证"检索结果有没有正确拼进 prompt",
不想真的建索引。
4.3 GenericFakeChatModel:模拟流式
fromlangchain_community.chat_models.fakeimportGenericFakeChatModelfromlangchain_core.messagesimportAIMessageChunk model=GenericFakeChatModel(messages=lambda:AIMessageChunk(content="逐字"))可以自定义生成逻辑,用来测试流式处理。
五、怎么验证"prompt 里有什么"
很多时候你想测的是:「检索到的文档有没有被正确拼进 prompt」。
这时不需要看最终输出,直接把 prompt 渲染出来:
deftest_context_in_prompt():prompt=ChatPromptTemplate.from_messages([("system","只依据上下文回答。\n\n上下文:\n{context}"),("human","{question}"),])messages=prompt.format_messages(context="【文档1】二线城市 450 元",question="住宿标准?",)text=messages[0].contentassert"450 元"intextassert"只依据上下文"intext这是最实用的一类测试——快、稳、直击要害。
比"调用真模型看它答得对不对"高效得多。
六、用 Mock 验证"调用了几次"
有些逻辑要验证的是调用行为:
fromunittest.mockimportMagicMockdeftest_calls_llm_once():mock_llm=MagicMock()mock_llm.invoke.return_value="结果"chain=prompt|mock_llm|StrOutputParser()chain.invoke({"q":"x"})assertmock_llm.invoke.call_count==1deftest_no_call_when_cached():mock_llm=MagicMock()mock_llm.invoke.return_value="结果"cached=CachedChain(mock_llm)cached.invoke({"q":"x"})cached.invoke({"q":"x"})# 同样的输入assertmock_llm.invoke.call_count==1# 第二次应命中缓存测缓存、测重试、测降级,用 Mock 数调用次数是标准做法。
七、录制回放:一次性拿到真实响应
有时候你既想要真实响应,又不想每次都真调。
办法是录一次,之后回放:
importjsonfrompathlibimportPath FIXTURES=Path("tests/fixtures")classRecordReplayLLM:"""有 fixture 就回放,没有就真调并录下来"""def__init__(self,real_llm,name):self.real=real_llm self.path=FIXTURES/f"{name}.json"definvoke(self,prompt,**kw):key=str(prompt)[:200]data=json.loads(self.path.read_text(encoding="utf-8"))ifself.path.exists()else{}ifkeyindata:returndata[key]result=self.real.invoke(prompt,**kw)data[key]=result self.path.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding="utf-8")returnresult第一次跑真调用并落盘,之后全部走 fixture——免费且稳定。
使用时注意:
- fixture 要提交到 git,团队共享;
- prompt 改了要删掉旧 fixture 重录,否则测的是过时的响应;
- 敏感数据别录进去。
八、什么该真跑
单元测试覆盖不到的,留少量真跑:
importpytest@pytest.mark.slow# 标记为慢测试,CI 可选跳过deftest_real_end_to_end():"""验证真实模型能跑通,不验证答案质量"""result=real_chain.invoke({"question":"报销标准?"})assertisinstance(result,str)assertlen(result)>0# 只断言"能跑通不断言内容对不对"真跑的测试只验证"不崩",不验证"答对"——
后者属于效果评测(用测试集打分,见评测那篇)。
九、一份 pytest 组织建议
tests/ ├── conftest.py # 共享的 fake 组件 ├── fixtures/ │ └── llm_responses.json # 录制的响应 ├── test_prompt.py # prompt 渲染(快) ├── test_chain.py # 链结构(Fake LLM) ├── test_tools.py # 工具逻辑 ├── test_agent.py # Agent 流程(Fake ChatModel) └── test_e2e.py # 真跑(标 @pytest.mark.slow)# conftest.pyimportpytestfromlangchain_community.llms.fakeimportFakeListLLM@pytest.fixturedeffake_llm():returnFakeListLLM(responses=["测试答案"])@pytest.fixturedeffake_retriever():returnFakeRetriever(documents=[Document(page_content="测试文档",metadata={"source":"test.md"}),])十、常见坑
| 坑 | 说明 |
|---|---|
| 忘记把 fake 换回来 | 上线后返回固定字符串——上线前全局搜 Fake |
| Fake 顺序搞错 | FakeListLLM按顺序消费,多调一次就错位 |
| 测试断言"内容对不对" | 那是评测的事,单测只管"结构对不对" |
| fixture 过期 | 改了 prompt 必须重录 |
| 只测 happy path | 也要测"工具报错""解析失败"这些分支 |
第一条最危险,建议在 CI 加一条检查:
grep-r"Fake"src/&&echo"生产代码里出现 Fake!"&&exit1||exit0十一、小结
- 分三层:单测(Fake,快免费)→ 集成(少量真跑)→ 效果评测(测试集打分);
FakeListLLM按顺序吐答案,验证链结构而非内容;FakeMessagesListChatModel能模拟tool_calls,测工具循环;- 配套:FakeEmbeddings(同文本同向量,可重复)、
FakeRetriever(固定返回,测拼装逻辑); - 验证"prompt 里有什么"用
format_messages直接断言,最实用; - 测调用行为(缓存/重试/降级)用Mock 数
call_count; - 想要真实响应又不想每次花钱 →录制回放;
- ⚠️上线前全局搜一遍 Fake,防止调试组件进生产。
到这里 LangChain 的工程实践部分告一段落。
下一篇进入 RAG 深水区:MultiVectorRetriever。