Schema Guard:如何在 LLM 輸出變化令 n8n 工作流程出錯前及早發現
The Schema Guard: How I Catch LLM Output Changes Before They Break My n8n Pipelines
作者在 n8n 工作流程中加入 Schema Guard,逐次核對 LLM 輸出的結構契約,並把不匹配資料隔離,避免錯誤流入下游節點。它檢查必填欄位、資料型別、枚舉值和數值範圍;新欄位只記錄為警告,不匹配資料則隔離而非重試。一次提示詞修改令解釋文字混入 category,觸發 24 小時內 41 個枚舉不符;作者把 reasoning 移到獨立欄位,並將 schema 版本升至 4。
My inbox triage workflow ran fine for six weeks. Then, on a Tuesday, it quietly started dumping every single email into the "low confidence" bucket -- three hundred of them, all waiting for human review. The workflow hadn't changed. The model hadn't been swapped. The LLM had simply decided that morning to return confidence on a 0-100 scale instead of 0-1. My IF confidence > 0.8 node kept working. It just kept being wrong. So I built a schema guard: a contract check between every LLM output and the nodes that consume it.
The cost ledger post covered per-run spend tracking. The retry budget post covered how many times an LLM call may fail. This one covers the failure mode neither of them sees: the call succeeds, the JSON parses, and the data is wrong-shaped.
The missing layer
n8n gives you execution logs, error branches, and retries. What it doesn't give you is a type contract between "the model returned something" and "the next node uses it." A Code node that reads $json.items will happily run when items is undefined -- JavaScript just gives you undefined, the IF node treats it as falsy, and the workflow routes everything down the wrong branch. No error. No alert. The run is green.
LLM outputs drift in shape all the time, and none of these are "errors" in the model's eyes:
- a key gets renamed (
confidencebecomesconfidence_score) - a type changes (0-1 float becomes 0-100 integer)
- an enum becomes free text (
"billing"becomes"this looks like a billing question") - a new key appears and an old one quietly stops appearing
My prompt contract process (covered earlier) versions the input side. The schema guard versions the output side: a declared shape, checked on every run, with mismatches quarantined instead of passed downstream.
The guard: one Code node after every LLM parse
Right after the node that parses the model's JSON -- and before any node that reads a field from it -- sits a Guard node. Same shape everywhere, contract declared per workflow:
// Guard node: validate LLM output shape against the contract
const SCHEMAS = {
'inbox-triage': {
version: 3,
required: { subject: 'string', category: 'string', confidence: 'number' },
enums: { category: ['billing', 'support', 'sales', 'spam'] },
ranges: { confidence: [0, 1] },
},
};
const data = $input.first().json;
const schema = SCHEMAS[$workflow.name] || SCHEMAS['inbox-triage'];
const problems = [];
// required keys + types
for (const [key, type] of Object.entries(schema.required)) {
if (!(key in data)) problems.push(`missing:${key}`);
else if (typeof data[key] !== type) problems.push(`type:${key}=${typeof data[key]}`);
}
// enums
for (const [key, allowed] of Object.entries(schema.enums || {})) {
if (key in data && !allowed.includes(data[key]))
problems.push(`enum:${key}="${String(data[key]).slice(0, 40)}"`);
}
// ranges
for (const [key, [lo, hi]] of Object.entries(schema.ranges || {})) {
if (key in data && (data[key] < lo || data[key] > hi))
problems.push(`range:${key}=${data[key]}`);
}
// new keys are warnings, not failures -- worth knowing, not worth blocking
const known = new Set(Object.keys(schema.required));
const newKeys = Object.keys(data).filter(k => !known.has(k));
const out = { ...data, _schema_version: schema.version, _guard_ok: problems.length === 0 };
if (problems.length) {
out._guard_problems = problems;
// log to drift_log (workflow, schema_version, problems, raw keys, ts)
// -> route to output 1: quarantine (Slack alert + manual review queue)
return [{ json: out }, { json: null }];
}
if (newKeys.length) {
// log newKeys to drift_log as warnings only
}
return [{ json: out }, { json: null }];
Three details that matter:
-
The contract is versioned, and the version travels with the data.
_schema_version: 3is stamped on every passing item. When the drift alert fires, the first thing I check is whether the version in the data matches the version in the node -- a version skew means I deployed a prompt change without updating the contract, which is my bug, not the model's. - New keys are warnings, never failures. Models add keys constantly ("helpful" elaborations). Blocking on that would mean alert fatigue within a week. New keys get logged and reviewed in the weekly tally; required-key problems get an immediate alert.
- Quarantine, don't retry. A schema mismatch is almost never transient -- retrying the same prompt against the same model usually returns the same wrong shape (this is why the retry budget post excludes schema failures from the retry budget entirely). Quarantined items go to a human review queue with the raw output attached, so I can see what changed.
The morning query
The guard writes every problem to drift_log. A scheduled workflow runs this at 07:00 alongside the cost ledger digest:
-- schema drift per workflow, last 24h
SELECT workflow, schema_version,
COUNT(*) FILTER (WHERE problems LIKE '%missing%') AS missing_key,
COUNT(*) FILTER (WHERE problems LIKE '%type%') AS type_change,
COUNT(*) FILTER (WHERE problems LIKE '%enum%') AS enum_drift,
COUNT(*) FILTER (WHERE problems LIKE '%range%') AS range_drift,
COUNT(*) AS total_mismatches
FROM drift_log
WHERE ts >= NOW() - INTERVAL 24 HOUR
GROUP BY workflow, schema_version
HAVING COUNT(*) > 0;
The alert rule is simple: any row in this query fires a Slack alert. Zero is the only acceptable number of schema mismatches per day. Unlike the cost ledger's ratio-based alert, schema drift has no "normal background level" -- a contract either holds or it doesn't. One mismatch is one investigation.
The weekly digest separately lists new-key warnings, which is how I learned that one model had started adding a reasoning field to every response -- harmless, but a 15% token increase I would otherwise never have noticed (and yes, that fed back into the cost ledger).
The incident that paid for it
Three weeks after wiring the guard, the alert fired on inbox triage: 41 mismatches in 24 hours, all enum:category="this looks like a billing question". The model had stopped returning my four allowed categories and started returning free-text descriptions.
The cause was a prompt edit I'd made the day before -- I'd asked the model to "explain its reasoning briefly" in the same response, and the explanation bled into the category field. The guard caught it within a day; the fix was one prompt line (reasoning goes in a separate reasoning key, category stays strict enum) and a schema version bump to 4.
Without the guard, the failure mode was silent misrouting: the IF node's strict match on "billing" failed, everything fell through to the default branch, and the human review queue filled up over a weekend. The workflow would have looked perfectly healthy in n8n's execution list -- all green, all wrong.
What the guard is not
It's not a fixer. It doesn't repair bad output, doesn't fall back to a second model, doesn't guess what the model meant. Quarantine is a deliberate dead end -- I'd rather look at 41 raw outputs myself than build an auto-repair layer that silently "fixes" drift and hides the signal.
It's not for free text. Summaries, drafts, generated content -- anything a human reads -- doesn't get a schema. The guard is only for structured outputs that machines consume downstream. Roughly a third of my LLM calls have guards; the rest don't need them.
And it's one more node in every pipeline. The validation itself is microseconds, but it is a place where a bug in my contract can block good data -- which is exactly why the version stamp travels with the data, and why contract changes go through the same shadow-run testing as prompt changes.
I'm building AI Automation Lab -- n8n workflow templates for AI automation -- and keeping a free lab on GitHub. I also send occasional notes on n8n reliability patterns: get them here.
來源:DEV Community · dev.to