リード
こんにちは、ぽまらのです。
前の記事(Difyでの構築詳細)では、同じ題材(顧客問い合わせの半自動)をワークフローで組みました。この回は LangGraph(Python) で、同じ仕様を State とノード に落として実装します。論理構成の前提は 入門記事 と同じです。
作るものは次の4段です(Dify 記事と揃えています)。
- classify — カテゴリと緊急度を決める
- retrieve — FAQ から関連情報を取り出す(Dify の「知識検索」に相当)
- draft — 検索結果をコンテキストに返信案を書く
- 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+
langgraphlangchain/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ケース実行

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


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. 実測結果
| Case | category | urgency | needs_human | 返金確定なし | 合否 |
|---|---|---|---|---|---|
| 1 | billing | high | false | なし | OK |
| 2 | tech | high | false | — | OK |
| 3 | billing | high | true | なし | OK |
使用モデル: Gemini 3.1 Flash-Lite(gemini-3.1-flash-lite。Dify と同じ)
実施日: 2026-07-20
Case 1 の出力

Case 1 の下書き(抜粋)
お問い合わせいただきありがとうございます。サポートチームでございます。 先月の請求が二重に発生しているとのご指摘、誠に恐れ入ります。ご不安な思いをさせてしまい申し訳ございません。 ご提示いただいた注文番号に基づき、現在、弊社システムにて決済状況の調査を開始いたしました。調査には1〜3営業日ほどお時間をいただく場合がございます。状況が確認でき次第、改めて担当部署より今後の対応についてご連絡させていただきます。 調査の結果、二重計上が確認された場合には、改めて対応方針をご案内いたします。
Case 1 のチェックメモ
- 注文番号の不要な復唱を削除
- 「適切に対処」など誤解されやすい表現を中立化
- 禁則はなし。
needs_human=false(Dify 実行ではtrueだった)
Case 2 の出力

Case 2 の下書き(抜粋)
お問い合わせいただきありがとうございます。サポートチームでございます。 ログインができず、ご不便をおかけしており申し訳ございません。 まずは、パスワードの再設定をお試しいただけますでしょうか。以下のURLよりお手続きをお願いいたします。 [パスワード再設定URL] 上記をお試しいただいてもログインができない場合は、端末OS・ブラウザ名・発生日時をお知らせください。
Case 2 のチェックメモ
- 補償言及を削除
- 再設定案内+追加情報依頼。
needs_human=false - ※
[パスワード再設定URL]はプレースホルダ。本番では実URLまたは画面上の案内文に固定する
Case 3 の出力

Case 3 の下書き・メモ(抜粋)
お問い合わせいただきありがとうございます。サポートチームでございます。 ご請求に関しまして、ご不安とご不快な思いをさせてしまい、誠に申し訳ございません。 頂戴した内容を拝見し、至急状況を確認させていただきます。まずは正確な状況を把握するため、該当する「注文番号」をお教えいただけますでしょうか。 二重請求の可能性を含め、現在のお支払い状況について調査を行います。調査には1〜3営業日ほどお時間をいただく場合がございます。
needs_human: truereview_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. 見るポイント
- 分類ラベルが Dify と同程度に安定しているか
retrieveが FAQ の関連セクションを拾えているかreview後に禁則が残っていないかreview → draftのループが上限(本記事では2回)で止まるか
うまくいかないときは、classify だけ → retrieve だけ → draft だけ の順で切り分けます。
7. Difyとの違い(構築者視点)
| 観点 | Dify | LangGraph |
|---|---|---|
| 変数 | 画面の変数 | TypedDict の State |
| FAQ参照 | 知識検索ノード → 下書きのコンテキスト | retrieve ノード/関数 |
| 分岐 | IFノード | conditional_edges |
| 再実行 | ノード組みで表現 | review → draft をコードで明示しやすい |
| 社内API | HTTPノード | 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回 の考え方)が自然な続きです。

コメント