Valid JSON, junk inside: guarding model output before users see it
Structured output mode guarantees JSON, not good JSON. A cheap second check against the schema and a short list of junk patterns turned unusable answers into failed steps the ladder could route around.
- LLMs
- Reliability
- JavaScript
Ask a model for JSON with a schema and you will get JSON. It will parse. Your code will not crash. And sometimes a field will start with ", ", or contain Wait — the output must be pure JSON, or politely explain that it cannot help, all inside perfectly valid JSON.
We found this the expensive way. On one of RetroReady's "tell me more" recipes, a cheap model we were trying produced zero clean answers in 30 attempts: every one parsed, and every one had something in it we could not show a user. Parsing is not validation.
Two checks, both cheap
Every JSON answer now passes two checks before anyone sees it.
1. The recipe's own schema, actually enforced. Required keys present, type matches, enum values respected, and a required string is not blank. We deliberately do not check extra keys or lengths here: those are the model being a little verbose, not the model being wrong.
function checkSchema(schema, value, path = "$", required = true) {
if (schema.enum && !schema.enum.includes(value)) return `${path}: not one of ${schema.enum.join(", ")}`;
const t = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
const want = schema.type === "integer" ? "number" : schema.type;
if (want && t !== want) return `${path}: expected ${schema.type}, got ${t}`;
if (want === "string" && required && !value.trim()) return `${path}: blank`;
if (want === "object") {
const req = new Set(schema.required ?? []);
for (const key of req) if (!(key in value)) return `${path}.${key}: missing`;
for (const [key, sub] of Object.entries(schema.properties ?? {}))
if (key in value) { const err = checkSchema(sub, value[key], `${path}.${key}`, req.has(key)); if (err) return err; }
}
if (want === "array" && schema.items)
for (const [i, item] of value.entries()) { const err = checkSchema(schema.items, item, `${path}[${i}]`); if (err) return err; }
return null; // extra keys and lengths are deliberately not checked
}
2. Every string against a short list of junk patterns. This is where the craft is. The patterns are anchored to the start of a field, or worded as talk about the task, so legitimate content passes:
const JUNK = [
/^\s*[,>;:|]/, // stray leading punctuation
/^\s*(wait|hmm|okay so|let me (think|check))\b/i, // leaked reasoning
/\b(output|response) (must|should) be (pure|valid|only) json\b/i, // talking about the format
/^\s*(i'?m sorry|sorry),? (but )?(i|we) (can'?t|cannot|am unable)/i, // a refusal, not a title
/^\s*as an ai\b/i,
];
function junkIn(value, path = "$") {
if (typeof value === "string") {
const hit = JUNK.find((re) => re.test(value));
return hit ? `${path}: matches ${hit}` : null;
}
if (value && typeof value === "object")
for (const [k, v] of Object.entries(value)) { const err = junkIn(v, `${path}.${k}`); if (err) return err; }
return null;
}
Notice what passes: a game literally titled "Sorry!", a series called ".hack", a guide line saying "the video output must be RGB". A pattern that only matches mid-sentence apologies or format-talk catches the junk without catching the catalogue. Every pattern on our list came from a real bad answer, and our tests pin real good answers ("Sorry!", ".hack//Infection") that it must never match.
A refusal is a failed step, not an answer
The guard's verdict plugs into the model ladder from the previous post: an answer the guard refuses counts as a failed step, exactly like a timeout, and the ladder moves on to the next model.
function guard(recipe, raw) {
let value;
try { value = JSON.parse(raw); } catch { return { ok: false, reason: "invalid JSON" }; }
const err = checkSchema(recipe.response.schema, value) ?? (recipe.outputGuard?.off ? null : junkIn(value));
return err ? { ok: false, reason: err } : { ok: true, value };
}
Three accounting rules keep this honest:
- A rejected step still cost money. Its spend goes into the recipe's spend counter and the daily budget. Pretending otherwise hides a model that is cheap per call and expensive per usable answer.
- The user is charged only for the answer they get. If every step fails, the call is refunded.
- Count rejections per model. A
rejected|<tier>|<model>counter tells you which model produces junk on which recipe, measured on your own traffic rather than a benchmark.
Free text is different
Text answers (a chat reply, an explanation) get only the recipe's own patterns, not the built-in list: a good answer may rightly open with "I'm sorry to say that cartridge is a reproduction". Per recipe, the config can add patterns, drop the defaults, or turn the guard off entirely, so tuning it never needs an app release.
Takeaway
Structured output is a parsing guarantee, not a quality guarantee. A forty-line check turned "occasionally embarrassing" into "occasionally slower", which is a trade every user will take. Write the schema check, keep a junk list built from real failures, test each pattern against real good answers, and let a refused answer fail over like any other error.