畸形输入让 n8n 流程绿灯报错
作者用 30 种畸形输入测试 5 个 n8n 工作流,报告 150 次运行中有 102 次静默失败。线索评分流程甚至将空值、表情和字符串 undefined 判为热线索,而运行状态仍显示成功。
AI 深度解读
Webhook 收到请求,n8n 执行记录全绿,Slack 还发来了「🔥 Hot lead (85)」。可用户填写的留言只有四个 emoji。作者拿 30 种异常或特殊输入测试自己的 5 条 AI 工作流,150 次执行中,102 次悄悄出了错,错误触发器一次也没发现这些问题。
这组实验最有用的地方,是把三件常被混在一起的事拆开了:流程执行成功、模型输出合法、业务结果正确。给模型前加输入校验,再给输出加严格解析,能堵住大量漏洞,但仍然抓不住所有业务错误。
为什么执行全绿,业务却已经出错
作者使用自托管的 n8n 1.123.84,测试线索评分、客服 FAQ、发票提取、会议纪要转行动项、评论回复这 5 条工作流。模型是 Ollama 在 CPU 上运行的 Qwen 1.8B(Q4_K_M),通过兼容 OpenAI 的 /v1 端点调用,配置为 temperature 0.2、response_format: json_object,上下文使用默认的 4096 token。
每条工作流各跑 30 种输入:22 种不可用输入、1 种 text/plain 边界情况,以及 7 种古怪但有效的输入。后者包括阿拉伯语、印地语、夹杂 emoji 的正文、HTML 包裹的正文、词内控制字符、300 个额外字段,以及粘贴进正文的 {{ $env.HOME }} ${process.env.PATH}。
作者没有只看执行状态,还检查了实际答案,例如发票总额是否为 11,800、FAQ 是否正确回答 14 天退款规则。
结果是,110 次不可用输入中,95 次执行仍然全绿。n8n 自己只挡住了截断的 JSON,5 条工作流都返回 HTTP 422,提示 Failed to parse request body。其他格式能解析、内容却不能用的请求,都进入了模型。
102 次静默失败里,46 次生成了看起来可信的结果,56 次返回空结果或转人工:
- 线索评分的 19 次静默失败全部给出了自信结论,总计触发 15 次虚假热线索提醒;Slack 阈值是分数 ≥ 70。
- 评论回复的 20 次静默失败全部生成了正式回复,纯 emoji 甚至被判为好评,
rating_guess为 5。 - 发票正文只有空白时,模型编出了供应商、发票号和金额,总额为 1,800 USD。
- FAQ 对 19 次不可用输入选择转人工,发票提取产生 18 行全空数据,会议流程产生 18 次空泛结果。
执行成功只能证明节点走完了,不能证明输入值得处理,也不能证明答案有依据。
另外 11 次执行确实触发了 Error Trigger,原因全部是上下文超限:约 300 KB 正文和单个 50,000 字符长串各造成 5 次失败,线索评分还因 300 个额外字段失败一次。它的 Build Prompt 会执行 JSON.stringify(body),把所有字段塞给模型。
更麻烦的是,这 11 次失败对调用方都返回 HTTP 200 和空响应体。作者的流程采用 Respond to Webhook 模式,执行在到达响应节点前就终止了。内部收到告警,外部表单或 Zapier 却可能认为提交成功。
输入校验为什么必须放在模型前面
作者的修复是:Webhook 后先接 Code 节点,再用 IF 节点判断输入能不能进入模型。
这段原文代码展示了评论工作流的基本约束,同时检查请求类型和顶层结构:
// Input Guard: runs right after the Webhook, before any LLM call.
const SPEC = { field: 'review', min: 3, max: 3000, optional: ['business', 'policy'] };
const problems = [];
const body = $json.body;
const ct = String(($json.headers || {})['content-type'] || '');
if (!ct.includes('application/json')) problems.push(`content-type must be application/json (got "${ct || 'none'}")`);
const isObj = body !== null && typeof body === 'object' && !Array.isArray(body);
if (!isObj) problems.push(`body must be a JSON object (got ${Array.isArray(body) ? 'array' : body === null ? 'null' : typeof body})`);
const b = isObj ? body : {};
要看的是 SPEC:字段名、最短有效内容、最长文本和可选字段,都由具体工作流决定。
后续校验要求主字段必须是字符串,拒绝 "undefined"、"[object Object]" 等占位字符串;清理 HTML、script/style 内容、不可见字符和模板定界符,再检查有效字符数。
这里用 \p{L}\p{N} 统计各语言的字母与数字,而非 [a-zA-Z]。否则,合法的阿拉伯语和印地语也会被当成无效内容。
长度上限分别是:FAQ 1,000 字符、评论 3,000、线索留言 4,000、发票和会议转录 6,000。代码还拒绝包含连续 60 个以上非空白字符、且全文没有 HTTP URL 的文本。可选字段只接受字符串或数字,清理后截到 300 字符。
另一个关键动作是字段白名单。输出只保留工作流认识的字段,已有的 $json.body.review 路径不变,因此 Build Prompt 无须改动,也不会再把 300 个垃圾字段送进模型。
IF 使用 {{ $json.guard.ok }},按布尔值 is true 分支。失败分支进入 Response Code 为 422 的 Respond to Webhook 节点,并用下面的表达式返回原因:
{{ JSON.stringify({ rejected: true, problems: $json.guard.problems }) }}
这里要看的是 problems:调用方能知道缺少 review,甚至知道自己误传了大小写不同的 Review,而不是只收到一个笼统错误。
作者没有让这些拒绝触发 Error Trigger。输入不合约,应反馈给提交方;把每次坏表单都升级成运维告警,只会制造噪声。
输入拦住了,为什么还会漏掉错误
输入校验之外,作者还替换了 Parse Result。旧实现捕获 JSON 解析异常后,把 {error: ...} 当普通数据继续传递;新实现遇到解析错误直接 throw,让执行失败并触发告警。
原因很具体:FAQ 的 31 次原始运行中,有 8 次返回了带前导空格的 " escalate" 键。它是合法 JSON,却不符合业务字段约定。下游读取 $json.result.escalate 得到 undefined,检查「是否需要转人工」的 IF 就可能漏掉交接。
严格解析因此要求 answer 是非空字符串、escalate 是布尔值,并拒绝输出中的 <script、{{、${。response_format: json_object 并不能替代字段校验。
同样的 150 次测试重跑后,145 次得到正确处理:
- 110 次不可用输入全部返回 422,其中校验节点拦住 105 次,n8n 自身解析器拦住 5 次。
- 5 次
text/plain请求也被明确拒绝。 - 35 次古怪但有效的输入全部通过输入校验,其中 30 次正确完成,1 次触发告警,4 次静默失败。
拒绝大约耗时 0.1 秒,不调用模型。若使用托管模型,被提前拦住的 105 次请求就不会产生模型调用费用。
剩下的 1 次告警,正是 FAQ 再次生成 " escalate",严格解析成功发现了它。4 次静默失败则全部来自会议流程:有效转录被提取为 action_items: []。
作者核对了执行数据,这些失败案例送入模型的提示词与原版本逐字节一致。随后给两个版本各追加 6 次相同转录,原版出现 1 次空行动项,加校验版出现 2 次。这个小样本支持模型输出存在波动,却不足以量化哪个版本更稳定。
空数组在结构上合法,有些会议也确实没有待办。要发现这种漏提取,必须进一步检查业务语义,例如原文有「我会在周四前完成」,结果里却没有行动项。
我的判断
我认同作者划出的边界:输入契约交给代码,输出结构交给校验,业务正确性另设检查。相比继续往提示词里补「不要编造」,这些确定性规则更容易定位问题,也能在调用模型前节省成本。
但不能把 102/150 当成普通团队的故障率。这是作者刻意构造的异常输入集,使用小尺寸本地模型,每个版本的主测试各跑一轮,没有更大模型或生产流量的对照数据。实验说明了故障机制,不能代表所有 n8n AI 工作流的稳定性。
清洗规则也有适用前提。删除 <script>、模板定界符和部分控制字符,适合作者测试的表单内容;若服务本来就接收前端代码、模板示例或报错片段,清洗会改动有效材料。连续长串规则也可能误伤合法数据,其 URL 例外更不是完整的安全判断。
字符上限同样只是作者针对该模型的配置经验。字符数不等于 token 数,完整上下文还要容纳系统提示词、其他字段和输出空间。
此外,作者尚未测试通过 LLM 节点错误输出返回正确 5xx 的方案;加固版工作流也尚未放进仓库。不能把这部分当成已经验证的完整交付。
能拿来做什么
- 给前端表单对应的 n8n 或 Node 接口补上服务端校验,明确字段名、类型、内容长度和允许字段,并让前端展示 422 返回的具体原因。
- 为日常 AI 编程维护一组异常请求样例,覆盖缺字段、
null、字符串"undefined"、纯 emoji、超长文本和字段大小写错误,验收时同时看响应与业务结果。 - 在模型输出处增加结构校验,让缺字段、错误类型和解析失败进入明确的失败路径;对会议行动项这类任务,再补充与原文证据关联的检查。
- 检查 Webhook 调用端的成功判断,尤其是 Respond to Webhook 模式下的异常路径;除了 HTTP 状态,还要确认响应体包含约定的业务结果。