Главная / Статьи

Создание ShopBot: агента поддержки LangGraph с памятью, инструментами и возможностью одобрения возвратов средств

Создайте агента обслуживания клиентов в модуле LangGraph поэтапно: состояния, инструменты, узлы, маршрутизация, динамические сводки и механизм прерывания, ожидающий одобрения возврата средств.

Издатель: REACTAPP.TOP18 мин чтения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. Замените MemorySaver() на SqliteSaver — это изменение займет одну строку в Модуле 6, — и добавьте запрос при запуске, опрашивающий, является ли это той же сессией или новой. Благодаря этому разговоры будут сохраняться после перезагрузки.
  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, функции обслуживания, рабочий процесс LangGraph из девяти узлов с использованием gpt-4o-mini, интерфейс чата в Streamlit и режим работы в виде ноутбука.
  • Первый инструмент для агента поддержки LangGraph: поиск по ключевым словам в документах — Как агент поддержки LangGraph получает инструмент поиска вместо работы с Markdown-документами: почему он игнорирует технологию RAG, как разделены инструмент и его реализация, и как состояние передаёт доказательства в ответе.