Skip to the solution
HardSystem Design17 min read

Guardrails for LLM apps: how do you implement input/output safety in 2025?

993 views

The problem

Question presented to candidate: "You are shipping a customer-facing LLM assistant that streams its answers and can call a few tools. Walk me through how you would implement input and output safety in production: which checks, where they run, what they cost in latency, and what happens when a check itself fails."

What a strong answer should cover:

  • A threat-driven list of guards: input (abuse, injection and jailbreak detection, PII, scope), retrieved and tool content (indirect injection), output (harmful content, PII and secret leaks, schema, grounding, brand policy), each tied to a concrete harm
  • Placement in a central gateway or middleware so every route and model gets the same policy, with versioned configuration and per-route settings
  • Latency engineering: cheap checks first with short-circuit, independent checks in parallel, per-guard timeouts, and an explicit fail-open or fail-closed rule per guard
  • Streaming output: the trade-off between buffering (safe, slower first text), sentence-by-sentence checking (synchronous) and scanning in the background (asynchronous, fast but unsafe text can be seen briefly), as cloud guardrail products also expose
  • The 2025-26 tooling landscape with honest limits: open classifiers (Llama Guard 4, Prompt Guard 2), hosted moderation and shield APIs, cloud guardrail services, and frameworks such as NeMo Guardrails, all of which still need tuning on your traffic
  • Operating it: logging every trigger, shadow mode before enforcing, threshold tuning on labelled data, red-team cases as regression tests

Clarifying questions expected:

  • What can the tools do, and which actions are irreversible?
  • What is the latency budget for time to first token?
  • Are there regulatory or data-residency limits on sending text to a third-party moderation service?

Code / implementation expected: Optional โ€” a guard pipeline with timeouts and fail modes, or a streaming output check; SDK calls to a moderation or guardrail API.

  • guardrails
  • safety

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

17 min read ยท 13 sections

Read the code

Streaming output moderation on a fake clock
// Guarding a streamed answer on a fake clock: no guard, buffer-everything,
// sentence-by-sentence (synchronous) and scan-in-background (asynchronous).
const TTFT = 600, PER_TOKEN = 40, CHECK_MS = 150; // assumed latencies in ms
const SENTENCES = [
  "Your alarm has a service mode for technicians.",
  "It is documented in chapter four of the manual.",
  "To bypass the alarm silently cut the blue sensor wire first.",
  "Call support if the panel shows a red light.",
];
const flagged = (s) => s.includes("bypass the alarm"); // stub safety classifier

// One token per word; token i leaves the model at TTFT + i * PER_TOKEN.
const tokens = [];
SENTENCES.forEach((s, k) => s.split(" ").forEach((w) => tokens.push({ k, w, t: TTFT + tokens.length * PER_TOKEN })));
const sentenceEnd = SENTENCES.map((_, k) => Math.max(...tokens.filter((x) => x.k === k).map((x) => x.t)));
const lastToken = tokens[tokens.length - 1].t;
const unsafeK = SENTENCES.findIndex(flagged);

// The checker handles one call at a time: sentence k is checked when it is complete and the checker is free.
function checkTimes() {
  let free = 0;
  return sentenceEnd.map((end) => { const start = Math.max(end, free); free = start + CHECK_MS; return free; });
}

function run(mode) {
  const done = checkTimes();
  if (mode === "no guard") {
    return { first: tokens[0].t, end: lastToken, shown: tokens.length, unsafe: tokens.filter((x) => x.k === unsafeK).length };
  }
  if (mode === "buffer whole answer") {
    const verdictAt = lastToken + CHECK_MS; // one check on the full text
    return { first: verdictAt, end: verdictAt, shown: 0, unsafe: 0, note: "whole answer replaced by a refusal" };
  }
  if (mode === "sync per sentence") {
    let shown = 0, first = null, end = 0;
    for (let k = 0; k < SENTENCES.length; k++) {
      end = done[k];
      if (flagged(SENTENCES[k])) return { first, end, shown, unsafe: 0, note: `stopped at sentence ${k + 1}` };
      if (first === null) first = done[k];
      shown += tokens.filter((x) => x.k === k).length;
    }
    return { first, end, shown, unsafe: 0 };
  }
  // async: every token is shown at once; the stream is cut when the flag comes back.
  const cutAt = done[unsafeK];
  const seen = tokens.filter((x) => x.t <= cutAt);
  return {
    first: tokens[0].t, end: cutAt, shown: seen.length,
    unsafe: seen.filter((x) => x.k === unsafeK).length,
    note: `cut at ${cutAt} ms, retract shown text`,
  };
}

console.log(`${tokens.length} tokens, last token at ${lastToken} ms, unsafe sentence = ${unsafeK + 1}, sentence ends at ${sentenceEnd.join("/")} ms`);
console.log("mode                  first text  last event  tokens shown  unsafe tokens seen  note");
for (const mode of ["no guard", "buffer whole answer", "sync per sentence", "async scan"]) {
  const r = run(mode);
  const first = r.first === null ? "none" : `${r.first} ms`;
  console.log(`${mode.padEnd(20)}  ${first.padStart(10)}  ${(r.end + " ms").padStart(10)}  ${String(r.shown).padStart(12)}  ${String(r.unsafe).padStart(18)}  ${r.note || ""}`.trimEnd());
}
Parallel guards with timeouts and fail policy
// Running input guards in sequence vs in parallel, with a per-guard timeout and a
// fail-open / fail-closed policy for when a guard itself breaks. Fake clock, no network.
const TIMEOUT_MS = 300;
// Each guard: cheap ones first. failMode says what an outage of that guard means.
const GUARDS = [
  { name: "pii_regex", failMode: "closed" },
  { name: "injection_clf", failMode: "closed" }, // the app has tools: never skip this one
  { name: "moderation_api", failMode: "open" }, // hosted service: allow, but log for review
  { name: "topic_llm", failMode: "open" },
];
// Simulated behaviour per request: [latency ms, verdict] where verdict is pass | block | error.
const PLAN = {
  r1: { pii_regex: [2, "pass"], injection_clf: [35, "pass"], moderation_api: [120, "pass"], topic_llm: [240, "pass"] },
  r2: { pii_regex: [2, "pass"], injection_clf: [35, "block"], moderation_api: [130, "pass"], topic_llm: [260, "pass"] },
  r3: { pii_regex: [2, "pass"], injection_clf: [40, "pass"], moderation_api: [900, "pass"], topic_llm: [250, "pass"] },
  r4: { pii_regex: [2, "pass"], injection_clf: [5, "error"], moderation_api: [110, "pass"], topic_llm: [230, "pass"] },
};

// What one guard call yields after the timeout is applied.
function call(req, g) {
  const [ms, verdict] = PLAN[req][g.name];
  if (ms > TIMEOUT_MS) return { ms: TIMEOUT_MS, outcome: "timeout" };
  return { ms, outcome: verdict };
}

// A broken guard (timeout or error) is turned into pass or block by its fail mode.
function decide(g, outcome, notes) {
  if (outcome === "pass") return "pass";
  if (outcome === "block") { notes.push(`${g.name} blocked`); return "block"; }
  notes.push(`${g.name} ${outcome} -> fail-${g.failMode}`);
  return g.failMode === "closed" ? "block" : "pass";
}

function sequential(req) {
  let clock = 0, calls = 0;
  const notes = [];
  for (const g of GUARDS) {
    const r = call(req, g);
    clock += r.ms; calls++;
    if (decide(g, r.outcome, notes) === "block") return { verdict: "BLOCK", clock, calls, notes };
  }
  return { verdict: "ALLOW", clock, calls, notes };
}

function parallel(req) {
  const notes = [];
  const results = GUARDS.map((g) => ({ g, r: call(req, g) }));
  const decisions = results.map(({ g, r }) => decide(g, r.outcome, notes));
  const blockAt = results.filter((x, i) => decisions[i] === "block").map((x) => x.r.ms);
  // Block as soon as the first blocking verdict lands; otherwise wait for every guard.
  const clock = blockAt.length ? Math.min(...blockAt) : Math.max(...results.map((x) => x.r.ms));
  return { verdict: blockAt.length ? "BLOCK" : "ALLOW", clock, calls: GUARDS.length, notes };
}

console.log(`timeout per guard = ${TIMEOUT_MS} ms`);
const totals = { seq: 0, par: 0 };
for (const req of Object.keys(PLAN)) {
  const s = sequential(req), p = parallel(req);
  totals.seq += s.clock; totals.par += p.clock;
  console.log(`${req}  sequential: ${s.verdict} at ${String(s.clock).padStart(3)} ms after ${s.calls} calls | parallel: ${p.verdict} at ${String(p.clock).padStart(3)} ms after ${p.calls} calls`);
  for (const n of p.notes) console.log(`      ${n}`);
}
console.log(`guard latency added over 4 requests: sequential ${totals.seq} ms, parallel ${totals.par} ms`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 65 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

What is semantic caching and when is it safe to use?

Continue