EXERCISE 90分

ストーリー

田中VPoE
LangGraphの基礎を一通り学んだ。実際にNetShop社のカスタマーサポートエージェントのワークフローを構築してみよう
あなた
条件分岐やHuman-in-the-Loopも組み込むんですね
田中VPoE
そうだ。問い合わせの意図分類、注文確認、返金処理(承認付き)を含む実用的なワークフローを設計・実装してもらう
あなた
学んだことの総合演習ですね。やってみます

ミッション概要

項目内容
目標LangGraphを使ったカスタマーサポートエージェントのワークフローを設計・実装する
所要時間90分
ミッション数3つ
使用知識StateGraph / 条件分岐 / Human-in-the-Loop / 永続化
評価観点State設計、グラフ構造、条件分岐ロジック、HITL実装

Mission 1: カスタマーサポートワークフローのState設計

要件

以下の処理フローに必要なStateを設計してください。

1. 問い合わせ受付 → 意図分類
2. 意図に応じた分岐:
   a. 注文照会 → 注文検索 → 回答生成
   b. 返金申請 → 注文確認 → 返金計算 → 【人間承認】 → 返金実行 → 回答生成
   c. 一般質問 → FAQ検索 → 回答生成
3. 回答送信

設計要件:

  • TypedDictでState型を定義
  • メッセージ履歴は add_messages アノテーション
  • 各ステップで必要なデータフィールドを過不足なく設計
  • 人間の承認結果を保持するフィールドを含む
解答例
from typing import Annotated, TypedDict, Literal
from langgraph.graph.message import add_messages

class CustomerSupportState(TypedDict):
    # メッセージ履歴
    messages: Annotated[list, add_messages]

    # 意図分類結果
    intent: Literal["order_inquiry", "refund_request", "general_question"] | None

    # 顧客情報
    customer_id: str | None

    # 注文関連
    order_id: str | None
    order_data: dict | None  # 検索結果の注文データ

    # 返金関連
    refund_amount: int | None    # 返金金額(円)
    refund_reason: str | None    # 返金理由
    refund_approved: bool | None # 人間の承認結果
    refund_result: dict | None   # 返金処理結果

    # FAQ関連
    faq_results: list[dict] | None  # FAQ検索結果

    # ワークフロー制御
    current_step: str              # 現在のステップ名
    error: str | None              # エラー情報
    final_response: str | None     # 最終回答

ポイント:

  • 各業務領域(注文、返金、FAQ)のデータを分離
  • refund_approved で人間の承認結果を保持
  • error フィールドでエラー状態を管理
  • current_step でデバッグ・ログ用の進捗追跡

Mission 2: 条件分岐付きグラフの構築

要件

Mission 1のStateを使い、意図分類に基づく条件分岐付きのLangGraphワークフローを構築してください。

実装要件:

  • classify_intent ノード: LLMで問い合わせの意図を分類
  • 3つの分岐先ノード: handle_order_inquiry, handle_refund, handle_general
  • ルーティング関数: Stateの intent フィールドに基づいて分岐
  • 全分岐が generate_response ノードに合流
  • Mermaid図で可視化
解答例
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage

llm = ChatOpenAI(model="gpt-4o", temperature=0)

def classify_intent(state: CustomerSupportState) -> CustomerSupportState:
    """問い合わせの意図を分類"""
    classification_prompt = SystemMessage(content="""
    以下の問い合わせを分類してください。
    カテゴリ: order_inquiry(注文照会), refund_request(返金申請), general_question(一般質問)
    カテゴリ名のみを回答してください。
    """)
    messages = [classification_prompt] + state["messages"]
    response = llm.invoke(messages)
    intent = response.content.strip().lower()

    valid_intents = ["order_inquiry", "refund_request", "general_question"]
    if intent not in valid_intents:
        intent = "general_question"

    return {"intent": intent, "current_step": "intent_classified"}

def handle_order_inquiry(state: CustomerSupportState) -> CustomerSupportState:
    """注文照会の処理"""
    # 注文データの検索(モック)
    order_data = {
        "order_id": state.get("order_id", "ORD-12345"),
        "status": "shipped",
        "items": [{"name": "ワイヤレスイヤホン", "price": 15800}],
        "estimated_delivery": "2026-03-08"
    }
    return {"order_data": order_data, "current_step": "order_searched"}

def handle_refund(state: CustomerSupportState) -> CustomerSupportState:
    """返金申請の処理(返金計算まで)"""
    order_data = {
        "order_id": state.get("order_id", "ORD-12345"),
        "status": "delivered",
        "total": 15800
    }
    return {
        "order_data": order_data,
        "refund_amount": order_data["total"],
        "refund_reason": "customer_request",
        "current_step": "refund_calculated"
    }

def handle_general(state: CustomerSupportState) -> CustomerSupportState:
    """一般質問の処理"""
    faq_results = [
        {"question": "返品ポリシーは?", "answer": "商品到着後14日以内に..."},
        {"question": "配送日数は?", "answer": "通常2-5営業日でお届け..."}
    ]
    return {"faq_results": faq_results, "current_step": "faq_searched"}

def generate_response(state: CustomerSupportState) -> CustomerSupportState:
    """最終回答を生成"""
    context = f"""
    意図: {state.get('intent')}
    注文データ: {state.get('order_data')}
    FAQ結果: {state.get('faq_results')}
    返金結果: {state.get('refund_result')}
    """
    prompt = SystemMessage(content=f"以下の情報を基に、丁寧な顧客対応の回答を生成してください。\n{context}")
    messages = [prompt] + state["messages"]
    response = llm.invoke(messages)
    return {
        "messages": [response],
        "final_response": response.content,
        "current_step": "response_generated"
    }

def route_by_intent(state: CustomerSupportState) -> str:
    intent = state.get("intent", "general_question")
    return {
        "order_inquiry": "handle_order_inquiry",
        "refund_request": "handle_refund",
        "general_question": "handle_general"
    }.get(intent, "handle_general")

# グラフ構築
workflow = StateGraph(CustomerSupportState)

workflow.add_node("classify_intent", classify_intent)
workflow.add_node("handle_order_inquiry", handle_order_inquiry)
workflow.add_node("handle_refund", handle_refund)
workflow.add_node("handle_general", handle_general)
workflow.add_node("generate_response", generate_response)

workflow.set_entry_point("classify_intent")
workflow.add_conditional_edges("classify_intent", route_by_intent)
workflow.add_edge("handle_order_inquiry", "generate_response")
workflow.add_edge("handle_refund", "generate_response")
workflow.add_edge("handle_general", "generate_response")
workflow.add_edge("generate_response", END)

app = workflow.compile()

Mermaid図:

graph TD
    start["__start__"] --> classify_intent
    classify_intent -->|order_inquiry| handle_order_inquiry
    classify_intent -->|refund_request| handle_refund
    classify_intent -->|general_question| handle_general
    handle_order_inquiry --> generate_response
    handle_refund --> generate_response
    handle_general --> generate_response
    generate_response --> end_node["__end__"]

Mission 3: Human-in-the-Loop付き返金フローの実装

要件

Mission 2の handle_refund フローにHuman-in-the-Loopの承認ステップを追加してください。

実装要件:

  • 返金額の計算後、人間の承認が必要
  • interrupt_before で承認ノードの前に中断
  • 承認された場合は返金実行、却下された場合は理由を添えて終了
  • PostgresSaverまたはMemorySaverでチェックポイントを使用
  • 承認/却下後のワークフロー再開処理
解答例
from langgraph.checkpoint.memory import MemorySaver

def calculate_refund(state: CustomerSupportState) -> CustomerSupportState:
    """返金額を計算"""
    order_data = {
        "order_id": state.get("order_id", "ORD-12345"),
        "status": "delivered",
        "total": 15800,
        "items": [{"name": "ワイヤレスイヤホン", "price": 15800}]
    }
    return {
        "order_data": order_data,
        "refund_amount": order_data["total"],
        "refund_reason": "customer_request",
        "current_step": "refund_calculated",
        "messages": [AIMessage(content=f"返金プラン: {order_data['total']}円の返金を提案します。承認待ちです。")]
    }

def human_approval(state: CustomerSupportState) -> CustomerSupportState:
    """人間の承認ステップ(中断ポイント)"""
    # interrupt_before で中断されるため、
    # 再開時には refund_approved が設定されている
    return {"current_step": "human_reviewed"}

def execute_refund(state: CustomerSupportState) -> CustomerSupportState:
    """返金を実行"""
    if state.get("refund_approved"):
        refund_result = {
            "status": "completed",
            "amount": state["refund_amount"],
            "refund_id": "REF-001",
            "estimated_arrival": "3-5営業日"
        }
        return {
            "refund_result": refund_result,
            "current_step": "refund_executed",
            "messages": [AIMessage(content=f"返金 {state['refund_amount']}円 が完了しました。")]
        }
    else:
        return {
            "refund_result": {"status": "rejected"},
            "current_step": "refund_rejected",
            "messages": [AIMessage(content="返金は承認されませんでした。")]
        }

# 返金専用サブグラフの構築
refund_workflow = StateGraph(CustomerSupportState)
refund_workflow.add_node("calculate_refund", calculate_refund)
refund_workflow.add_node("human_approval", human_approval)
refund_workflow.add_node("execute_refund", execute_refund)
refund_workflow.add_node("generate_response", generate_response)

refund_workflow.set_entry_point("calculate_refund")
refund_workflow.add_edge("calculate_refund", "human_approval")
refund_workflow.add_edge("human_approval", "execute_refund")
refund_workflow.add_edge("execute_refund", "generate_response")
refund_workflow.add_edge("generate_response", END)

# チェックポイント付きでコンパイル
memory = MemorySaver()
refund_app = refund_workflow.compile(
    checkpointer=memory,
    interrupt_before=["human_approval"]
)

# --- 実行フロー ---

# Phase 1: 返金計算まで実行して中断
config = {"configurable": {"thread_id": "refund-session-001"}}
result = refund_app.invoke(
    {
        "messages": [HumanMessage(content="商品を返品して返金してほしい")],
        "intent": "refund_request",
        "order_id": "ORD-12345",
        "customer_id": "CUST-001",
        "refund_approved": None,
        "current_step": "start",
        "order_data": None,
        "refund_amount": None,
        "refund_reason": None,
        "refund_result": None,
        "faq_results": None,
        "error": None,
        "final_response": None
    },
    config
)
# → calculate_refund まで実行、human_approval の前で中断

# Phase 2: 人間が承認
print(f"返金プラン: {result.get('refund_amount')}円")
# UIやSlackで承認を受けた後...
refund_app.update_state(config, {"refund_approved": True}, as_node="human_approval")

# Phase 3: ワークフロー再開
final_result = refund_app.invoke(None, config)
print(final_result["final_response"])

ポイント:

  • interrupt_before=["human_approval"] で承認前に自動中断
  • update_state で人間の判断を注入
  • invoke(None, config) で中断ポイントから再開
  • 承認/却下の両方のパスを処理

達成度チェック

  • Mission 1: カスタマーサポートワークフローに必要なStateを過不足なく設計できた
  • Mission 2: 意図分類に基づく条件分岐付きグラフを構築できた
  • Mission 2: ルーティング関数を適切に実装できた
  • Mission 3: Human-in-the-Loop付きの返金フローを実装できた
  • Mission 3: 中断・承認・再開のフローを理解して実装できた

推定所要時間: 90分