使用 LangGraph 构建智能体时,首先需要将其分解为离散的步骤,称为 **节点**。然后,描述每个节点的不同决策和转换。最后,通过共享的 **状态** 将节点连接在一起,每个节点都可以从中读取和写入。
在本指南中,我们将引导您完成使用 LangGraph 构建客户支持邮件智能体的思维过程。
从您想要自动化的流程开始
假设您需要构建一个处理客户支持邮件的 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 中实现智能体,通常需要遵循相同的五个步骤。
步骤 1:将工作流程映射为离散步骤
首先识别流程中的不同步骤。每个步骤将成为一个 **节点** (执行特定单一功能的函数)。然后,勾勒这些步骤如何相互连接。
flowchart TD
A[START] --> B[Read Email]
B --> C[Classify Intent]
C -.-> D[Doc Search]
C -.-> E[Bug Track]
C -.-> F[Human Review]
D --> G[Draft Reply]
E --> G
F --> G
G -.-> H[Human Review]
G -.-> I[Send Reply]
H --> J[END]
I --> J[END]
classDef process fill:#E5F4FF,stroke:#006DDD,stroke-width:2px,color:#030710
class A,B,C,D,E,F,G,H,I,J process
此图中的箭头显示了可能的路径,但实际选择哪条路径的决策发生在每个节点内部。
现在我们已经确定了工作流程中的各个组件,接下来了解每个节点需要做什么:
- -
Read Email:提取并解析邮件内容 - -
Classify Intent:使用 LLM 对紧急程度和主题进行分类,然后路由到适当的操作 - -
Doc Search:在知识库中查询相关信息 - -
Bug Track:在跟踪系统中创建或更新问题 - -
Draft Reply:生成适当的回复 - -
Human Review:升级给人工代理进行审批或处理 - -
Send Reply:发送邮件回复
步骤 2:确定每个步骤需要做什么
对于图中的每个节点,确定它代表什么类型的操作,以及它需要什么上下文才能正常工作。
LLM steps
当需要理解、分析、生成文本或进行推理决策时使用
Data steps
当需要从外部来源检索信息时使用
Action steps
当需要执行外部操作时使用
User input steps
当需要人工干预时使用
LLM 步骤
当某个步骤需要理解、分析、生成文本或进行推理决策时:
Classify intent
- 静态上下文(提示词):分类类别、紧急程度定义、回复格式 - 动态上下文(来自状态):邮件内容、发件人信息 - 期望结果:决定路由的结构化分类
Draft reply
- 静态上下文(提示词):语调指南、公司政策、回复模板 - 动态上下文(来自状态):分类结果、搜索结果、客户历史 - 期望结果:可供审阅的专业邮件回复
数据步骤
当某个步骤需要从外部来源检索信息时:
Document search
- 参数:从意图和主题构建的查询 - 重试策略:是,对于暂时性故障使用指数退避 - 缓存:可以缓存常见查询以减少 API 调用
Customer history lookup
- 参数:状态中的客户邮箱或 ID - 重试策略:是,但如不可用则回退到基本信息 - 缓存:是,使用生存时间在新鲜度和性能之间取得平衡
操作步骤
当某个步骤需要执行外部操作时:
Send reply
- 何时执行节点:批准后(人工或自动) - 重试策略:是,对于网络问题使用指数退避 - 不应缓存:每次发送都是唯一操作
Bug track
- 何时执行节点:当意图为"bug"时始终执行 - 重试策略:是,关键是不能丢失 bug 报告 - 返回:响应中包含工单 ID
用户输入步骤
当某个步骤需要人工干预时:
Human review node
- 决策上下文:原始邮件、回复草稿、紧急程度、分类 - 期望的输入格式:批准布尔值加上可选的编辑回复 - 触发时机:高紧急程度、复杂问题或质量问题
步骤 3:设计您的状态
状态是共享的 内存 可被代理中的所有节点访问。将其视为代理用来跟踪在处理过程中学习和决定的所有内容的笔记本。
什么应该放在状态中?
关于每条数据,问问自己以下问题:
Include in state
它需要在步骤之间持久化吗?如果是,则放入状态中。
Don't store
你能从其他数据中推导出来吗?如果是,需要时再计算而不是存储在状态中。
对于我们的邮件代理,我们需要跟踪:
- - 原始邮件和发送者信息(之后无法重建)
- - Classification results (needed by multiple later/downstream nodes)
- - 搜索结果和客户数据(重新获取成本高)
- - 回复草稿(需要通过审核持久化)
- - 执行元数据(用于调试和恢复)
保持状态原始,按需格式化提示
这种分离意味着:
- - 不同的节点可以根据自身需求以不同方式格式化相同数据
- - 你可以修改提示模板而不改变状态模式
- - 调试更清晰——你可以准确看到每个节点接收到的数据
- - 你的代理可以演进而不破坏现有状态
让我们定义我们的状态:
// 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 的单个字典存储。
步骤 4:构建你的节点
现在我们将每个步骤实现为函数。LangGraph 中的节点只是一个 JavaScript 函数,它接收当前状态并返回对状态的更新。
适当处理错误
不同的错误需要不同的处理策略:
| 错误类型 | 由谁修复 | 策略 | 何时使用 |
|---|---|---|---|
| 临时性错误(网络问题、速率限制) | 系统(自动) | 重试策略 | 重试后通常会解决的临时性故障 |
| LLM 可恢复的错误(工具失败、解析问题) | LLM | 将错误存储在状态中并循环回退 | LLM 可以看到错误并调整其方法 |
| 用户可修复的错误(信息缺失、指令不清晰) | 人工 | 暂停并 interrupt() | 需要用户输入才能继续 |
| 重试后仍可恢复的失败 | 开发者(声明式) | error_handler | Run a compensation/recovery branch after retry exhaustion |
| 意外错误 | 开发者 | 让它们向上冒泡 | 需要调试的未知问题 |
Transient errors
添加重试策略以自动重试网络问题和速率限制。
workflow.addNode(
"searchDocumentation",
searchDocumentation,
{
retryPolicy: { maxAttempts: 3, initialInterval: 1.0 },
},
);
LLM-recoverable
将错误存储在状态中并循环回退,这样 LLM 可以看到出了什么问题并重试:
const executeTool: GraphNode<typeof State> = async (state, config) => {
try {
const result = await runTool(state.toolCall);
return new Command({
update: { toolResult: result },
goto: "agent",
});
} catch (error) {
// Let the LLM see what went wrong and try again
return new Command({
update: { toolResult: `Tool error: ${error}` },
goto: "agent"
});
}
}
User-fixable
在需要时暂停并收集用户信息(如账户 ID、订单号或澄清说明):
const lookupCustomerHistory: GraphNode<typeof State> = async (state, config) => {
if (!state.customerId) {
const userInput = interrupt({
message: "Customer ID needed",
request: "Please provide the customer's account ID to look up their subscription history",
});
return new Command({
update: { customerId: userInput.customerId },
goto: "lookupCustomerHistory",
});
}
// Now proceed with the lookup
const customerData = await fetchCustomerHistory(state.customerId);
return new Command({
update: { customerHistory: customerData },
goto: "draftResponse",
});
}
Unexpected
让它们向上冒泡以便调试。不要捕获你无法处理的内容:
const sendReply: GraphNode<typeof EmailAgentState> = async (state, config) => {
try {
await emailService.send(state.responseText);
} catch (error) {
throw error; // Surface unexpected errors
}
}
Saga / compensation
重试耗尽后,运行一个恢复函数来更新状态并将流程路由到补偿分支。
实现我们的电子邮件代理节点
我们将把每个节点实现为一个简单的函数。记住:节点接收状态、执行工作并返回更新。
Read and classify nodes
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,
});
}
Search and tracking nodes
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",
});
}
Response nodes
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 {};
}
第 5 步:将它们连接在一起
现在我们将节点连接成一个可工作的图。由于我们的节点处理自己的路由决策,我们只需要几个必要的边。
要启用 human-in-the-loop 配合 interrupt()使用,我们需要使用 检查点器 编译以在运行之间保存状态:
Graph compilation code
// 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 对象发生。每个节点声明它可以去哪里,使流程明确且可追踪。
尝试你的代理
让我们用一个需要人工审核的紧急计费问题来运行我们的代理:
Testing the 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)}...`);
// 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()时会暂停,将所有内容保存到检查点器,然后等待。它可以在几天后恢复,从中断的地方精确地继续。 thread_id 确保此对话的所有状态都保存在一起。
总结和后续步骤
关键洞察
构建这个邮件代理向我们展示了 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
您定义必要的连接,您的节点处理它们自己的路由逻辑。这保持控制流清晰和可追踪——您始终可以通过查看当前节点来理解代理的下一步操作。
高级注意事项
Node granularity trade-offs
您可能想知道:为什么不合并 Read Email 和 Classify Intent 到一个节点中?
或者为什么要将文档搜索与草稿回复分开?
答案涉及弹性和可观察性之间的权衡。
弹性考虑: LangGraph 的 持久化层 在节点边界创建检查点。当工作流在中断或失败后恢复时,它会从执行停止的节点开始重新启动。较小的节点意味着更频繁的检查点,这意味着如果出现问题,需要重复的工作更少。如果您将多个操作组合到一个大节点中,靠近末尾的故障意味着从该节点的开头重新执行所有内容。
为什么我们为邮件代理选择这种分解:
- 外部服务隔离: 文档搜索和缺陷跟踪是独立的节点,因为它们调用外部 API。如果搜索服务缓慢或失败,我们希望将其与 LLM 调用隔离开来。我们可以向这些特定节点添加重试策略,而不影响其他节点。
- 中间可见性: 将
Classify Intent作为一个单独的节点,让我们能够在采取行动之前检查 LLM 的决定。这对于调试和监控很有价值——您可以准确看到代理何时以及为何路由到人工审核。 - 不同的失败模式: LLM 调用、数据库查询和电子邮件发送具有不同的重试策略。单独的节点让您能够独立配置这些。
- 可重用性和测试: 较小的节点更容易独立测试,并在其他工作流中重用。
另一种有效的方法:您可以将 Read Email 和 Classify Intent 合并到单个节点中。您将失去在分类之前检查原始电子邮件的能力,并且在该节点中的任何故障时都会重复两个操作。对于大多数应用程序来说,单独节点的可见性和调试好处值得这个权衡。
应用层关注点:第2步中的缓存讨论(是否缓存搜索结果)是一个应用层决策,而非 LangGraph 框架的功能。你根据具体需求在节点函数中实现缓存——LangGraph 不规定这一点。
性能考虑:更多节点并不意味着更慢的执行。LangGraph 默认在后台写入检查点(异步持久化模式),因此你的图会继续运行,无需等待检查点完成。这意味着你可以在最小性能影响下获得频繁的检查点。如有需要,你可以调整此行为——使用 "exit" 模式仅在完成时创建检查点,或使用 "sync" 模式阻塞执行直到每个检查点写入完成。
下一步去哪里
这是关于使用 LangGraph 构建智能体的思维方式的介绍。你可以在此基础上进行扩展:
Human-in-the-loop patterns
了解如何在执行前添加工具审批、批量审批及其他模式
Subgraphs
创建子图用于复杂的多步骤操作
Streaming
添加流式输出以向用户显示实时进度
Observability
使用 LangSmith 添加可观测性以进行调试和监控
Tool Integration
集成更多工具用于网络搜索、数据库查询和 API 调用
Retry Logic
使用指数退避实现失败操作的自动重试逻辑