当你使用 LangGraph 构建智能体时,首先要把它拆分成一个个离散步骤,这些步骤称为 节点。然后,你需要描述每个节点中的不同决策和转换。最后,你通过共享的 状态 将节点连接起来,每个节点都可以读取和写入这个状态。 在本教程中,我们将带你了解如何用 LangGraph 构建一个客户支持邮件智能体。

Start with the process you want to automate

假设你需要构建一个用于处理客户支持邮件的 AI 智能体。你的产品团队给出了以下需求:
The agent should:

- Read incoming customer emails
- Classify them by urgency and topic
- Search relevant documentation to answer questions
- Draft appropriate responses
- Escalate complex issues to human agents
- Schedule follow-ups when needed

Example scenarios to handle:

1. Simple product question: "How do I reset my password?"
2. Bug report: "The export feature crashes when I select PDF format"
3. Urgent billing issue: "I was charged twice for my subscription!"
4. Feature request: "Can you add dark mode to the mobile app?"
5. Complex technical issue: "Our API integration fails intermittently with 504 errors"
要在 LangGraph 中实现一个智能体,通常会遵循相同的五个步骤。

Step 1: Map out your workflow as discrete steps

首先识别流程中的不同步骤。每个步骤都会成为一个 节点(一个只做一件具体事情的函数)。然后,画出这些步骤之间是如何连接的。 这张图中的箭头表示可能的路径,但实际选择哪条路径,是在每个节点内部决定的。 既然我们已经识别出了工作流中的组件,接下来看看每个节点需要做什么:
  • Read Email:提取并解析邮件内容
  • Classify Intent:使用 LLM 对紧急程度和主题进行分类,然后路由到合适的操作
  • Doc Search:查询知识库,查找相关信息
  • Bug Track:在跟踪系统中创建或更新问题
  • Draft Reply:生成合适的回复
  • Human Review:升级给人工客服进行审批或处理
  • Send Reply:发送邮件回复
注意,有些节点会决定下一步去哪里(Classify IntentDraft ReplyHuman Review),而其他节点总是进入同一个下一步(Read Email 总是进入 Classify IntentDoc Search 总是进入 Draft Reply)。

Step 2: Identify what each step needs to do

对于图中的每个节点,确定它代表哪种类型的操作,以及它需要什么上下文才能正常工作。

LLM steps

当你需要理解、分析、生成文本或进行推理决策时使用

Data steps

当你需要从外部来源检索信息时使用

Action steps

当你需要执行外部操作时使用

User input steps

当你需要人工介入时使用

LLM steps

当一个步骤需要理解、分析、生成文本或进行推理决策时:
  • 静态上下文(提示词):分类类别、紧急程度定义、响应格式
  • 动态上下文(来自状态):邮件内容、发件人信息
  • 期望结果:用于决定路由的结构化分类
  • 静态上下文(提示词):语气指南、公司政策、回复模板
  • 动态上下文(来自状态):分类结果、搜索结果、客户历史
  • 期望结果:可供审核的专业邮件回复

Data steps

当一个步骤需要从外部来源检索信息时:
  • 参数:来自状态的客户邮箱或 ID
  • 重试策略:需要,但如果不可用,则回退到基础信息
  • 缓存:需要,使用生存时间来平衡新鲜度和性能

Action steps

当一个步骤需要执行外部操作时:
  • 何时执行节点:批准之后(人工或自动)
  • 重试策略:需要,对网络问题使用指数退避
  • 不应缓存:每次发送都是唯一的操作
  • 何时执行节点:当意图是 “bug” 时始终执行
  • 重试策略:需要,必须确保不丢失错误报告
  • 返回:用于包含在回复中的工单 ID

User input steps

当一个步骤需要人工介入时:
  • 决策上下文:原始邮件、草稿回复、紧急程度、分类
  • 预期输入格式:审批布尔值,以及可选的编辑后回复
  • 触发时机:高紧急程度、复杂问题或质量疑虑

Step 3: Design your state

状态是智能体中所有节点都可以访问的共享 memory。你可以把它理解为智能体的笔记本,用来记录它在流程中学到和决定的一切。

What belongs in state?

针对每一份数据,问自己这些问题:

Include in state

它是否需要跨步骤保留?如果是,就放入状态。

Don't store

它是否可以从其他数据推导出来?如果可以,就在需要时计算,而不是存入状态。
对于我们的邮件智能体,需要跟踪:
  • 原始邮件和发件人信息(之后无法重新构造)
  • 分类结果(多个后续/下游节点都需要)
  • 搜索结果和客户数据(重新获取成本较高)
  • 草稿回复(需要在审核过程中保留)
  • 执行元数据(用于调试和恢复)

Keep state raw, format prompts on-demand

一个关键原则:你的状态应该存储原始数据,而不是格式化文本。需要时在节点内部格式化提示词。
这种分离意味着:
  • 不同节点可以根据自己的需要,以不同方式格式化同一份数据
  • 你可以修改提示词模板,而无需修改状态 schema
  • 调试更清晰——你能准确看到每个节点接收到了哪些数据
  • 你的智能体可以持续演进,而不会破坏已有状态
让我们定义状态:
import { StateSchema } from "@langchain/langgraph";
import * as z from "zod";

// Define the structure for email classification
const EmailClassificationSchema = z.object({
  intent: z.enum(["question", "bug", "billing", "feature", "complex"]),
  urgency: z.enum(["low", "medium", "high", "critical"]),
  topic: z.string(),
  summary: z.string(),
});

const EmailAgentState = new StateSchema({
  // Raw email data
  emailContent: z.string(),
  senderEmail: z.string(),
  emailId: z.string(),

  // Classification result
  classification: EmailClassificationSchema.optional(),

  // Raw search/API results
  searchResults: z.array(z.string()).optional(),  // List of raw document chunks
  customerHistory: z.record(z.string(), z.any()).optional(),  // Raw customer data from CRM

  // Generated content
  responseText: z.string().optional(),
});

type EmailClassificationType = z.infer<typeof EmailClassificationSchema>;
注意,状态只包含原始数据——没有提示词模板,没有格式化字符串,也没有指令。分类输出作为单个字典直接从 LLM 存入状态。

Step 4: Build your nodes

现在我们将每个步骤实现为一个函数。在 LangGraph 中,节点就是一个 JavaScript 函数,它接收当前状态,并返回对状态的更新。

Handle errors appropriately

不同错误需要不同的处理策略:
Error TypeWho Fixes ItStrategyWhen to Use
短暂错误(网络问题、速率限制)系统(自动)重试策略通常通过重试即可解决的临时故障
LLM 可恢复错误(工具失败、解析问题)LLM将错误存入状态并循环返回LLM 可以看到错误并调整做法
用户可修复错误(缺少信息、指令不清)人类使用 interrupt() 暂停需要用户输入才能继续
重试后的可恢复失败开发者(声明式)error_handler重试耗尽后运行补偿/恢复分支
意外错误开发者让错误向上抛出需要调试的未知问题
添加重试策略,自动重试网络问题和速率限制。
import type { RetryPolicy } from "@langchain/langgraph";

workflow.addNode(
  "searchDocumentation",
  searchDocumentation,
  {
    retryPolicy: { maxAttempts: 3, initialInterval: 1.0 },
  },
);

Implementing our email agent nodes

我们会把每个节点实现为一个简单函数。记住:节点接收状态、执行工作,并返回更新。
import { StateGraph, START, END, GraphNode, Command } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { ChatAnthropic } from "@langchain/anthropic";

const llm = new ChatAnthropic({ model: "claude-sonnet-4-6" });

const readEmail: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Extract and parse email content
  // In production, this would connect to your email service
  console.log(`Processing email: ${state.emailContent}`);
  return {};
}

const classifyIntent: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Use LLM to classify email intent and urgency, then route accordingly

  // Create structured LLM that returns EmailClassification object
  const structuredLlm = llm.withStructuredOutput(EmailClassificationSchema);

  // Format the prompt on-demand, not stored in state
  const classificationPrompt = `
  Analyze this customer email and classify it:

  Email: ${state.emailContent}
  From: ${state.senderEmail}

  Provide classification including intent, urgency, topic, and summary.
  `;

  // Get structured response directly as object
  const classification = await structuredLlm.invoke(classificationPrompt);

  // Determine next node based on classification
  let nextNode: "searchDocumentation" | "humanReview" | "draftResponse" | "bugTracking";

  if (classification.intent === "billing" || classification.urgency === "critical") {
    nextNode = "humanReview";
  } else if (classification.intent === "question" || classification.intent === "feature") {
    nextNode = "searchDocumentation";
  } else if (classification.intent === "bug") {
    nextNode = "bugTracking";
  } else {
    nextNode = "draftResponse";
  }

  // Store classification as a single object in state
  return new Command({
    update: { classification },
    goto: nextNode,
  });
}
import { Command, GraphNode } from "@langchain/langgraph";

const searchDocumentation: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Search knowledge base for relevant information

  // Build search query from classification
  const classification = state.classification!;
  const query = `${classification.intent} ${classification.topic}`;

  let searchResults: string[];

  try {
    // Implement your search logic here
    // Store raw search results, not formatted text
    searchResults = [
      "Reset password via Settings > Security > Change Password",
      "Password must be at least 12 characters",
      "Include uppercase, lowercase, numbers, and symbols",
    ];
  } catch (error) {
    // For recoverable search errors, store error and continue
    searchResults = [`Search temporarily unavailable: ${error}`];
  }

  return new Command({
    update: { searchResults },  // Store raw results or error
    goto: "draftResponse",
  });
}

const bugTracking: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Create or update bug tracking ticket

  // Create ticket in your bug tracking system
  const ticketId = "BUG-12345";  // Would be created via API

  return new Command({
    update: { searchResults: [`Bug ticket ${ticketId} created`] },
    goto: "draftResponse",
  });
}
import { Command, interrupt } from "@langchain/langgraph";

const draftResponse: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Generate response using context and route based on quality

  const classification = state.classification!;

  // Format context from raw state data on-demand
  const contextSections: string[] = [];

  if (state.searchResults) {
    // Format search results for the prompt
    const formattedDocs = state.searchResults.map(doc => `- ${doc}`).join("\n");
    contextSections.push(`Relevant documentation:\n${formattedDocs}`);
  }

  if (state.customerHistory) {
    // Format customer data for the prompt
    contextSections.push(`Customer tier: ${state.customerHistory.tier ?? "standard"}`);
  }

  // Build the prompt with formatted context
  const draftPrompt = `
  Draft a response to this customer email:
  ${state.emailContent}

  Email intent: ${classification.intent}
  Urgency level: ${classification.urgency}

  ${contextSections.join("\n\n")}

  Guidelines:
  - Be professional and helpful
  - Address their specific concern
  - Use the provided documentation when relevant
  `;

  const response = await llm.invoke([new HumanMessage(draftPrompt)]);

  // Determine if human review needed based on urgency and intent
  const needsReview = (
    classification.urgency === "high" ||
    classification.urgency === "critical" ||
    classification.intent === "complex"
  );

  // Route to appropriate next node
  const nextNode = needsReview ? "humanReview" : "sendReply";

  return new Command({
    update: { responseText: response.content.toString() },  // Store only the raw response
    goto: nextNode,
  });
}

const humanReview: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Pause for human review using interrupt and route based on decision
  const classification = state.classification!;

  // interrupt() must come first - any code before it will re-run on resume
  const humanDecision = interrupt({
    emailId: state.emailId,
    originalEmail: state.emailContent,
    draftResponse: state.responseText,
    urgency: classification.urgency,
    intent: classification.intent,
    action: "Please review and approve/edit this response",
  });

  // Now process the human's decision
  if (humanDecision.approved) {
    return new Command({
      update: { responseText: humanDecision.editedResponse || state.responseText },
      goto: "sendReply",
    });
  } else {
    // Rejection means human will handle directly
    return new Command({ update: {}, goto: END });
  }
}

const sendReply: GraphNode<typeof EmailAgentState> = async (state, config) => {
  // Send the email response
  // Integrate with email service
  console.log(`Sending reply: ${state.responseText!.substring(0, 100)}...`);
  return {};
}

Step 5: Wire it together

现在我们将节点连接成一个可运行的图。由于我们的节点会自行处理路由决策,因此只需要少量必要的边。 要通过 interrupt() 启用 human-in-the-loop,我们需要使用 checkpointer 进行编译,以便在运行之间保存状态:

Graph compilation code

import { MemorySaver, RetryPolicy } from "@langchain/langgraph";

// Create the graph
const workflow = new StateGraph(EmailAgentState)
  // Add nodes with appropriate error handling
  .addNode("readEmail", readEmail)
  .addNode("classifyIntent", classifyIntent)
  // Add retry policy for nodes that might have transient failures
  .addNode(
    "searchDocumentation",
    searchDocumentation,
    { retryPolicy: { maxAttempts: 3 } },
  )
  .addNode("bugTracking", bugTracking)
  .addNode("draftResponse", draftResponse)
  .addNode("humanReview", humanReview)
  .addNode("sendReply", sendReply)
  // Add only the essential edges
  .addEdge(START, "readEmail")
  .addEdge("readEmail", "classifyIntent")
  .addEdge("sendReply", END);

// Compile with checkpointer for persistence
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
图结构很简洁,因为路由是通过 Command 对象在节点内部完成的。每个节点都会声明它可以前往哪些位置,使流程清晰且可追踪。

Try out your agent

让我们用一个需要人工审核的紧急账单问题来运行智能体:
// Test with an urgent billing issue
const initialState: EmailAgentStateType = {
  emailContent: "I was charged twice for my subscription! This is urgent!",
  senderEmail: "customer@example.com",
  emailId: "email_123"
};

// Run with a thread_id for persistence
const config = { configurable: { thread_id: "customer_123" } };
const result = await app.invoke(initialState, config);
// The graph will pause at human_review
console.log(`Draft ready for review: ${result.responseText?.substring(0, 100)}...`);
import { Command } from "@langchain/langgraph";

// When ready, provide human input to resume
const humanResponse = new Command({
  resume: {
    approved: true,
    editedResponse: "We sincerely apologize for the double charge. I've initiated an immediate refund...",
  }
});

// Resume execution
const finalResult = await app.invoke(humanResponse, config);
console.log("Email sent successfully!");
当图运行到 interrupt() 时会暂停,将所有内容保存到 checkpointer,然后等待。它可以在几天后继续运行,并从暂停的位置精确恢复。thread_id 确保这个对话的所有状态都被保存在一起。

Summary and next steps

Key Insights

构建这个邮件智能体向我们展示了 LangGraph 的思考方式:

Break into discrete steps

每个节点只做好一件事。这种拆分支持流式进度更新、可暂停和恢复的持久执行,并且由于你可以在步骤之间检查状态,调试也更清晰。

State is shared memory

存储原始数据,而不是格式化文本。这样不同节点就可以用不同方式使用同一份信息。

Nodes are functions

它们接收状态、执行工作并返回更新。当它们需要做路由决策时,会同时指定状态更新和下一个目标。

Errors are part of the flow

短暂故障会重试,LLM 可恢复错误会带着上下文循环返回,用户可修复问题会暂停等待输入,意外错误则向上抛出以便调试。

Human input is first-class

interrupt() 函数可以无限期暂停执行,保存所有状态,并在你提供输入后从暂停的位置精确恢复。当它与节点中的其他操作结合使用时,必须放在最前面。

Graph structure emerges naturally

你定义必要的连接,节点自行处理路由逻辑。这让控制流保持明确且可追踪——只要查看当前节点,你总能理解智能体下一步会做什么。

Advanced considerations

本节探讨节点粒度设计中的权衡。大多数应用可以跳过本节,直接使用上面展示的模式。
你可能会疑惑:为什么不把 Read EmailClassify Intent 合并成一个节点?或者为什么要把 Doc Search 和 Draft Reply 分开?答案涉及韧性和可观测性之间的权衡。韧性方面的考虑: LangGraph 的 persistence layer 会在节点边界创建检查点。当工作流在中断或失败后恢复时,会从执行停止所在节点的开头开始。节点越小,检查点越频繁,这意味着如果出现问题,需要重复的工作就越少。如果你把多个操作合并到一个大节点中,那么如果在接近末尾时失败,就需要从该节点开头重新执行所有内容。我们为邮件智能体选择这种拆分方式的原因:
  • 外部服务隔离: Doc Search 和 Bug Track 是独立节点,因为它们会调用外部 API。如果搜索服务很慢或失败,我们希望将它与 LLM 调用隔离开。我们可以只给这些特定节点添加重试策略,而不影响其他节点。
  • 中间结果可见性:Classify Intent 作为独立节点,可以让我们在采取行动之前检查 LLM 的决策。这对调试和监控很有价值——你可以清楚看到智能体何时以及为什么路由到人工审核。
  • 不同失败模式: LLM 调用、数据库查找和邮件发送具有不同的重试策略。独立节点让你可以分别配置这些策略。
  • 可复用性和测试: 更小的节点更容易单独测试,也更容易在其他工作流中复用。
另一种同样有效的做法:你可以把 Read EmailClassify Intent 合并成一个节点。但你会失去在分类前检查原始邮件的能力,并且该节点发生任何失败时都要重复这两个操作。对大多数应用来说,独立节点带来的可观测性和调试收益值得这种权衡。应用层面的关注点:步骤 2 中关于缓存的讨论(是否缓存搜索结果)是应用层面的决策,而不是 LangGraph 框架功能。你需要根据自己的具体需求在节点函数中实现缓存——LangGraph 不会规定这一点。性能方面的考虑:节点更多并不意味着执行更慢。LangGraph 默认会在后台写入检查点(async durability mode),因此图会继续运行,而不需要等待检查点完成。这意味着你可以用很小的性能影响获得频繁检查点。你也可以按需调整这种行为——使用 "exit" 模式只在完成时创建检查点,或使用 "sync" 模式阻塞执行,直到每个检查点写入完成。

Where to go from here

本文介绍了如何用 LangGraph 的方式思考智能体构建。你可以在这个基础上继续扩展:

Human-in-the-loop patterns

学习如何在执行前添加工具审批、批量审批以及其他模式

Subgraphs

为复杂的多步骤操作创建子图

Streaming

添加流式输出,向用户展示实时进度

Observability

使用 LangSmith 添加可观测性,用于调试和监控

Tool Integration

集成更多用于网页搜索、数据库查询和 API 调用的工具

Retry Logic

为失败操作实现带指数退避的重试逻辑