teachyou.ai academy
← All posts
LangGraph

LangGraph for Content Moderation Pipelines

Ira Menon · Jun 20, 2026 · 14 min read

Why Content Moderation Breaks Simple LLM Chains

Most teams start content moderation with a single prompt: send the text to an LLM, ask "is this safe," parse yes or no. It works for a demo. It falls apart in production the moment you need to handle appeals, escalate ambiguous cases to a human, apply different rules for different content categories, or explain why a decision was made six weeks later during an audit.

The core problem is that moderation is not a single decision — it is a workflow with branches, retries, and state that needs to persist across steps. A comment might pass a toxicity classifier but fail a policy-specific check for medical misinformation. An image caption might need OCR extraction before any text classifier can even run. A borderline case might need to sit in a queue for a human reviewer, then re-enter the pipeline once a verdict comes back. None of this maps cleanly onto "one prompt in, one label out."

This is exactly the class of problem LangGraph was built for. Instead of chaining calls linearly, you model moderation as a graph of nodes and edges, where each node does one job (classify, extract, escalate, log) and the edges encode the actual decision logic your policy team already has in a spreadsheet somewhere. State flows through the graph as a typed object, so every node can read what happened before it and add to the record without losing history. When you need to explain a decision, the state object *is* your audit trail.

In this article we will walk through building a moderation pipeline with LangGraph: the state schema, the node structure, conditional routing for escalation, human-in-the-loop review, and the operational concerns — latency, cost, false positives — that determine whether this thing survives contact with real traffic.

Modeling Moderation as a Graph, Not a Chain

Before writing any code, it helps to sketch the actual decision tree your moderation policy implies. A realistic e-learning platform (think: comments under a course video, forum posts, or user-submitted project descriptions) usually has a policy that looks something like this:

  • Check for spam and prompt injection attempts first — cheap, catches a large fraction of junk before you spend money on anything else.
  • Run a fast classifier for clear-cut categories: hate speech, sexual content, violence.
  • If the fast classifier is confident (either direction), resolve immediately.
  • If the fast classifier is unsure, hand off to a slower, more capable LLM judge with the platform's specific policy text as context.
  • If the LLM judge is still unsure, or the content touches a sensitive category (self-harm, harassment of a named individual, legal threats), route to a human moderator queue.
  • Whatever the outcome, log the full decision trail with reasoning, so a human can later review why the system did what it did.

Notice this is not a straight line. It has conditional forks (confident vs. unsure), a loop-back point (human review can override and re-enter the pipeline for downstream actions like notifying the user), and a shared piece of state that every node touches (the growing audit log). That's a graph, and LangGraph gives you the primitives to express it directly instead of hiding it inside nested if-statements.

The mapping is straightforward once you see it:

  • Each bullet point above becomes a node — a Python function that takes the current state and returns updates to it.
  • Each "if confident, else" becomes a conditional edge — a function that inspects state and returns the name of the next node.
  • The growing audit log, category scores, and final verdict all live in a single state schema, typically a TypedDict or a Pydantic model.

Designing the State Schema

Get the state schema right early — it is the backbone everything else depends on, and reworking it after you have ten nodes built is painful. For a moderation pipeline, the state needs to carry the original content, intermediate scores, the routing decisions made so far, and the final verdict.

from typing import TypedDict, Literal, Optional
from langgraph.graph import StateGraph, END

class ModerationState(TypedDict):
    content_id: str
    text: str
    author_id: str
    spam_score: Optional[float]
    toxicity_scores: Optional[dict]
    llm_verdict: Optional[dict]
    needs_human_review: bool
    final_decision: Optional[Literal["approve", "reject", "escalate"]]
    audit_log: list[dict]

Two design choices here matter more than they look. First, audit_log is a list that every node appends to, never overwrites — this is your compliance trail, and if a platform ever gets asked "why was this comment removed," you want a chronological record of every score and every reasoning step, not just the final label. Second, needs_human_review is a plain boolean flag rather than being inferred from other fields at read time — computing it once, in the node that decides it, keeps your conditional edges simple and avoids subtle bugs where two nodes disagree about what "unsure" means.

A common mistake is under-modeling this state — cramming everything into a single notes: str field and parsing it back out downstream. Resist that. Typed fields let you validate at each step, and they make it trivial to render a structured moderation dashboard later without touching the graph logic.

Building the Core Nodes

Each node is a plain function. Keep them narrow — one responsibility each — so you can test, replace, and reorder them independently.

def spam_check(state: ModerationState) -> dict:
    score = run_spam_classifier(state["text"])
    return {
        "spam_score": score,
        "audit_log": state["audit_log"] + [
            {"node": "spam_check", "score": score}
        ],
    }

def toxicity_classifier(state: ModerationState) -> dict:
    scores = run_toxicity_model(state["text"])
    return {
        "toxicity_scores": scores,
        "audit_log": state["audit_log"] + [
            {"node": "toxicity_classifier", "scores": scores}
        ],
    }

def llm_judge(state: ModerationState) -> dict:
    verdict = call_llm_with_policy(
        text=state["text"],
        policy=MODERATION_POLICY_TEXT,
        prior_scores=state["toxicity_scores"],
    )
    return {
        "llm_verdict": verdict,
        "needs_human_review": verdict["confidence"] < 0.75,
        "audit_log": state["audit_log"] + [
            {"node": "llm_judge", "verdict": verdict}
        ],
    }

def human_review_queue(state: ModerationState) -> dict:
    enqueue_for_human(state["content_id"], state)
    return {
        "final_decision": "escalate",
        "audit_log": state["audit_log"] + [
            {"node": "human_review_queue", "action": "enqueued"}
        ],
    }

def finalize(state: ModerationState) -> dict:
    decision = state.get("final_decision") or (
        "reject" if state["llm_verdict"]["label"] == "violation" else "approve"
    )
    persist_decision(state["content_id"], decision, state["audit_log"])
    return {"final_decision": decision}

Notice the pattern: every node returns only the fields it changed, LangGraph merges those into the running state, and the audit log grows by appending rather than replacing. This is what makes the graph debuggable — at any point you can dump state["audit_log"] and see exactly which functions ran, in what order, with what inputs and outputs.

Wiring Conditional Routing

The interesting part of a moderation pipeline is the branching logic, not the classifiers themselves. LangGraph expresses this with add_conditional_edges, where a router function inspects state and returns the name of the next node.

def route_after_spam(state: ModerationState) -> str:
    if state["spam_score"] > 0.9:
        return "finalize"
    return "toxicity_classifier"

def route_after_toxicity(state: ModerationState) -> str:
    scores = state["toxicity_scores"]
    if max(scores.values()) > 0.85:
        return "finalize"
    if max(scores.values()) < 0.2:
        return "finalize"
    return "llm_judge"

def route_after_llm(state: ModerationState) -> str:
    if state["needs_human_review"]:
        return "human_review_queue"
    return "finalize"

graph = StateGraph(ModerationState)
graph.add_node("spam_check", spam_check)
graph.add_node("toxicity_classifier", toxicity_classifier)
graph.add_node("llm_judge", llm_judge)
graph.add_node("human_review_queue", human_review_queue)
graph.add_node("finalize", finalize)

graph.set_entry_point("spam_check")
graph.add_conditional_edges("spam_check", route_after_spam)
graph.add_conditional_edges("toxicity_classifier", route_after_toxicity)
graph.add_conditional_edges("llm_judge", route_after_llm)
graph.add_edge("human_review_queue", END)
graph.add_edge("finalize", END)

moderation_app = graph.compile()

A few things worth calling out. The spam check short-circuits straight to finalize when confidence is high in either direction — there is no reason to pay for a toxicity model call or an LLM call on obvious spam. The toxicity router has two exit conditions to finalize: clearly bad and clearly fine. Only the ambiguous middle band goes to the more expensive LLM judge. This tiered structure is the single biggest lever for controlling cost, which we'll come back to.

You can compile this graph once at startup and reuse the compiled app across requests — it is stateless between invocations unless you attach a checkpointer, which is exactly what you want for a stateless per-item moderation call.

Human-in-the-Loop Without Blocking the Graph

A naive implementation makes human_review_queue block until a moderator clicks a button, which is a terrible idea for a pipeline processing thousands of items an hour. The better pattern is to treat human review as a genuine pause-and-resume point using LangGraph's checkpointing and interrupt support.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()
moderation_app = graph.compile(
    checkpointer=checkpointer,
    interrupt_before=["human_review_queue"],
)

config = {"configurable": {"thread_id": state["content_id"]}}
result = moderation_app.invoke(initial_state, config=config)

# Later, when a human moderator submits a verdict:
moderation_app.update_state(
    config,
    {"final_decision": human_verdict, "needs_human_review": False},
)
moderation_app.invoke(None, config=config)

The interrupt_before parameter pauses execution right before the human review node runs, persists the full state via the checkpointer, and returns control to your application. Your API can then show the item in a moderator dashboard, and whenever a human submits a decision, you call update_state to inject their verdict and invoke(None, ...) to resume the graph from where it left off. This means your moderation service can handle thousands of pending human reviews concurrently without holding threads or connections open — each one is just a row in your checkpoint store waiting for update_state.

For production you'd swap MemorySaver for a Postgres or Redis-backed checkpointer so state survives a restart. This is also where the audit trail pays for itself: when a human overrides a model's verdict, that override is itself logged as a state update, so you have a full record of human vs. model disagreement — useful both for compliance and for finding cases to add to your classifier's training set later.

Handling Retries, Timeouts, and Model Failures

Classifiers and LLM calls fail. Rate limits get hit, timeouts happen, and a moderation pipeline that crashes on a transient API error instead of degrading gracefully will pile up backlog fast. Wrap the actual model calls with retry logic, and make sure your node functions convert exceptions into state rather than letting them propagate and kill the whole run.

import time

def call_llm_with_retry(text, policy, prior_scores, max_attempts=3):
    last_error = None
    for attempt in range(max_attempts):
        try:
            return call_llm_with_policy(text, policy, prior_scores)
        except RateLimitError:
            time.sleep(2 ** attempt)
            last_error = "rate_limited"
        except TimeoutError:
            last_error = "timeout"
    return {
        "label": "review_required",
        "confidence": 0.0,
        "error": last_error,
    }

def llm_judge(state: ModerationState) -> dict:
    verdict = call_llm_with_retry(
        state["text"], MODERATION_POLICY_TEXT, state["toxicity_scores"]
    )
    return {
        "llm_verdict": verdict,
        "needs_human_review": verdict["confidence"] < 0.75,
        "audit_log": state["audit_log"] + [
            {"node": "llm_judge", "verdict": verdict}
        ],
    }

The key design decision: when the LLM judge fails after retries, it returns a low-confidence "review_required" verdict rather than raising. Because needs_human_review is computed from confidence, a failed model call automatically routes to a human rather than silently approving or rejecting content it never actually evaluated. This is a small detail with outsized safety implications — fail closed toward human review, never fail open toward auto-approval, especially for a platform serving students of any age.

Cost and Latency: The Tiered Classifier Strategy

The single biggest practical lesson from running a moderation pipeline like this is that the LLM judge is expensive relative to the classifiers, both in latency and in dollars, and most content never needs it. A well-tuned pipeline should send well under a third of incoming content to the LLM judge stage — the rest gets resolved by cheap classifiers at the spam and toxicity stages.

To make that tiering effective in practice:

  1. Tune your confidence thresholds against real data, not intuition. Pull a sample of a few hundred real moderation decisions, run them through your classifiers, and plot where the confident-approve and confident-reject bands actually sit. Thresholds set from a hunch tend to route far too much traffic to the expensive LLM stage.
  2. Batch LLM calls where the workflow allows it. If moderation doesn't need to be synchronous with the user's submission (e.g., a forum post that appears immediately but can be retroactively removed), queue ambiguous items and batch the LLM judge calls every few seconds rather than one request per item.
  3. Cache on content hash. Duplicate or near-duplicate spam is extremely common — the same scam text posted by a hundred bot accounts. Hashing normalized text and caching the verdict avoids re-running the full graph for content you've already judged.
  4. Track cost per decision as a first-class metric, not just accuracy. A pipeline that's 2% more accurate but five times more expensive per item is usually the wrong trade for a platform at scale.
  5. Right-size the LLM judge's context. Sending your entire policy document as a system prompt on every call adds tokens you're paying for on every single ambiguous item — precompute policy sections relevant to the category the toxicity classifier flagged, and only include those.

Latency-wise, the spam and toxicity stages should resolve in well under a second combined, since they're typically local or lightweight hosted models. The LLM judge stage is where most of your tail latency lives, so if your product surface needs an immediate "your comment is posted" experience, consider optimistic display (show it immediately, retract if the async pipeline later rejects it) rather than blocking the user on the full graph.

Observability: Making the Graph Debuggable in Production

Once this pipeline is live, the questions you'll get are "why was my content removed" and "why did this obviously bad post get through." Both require being able to replay a decision. Because every node writes to audit_log and LangGraph's checkpointer persists state by thread ID, replaying a decision is just fetching the checkpoint for that content ID.

A few practices that make this materially easier:

  • Use the content ID as the thread ID consistently, so there is a single lookup key across your application, your checkpoint store, and your moderator dashboard.
  • Log node entry and exit timestamps in the audit log, not just the decision — this is what tells you the LLM judge stage took 4 seconds versus the classifier stage taking 40 milliseconds, which matters when someone asks "why is moderation slow today."
  • Version your policy text and model versions in the state or alongside it. When you update your moderation policy, you want to know which decisions were made under the old policy versus the new one, especially if you get an appeal for content moderated last month.
  • Emit structured events at each conditional edge, not just at nodes — knowing that route_after_toxicity chose the LLM judge path (and why: which score triggered it) is often more useful for debugging than the raw scores themselves.
  • Sample and review a rotating slice of auto-approved content. The dangerous failure mode isn't the cases that get flagged — it's the false negatives that never surface. A weekly human sample of "approved" decisions catches drift in your classifiers before it becomes a policy incident.

Testing the Pipeline Before It Touches Real Users

Graph-based pipelines are easier to unit test than monolithic prompts because each node is a pure-ish function you can call directly with a constructed state, independent of the rest of the graph.

def test_toxicity_router_sends_ambiguous_to_llm():
    state = {
        "toxicity_scores": {"hate": 0.5, "violence": 0.1},
        "audit_log": [],
    }
    assert route_after_toxicity(state) == "llm_judge"

def test_spam_short_circuits_high_confidence():
    state = {"spam_score": 0.95, "audit_log": []}
    assert route_after_spam(state) == "finalize"

def test_failed_llm_call_forces_human_review():
    state = {"text": "...", "toxicity_scores": {}, "audit_log": []}
    result = llm_judge_with_mocked_failure(state)
    assert result["needs_human_review"] is True

Beyond unit tests on routers and nodes, build a regression suite from real historical moderation decisions — a few hundred labeled examples spanning clear approvals, clear rejections, and genuinely ambiguous cases. Run the full compiled graph against this suite whenever you change a threshold, swap a model, or update policy text, and track the false-positive and false-negative rate over time. This is the same discipline as testing any classifier system, but the graph structure makes it easy to test each layer of your defense in isolation before testing the whole pipeline end to end.

Bringing It Together

A content moderation pipeline is a workflow problem wearing an ML costume: multiple checks, conditional routing based on confidence, a place for humans to step in on the hard cases, and a requirement that every decision be explainable after the fact. LangGraph's state graph model fits this shape directly — nodes for each check, conditional edges for the routing logic your policy team already has in their heads, a typed state object that doubles as your audit trail, and checkpointing that turns "wait for a human" from a blocking call into a proper pause-and-resume workflow.

The patterns in this article — tiered classifiers to control cost, fail-closed error handling that routes failures to human review instead of silent approval, and audit logging built into the state schema from day one — apply whether you're moderating course comments, forum posts, or user-generated project submissions. Start with the decision tree your policy already implies, map it directly onto nodes and conditional edges, and resist the urge to collapse it back into a single prompt just because it looks simpler on day one. It won't stay simple once real traffic and real edge cases show up.

If you want to go deeper on building graphs like this — state design, checkpointing, subgraphs, and debugging multi-step agent workflows — our LangGraph Tutorial course on teachyou.ai walks through building production-grade pipelines like this one from scratch, with the same node-by-node approach used here.