文章讨论模型输出虽能解析成 JSON,却因字段缺失或数值尺度变化导致工作流静默出错的问题。提出在模型输出与下游节点之间加入契约检查,捕获形状和取值约定的漂移。
我的收件箱分类工作流稳定运行了六周。然后,在一个周二,它悄无声息地开始把每一封邮件都扔进“低置信度”分组——整整三百封,全都等着人工审核。工作流没改,模型也没换。只是那天早上,LLM 突然决定把 confidence 的取值范围从 0-1 改成了 0-100。我的 IF confidence > 0.8 节点还在正常运行,只是判断一直出错。于是,我做了一个 schema guard:在每份 LLM 输出与消费它的节点之间,加一道契约校验。
之前的成本台账文章讲的是每次运行的费用追踪,重试预算文章讲的是一次 LLM 调用最多允许失败多少次。这篇要讲的是它们都发现不了的一种故障:调用成功了,JSON 也解析成功了,但数据结构不符合预期。
n8n 提供执行日志、错误分支和重试机制,但在“模型返回了内容”和“下一个节点使用这些内容”之间,它没有提供类型契约。一个读取 $json.items 的 Code 节点,即使 items 是 undefined,也会照常运行——JavaScript 只会返回 undefined,IF 节点把它当作假值,工作流就把所有数据送进了错误的分支。没有报错,没有告警,运行状态还是绿色。
LLM 输出的结构经常发生漂移,而在模型看来,下面这些都不算“错误”:
我之前介绍过的 prompt 契约流程,管理的是输入端的版本。schema guard 管理的则是输出端的版本:声明预期的数据结构,每次运行都做校验,遇到不匹配的数据就将其隔离,而不是继续传给下游。
在解析模型 JSON 的节点之后、任何读取其中字段的节点之前,放一个 Guard 节点。各处的校验逻辑相同,契约则按工作流分别声明:
// 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 }];
有三个细节很关键:
契约有版本号,而且版本号会随数据一起流转。 每条通过校验的数据都会带上 _schema_version: 3。漂移告警触发后,我首先检查数据中的版本号是否与节点中的版本号一致。如果版本不一致,就说明我部署了 prompt 变更,却没有更新契约。这是我的 bug,不是模型的问题。
新增字段只记警告,绝不判定为失败。 模型经常添加字段,做一些它自认为“有帮助”的补充。如果因此阻断流程,不出一周就会陷入告警疲劳。新增字段会被记录下来,在每周汇总时审核;必填字段的问题则会立即触发告警。
隔离,不重试。 schema 不匹配几乎都不是暂时性问题——拿同一个 prompt 再调用同一个模型,通常还是会得到同样错误的结构。这也是为什么重试预算那篇文章完全不把 schema 校验失败计入重试预算。被隔离的数据会连同原始输出一起进入人工审核队列,让我能看到到底发生了什么变化。
Guard 会把每个问题写入 drift_log。一个定时工作流会在每天 07:00 执行下面的查询,与成本台账摘要一同运行:
-- 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;
告警规则很简单:只要这个查询返回任何一行,就触发 Slack 告警。每天的 schema 不匹配次数,唯一能接受的数字就是零。与成本台账基于比例的告警不同,schema 漂移没有所谓“正常的背景水平”——契约要么满足,要么不满足。出现一次不匹配,就调查一次。
每周摘要会单独列出新增字段警告。也正是通过它,我才发现一个模型开始在每次响应中添加 reasoning 字段——虽然无害,却让 token 用量增加了 15%。没有这份摘要,我根本不会注意到。当然,这个发现也反馈到了成本台账中。
接入 Guard 三周后,收件箱分类工作流触发了告警:24 小时内出现 41 次不匹配,全部都是 enum:category="this looks like a billing question"。模型不再返回我允许的四个类别,而是开始返回自由文本描述。
原因是我前一天改了 prompt——我让模型在同一份响应中“简要解释推理过程”,结果解释内容混进了 category 字段。Guard 在一天内发现了问题;修复只需要改一行 prompt:推理内容放进单独的 reasoning 字段,category 仍然严格使用 enum,再把 schema 版本升到 4。
如果没有 Guard,这次故障就会表现为悄无声息的错误路由:IF 节点无法严格匹配 "billing",所有数据都落入默认分支,人工审核队列在一个周末里不断堆积。而在 n8n 的执行列表中,工作流看起来依然完全健康——一片绿色,全都错了。
它不负责修复。 它不会修补错误输出,不会回退到第二个模型,也不会猜测模型想表达什么。隔离是我刻意设置的终点——我宁可亲自看完 41 份原始输出,也不想搭建一个自动修复层,悄悄“修好”漂移,把问题信号藏起来。
它不用于自由文本。 摘要、草稿、生成内容——凡是给人看的内容,都不需要 schema。Guard 只用于下游机器消费的结构化输出。我的 LLM 调用中,大约三分之一加了 Guard,其余的都不需要。
它也意味着每条流水线都要多一个节点。 校验本身只需要微秒级的时间,但如果我写的契约有 bug,这个节点也可能拦住本来正确的数据。这正是版本标记必须随数据流转、契约变更必须像 prompt 变更一样经过影子运行测试的原因。
我正在开发 AI Automation Lab,为 AI 自动化提供 n8n 工作流模板,同时在 GitHub 上维护一个免费实验室。我也会不定期发送一些关于 n8n 可靠性模式的笔记,你可以在这里订阅。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。