LangGraphで顧客問い合わせエージェントを組む — 分類→下書き→チェックの構築詳細

リード

こんにちは、ぽまらのです。

前の記事(Difyでの構築詳細)では、同じ題材(顧客問い合わせの半自動)をワークフローで組みました。この回は LangGraph(Python) で、同じ仕様を State とノード に落として実装します。論理構成の前提は 入門記事 と同じです。

作るものは次の4段です(Dify 記事と揃えています)。

  1. classify — カテゴリと緊急度を決める
  2. retrieve — FAQ から関連情報を取り出す(Dify の「知識検索」に相当)
  3. draft — 検索結果をコンテキストに返信案を書く
  4. review — トーン・禁則・必須項目を見直す(自己反省)

違うのは、分岐・再実行・社内APIを コードで明示できる ことです。送信は引き続き人間が行います。記事後半では、Dify と同じ3ケースでの 実測結果 も載せます。

※ コードはイメージ付きの最小構成です。パッケージ版やAPIの細部は、利用中の LangGraph / LangChain の版に合わせて読み替えてください。


この回の全体像

flowchart LR
  K["FAQ<br/>faq-sample.md"]
  S["State"]
  C["classify"]
  R["retrieve"]
  D["draft"]
  V["review"]
  H["人間が確認"]

  K --> R
  S --> C --> R --> D --> V --> H
  V -->|要修正| D

  classDef agent fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a
  classDef memory fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#1a1a1a
  classDef io fill:#eceff1,stroke:#607d8b,stroke-width:2px,color:#1a1a1a
  class C,D,V agent
  class R memory
  class K,S,H io
内容
入門(全体像)AIエージェント入門
Dify での構築詳細
本記事LangGraph での構築詳細

1. Dify版と同じ仕様(揃える)

入出力は Dify 記事 と同一にします。比較しやすくするためです。

1-1. 入力

項目
問い合わせ本文「先月の請求が二重になっている。注文番号 A-1001。」

State では inquiry フィールドに入れます。

1-2. 分類JSON

{
  "category": "billing",
  "urgency": "normal",
  "reason": "二重請求の疑い"
}
category意味
billing請求・支払い
tech不具合・使い方
cancel解約・退会
otherその他
urgency意味
high当日中に返したい
normal通常
low余裕あり

1-3. 最終出力

フィールド内容
reply_draft返信下書き
review_notesチェックメモ
needs_human人間必須フラグ
category / urgency分類結果

1-4. 4要素との対応(LangGraph)

4要素LangGraph での置き場
思考各ノードの LLM 呼び出し(classify / draft / review)
記憶State(短期)、retrieve ノード+FAQファイル(長期)
ツールdraft 前後で呼ぶ関数(注文照会など)。初回は retrieve のみ
自己反省review ノード。条件付きエッジで draft に戻す

2. 環境準備

想定スタック(例):

  • Python 3.11+
  • langgraph
  • langchain / langchain-google-genai(本記事は Gemini。OpenAI 等に差し替えてもよい)
cd assets/langgraph-inquiry-assist
pip install -r requirements.txt
export GOOGLE_API_KEY="..."
export GEMINI_MODEL="gemini-3.1-flash-lite"   # 任意(未設定時も同モデル)

APIキーは環境変数で渡します。リポジトリにキーを書かないでください。

モデルについて: Dify 記事 と同じく Gemini 3.1 Flash-Lite(API ID: gemini-3.1-flash-lite)を使います。GEMINI_MODEL で他モデルに差し替え可能です。Python は 3.10 以上 を推奨します。

ディレクトリ例(本記事のサンプルコード):

assets/langgraph-inquiry-assist/
  faq-sample.md           # Dify と同じ FAQ
  requirements.txt
  inquiry_agent/
    state.py              # State 定義
    prompts.py            # プロンプト
    nodes.py              # classify / retrieve / draft / review
    graph.py              # グラフ定義
    run_local.py          # 3ケース実行
Screenshot

3. State を決める

LangGraph の中心は 共有State です。ノードは State を受け取り、更新して返します。Dify の「変数」に相当します。

from typing import Literal, TypedDict

Category = Literal["billing", "tech", "cancel", "other"]
Urgency = Literal["high", "normal", "low"]

class InquiryState(TypedDict, total=False):
    inquiry: str
    category: Category
    urgency: Urgency
    classify_reason: str
    faq_context: str       # retrieve の結果
    reply_draft: str
    review_notes: str
    needs_human: bool
    review_passed: bool
    loop_count: int        # 無限ループ防止

faq_context は Dify の「知識検索 → 下書きのコンテキスト」に相当します。


4. ノードを実装する

4-1. classify(思考)

import json
import os
from langchain_google_genai import ChatGoogleGenerativeAI

llm = ChatGoogleGenerativeAI(
    model=os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite"),
    temperature=0,
)

CLASSIFY_PROMPT = """あなたはカスタマーサポートの振り分け担当です。
JSONのみ返してください。説明は不要です。
category: billing|tech|cancel|other
urgency: high|normal|low

問い合わせ:
{inquiry}
"""

def classify(state: InquiryState) -> InquiryState:
    msg = llm.invoke(CLASSIFY_PROMPT.format(inquiry=state["inquiry"]))
    try:
        data = json.loads(msg.content)
    except json.JSONDecodeError:
        data = {"category": "other", "urgency": "normal", "reason": "parse fallback"}
    return {
        **state,
        "category": data["category"],
        "urgency": data["urgency"],
        "classify_reason": data.get("reason", ""),
        "loop_count": state.get("loop_count", 0),
    }

JSONが壊れたときは other / normal にフォールバックします。

4-2. retrieve(記憶)

Dify の 知識検索ノード に相当する処理です。最初は FAQ ファイルから関連セクションを抜き出す簡易版で十分です。

from pathlib import Path

FAQ = Path("faq-sample.md").read_text(encoding="utf-8")

def retrieve_faq(inquiry: str, faq_text: str = FAQ, top_k: int = 2) -> str:
    """見出し単位でキーワードスコアリング。PoC向け。"""
    sections = faq_text.split("\n## ")
    # ... 問い合わせ文と見出し・本文の一致度で上位 top_k を返す
    return picked_sections

def retrieve(state: InquiryState) -> InquiryState:
    context = retrieve_faq(state["inquiry"])
    return {**state, "faq_context": context}

慣れたら Vector Store + Retriever に置き換えます。Dify のナレッジ検索と同じ役割です。

4-3. draft(思考+記憶)

faq_context をプロンプトに埋め込みます。

DRAFT_PROMPT = """あなたはカスタマーサポートの返信担当です。
カテゴリ: {category}
緊急度: {urgency}
分類理由: {reason}

次の問い合わせへの返信下書きを日本語で書いてください。
- コンテキスト(FAQ抜粋)の内容に沿う
- 返金・補償の「確定」はしない
- 200〜400字程度
- 署名は「サポートチーム」まで

# コンテキスト(FAQ抜粋)
{faq_context}

# 問い合わせ
{inquiry}
"""

def draft(state: InquiryState) -> InquiryState:
    msg = llm.invoke(DRAFT_PROMPT.format(
        category=state["category"],
        urgency=state["urgency"],
        reason=state.get("classify_reason", ""),
        faq_context=state.get("faq_context", ""),
        inquiry=state["inquiry"],
    ))
    return {**state, "reply_draft": msg.content}

ツールを足す場合(発展)

def lookup_order(order_id: str) -> dict:
    return {"order_id": order_id, "status": "paid", "charged_count": 2}

draft の前に注文番号を正規表現で抜き、結果をプロンプトへ渡します。後で同じ関数を MCP サーバー化することもできます。

4-4. review(自己反省)

REVIEW_PROMPT = """品質チェック担当です。JSONのみ返してください。
禁則: 返金確定、即時対応の約束、顧客を責める表現
必須: 受け止め、次アクションが1つ以上

出力:
{{"reply_draft":"...","review_notes":"...","needs_human":false,"passed":true}}

下書き:
{draft}

問い合わせ:
{inquiry}
"""

def review(state: InquiryState) -> InquiryState:
    msg = llm.invoke(REVIEW_PROMPT.format(
        draft=state["reply_draft"], inquiry=state["inquiry"]
    ))
    data = json.loads(msg.content)
    return {
        **state,
        "reply_draft": data["reply_draft"],
        "review_notes": data.get("review_notes", ""),
        "needs_human": bool(data.get("needs_human", False)),
        "review_passed": bool(data.get("passed", False)),
        "loop_count": state.get("loop_count", 0) + 1,
    }

5. グラフをつなぐ

from langgraph.graph import StateGraph, END

def route_after_review(state: InquiryState) -> str:
    if state.get("review_passed") or state.get("loop_count", 0) >= 2:
        return "end"
    return "draft"

graph = StateGraph(InquiryState)
graph.add_node("classify", classify)
graph.add_node("retrieve", retrieve)
graph.add_node("draft", draft)
graph.add_node("review", review)

graph.set_entry_point("classify")
graph.add_edge("classify", "retrieve")
graph.add_edge("retrieve", "draft")
graph.add_edge("draft", "review")
graph.add_conditional_edges(
    "review",
    route_after_review,
    {"draft": "draft", "end": END},
)

app = graph.compile()

Dify の「条件分岐ノード」に相当するのが add_conditional_edges です。自己反省で落ちたら下書きに戻す、をコードで明示できます。

flowchart LR
  C["classify"]
  R["retrieve"]
  D["draft"]
  V["review"]
  E["END"]

  C --> R --> D --> V
  V -->|passed or max loop| E
  V -->|not passed| D

  classDef agent fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a
  classDef memory fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#1a1a1a
  class C,D,V agent
  class R memory
グラフ構造(classify→retrieve→draft→review)

6. 動作確認と実測結果

Dify 記事同じ3ケース で実行します。

python -m inquiry_agent.run_local
#入力の要点期待
1二重請求+注文番号+すぐ返金billing、返金確定なし
2ログインできないtech、再設定案内
3「今すぐ全額返せ。約束しろ」needs_human=true になりやすい

6-1. 入力全文

Case 1

先月の請求が二重になっているようです。注文番号は A-1001 です。すぐに返金してください。

Case 2

ログインできません。パスワードを忘れたかもしれません。

Case 3

今すぐ全額返せ。絶対に今日中に振り込めと約束しろ。

6-2. 実測結果

Casecategoryurgencyneeds_human返金確定なし合否
1billinghighfalseなしOK
2techhighfalseOK
3billinghightrueなしOK

使用モデル: Gemini 3.1 Flash-Litegemini-3.1-flash-lite。Dify と同じ)
実施日: 2026-07-20

Case 1 の出力

Screenshot

Case 1 の下書き(抜粋)

お問い合わせいただきありがとうございます。サポートチームでございます。

先月の請求が二重に発生しているとのご指摘、誠に恐れ入ります。ご不安な思いをさせてしまい申し訳ございません。

ご提示いただいた注文番号に基づき、現在、弊社システムにて決済状況の調査を開始いたしました。調査には1〜3営業日ほどお時間をいただく場合がございます。状況が確認でき次第、改めて担当部署より今後の対応についてご連絡させていただきます。

調査の結果、二重計上が確認された場合には、改めて対応方針をご案内いたします。

Case 1 のチェックメモ

  • 注文番号の不要な復唱を削除
  • 「適切に対処」など誤解されやすい表現を中立化
  • 禁則はなし。needs_human=false(Dify 実行では true だった)

Case 2 の出力

Screenshot

Case 2 の下書き(抜粋)

お問い合わせいただきありがとうございます。サポートチームでございます。

ログインができず、ご不便をおかけしており申し訳ございません。

まずは、パスワードの再設定をお試しいただけますでしょうか。以下のURLよりお手続きをお願いいたします。
[パスワード再設定URL]

上記をお試しいただいてもログインができない場合は、端末OS・ブラウザ名・発生日時をお知らせください。

Case 2 のチェックメモ

  • 補償言及を削除
  • 再設定案内+追加情報依頼。needs_human=false
  • [パスワード再設定URL] はプレースホルダ。本番では実URLまたは画面上の案内文に固定する

Case 3 の出力

Screenshot

Case 3 の下書き・メモ(抜粋)

お問い合わせいただきありがとうございます。サポートチームでございます。

ご請求に関しまして、ご不安とご不快な思いをさせてしまい、誠に申し訳ございません。

頂戴した内容を拝見し、至急状況を確認させていただきます。まずは正確な状況を把握するため、該当する「注文番号」をお教えいただけますでしょうか。

二重請求の可能性を含め、現在のお支払い状況について調査を行います。調査には1〜3営業日ほどお時間をいただく場合がございます。

needs_human: true
review_notes: 権限外の断定を避け調査優先/返金確定の表現を削除

6-2-1. Dify との比較(所感)

  • 請求系(Case 1・3): どちらも返金確定なし・調査案内。Case 3 は両方 needs_human=true。Case 1 は Dify=true / LangGraph=false で、フラグ閾値に差が出た。
  • 技術系(Case 2): 両方 tech・再設定案内・needs_human=false。LangGraph 側は URL プレースホルダが残ることがある。
  • チェック(review ノード): 表現の緩和・補償削除・禁則回避は機能。FAQの「1〜3営業日」が下書きに乗っている。

6-3. 見るポイント

  1. 分類ラベルが Dify と同程度に安定しているか
  2. retrieve が FAQ の関連セクションを拾えているか
  3. review 後に禁則が残っていないか
  4. review → draft のループが上限(本記事では2回)で止まるか

うまくいかないときは、classify だけretrieve だけdraft だけ の順で切り分けます。


7. Difyとの違い(構築者視点)

観点DifyLangGraph
変数画面の変数TypedDict の State
FAQ参照知識検索ノード → 下書きのコンテキストretrieve ノード/関数
分岐IFノードconditional_edges
再実行ノード組みで表現review → draft をコードで明示しやすい
社内APIHTTPノードPython関数・SDKを直接
説明コスト低い(画面)高いが、レビューしやすい

向き不向きは入門記事のとおりで、フロー検証はDify、制御と連携はLangGraph が現実的な分担です。


8. 運用に載せるとき

項目
実行トリガー社内API、フォーム、チケットWebhook
成果物の届け先Slack、メール下書き、チケット内部メモ
人間ゲートneeds_human または全件レビュー
ログinquiry / category / 最終draft / ノード所要時間
秘密情報APIキーはSecret Manager等。プロンプトに不要な個人情報を載せない

デプロイ先は FastAPI + コンテナ、やバッチ、など好みでよいです。エージェント本体より、人間レビューの待ち行列 を先に決めた方が事故が減ります。


9. つまずきやすい点

症状対処
JSONパース失敗構造化出力、修復用の再プロンプト、フォールバック
review → draft が無限loop_count 上限(本記事では2)
FAQを無視した回答retrieve の結果を必ずプロンプトに埋め込む
コスト増classify だけ小型モデル、draft/review は別モデル
Difyと結果が食い違うプロンプトとFAQ文書を共通化し、温度を揃える(モデルは両方 3.1 Flash-Lite)

まとめ

  • LangGraph では State + classify / retrieve / draft / review で、Dify と同じ問い合わせ半自動が組める
  • Dify の「知識検索」は retrieve ノード に相当。FAQ は最初ファイル読み込みでよい
  • 自己反省review と条件付きエッジで表現し、やり直し回数に上限を付ける
  • Dify で論理を固め、LangGraph で制御とAPI連携を厚くする、という順番が取りやすい

次の発展としては、注文照会の MCP 化や、合格率の実測(勉強記第3回 の考え方)が自然な続きです。


コメント