首页 / 文章

构建ShopBot:一款具备记忆功能、工具支持及退款审批功能的LangGraph智能客服机器人

逐步在LangGraph模块中构建客户支持代理系统:包括状态、工具、节点、路由、滚动摘要,以及用于等待退款审批的中断机制。

发布者:REACTAPP.TOP阅读约 18 分钟4103 词

来源: Your First Real LangGraph Project: Building a Customer Support Agent.

该项目将 LangGraph 的三种理念整合到一个智能体中:图结构、对话记忆以及人工审核机制。该智能体是一个名为 ShopBot 的客户支持机器人,服务于一家虚构的电子商务商店。顾客可以询问订单情况、申请退款或升级投诉。该机器人会记住对话内容,使用真实工具查询订单数据,并在任何退款处理之前等待人工审核。代码是逐模块构建的,每部分都会进行说明,无需具备之前的 LangGraph 使用经验。

先规划图结构

初学者常犯的错误是打开一个空文件就开始输入代码。LangGraph鼓励先在纸上或脑海中画出图表结构。在纸上,ShopBot的架构相当简单:代理步骤、工具执行器、退款审核模块和总结模块构成节点,两条分支决定流程走向,一个interrupt()语句用于暂停等待审批,还有一个触发器负责启动总结功能。代码只是将这个结构用代码形式表达出来而已。

环境准备

创建一个名为shopbot.py的文件,或者打开一个Colab笔记本。接着安装所需的依赖项:

pip install langgraph langchain langchain-openai langgraph-checkpoint-sqlite python-dotenv

然后在同一文件夹中创建一个 .env 文件来存储该密钥:

OPENAI_API_KEY=your-key-here

模块1:导入与配置

整个文件的所有导入语句都应放在最顶部,不得在中间部分插入导入语句。配置内容也放置于此。

# ============================================================
# MODULE 1: IMPORTS & CONFIGURATION
# ============================================================
import os
import sqlite3
from typing import Annotated, Literal
from datetime import datetime
from dotenv import load_dotenv
# LangChain - the AI layer
from langchain_openai import ChatOpenAI
from langchain_core.messages import (
    HumanMessage,
    AIMessage,
    SystemMessage,
    BaseMessage,
    RemoveMessage,
)
from langchain_core.tools import tool
# LangGraph - the graph layer
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
load_dotenv()
# ── The LLM ─────────────────────────────────────────────────
# temperature=0 means deterministic - the agent behaves
# consistently, which is what you want for a support bot.
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

所选择的模型是 gpt-4o-mini。它具备足够的处理能力来承担辅助工作,价格便宜,可以频繁调用,响应速度也快,用户不会察觉到延迟。对于需要更高推理能力的场景,生产系统可能会转向 gpt-4o,但从小规模开始使用更为明智。

模块2:状态

状态即智能体的工作记忆,所有在图中传递的信息都必须存储在其中。需要确定智能体在每个阶段必须掌握的信息。

# ============================================================
# MODULE 2: STATE
# ============================================================

class SupportState(MessagesState):
    # MessagesState already gives us:
    #   messages: Annotated[list[BaseMessage], add_messages]

    # We add three more fields for our specific needs:
    # The running summary of the conversation (Part 2 pattern).
    # Starts empty. Gets written by summarize_node when conversation gets long.
    summary: str

    # The name of the customer, extracted early in the conversation.
    # Used to personalise every response. Starts empty.
    customer_name: str

    # Tracks the current ticket category, set by the agent.
    # Helps the human reviewer understand context during escalation.
    # Values: "order_inquiry" | "refund_request" | "complaint" | "general"
    ticket_category: str

除了消息历史记录之外,还有三个额外的字段。summary用于存储之前对话的压缩记录:一旦对话内容超过六条消息,智能体就会将历史记录浓缩到该字段中并删除旧的原始消息,从而保持令牌成本稳定。customer_name是几乎所有客服智能体都需要的实用字段,只要将客户姓名存储在状态中,智能体就能直接称呼客户,无需每个节点都重新读取聊天记录来查找姓名。ticket_category则使得条件路由成为可能,因为智能体会设置该字段,而路由函数会读取它来决定是否需要触发人工审批流程。

模块3:工具

工具相当于智能体的双手,没有工具它只能进行对话,而有了工具它就能查询信息并执行操作。ShopBot配备了三种工具,这些工具由一个模拟的内存数据库提供支持:

# ============================================================
# MODULE 3: TOOLS
# ============================================================
# ── Fake Database ────────────────────────────────────────────
# In a real project, these would be database queries or API calls.
# For learning purposes, we use a simple Python dictionary.
ORDERS_DB = {
    "ORD-001": {
        "customer": "Alex",
        "product": "Wireless Headphones",
        "status": "Delivered",
        "amount": 89.99,
        "delivery_date": "2025-06-10",
    },
    "ORD-002": {
        "customer": "Sam",
        "product": "Phone Case",
        "status": "In Transit",
        "amount": 14.99,
        "delivery_date": "Expected 2025-06-18",
    },
    "ORD-003": {
        "customer": "Jordan",
        "product": "Laptop Stand",
        "status": "Processing",
        "amount": 45.00,
        "delivery_date": "Expected 2025-06-20",
    },
}

@tool
def lookup_order(order_id: str) -> str:
    """Look up the details of a customer's order by order ID.
    Use this when the customer provides an order number and wants
    to know the status, product name, or delivery date of their order.
    Args:
        order_id: The order ID string, e.g. 'ORD-001'
    Returns:
        A formatted string with full order details, or an error message
        if the order is not found.
    """
    order = ORDERS_DB.get(order_id.upper())
    if not order:
        return f"No order found with ID '{order_id}'. Please double-check the order number."
    return (
        f"Order {order_id.upper()}: {order['product']} | "
        f"Status: {order['status']} | "
        f"Amount: ${order['amount']:.2f} | "
        f"Delivery: {order['delivery_date']}"
    )

@tool
def check_refund_eligibility(order_id: str) -> str:
    """Check whether an order is eligible for a refund.
    Use this BEFORE processing any refund request. An order is eligible
    for a refund only if its status is 'Delivered'. Orders Fthat are
    'In Transit' or 'Processing' cannot be refunded yet.
    Args:
        order_id: The order ID string, e.g. 'ORD-001'
    Returns:
        A string stating whether the order is eligible and why.
    """
    order = ORDERS_DB.get(order_id.upper())
    if not order:
        return f"Cannot check refund: order '{order_id}' not found."
    if order["status"] == "Delivered":
        return (
            f"Order {order_id.upper()} IS eligible for a refund. "
            f"Product: {order['product']}, Amount: ${order['amount']:.2f}. "
            f"Proceed to refund processing."
        )
    else:
        return (
            f"Order {order_id.upper()} is NOT eligible for a refund yet. "
            f"Current status: {order['status']}. Refunds are only available "
            f"for delivered orders."
        )

@tool
def process_refund(order_id: str, reason: str) -> str:
    """Process a refund for a delivered order.
    IMPORTANT: This tool actually issues the refund. It should only be
    called AFTER human approval has been obtained. Never call this tool
    without prior confirmation.
    Args:
        order_id: The order ID to refund
        reason: The customer's stated reason for the refund
    Returns:
        A confirmation string with the refund reference number.
    """
    order = ORDERS_DB.get(order_id.upper())
    if not order:
        return f"Refund failed: order '{order_id}' not found."
    # In a real system, this would hit your payments API.
    refund_ref = f"REF-{order_id.upper()}-{datetime.now().strftime('%H%M%S')}"
    return (
        f"Refund APPROVED and PROCESSED. Reference: {refund_ref}. "
        f"${order['amount']:.2f} will be returned to the original payment method "
        f"within 3–5 business days. Reason logged: '{reason}'."
    )

# ── Collect tools and bind to LLM ───────────────────────────
# All three tools in one list.
tools = [lookup_order, check_refund_eligibility, process_refund]
# llm_with_tools = the LLM that KNOWS about the tools and can decide to call them.
# This is what we use inside agent_node.
llm_with_tools = llm.bind_tools(tools)
# tool_node = the pre-built node that EXECUTES whatever tool the LLM chose.
# This is what we register in Module 6.
tool_node = ToolNode(tools)

仔细阅读process_refund的文档字符串,其中说明该工具确实会执行退款操作,且只能在人工审批之后调用。模型会读取这些文档字符串,并在决策时加以参考。撰写文档字符串时,要当作是在向还不了解业务规则的智能实习生讲解,因为实际上就是这样的过程。

模块4:节点

节点负责执行具体任务。每个节点接收状态,执行相应操作,然后返回一个包含更新内容的字典,仅此而已。ShopBot中有三个需要你编写的节点,再加上上一个模块中预置的tool_node。这些节点包括系统提示、主要代理步骤、带有interrupt()功能的退款审核步骤以及总结模块:

# ============================================================
# MODULE 4: NODES
# ============================================================

# ── The System Prompt ────────────────────────────────────────
# Written once, used in every call to the LLM from agent_node.
# This is the personality and rulebook of your agent.

SYSTEM_PROMPT = """You are ShopBot, a friendly and professional customer support \
agent for an e-commerce store.
Your capabilities:
- Look up order details using the lookup_order tool
- Check if an order qualifies for a refund using check_refund_eligibility
- Process approved refunds using the process_refund tool
Your rules:
- Always greet the customer by name once you know it
- Always check refund eligibility BEFORE attempting to process a refund
- For refund requests, set ticket_category to "refund_request" in your reasoning
- Be empathetic, clear, and concise
- If you cannot help, offer to escalate to a human agent
Important: The process_refund tool requires prior human approval. Do not call it \
unless the conversation shows that a human has already approved the refund."""

# ── Node 1: agent_node ──────────────────────────────────────
def agent_node(state: SupportState) -> dict:
    """The brain of the operation. Reads state, calls the LLM, and decides
    whether to use a tool, give a final answer, or do something else.
    This node handles two cases:
    1. Normal conversation - just call the LLM and respond
    2. Long conversation - if a summary exists, prepend it so the LLM
       has context without seeing all the raw messages
    """

    # Part 2 pattern: check for an existing summary
    summary = state.get("summary", "")
    if summary:
        # Build context: system prompt + compressed history + recent messages
        system_with_summary = SystemMessage(
            content=f"{SYSTEM_PROMPT}\n\nSummary of conversation so far:\n{summary}"
        )
        messages_to_send = [system_with_summary] + state["messages"]
    else:
        # No summary yet - full history is short enough to send as-is
        system_msg = SystemMessage(content=SYSTEM_PROMPT)
        messages_to_send = [system_msg] + state["messages"]
    # Call the LLM. It sees tools and can choose to call one.
    response = llm_with_tools.invoke(messages_to_send)

    # Detect ticket category from the response for routing purposes.
    # A smarter version would have the LLM explicitly set this -
    # for now, we scan for keywords.
    content_lower = response.content.lower() if response.content else ""
    updates: dict = {"messages": [response]}
    if "refund" in content_lower or (
        hasattr(response, "tool_calls")
        and any("refund" in str(tc).lower() for tc in (response.tool_calls or []))
    ):
        updates["ticket_category"] = "refund_request"
    return updates

# ── Node 2: review_refund ───────────────────────────────────
def review_refund(state: SupportState) -> dict:
    """The human approval gate. Pauses execution, shows the pending refund
    details to a human agent, and waits for their decision.
    This implements the Part 3 interrupt() pattern. Execution stops here
    until someone calls graph.invoke(Command(resume=...), config).
    Three outcomes the human can choose:
    - "approve"  → let the refund tool call proceed unchanged
    - "reject"   → cancel the refund, send a message to the customer
    - "escalate" → hand the entire ticket to a human support agent
    """

    last_message = state["messages"][-1]
    # Find the refund-related tool call in the last AI message.
    # We look for process_refund specifically - the "real action" tool.
    refund_tool_call = None
    if hasattr(last_message, "tool_calls"):
        for tc in last_message.tool_calls:
            if "refund" in tc["name"].lower():
                refund_tool_call = tc
                break

    # Surface the context to the human reviewer via interrupt().
    # Everything in this dict is what the human sees before deciding.
    human_decision = interrupt({
        "message": "⚠️ Refund approval required",
        "customer_name": state.get("customer_name", "Unknown"),
        "tool_being_called": refund_tool_call["name"] if refund_tool_call else "refund tool",
        "arguments": refund_tool_call["args"] if refund_tool_call else {},
        "conversation_summary": state.get("summary", "No summary yet"),
        "options": ["approve", "reject", "escalate"],
    })

    # ── Handle the human's decision ─────────────────────────
    if human_decision == "approve":
        # Do nothing to state - let tool_node execute the tool call as-is
        return {}
    elif human_decision == "reject":
        # Cancel the tool call. The LLM will see a ToolMessage explaining why,
        # and generate a polite response to the customer.
        from langchain_core.messages import ToolMessage
        return {
            "messages": [
                ToolMessage(
                    content=(
                        "Refund request was reviewed and declined by our support team. "
                        "Please inform the customer politely and offer alternatives."
                    ),
                    tool_call_id=refund_tool_call["id"] if refund_tool_call else "unknown",
                )
            ]
        }
    elif human_decision == "escalate":
        # Signal escalation - in a real system you'd open a ticket,
        # ping Slack, or transfer to a live agent queue.
        from langchain_core.messages import ToolMessage
        return {
            "messages": [
                ToolMessage(
                    content=(
                        "This ticket has been escalated to a senior support agent. "
                        "Inform the customer that a human agent will contact them "
                        "within 2 business hours."
                    ),
                    tool_call_id=refund_tool_call["id"] if refund_tool_call else "unknown",
                )
            ]
        }
    # Fallback - treat as approve
    return {}

# ── Node 3: summarize_node ──────────────────────────────────
def summarize_node(state: SupportState) -> dict:
    """Triggered when the conversation exceeds 6 messages. Compresses the
    full message history into a short summary, then deletes old raw messages.
    This is the rolling summary pattern from Part 2. The summary grows
    richer turn by turn. Token costs stay nearly flat no matter how long
    the conversation runs.
    """

    existing_summary = state.get("summary", "")
    if existing_summary:
        # Extend the existing summary with new messages
        summary_instruction = (
            f"Current summary:\n{existing_summary}\n\n"
            "Extend this summary with the new messages above. "
            "Keep it under 5 sentences. Focus on: the customer's name, "
            "their issue, any orders mentioned, and what actions were taken."
        )
    else:
        # First time summarising
        summary_instruction = (
            "Summarise this customer support conversation in under 5 sentences. "
            "Include: the customer's name (if mentioned), their issue, "
            "any order numbers discussed, and what actions were taken so far."
        )

    messages = state["messages"] + [HumanMessage(content=summary_instruction)]
    response = llm.invoke(messages)  # Plain llm, no tools needed here
    # Delete all but the 2 most recent messages.
    # The summary now holds everything that was in the deleted messages.
    messages_to_delete = [
        RemoveMessage(id=m.id) for m in state["messages"][:-2]
    ]
    return {
        "summary": response.content,
        "messages": messages_to_delete,
    }

模块5:边与路由

路由函数负责决定下一步该前往何处。它们会检查状态并返回一个字符串,且绝不会修改状态,因为那是节点的任务。需要两种路由函数:

# ============================================================
# MODULE 5: EDGES & ROUTING
# ============================================================

def route_after_agent(state: SupportState) -> Literal[
    "review_refund", "tools", "summarize_node", "__end__"
]:
    """Called after agent_node runs. Decides what happens next.
    Four possible routes:
    1. The LLM wants to call process_refund → must go through human review first
    2. The LLM wants to call any other tool → go directly to tool_node
    3. The LLM gave a plain text answer AND the conversation is long → summarise
    4. The LLM gave a plain text answer and conversation is short → we're done
    """
    last_message = state["messages"][-1]
    has_tool_calls = hasattr(last_message, "tool_calls") and bool(last_message.tool_calls)
    if has_tool_calls:
        # Check if ANY of the tool calls is the sensitive process_refund tool
        tool_names = [tc["name"] for tc in last_message.tool_calls]
        if "process_refund" in tool_names:
            return "review_refund"   # → Pause for human approval first
        return "tools"               # → Safe tool, run it directly
    # No tool call - the LLM gave a plain response.
    # Check if the conversation is long enough to need summarisation.
    if len(state["messages"]) > 6:
        return "summarize_node"
    return "__end__"                 # → Conversation turn is complete

def route_after_review(state: SupportState) -> Literal["tools", "agent_node"]:
    """Called after review_refund runs (i.e., after the human has decided).
    Two routes:
    1. Human approved or escalated → run the tool (tool_node handles the call)
    2. Human rejected → the review node already added a ToolMessage cancelling
       the tool call, so skip tool_node and go back to agent_node to respond
    """
    last_message = state["messages"][-1]
    # If the last message is a ToolMessage, the review node cancelled the call.
    # Go back to agent_node so it can generate a customer-facing response.
    from langchain_core.messages import ToolMessage
    if isinstance(last_message, ToolMessage):
        return "agent_node"
    # Otherwise, the review node returned {} (approved) - proceed to tools.
    return "tools"

模块6:构建图结构

现在所有组件都已准备就绪,构建过程始终遵循五个步骤:初始化、注册节点、设置入口点、添加边以及编译。

# ============================================================
# MODULE 6: GRAPH ASSEMBLY
# ============================================================

# ── Step 1: Initialize ──────────────────────────────────────
graph_builder = StateGraph(SupportState)
# ── Step 2: Register All Nodes ──────────────────────────────
# Format: add_node("string_name", function)
# The string name is what you use in every edge definition below.
graph_builder.add_node("agent_node", agent_node)
graph_builder.add_node("tools", tool_node)          # Pre-built from Module 3
graph_builder.add_node("review_refund", review_refund)
graph_builder.add_node("summarize_node", summarize_node)
# ── Step 3: Set Entry Point ─────────────────────────────────
# The first node that runs when a user sends a message.
graph_builder.add_edge(START, "agent_node")
# ── Step 4: Wire the Edges ──────────────────────────────────
# After agent_node: conditional - depends on what the LLM decided
graph_builder.add_conditional_edges(
    "agent_node",           # Source
    route_after_agent,      # Router function from Module 5
    {
        "review_refund": "review_refund",   # Refund tool → human review first
        "tools": "tools",                   # Other tools → run directly
        "summarize_node": "summarize_node", # Long conversation → summarise
        "__end__": END,                     # Plain answer → done
    }
)
# After review_refund: conditional - depends on human's decision
graph_builder.add_conditional_edges(
    "review_refund",
    route_after_review,
    {
        "tools": "tools",           # Approved → execute the tool
        "agent_node": "agent_node", # Rejected → back to agent to respond
    }
)
# After tools run: always go back to agent_node
# (the ReAct loop - agent sees tool result, decides what to do next)
graph_builder.add_edge("tools", "agent_node")
# After summarization: conversation turn is done
graph_builder.add_edge("summarize_node", END)
# ── Step 5: Compile ─────────────────────────────────────────
# Using MemorySaver for development.
# For production, swap this one line to SqliteSaver or PostgresSaver.
memory = MemorySaver()
shopbot = graph_builder.compile(checkpointer=memory)

在 Colab 或 Jupyter 中,你可以将图表渲染为图形:

from IPython.display import display, Image
from langchain_core.runnables.graph import MermaidDrawMethod

display(Image(
    shopbot.get_graph().draw_mermaid_png(
        draw_method=MermaidDrawMethod.API
    )
))

能够看到自己所构建内容的实际流程图,是 LangGraph 为学习者准备的最佳功能之一。如果图表显示有误,那就说明其中的边连接有误。

第7模块:入口点

这是智能体实际运行的地方。在脚本中,它对应的是 if __name__ == „__main__“ 这段代码;在 API 服务器中,则是路由处理程序,不过两者的模式是相同的。

# ============================================================
# MODULE 7: ENTRYPOINT
# ============================================================

def run_shopbot():
    """
    Interactive command-line session with ShopBot.
    Demonstrates: multi-turn conversation, tool use, and human-in-the-loop.
    """
    print("=" * 55)
    print("  ShopBot - Customer Support Agent")
    print("  Powered by LangGraph")
    print("=" * 55)
    print("Type your message below. Type 'exit' to quit.")
    print("Type 'state' to inspect what ShopBot currently remembers.\n")
    # One config per session.
    # thread_id is the session key - same ID = same memory thread.
    # Change the ID to start a completely fresh conversation.

    config = {"configurable": {"thread_id": "customer-session-001"}}
    while True:
        user_input = input("You: ").strip()
        if not user_input:
            continue
        if user_input.lower() == "exit":
            print("ShopBot: Thank you for contacting support. Have a great day!")
            break

        # ── Debug: inspect current state ────────────────────
        if user_input.lower() == "state":
            snapshot = shopbot.get_state(config)
            print("\n[DEBUG] Current State:")
            print(f"  Messages in state : {len(snapshot.values.get('messages', []))}")
            print(f"  Customer name     : {snapshot.values.get('customer_name', '(not set)')}")
            print(f"  Ticket category   : {snapshot.values.get('ticket_category', '(not set)')}")
            print(f"  Summary           : {snapshot.values.get('summary', '(none yet)')}")
            print(f"  Next node(s)      : {snapshot.next}\n")
            continue

        # ── Normal message: invoke the graph ─────────────────
        result = shopbot.invoke(
            {"messages": [HumanMessage(content=user_input)]},
            config=config,
        )

        # ── Check if graph paused for human approval ─────────
        # This is how you detect that interrupt() was called inside review_refund.
        while "__interrupt__" in result:
            interrupt_data = result["__interrupt__"][0].value
            print("\n" + "=" * 55)
            print("  HUMAN APPROVAL REQUIRED")
            print("=" * 55)
            print(f"  Customer    : {interrupt_data.get('customer_name', 'Unknown')}")
            print(f"  Action      : {interrupt_data.get('tool_being_called', 'refund')}")
            print(f"  Arguments   : {interrupt_data.get('arguments', {})}")
            print(f"  Context     : {interrupt_data.get('conversation_summary', 'N/A')}")
            print("=" * 55)
            print("Options: [a] Approve   [r] Reject   [e] Escalate")
            human_choice = input("Your decision: ").strip().lower()
            if human_choice == "a":
                resume_value = "approve"
            elif human_choice == "r":
                resume_value = "reject"
            elif human_choice == "e":
                resume_value = "escalate"
            else:
                print("Invalid choice. Defaulting to reject.")
                resume_value = "reject"
            # Resume the graph with the human's decision.
            # Command(resume=...) answers the pending interrupt() call.
            result = shopbot.invoke(
                Command(resume=resume_value),
                config=config,
            )

        # ── Print the agent's final response ─────────────────
        last_message = result["messages"][-1]
        print(f"\nShopBot: {last_message.content}\n")

if __name__ == "__main__":
    run_shopbot()

真实的对话场景

一个示例对话展示了运行时的效果:

=======================================================
  ShopBot — Customer Support Agent
  Powered by LangGraph
=======================================================

Type your message below. Type 'exit' to quit.
Type 'state' to inspect what ShopBot currently remembers.

You: Hi, my name is Alex and I need help with order ORD-001
ShopBot: Hi Alex! I'd be happy to help you with order ORD-001.
I can see your order:
- Product: Wireless Headphones
- Status: Delivered on 2025-06-10
- Amount: $89.99
What can I help you with regarding this order?
You: I want to return it and get a refund
ShopBot: I've checked your order ORD-001 and it's eligible for a refund
since it's been delivered. Let me process that for you right away.


=======================================================
  HUMAN APPROVAL REQUIRED
=======================================================

  Customer    : Alex
  Action      : process_refund
  Arguments   : {'order_id': 'ORD-001', 'reason': 'Customer requested return'}
  Context     : No summary yet
=======================================================

Options: [a] Approve   [r] Reject   [e] Escalate
Your decision: a
ShopBot: Great news, Alex! Your refund has been successfully processed.
Refund Reference: REF-ORD-001-143022
Amount: $89.99 will be returned to your original payment method within
3–5 business days.

Is there anything else I can help you with?
You: state
[DEBUG] Current State:
  Messages in state : 6
  Customer name     : (not set)
  Ticket category   : refund_request
  Summary           : (none yet)
  Next node(s)      : ()
You: exit
ShopBot: Thank you for contacting support. Have a great day!

该项目所展示的内容

七模块的布局展示了从开头到结尾的架构:StateGraph、add_node、add_conditional_edges以及compile,所有模块都按正确顺序排列。在内存管理方面,有summary字段、summarize_node函数以及滚动总结机制,通过该机制新消息会被压缩而旧消息会被删除,此外还有MemorySaver检查指针,用于在多轮处理中保留历史记录。在人工干预方面,review_refund节点可通过interrupt()暂停,入口点则可通过Command(resume=...)恢复,而while "__interrupt__" in result循环则可处理单轮中的多次审批,允许选择批准、拒绝或上报。

有几项功能源自工具系统。真正的工具会通过 @tool 标记,预构建的 ToolNode 会负责执行这些工具,而 bind_tools 则将它们绑定到模型上。ReAct 循环会依次处理工具、智能体节点、工具等。路由机制对工具的处理方式有所不同:安全的工具会直接传递给 tool_node,而敏感的工具则需先经过 review_refund 处理。

后续发展方向

该项目在设计上刻意保持完整但简洁。有三个扩展方向,从最简单的开始:

  1. 更完善的类别检测功能。目前 ticket_category 是通过 agent_node 中的关键词扫描获得的。更优的方式是让模型以结构化输出的形式明确对工单进行分类,可研究 with_structured_output() 函数。
  2. SQLite持久化存储。在模块6中只需将MemorySaver()替换为SqliteSaver,只需一行改动,同时添加启动提示,询问当前是同一会话还是新会话,这样对话内容就能在重启后保留。
  3. 投诉升级路径。新增第四个节点escalate_node,当ticket_category == “complaint”且客户情绪为“FRUSTRATED”时触发该节点。它会调用interrupt()来通知人工客服,然后作为新分支连接到route_after_agent。

快速参考

该项目新增了若干模式。ToolNode(tools)是一个预构建的节点,用于执行模型上一条消息中指定的工具调用。llm.bind_tools(tools)将工具列表与模型连接起来,之后模型可以通过返回tool_calls字段而非纯文本来请求使用工具。@tool可将Python函数转换为模型能够调用的形式,其文档字符串会被模型用来判断何时使用该函数。检查hasattr(message, "tool_calls") and message.tool_calls是判断上一条消息是否包含工具调用的标准方法,且在访问属性之前应先使用hasattr。while "__interrupt__" in result循环用于处理一个或多个待处理的中断,因为每个Command(resume=...)只会处理一个中断,但处理完当前中断后可能会触发下一个中断。 最后,shopbot.get_state(config) 可以随时显示完整的当前状态:.values 返回状态字典,而 .next 则指示接下来会执行的内容,这对调试而言极为有用。

理解一个概念与为其编写代码之间确实存在差距,而一个能够容纳在脑海中、同时运用所有可用工具的小项目正是缩小这一差距的方法。在这里,检查点、实际工具、审批机制以及总结触发器都在同一个智能体中协同工作。先构建它,故意让它出错,再修复它,最后对其进行扩展。

相关阅读

  • LlamaIndex Plus LangGraph:RAG智能体的标准化架构 — 讲解如何使用LlamaIndex构建知识库,并将其作为工具提供给LangGraph智能体使用,同时涵盖持久化存储、Chroma框架以及仅检索模式的相关内容。
  • 利用LangGraph、SQLite和Streamlit逐步构建诊所预约聊天机器人 — 教导如何为诊所打造预约辅助系统:包括SQLite数据库表结构、服务功能实现、基于gpt-4o-mini的九节点LangGraph工作流、Streamlit聊天界面以及笔记本模式的使用方法。
  • 为 LangGraph 客服代理配备首个工具:关键词文档搜索 —— LangGraph 客服代理为何选择关键词文档搜索而非 Markdown 文档作为搜索工具:为何放弃 RAG 技术、该工具与实现方式的区分,以及状态如何将证据传递至回复中。