Building a content moderation pipeline with LLMs
Content moderation at scale is one of the most operationally demanding problems a platform can face. Rule-based keyword filters catch obvious violations but miss context. Manual review queues are expensive and slow. LLMs occupy the middle ground: they understand nuance, follow complex policies, and can be updated by changing a prompt rather than retraining a model. This article walks through building a complete moderation pipeline in production, from first classification call to audit logging.
Framing the problem correctly
Before writing any code, you need to decide what the pipeline is actually responsible for. Content moderation is not a binary spam/not-spam classifier. A mature system typically handles several distinct categories simultaneously:
- Toxicity and hate speech — slurs, dehumanisation, targeted harassment
- Sexual content — explicit material, content involving minors
- Violence and threats — credible threats, graphic descriptions, glorification
- Spam and manipulation — coordinated inauthentic behaviour, scam links, fake reviews
- Misinformation — health disinformation, election interference claims
- Self-harm — content that may encourage or instruct harmful behaviour
Each category may have different thresholds (a news platform tolerates graphic violence in journalism; a children's app does not) and different escalation paths. Map these before designing your schema.
The pipeline architecture has three layers:
- Pre-filter — cheap heuristics that catch obvious violations without an LLM call (regex, blocklists, image hashes)
- LLM classifier — structured classification against your policy
- Human review queue — uncertain cases forwarded to moderators
Getting the routing logic right is more important than any single model choice.
Structured classification with tool use
The most reliable way to get consistent output from an LLM classifier is to define an explicit tool call schema. This avoids free-text parsing and gives you typed confidence scores.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const moderationTool: Anthropic.Tool = {
name: "classify_content",
description: "Classify user-generated content against the platform policy",
input_schema: {
type: "object" as const,
properties: {
categories: {
type: "object",
properties: {
toxicity: { type: "number", minimum: 0, maximum: 1 },
sexual_content: { type: "number", minimum: 0, maximum: 1 },
violence: { type: "number", minimum: 0, maximum: 1 },
spam: { type: "number", minimum: 0, maximum: 1 },
self_harm: { type: "number", minimum: 0, maximum: 1 },
},
required: ["toxicity", "sexual_content", "violence", "spam", "self_harm"],
},
overall_verdict: {
type: "string",
enum: ["allow", "flag_for_review", "reject"],
},
policy_violations: {
type: "array",
items: { type: "string" },
description: "Specific policy sections violated, empty if none",
},
reasoning: {
type: "string",
description: "One-sentence explanation of the verdict for the audit log",
},
},
required: ["categories", "overall_verdict", "policy_violations", "reasoning"],
},
};
interface ModerationResult {
categories: Record<string, number>;
overall_verdict: "allow" | "flag_for_review" | "reject";
policy_violations: string[];
reasoning: string;
}
async function classifyContent(
content: string,
systemPrompt: string
): Promise<ModerationResult> {
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 512,
system: systemPrompt,
tools: [moderationTool],
tool_choice: { type: "any" },
messages: [
{
role: "user",
content: `Classify the following user-generated content:\n\n<content>${content}</content>`,
},
],
});
const toolUse = response.content.find((block) => block.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") {
throw new Error("Model did not return a tool call");
}
return toolUse.input as ModerationResult;
}
The system prompt is where your actual policy lives. Keep it versioned in source control alongside the code:
const MODERATION_SYSTEM_PROMPT = `
You are a content moderation classifier for Acme Platform.
## Platform context
Acme is a professional networking site for adult users. Content must be appropriate
for a business environment.
## Policy summary
- REJECT: hate speech targeting protected characteristics, explicit sexual content,
credible threats of violence, spam/phishing links, content encouraging self-harm
- FLAG FOR REVIEW: borderline cases, satire involving sensitive topics, news content
referencing violence, content with ambiguous intent
- ALLOW: professional discussion, criticism of products/services, strong but non-hateful
language, clearly humorous content
## Confidence scores
Assign a float from 0.0 to 1.0 for each category representing how strongly the
content exhibits that characteristic. Use 0.0 for clear absence, 1.0 for unambiguous
presence.
## Important
- Evaluate intent and context, not surface-level keywords
- A sentence containing the word "kill" in "I could kill for a coffee right now" is not violence
- Apply the policy consistently regardless of the target group
`;
Building the routing layer
Classification is one step. Routing is where business logic lives. You need to combine category scores, apply platform-specific thresholds, and decide what happens next.
interface RoutingConfig {
autoRejectThresholds: Record<string, number>;
autoAllowThresholds: Record<string, number>;
humanReviewQueue: string;
}
const DEFAULT_CONFIG: RoutingConfig = {
autoRejectThresholds: {
toxicity: 0.85,
sexual_content: 0.7,
violence: 0.8,
spam: 0.9,
self_harm: 0.75,
},
autoAllowThresholds: {
toxicity: 0.15,
sexual_content: 0.05,
violence: 0.1,
spam: 0.1,
self_harm: 0.05,
},
humanReviewQueue: "moderation-review",
};
type RouteDecision = {
action: "allow" | "reject" | "human_review";
reason: string;
triggered_by?: string;
};
function routeDecision(
result: ModerationResult,
config: RoutingConfig = DEFAULT_CONFIG
): RouteDecision {
// Hard reject: any category exceeds auto-reject threshold
for (const [category, score] of Object.entries(result.categories)) {
if (score >= (config.autoRejectThresholds[category] ?? 1)) {
return {
action: "reject",
reason: `Auto-rejected: ${category} score ${score.toFixed(2)} exceeds threshold`,
triggered_by: category,
};
}
}
// Model explicitly flagged for review
if (result.overall_verdict === "flag_for_review") {
return {
action: "human_review",
reason: `Model flagged for review: ${result.reasoning}`,
};
}
// All categories below auto-allow threshold
const allBelowAllowThreshold = Object.entries(result.categories).every(
([category, score]) => score <= (config.autoAllowThresholds[category] ?? 0)
);
if (allBelowAllowThreshold && result.overall_verdict === "allow") {
return { action: "allow", reason: "All scores below allow threshold" };
}
// Uncertain: send to humans
return {
action: "human_review",
reason: `Uncertain: scores outside threshold bands, verdict was ${result.overall_verdict}`,
};
}
Pre-filtering and cost control
LLM calls are expensive. A realistic platform with millions of daily posts cannot run every piece of content through a frontier model. A layered pre-filter slashes costs without reducing safety:
interface PreFilterResult {
skip_llm: boolean;
auto_action?: "allow" | "reject";
reason: string;
}
// Extremely fast pre-filter — runs before any LLM call
function preFilter(content: string): PreFilterResult {
// Empty or trivially short content
if (!content || content.trim().length < 3) {
return { skip_llm: true, auto_action: "allow", reason: "Too short to classify" };
}
// Hard blocklist (maintained separately, loaded at startup)
const blocklist = getBlocklist(); // your implementation
const lowerContent = content.toLowerCase();
for (const term of blocklist) {
if (lowerContent.includes(term)) {
return { skip_llm: true, auto_action: "reject", reason: `Blocklist match: ${term}` };
}
}
// Known-safe patterns (verified user posts a simple link, etc.)
const urlOnlyPattern = /^https?:\/\/[^\s]+$/;
if (urlOnlyPattern.test(content.trim())) {
// URL-only: run through URL reputation checker, not LLM
return { skip_llm: true, reason: "URL-only, route to URL checker" };
}
return { skip_llm: false, reason: "Requires LLM classification" };
}
In practice, a well-tuned pre-filter catches 60–80% of clearly safe content and 15–25% of clearly violating content, leaving a fraction of borderline posts for the LLM.
Async pipeline with queues
Synchronous moderation blocks content before display. Asynchronous moderation allows immediate posting with retroactive removal. The right choice depends on your product:
- Synchronous — higher latency, no violations visible even briefly, appropriate for payment messages, healthcare content, content involving minors
- Asynchronous — low latency, violations briefly visible, appropriate for social feeds, comments, reviews
For asynchronous processing, use a queue:
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
const connection = new Redis(process.env.REDIS_URL!);
interface ModerationJob {
contentId: string;
content: string;
authorId: string;
contentType: "post" | "comment" | "review" | "profile_bio";
submittedAt: string;
}
// Producer — called when user submits content
const moderationQueue = new Queue<ModerationJob>("content-moderation", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
export async function enqueueForModeration(job: ModerationJob) {
await moderationQueue.add("classify", job, {
priority: job.contentType === "profile_bio" ? 10 : 1, // bios get lower priority
});
}
// Consumer — runs on separate worker processes
const worker = new Worker<ModerationJob>(
"content-moderation",
async (job) => {
const preFilterResult = preFilter(job.data.content);
if (preFilterResult.skip_llm && preFilterResult.auto_action) {
await applyModerationDecision(job.data.contentId, {
action: preFilterResult.auto_action,
reason: preFilterResult.reason,
source: "pre_filter",
});
return;
}
const classification = await classifyContent(
job.data.content,
MODERATION_SYSTEM_PROMPT
);
const decision = routeDecision(classification);
await applyModerationDecision(job.data.contentId, {
action: decision.action,
reason: decision.reason,
source: "llm",
llmResult: classification,
});
if (decision.action === "human_review") {
await enqueueHumanReview(job.data, classification, decision);
}
},
{ connection, concurrency: 20 }
);
Audit logging and explainability
Every moderation decision must be logged with enough context to reconstruct why it was made. This matters for appeals, regulatory compliance, and debugging systematic errors.
import { Firestore, FieldValue } from "firebase-admin/firestore";
const db = new Firestore();
interface ModerationAuditEntry {
contentId: string;
authorId: string;
content: string; // store only if legally required; hash otherwise
contentHash: string;
action: "allow" | "reject" | "human_review";
source: "pre_filter" | "llm" | "human";
reason: string;
llmResult?: ModerationResult;
policyVersion: string;
modelVersion: string;
decidedAt: FirebaseFirestore.FieldValue;
reviewerId?: string; // set when human overrides
appealStatus?: "pending" | "upheld" | "overturned";
}
async function logModerationDecision(entry: Omit<ModerationAuditEntry, "decidedAt">) {
const docRef = db.collection("moderation_log").doc();
await docRef.set({
...entry,
decidedAt: FieldValue.serverTimestamp(),
});
// Separate collection for fast appeals lookup
if (entry.action === "reject") {
await db.collection("moderation_rejections").doc(entry.contentId).set({
logRef: docRef.id,
authorId: entry.authorId,
reason: entry.reason,
appealStatus: "none",
decidedAt: FieldValue.serverTimestamp(),
});
}
}
Track policy version and model version separately. When you update either, you can compare decision distributions before and after to detect regressions.
Handling human review queues
The human review queue is not an afterthought — it is where your policy is actually refined. Build it properly from day one:
interface ReviewQueueItem {
contentId: string;
content: string;
llmClassification: ModerationResult;
llmDecision: RouteDecision;
priority: number; // higher = review sooner
createdAt: string;
assignedTo?: string;
reviewedAt?: string;
reviewDecision?: "allow" | "reject";
reviewNotes?: string;
}
// Priority scoring: higher scores get reviewed first
function calculateReviewPriority(
classification: ModerationResult,
contentType: string
): number {
let priority = 0;
// High self-harm score → urgent
if (classification.categories.self_harm > 0.5) priority += 100;
// Violence with credible threat language
if (classification.categories.violence > 0.6) priority += 50;
// Verified users get faster review (reputational risk)
if (contentType === "verified_user_post") priority += 30;
// Recency — older items lose urgency
return priority;
}
// When a moderator submits their decision, feed it back as training signal
async function submitHumanReview(
itemId: string,
reviewerId: string,
decision: "allow" | "reject",
notes: string
) {
const item = await getReviewQueueItem(itemId);
const wasLlmCorrect = item.llmDecision.action === decision ||
(item.llmDecision.action === "human_review"); // escalation = correct
await db.collection("moderation_reviews").doc(itemId).update({
assignedTo: reviewerId,
reviewedAt: FieldValue.serverTimestamp(),
reviewDecision: decision,
reviewNotes: notes,
llmAgreement: wasLlmCorrect,
});
// Track disagreement rate by category for prompt tuning
if (!wasLlmCorrect) {
await logLlmDisagreement(item, decision, reviewerId);
}
}
Review queue disagreement rate is your primary signal for prompt quality. If moderators are overturning LLM decisions at more than 15%, your prompt is miscalibrated — either too aggressive or too permissive in specific categories.
Measuring pipeline health
A moderation pipeline without metrics is flying blind. Track these at minimum:
interface PipelineMetrics {
// Volume
contentSubmittedPerMinute: number;
preFilteredRate: number; // % caught by pre-filter
llmClassifiedRate: number;
humanReviewRate: number;
// Decisions
autoAllowRate: number;
autoRejectRate: number;
escalationRate: number; // % sent to human review
// Quality (requires human labels)
llmPrecision: number; // of LLM rejects, % that humans agree are violations
llmRecall: number; // of true violations, % that LLM caught
humanOverrideRate: number; // % of LLM decisions humans change
// Latency
p50LatencyMs: number;
p99LatencyMs: number;
// Cost
llmTokensPerContent: number;
costPerThousandItems: number;
}
Set up alerts on escalation rate (sudden spike = prompt broke), human override rate (steady increase = policy drift), and p99 latency (indicates queue backup).
Prompt versioning and regression testing
Every prompt change is a code change. Treat it the same way:
// moderation-prompt-v2.ts — check into source control
export const PROMPT_VERSION = "2.1.0";
export const MODERATION_SYSTEM_PROMPT = `...`;
// test/moderation.test.ts
import { describe, it, expect } from "vitest";
import { classifyContent, routeDecision } from "../src/moderation";
import { MODERATION_SYSTEM_PROMPT } from "../src/moderation-prompt-v2";
const testCases = [
{
name: "obvious spam",
content: "CLICK HERE FOR FREE IPHONE https://scam.example.com",
expectedAction: "reject",
expectedCategory: "spam",
},
{
name: "idiomatic violence",
content: "I could absolutely kill for a coffee right now",
expectedAction: "allow",
},
{
name: "borderline political satire",
content: "That politician is a clown who should be thrown out",
expectedAction: "allow",
},
{
name: "targeted harassment",
content: "Everyone should report @user and get them fired",
expectedAction: "flag_for_review",
},
];
describe("content moderation classifier", () => {
for (const tc of testCases) {
it(tc.name, async () => {
const result = await classifyContent(tc.content, MODERATION_SYSTEM_PROMPT);
const decision = routeDecision(result);
expect(decision.action).toBe(tc.expectedAction);
});
}
});
Run this test suite before deploying any prompt change. Add every moderator-corrected case to it over time. The suite becomes a regression oracle for your policy.
Scaling considerations
At 10,000 requests per minute, a few architectural decisions become critical:
Model selection by risk tier. Not every piece of content needs your best model. Route clearly structured posts (job listings, product reviews with structured fields) to a smaller, faster model. Reserve the frontier model for unstructured text and appeal re-reviews.
Batch API for non-urgent content. Most platforms can tolerate 30-minute moderation latency for historical content re-review, user-flagged content, and profile audit passes. Use the Anthropic Batch API for these workloads at 50% cost reduction.
Cache identical content. Profile bios, standard templates, and repeated spam campaigns often produce identical content. A content hash → decision cache with a short TTL (30 minutes) eliminates redundant LLM calls. Invalidate on policy version change.
Idempotent workers. Your queue worker will process the same job multiple times during failures. Make applyModerationDecision idempotent by checking existing decision state before writing.
A well-built content moderation pipeline reduces moderator workload by 70–90%, catches violations in seconds rather than hours, and produces an audit trail that satisfies regulatory requirements. The investment in proper schema, routing logic, and metrics pays off quickly as the platform scales.