Building a Customer Inquiry Agent with LangGraph — Classify → Retrieve → Draft → Review

Lead

Hi, I’m Pomarano.

The Dify build article (Japanese) wired the same semi-automated customer inquiry case as a visual workflow. This post implements the same spec in LangGraph (Python) using State and nodes. Background concepts match the AI Agents Primer.

Four stages (aligned with Dify):

  1. classify — category and urgency
  2. retrieve — FAQ chunks (Dify’s “Knowledge Retrieval”)
  3. draft — reply using retrieval context
  4. review — tone, rules, required items (reflection)

The difference: branching, retries, and internal APIs are explicit in code. Humans still send replies. Same three test cases as Dify with measured results below.

Code is a minimal runnable sample; adjust for your LangGraph / LangChain versions.

  • Japanese version: here

Overview

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

  K --> R
  S --> C --> R --> D --> V --> H
  V -->|needs fix| 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
PartContent
PrimerAI Agents Primer (JP)
PreviousDify build (JP)
This postLangGraph build

1. Same Spec as Dify (for Comparison)

Inputs/outputs match the Dify article.

1-1. Input

FieldExample
Inquiry body“I was billed twice last month. Order A-1001.”

Stored in State as inquiry.

1-2. Classify JSON

{
  "category": "billing",
  "urgency": "normal",
  "reason": "Suspected double billing"
}
categoryMeaning
billingBilling / payment
techBug / how-to
cancelCancellation
otherOther
urgencyMeaning
highSame-day reply
normalStandard
lowLow priority

1-3. Final output

FieldContent
reply_draftReply draft
review_notesReview notes
needs_humanHuman required flag
category / urgencyClassify results

1-4. Four elements in LangGraph

ElementIn LangGraph
BrainLLM calls in classify / draft / review nodes
MemoryState (short-term), retrieve + FAQ file (long-term)
ToolsFunctions around draft (order lookup). v1: retrieve only
Reflectionreview node; conditional edge back to draft

2. Environment

Stack:

  • Python 3.11+
  • langgraph
  • langchain / langchain-google-genai (Gemini in this build; swap for OpenAI etc.)
cd assets/langgraph-inquiry-assist
pip install -r requirements.txt
export GOOGLE_API_KEY="..."
export GEMINI_MODEL="gemini-3.1-flash-lite"   # optional; same default

Never commit API keys. Same model as Dify: Gemini 3.1 Flash-Lite (gemini-3.1-flash-lite). Python 3.10+ recommended.

Sample layout:

assets/langgraph-inquiry-assist/
  faq-sample.md           # Same FAQ as Dify
  requirements.txt
  inquiry_agent/
    state.py              # State definition
    prompts.py            # Prompts
    nodes.py              # classify / retrieve / draft / review
    graph.py              # Graph wiring
    run_local.py          # Run 3 test cases
Screenshot

3. Define State

LangGraph centers on shared State. Nodes read and return partial updates — like Dify variables.

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 output
    reply_draft: str
    review_notes: str
    needs_human: bool
    review_passed: bool
    loop_count: int        # prevent infinite loops

faq_context = Dify’s “retrieval → draft context”.


4. Implement Nodes

4-1. classify (brain)

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),
    }

Fallback to other / normal on bad JSON.

4-2. retrieve (memory)

Equivalent to Dify’s Knowledge Retrieval. v1: keyword scoring over FAQ sections.

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:
    """Score by heading; PoC-friendly."""
    sections = faq_text.split("\n## ")
    # ... rank sections by inquiry keyword overlap; return top_k
    return picked_sections

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

Upgrade to Vector Store + Retriever later.

4-3. draft (brain + memory)

Embed faq_context in the prompt.

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}

Adding tools (extension)

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

Extract order ID with regex before draft; pass result into prompt. Same function can become an MCP server later.

4-4. review (reflection)

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. Wire the Graph

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 IF nodes ≈ add_conditional_edges. Failed review loops back to draft in code.

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
Graph structure

6. Testing and Measured Results

Same three cases as the Dify article:

python -m inquiry_agent.run_local
#Input gistExpected
1Double billing + order ID + immediate refundbilling, no refund confirmation
2Cannot log intech, reset guidance
3“Refund today. Promise transfer.”Likely needs_human=true

6-1. Full inputs (Japanese)

Case 1

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

Case 2

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

Case 3

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

6-2. Results

Casecategoryurgencyneeds_humanNo refund promisePass
1billinghighfalseYesOK
2techhighfalseOK
3billinghightrueYesOK

Model: Gemini 3.1 Flash-Lite (gemini-3.1-flash-lite, same as Dify)
Date: 2026-07-20

Case 1 output

Screenshot

Case 1 review notes

  • Removed unnecessary order ID repetition
  • Neutralized ambiguous phrasing
  • No forbidden phrases. needs_human=false (Dify had true for same input)

Case 2 output

Screenshot

Case 2 review notes

  • Removed compensation mentions
  • Reset guidance + request for device info. needs_human=false
  • [password reset URL] placeholder — replace with real URL in production

Case 3 output

Screenshot

needs_human: true
Review: avoid out-of-scope promises; investigation first; no refund confirmation

6-2-1. Comparison with Dify

  • Billing (Cases 1 & 3): both avoid refund confirmation; investigation guidance. Case 3: both needs_human=true. Case 1: Dify=true / LangGraph=false — flag threshold differs.
  • Tech (Case 2): both tech, reset guidance, needs_human=false. LangGraph may leave URL placeholders.
  • Review: softens tone, removes compensation, FAQ “1–3 business days” appears in drafts.

6-3. What to check

  1. Classify stability vs Dify
  2. retrieve picks relevant FAQ sections
  3. No forbidden phrases after review
  4. review → draft loop stops at cap (2 in this build)

Debug order: classify onlyretrieve onlydraft only.


7. Dify vs LangGraph (Builder View)

AspectDifyLangGraph
VariablesUI variablesTypedDict State
FAQRetrieval node → draft contextretrieve node / function
BranchingIF nodeconditional_edges
RetryNode wiringreview → draft explicit in code
Internal APIHTTP nodePython functions / SDK
Explain costLow (visual)Higher, but reviewable in PRs

Practical split: validate flow in Dify, thicken control and APIs in LangGraph.


8. Production Considerations

ItemExample
TriggerInternal API, form, ticket webhook
Output destinationSlack, email draft, ticket internal note
Human gateneeds_human or review all
Logginginquiry / category / final draft / per-node timing
SecretsSecret Manager for API keys; no unnecessary PII in prompts

Deploy via FastAPI + container, batch, etc. Define the human review queue before optimizing the agent.


9. Common Issues

SymptomFix
JSON parse failureStructured output, repair prompt, fallback
Infinite review → draftloop_count cap (2 here)
Ignores FAQAlways embed retrieve output in draft prompt
CostSmall model for classify; separate models for draft/review
Diverges from DifyShare prompts and FAQ; align temperature (both 3.1 Flash-Lite)

Summary

  • LangGraph: State + classify / retrieve / draft / review — same semi-automated inquiry as Dify
  • Dify “Knowledge Retrieval” = retrieve node; file-based FAQ is fine to start
  • Reflection via review + conditional edges; cap retry loops
  • Natural path: solidify logic in Dify, then LangGraph for control and API integration

Next steps: MCP for order lookup, pass-rate measurement (Study Notes Part 3 approach).


コメント