单元测试
使用伪聊天模型与内存持久化,在无需 API 调用的情况下测试 Agent 逻辑。
单元测试能够对 Agent 中小型且确定性的部分进行隔离测试。通过将真实的 LLM 替换为内存中的伪模型(fake,即 fixture),你可以预先设定精确的响应(文本、工具调用和错误),使测试快速、免费且可重复,并且无需 API 密钥。
使用 fakeModel Mock 聊天模型
fakeModel 是一个构建器风格(builder-style)的伪聊天模型,允许你设定精确的响应(文本、工具调用、错误),并断言模型接收到的内容。它继承自 BaseChatModel,因此适用于任何需要真实模型的场景。
import { fakeModel } from "langchain";快速开始
创建一个模型,使用 .respond() 将响应加入队列,然后进行调用。每次 invoke() 都会按顺序消费队列中的下一个响应:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond(new AIMessage("I can help with that."))
.respond(new AIMessage("Here's what I found."))
.respond(new AIMessage("You're welcome!"));
const r1 = await model.invoke([new HumanMessage("Can you help?")]);
// r1.content === "I can help with that."
const r2 = await model.invoke([new HumanMessage("What did you find?")]);
// r2.content === "Here's what I found."
const r3 = await model.invoke([new HumanMessage("Thanks!")]);
// r3.content === "You're welcome!"如果模型的调用次数超过了排队的响应数量,它将抛出一个描述性的错误:
const model = fakeModel()
.respond(new AIMessage("only one"));
await model.invoke([new HumanMessage("first")]); // 正常运行
await model.invoke([new HumanMessage("second")]); // 抛出错误:"no response queued for invocation 1"工具调用响应
.respond() 支持通过传入带有 tool_calls 的 AIMessage 来模拟工具调用:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond(new AIMessage({
content: "",
tool_calls: [
{ name: "get_weather", args: { city: "San Francisco" }, id: "call_1", type: "tool_call" },
],
}))
.respond(new AIMessage("It's 72°F and sunny in San Francisco."));
const r1 = await model.invoke([new HumanMessage("What's the weather in SF?")]);
console.log(r1.tool_calls[0].name); // "get_weather"
const r2 = await model.invoke([new HumanMessage("Thanks")]);
console.log(r2.content); // "It's 72°F and sunny in San Francisco.".respondWithTools() 是实现相同功能的简写方式。无需构建完整的 AIMessage,只需提供工具名称和参数即可:
// 这两个排队条目会生成相同的响应:
model.respond(new AIMessage({
content: "",
tool_calls: [
{ name: "get_weather", args: { city: "SF" }, id: "call_1", type: "tool_call" },
],
}));
// 等价的简写形式:
model.respondWithTools([
{ name: "get_weather", args: { city: "SF" }, id: "call_1" },
]); id 字段是可选的。如果省略,将自动生成一个唯一 ID。
TIP
.respond() 和 .respondWithTools() 可以按任意顺序自由混用。这在测试 Agent 循环时特别有用,因为模型会在工具调用和文本响应之间交替进行。
模拟错误
特定轮次错误
向 .respond() 传递一个 Error 会使模型在该特定调用时抛出错误。错误可以出现在序列中的任何位置:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond(new Error("rate limit exceeded")) // 轮次 1:抛出错误
.respond(new AIMessage("Recovered!")); // 轮次 2:成功
try {
await model.invoke([new HumanMessage("first")]);
} catch (e) {
console.log(e.message); // "rate limit exceeded"
}
const result = await model.invoke([new HumanMessage("retry")]);
console.log(result.content); // "Recovered!"每次调用都出错
.alwaysThrow() 会使每次调用都抛出错误,无论队列状态如何。这对于测试错误处理和重试逻辑非常有用:
import { fakeModel } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
const model = fakeModel().alwaysThrow(new Error("service unavailable"));
await model.invoke([new HumanMessage("a")]); // 抛出错误 "service unavailable"
await model.invoke([new HumanMessage("b")]); // 抛出错误 "service unavailable"使用工厂函数的动态响应
.respond() 还接受一个根据输入消息计算响应的函数。该函数接收完整的消息数组,并返回 BaseMessage 或 Error:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond((messages) => {
const last = messages[messages.length - 1].text;
return new AIMessage(`You said: ${last}`);
});
const result = await model.invoke([new HumanMessage("hello")]);
console.log(result.content); // "You said: hello"工厂函数也可以返回错误:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond((messages) => {
const content = messages[messages.length - 1].text;
if (content.includes("forbidden")) {
return new Error("Content policy violation");
}
return new AIMessage("OK");
});
await model.invoke([new HumanMessage("forbidden topic")]); // 抛出错误 "Content policy violation"NOTE
每个函数都是一个单独的队列条目,仅被消费一次。若要在多个轮次中复用相同的动态逻辑,请将多个 respond 函数调用加入队列。
结构化输出
对于使用 .withStructuredOutput() 的代码,使用 .structuredResponse() 配置伪返回值:
import { fakeModel } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
import { z } from "zod";
const model = fakeModel()
.structuredResponse({ temperature: 72, unit: "fahrenheit" });
const structured = model.withStructuredOutput(
z.object({
temperature: z.number(),
unit: z.string(),
})
);
const result = await structured.invoke([new HumanMessage("Weather?")]);
console.log(result);
// { temperature: 72, unit: "fahrenheit" }传递给 .withStructuredOutput() 的模式(schema)会被忽略。模型始终返回通过 .structuredResponse() 配置的值。这使得测试可以专注于应用逻辑而不是解析。
断言模型接收到的内容
fakeModel 会记录每一次调用,包括传递给模型的消息和选项。其工作方式类似于传统测试框架中的 spy 或 mock:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
const model = fakeModel()
.respond(new AIMessage("first"))
.respond(new AIMessage("second"));
await model.invoke([new HumanMessage("question 1")]);
await model.invoke([new HumanMessage("question 2")]);
console.log(model.callCount); // 2
console.log(model.calls[0].messages[0].content); // "question 1"
console.log(model.calls[1].messages[0].content); // "question 2"即使模型抛出错误,调用也会被记录:
import { fakeModel } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
const model = fakeModel().respond(new Error("boom"));
try {
await model.invoke([new HumanMessage("will fail")]);
} catch {
// 错误已处理
}
console.log(model.callCount); // 1
console.log(model.calls[0].messages[0].content); // "will fail"与 bindTools 结合使用
像 LangChain agents 和 LangGraph 这样的 Agent 框架会在内部调用 model.bindTools(tools)。fakeModel 会自动处理这一调用。绑定后的模型与原始模型共享相同的响应队列和调用记录,因此无需特殊配置:
import { fakeModel } from "langchain";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const searchTool = tool(async ({ query }) => `Results for: ${query}`, {
name: "search",
description: "Search the web",
schema: z.object({ query: z.string() }),
});
const model = fakeModel()
.respondWithTools([{ name: "search", args: { query: "weather" }, id: "1" }])
.respond(new AIMessage("The weather is sunny."));
const bound = model.bindTools([searchTool]);
const r1 = await bound.invoke([new HumanMessage("weather?")]);
console.log(r1.tool_calls[0].name); // "search"
const r2 = await bound.invoke([new HumanMessage("thanks")]);
console.log(r2.content); // "The weather is sunny."
// 调用记录是共享的。通过原始模型进行检查。
console.log(model.callCount); // 2完整示例:使用 vitest 测试工具调用 Agent
typescriptimport { describe, test, expect } from "vitest"; import { fakeModel } from "langchain"; import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; const getWeather = tool( async ({ city }) => `72°F and sunny in ${city}`, { name: "get_weather", description: "Get weather for a city", schema: z.object({ city: z.string() }), } ); async function runAgent( model: ReturnType<typeof fakeModel>, input: string ) { const messages: any[] = [new HumanMessage(input)]; const bound = model.bindTools([getWeather]); while (true) { const response = await bound.invoke(messages); messages.push(response); if (!response.tool_calls?.length) { return { messages, finalResponse: response }; } for (const tc of response.tool_calls) { const result = await getWeather.invoke(tc.args); messages.push(new ToolMessage({ content: result as string, tool_call_id: tc.id!, })); } } } describe("weather agent", () => { test("calls get_weather and returns a final answer", async () => { const model = fakeModel() .respondWithTools([ { name: "get_weather", args: { city: "SF" }, id: "call_1" }, ]) .respond(new AIMessage("It's 72°F and sunny in SF!")); const { finalResponse } = await runAgent(model, "Weather in SF?"); expect(finalResponse.content).toBe("It's 72°F and sunny in SF!"); expect(model.callCount).toBe(2); const secondCall = model.calls[1].messages; const toolMsg = secondCall.find((m: any) => m._getType() === "tool"); expect(toolMsg?.content).toContain("72°F and sunny in SF"); }); test("handles model errors gracefully", async () => { const model = fakeModel() .respond(new Error("rate limit")); await expect( runAgent(model, "Weather?") ).rejects.toThrow("rate limit"); expect(model.callCount).toBe(1); }); });
后续步骤
在集成测试中学习如何使用真实模型提供商 API 测试你的 Agent。
