Skip to content

单元测试 ​

使用伪聊天模型与内存持久化,在无需 API 调用的情况下测试 Agent 逻辑。

单元测试能够对 Agent 中小型且确定性的部分进行隔离测试。通过将真实的 LLM 替换为内存中的伪模型(fake,即 fixture),你可以预先设定精确的响应(文本、工具调用和错误),使测试快速、免费且可重复,并且无需 API 密钥。

使用 fakeModel Mock 聊天模型 ​

fakeModel 是一个构建器风格(builder-style)的伪聊天模型,允许你设定精确的响应(文本、工具调用、错误),并断言模型接收到的内容。它继承自 BaseChatModel,因此适用于任何需要真实模型的场景。

typescript
import { fakeModel } from "langchain";

快速开始 ​

创建一个模型,使用 .respond() 将响应加入队列,然后进行调用。每次 invoke() 都会按顺序消费队列中的下一个响应:

typescript
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!"

如果模型的调用次数超过了排队的响应数量,它将抛出一个描述性的错误:

typescript
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 来模拟工具调用:

typescript
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,只需提供工具名称和参数即可:

typescript
// 这两个排队条目会生成相同的响应:

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 会使模型在该特定调用时抛出错误。错误可以出现在序列中的任何位置:

typescript
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() 会使每次调用都抛出错误,无论队列状态如何。这对于测试错误处理和重试逻辑非常有用:

typescript
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:

typescript
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"

工厂函数也可以返回错误:

typescript
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() 配置伪返回值:

typescript
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:

typescript
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"

即使模型抛出错误,调用也会被记录:

typescript
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 会自动处理这一调用。绑定后的模型与原始模型共享相同的响应队列和调用记录,因此无需特殊配置:

typescript
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

typescript
import { 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。