Building a Content Moderation Pipeline

AILLMSecurityArchitecture
Share on LinkedIn

A user pasted content into your AI writing assistant that shouldn't have reached the model. The model complied. The output violated your terms of service and appeared on screen before any human saw it. Content moderation for LLM apps isn't one API call — it's a pipeline that checks input before inference, output before display, and logs everything for review and compliance.

Pipeline architecture

User input
    ↓
[Input moderation] → BLOCK → generic refusal
    ↓ pass
[LLM generation]
    ↓
[Output moderation] → BLOCK/REDACT → safe response
    ↓ pass
User sees response
    ↓
[Async audit log + sampling for human review]

Each stage is independent — a failure in one doesn't skip the others.

Input moderation

async def moderate_input(text: str, tenant_id: str) -> ModerationResult:
    # Layer 1: Provider API (fast, broad)
    provider_result = await openai.moderations.create(input=text)
    if provider_result.results[0].flagged:
        return ModerationResult(action="block", reason="provider_flag", details=provider_result)

    # Layer 2: Custom business rules
    custom = await custom_classifier.check(text, tenant_id)
    if custom.violation:
        return ModerationResult(action="block", reason=custom.category)

    # Layer 3: Prompt injection heuristics
    if injection_score(text) > INJECTION_THRESHOLD:
        return ModerationResult(action="block", reason="injection_attempt")

    return ModerationResult(action="pass")

Run input moderation in parallel with auth/rate limiting — don't add sequential latency.

Output moderation

Model outputs need the same scrutiny:

async def moderate_output(text: str, context: RequestContext) -> ModerationResult:
    result = await openai.moderations.create(input=text)
    if result.results[0].flagged:
        categories = result.results[0].categories
        if categories.sexual_minors or categories.self_harm:
            return ModerationResult(action="block", log_level="critical")
        return ModerationResult(action="block", log_level="high")

    # Custom: no competitor mentions
    if context.tenant_policy.no_competitors:
        if mentions_competitor(text):
            return ModerationResult(action="redact", redact_patterns=COMPETITOR_NAMES)

    # PII scan
    pii = detect_pii(text)
    if pii:
        return ModerationResult(action="redact", redact_spans=pii)

    return ModerationResult(action="pass")

Custom classifiers

Train or prompt classifiers for domain rules:

DOMAIN_MODERATION_PROMPT = """
Classify if this text violates any policy:
- MEDICAL_ADVICE: specific medical recommendations
- FINANCIAL_ADVICE: investment recommendations without disclaimer
- COMPETITOR_MENTION: names competing products
- OFF_BRAND: tone inconsistent with professional support

Return JSON: {"violations": [...], "severity": "none|low|medium|high|critical"}
"""

Use a cheap, fast model (GPT-4o-mini). Cache results for identical inputs.

Action matrix

Severity Input action Output action Logging
Critical Block + alert Block + alert Full audit, legal review
High Block Block + generic response Full audit
Medium Warn + proceed Redact + deliver Standard log
Low Log Log Sampled human review
REFUSAL_MESSAGES = {
    "default": "I can't help with that request.",
    "injection": "I can't process that message. Please rephrase your question.",
    # Never explain WHY in detail — aids adversarial iteration
}

Latency budget

Moderation adds latency. Budget:

Stage Target latency
Provider moderation API 100–300ms
Custom classifier 200–500ms
PII regex scan <10ms
Total pipeline overhead <500ms

Run provider and custom checks in parallel:

provider, custom = await asyncio.gather(
    provider_moderate(text),
    custom_moderate(text),
)

Human review queue

Sample flagged and borderline content:

async def post_moderation_audit(result: ModerationResult, context: RequestContext):
    await audit_log.write(result, context)
    if result.severity in ("medium", "low") and random.random() < 0.05:
        await review_queue.add(result, context, priority=result.severity)
    if result.severity == "critical":
        await alert_oncall(result, context)

Reviewers need input, output, moderation scores, and tenant context.

Compliance and retention

Multi-layer moderation architecture

Production moderation stacks multiple layers — no single point of failure:

Layer 1: Input keyword blocklist (< 1ms, free)
Layer 2: Provider moderation API (~100ms, OpenAI/Anthropic)
Layer 3: Custom classifier (~50ms, domain-specific)
Layer 4: LLM output guardrail (~500ms, high-risk paths only)
Layer 5: Human review queue (async, sampled)
async def moderate_request(input_text: str, context: RequestContext) -> ModerationResult:
    # Layer 1: instant blocklist
    if blocklist.match(input_text):
        return ModerationResult(action="block", reason="blocklist", layer=1)

    # Layer 2: provider API
    provider_result = await openai_moderation(input_text)
    if provider_result.flagged:
        return ModerationResult(action="block", reason=provider_result.categories, layer=2)

    # Layer 3: custom classifier for domain-specific harm
    custom_score = await domain_classifier(input_text, context.tenant_id)
    if custom_score > 0.9:
        return ModerationResult(action="block", reason="domain_classifier", layer=3)

    return ModerationResult(action="allow")

Early layers are cheap and fast — only escalate to expensive layers on borderline cases.

Output moderation for generated content

Input moderation isn't enough — filter LLM outputs too:

async def moderate_output(response: str, context: RequestContext) -> str:
    result = await openai_moderation(response)
    if result.flagged:
        await audit_log.write("output_blocked", context, result.categories)
        return "I can't provide that response. Please rephrase your request."
    return response

Prompt injection can cause model to generate harmful content even from benign input. Always moderate output for user-facing features.

Moderation metrics and transparency

Track and report moderation activity:

MODERATION_METRICS = [
    "moderation.input_blocked_rate",
    "moderation.output_blocked_rate",
    "moderation.layer_1_blocklist_hits",
    "moderation.human_review_queue_depth",
    "moderation.false_positive_rate",  # from human review feedback
]

Publish transparency reports: total requests, blocked rate, top block categories, human review overturn rate. Required for EU AI Act high-risk system compliance.

Failure modes

Production checklist

Resources

Frequently asked questions

Should I moderate LLM inputs, outputs, or both?

Both. Input moderation blocks harmful requests before they reach the model (jailbreaks, CSAM queries, abuse). Output moderation catches policy violations the model produces despite instructions (harmful content, PII leaks, off-brand responses). Input-only moderation misses model-generated harm; output-only wastes compute on requests you should reject upfront.

Provider moderation API vs custom classifier — which do I need?

Start with provider APIs (OpenAI Moderation, Anthropic safety classifiers) — free or cheap, maintained, broad coverage. Add custom classifiers for domain-specific policies (competitor mentions, medical advice, financial recommendations) that generic models don't cover. Layer both — provider catches universal harm, custom catches business rules.

What should happen when moderation flags content?

Tier by severity. Block and log critical violations (CSAM, violence, hate). Refuse with generic message for high-severity. Rewrite or redact for medium (PII in output). Log-only for low-severity borderline cases with human review sampling. Never silently pass flagged critical content.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →