n8n表达式与函数实战:机器学习工作流动态配置与数据转换
2026/9/15 2:00:31 网站建设 项目流程

做 n8n 工作流做得多了,你会发现真正把人卡住的往往不是节点怎么连,而是表达式怎么写。尤其是想把机器学习这类场景塞进自动化流程里时,模型参数、阈值、版本号、特征数组这些东西总不能写死在节点里,一旦要换环境、调参数、改格式,整个人就被硬编码绑架了。n8n 表达式和函数,就是用来解决这个问题的关键工具:它让你在节点之间动态生成配置、灵活做复杂数据转换,把“写死”变成“算出来”。

这篇文章我打算围绕一个非常具体的诉求来写:如何在 n8n 里用表达式与函数实现机器学习场景下的动态配置,以及面对嵌套 JSON、数组聚合、字段清洗这类复杂数据转换时,表达式到底能写到什么程度、哪些事应该交给 Code 节点或专门的节点。无论你是刚接触 n8n 的新手,还是已经在搭企业级自动化流程的老手,只要你需要在工作流里动态传参、批量转换数据,这篇内容应该能让你少踩不少坑。

1. 先搞清楚:n8n 表达式在机器学习工作流里到底解决什么问题

1.1 “动态配置”的本质:把硬编码变成运行时求值

先说一个我经常看到的误区:很多人把 n8n 当成一个“连线工具”,觉得把节点拖出来、连上线、填好参数,工作流就能跑了。遇到机器学习相关需求时,他们会在 HTTP Request 节点里写死模型 URL,在参数里写死 temperature、top_k、batch_size,甚至在 IF 节点里写死阈值 0.8。

这些值一旦需要变化,就要进编辑器里改节点参数。你可能会想:改参数就改参数呗,反正也不频繁。但真实业务里,模型版本要灰度、A/B 测试要切流量、不同客户要不同阈值、不同环境要用不同的 API Key,这些需求叠加在一起,硬编码的工作流根本维护不住。

动态配置的本质,就是把这些“运行时才会确定”的参数,变成表达式。n8n 的表达式写在双大括号{{ }}里,本质是一段 JavaScript 表达式,它在工作流执行到该节点时被求值。你可以引用上游节点的输出、环境变量、当前时间、甚至是节点内部的固定参数,然后拼接、计算、格式化,最终得到一个字符串、数字或对象。

比如说,你要调用一个文本分类服务,模型版本是上游节点返回的,阈值是环境变量里配置的,那么 HTTP Request 的 body 完全可以写成这样:

{ "model": "{{ $json.modelVersion }}", "texts": "{{ $json.texts }}", "threshold": "{{ $env.CLASSIFY_THRESHOLD }}" }

工作流跑起来的时候,n8n 会逐个计算出$json.modelVersion的值、$env.CLASSIFY_THRESHOLD的值,再填到请求体里。这就叫运行时求值。真正的好处是:模型切版本不用改工作流,改上游数据库或环境变量就行;阈值调优不用发版,改配置就行。

我自己的经验是,动态配置要尽量遵循一个原则:常量进环境变量或者工作流参数,变量进上游节点数据,格式转换交给表达式。这样每一层职责清晰,出问题也好定位。

1.2 复杂数据转换的本质:表达式就是工作流里的“计算器 + 转换器”

说完动态配置,再说复杂数据转换。n8n 的节点与节点之间,传递的是结构化 JSON 数据。比如 Webhook 节点收到一条 Webhook 请求,里面是一大段嵌套 JSON;数据库节点查出来的是一个数组;HTTP Request 节点调完外部 API,返回的可能是一个带datametaerrors多层结构的对象。

但下游节点往往只关心其中某几个字段,或者需要把字段转换成特定格式。比如机器学习服务要求输入是固定顺序的数值数组,可上游接口返回的是带 key 的对象;比如模型要的特征是清洗后的无空格小写字符串,可原始数据里有换行和非法字符;又比如你需要把三天的日志聚合成一个统计向量,再传给模型做预测。

这些操作看起来不起眼,但却是整个自动化流程里最容易出错的地方。n8n 表达式在这里扮演的角色,就是工作流里的“计算器 + 转换器”:它让你在不写完整代码的情况下,完成字段抽取、数值计算、字符串拼接、数组变换、条件选择。

很多人一想到转换复杂数据,第一反应是拖一个 Code 节点写 JavaScript。这当然可以,而且我也经常这么做。但表达式的优势在于:它可以直接写在节点参数里,不用额外增加节点,也不会打断流程的可读性。尤其是“只是取一个字段”“只是拼个字符串”“只是判断一下空值”这种轻量任务,用表达式比用 Code 节点清爽得多。

这里插一句。搜索“n8n 表达式”的时候,经常会看到“表达式必须包含类类型”之类的词,那其实是 Java、C# 这类静态语言编译器抛出来的错误,不是 n8n 环境里的报错。n8n 表达式就是 JavaScript 表达式,它没有“类类型”“类对象”这种概念。你不需要去背什么波兰表达式、表达式树,那些是编译原理里的东西,跟你在 n8n 里写{{ $json.name }}完全不是一回事。n8n 里你只要理解两件事:双大括号里面是一段会被求值的 JavaScript 表达式;表达式的最终结果会替换掉外面那一层字符串。

2. 动态配置实战:用手写表达式替代“每次改参数”

2.1 动态请求体与模型参数组包

先说一个最常见的场景:调用外部机器学习 API。不管你是调用云厂商的 NLP 服务,还是调用团队自己部署的 FastAPI 推理服务,流程都差不多:准备请求体、发送请求、解析响应。请求体里的参数往往不是写死的,而是由上游业务数据决定的。

举个例子。假设你有一个客户评分模型服务,接口接收的参数是customer_idfeatures(一个长度为 8 的数值数组)、model_versionthreshold。如果上游节点已经查询出了客户资料,数据库节点输出类似这样的 JSON:

{ "customer_id": "C10086", "age": 34, "income": 25000, "credit_score": 720, "active_months": 18, "total_orders": 42, "avg_order_value": 326.5, "return_rate": 0.08, "support_tickets": 2 }

你要构造请求体,最简单的方式是在 HTTP Request 节点的 Body 参数里,用 JSON 格式配合表达式:

{ "customer_id": "{{ $json.customer_id }}", "features": [ {{ $json.age }}, {{ $json.income }}, {{ $json.credit_score }}, {{ $json.active_months }}, {{ $json.total_orders }}, {{ $json.avg_order_value }}, {{ $json.return_rate }}, {{ $json.support_tickets }} ], "model_version": "v2", "threshold": "{{ $env.SCORE_THRESHOLD }}" }

这一步的关键是理解$json对象。在 n8n 里,$json代表“当前节点的输入数据”,通常是上一个节点输出的第一条数据。表达式中写{{ $json.income }},就是把输入里的income字段值取出来填进去。

很多人第一次写这种表达式会犯一个错误:在数组里写[{{ $json.age }}, {{ $json.income }}]时,如果上游字段里有字符串,拼出来的 JSON 就是非法的。比如字段credit_score在数据库里是字符串类型,那表达式结果可能是"720",组包后变成["720"],机器学习服务端直接报类型错误。

踩过这个坑之后,我的习惯是:在组包之前先确认上游字段类型,或者干脆用{{ Number($json.credit_score) }}这类显式转换,把数值统一转成 number。表达式里是可以写函数的,Number()String()parseFloat()Math.round()都可以用。

2.2 环境变量、工作流参数和模型版本切换

动态请求体只是第一步。真正让工作流变得“可配置”的,是把那些经常变、但不该被普通用户碰的值,放到环境变量或工作流参数里。n8n 的表达式支持用$env访问环境变量。

举个例子。同一个工作流,你在开发环境调用的是https://dev-model.example.com/predict,生产环境调用的是https://prod-model.example.com/predict。如果你把 URL 写死在 HTTP Request 节点里,每次部署都要改工作流,非常容易出错。正确做法是在环境变量里配置:

ML_ENDPOINT=https://dev-model.example.com/predict ML_MODEL_VERSION=v2 ML_CONFIDENCE_THRESHOLD=0.85

然后在 HTTP Request 节点里这样写:

{ "url": "{{ $env.ML_ENDPOINT }}", "body": { "model": "{{ $env.ML_MODEL_VERSION }}", "threshold": "{{ $env.ML_CONFIDENCE_THRESHOLD }}" } }

这样,开发、测试、生产环境切换时,只需要改环境变量,工作流本身完全不用动。这是我在企业级部署方案里最常用的一招:把环境相关配置全部外部化,工作流里只保留业务逻辑。

除了环境变量,n8n 的节点参数本身也可以当作“静态配置”来用。在表达式里,你可以用$parameter获取当前节点的参数值,也可以把工作流级的某个固定值通过 Set 节点或多个分支传递下去。不过我更推荐的做法是:把“业务上允许随时人工调整的参数”写成工作流里的第一个 Set 节点,作为一个“配置区”,后面的节点统一引用这个配置区里的值。

比如一个定时推理工作流,每天跑一次,batch size 和模型版本会频繁调整。我习惯在工作流最前面放一个 Set 节点,手动设置:

{ "batch_size": 64, "model_version": "v3", "use_cache": true }

后面所有用到这些参数的地方,统一写{{ $node["配置区"].json.batch_size }}。这样调整参数时,只需要打开工作流改第一个节点的值,不用去几十个节点里翻表达式。

这里要注意一个细节:跨节点引用时,表达式要写完整路径。$node["配置区"].json.batch_sizejson就是该节点输出数据的属性。如果你用了多个输出分支,还需要指定第几条,写法是$node["配置区"].json[0].batch_size。具体用哪种取决于你节点的连接方式,建议在表达式编辑器里点选自动生成,不要手写。

2.3 用 Cron 表达式控制模型定时任务,以及动态调度的替代方案

机器学习工作流里,有一类非常典型的定时场景:每天凌晨跑批量推理、每整点同步一次模型文件、每周一重新计算特征缓存。n8n 的 Schedule Trigger 节点支持 Cron 表达式,这是很多人在第一次配置时头大的地方。

Cron 表达式是一个由 5 个星号分隔的字段:分、时、日、月、星期。比如每天凌晨 2 点 30 分跑批量任务,写法是30 2 * * *;每周一早上 8 点整同步模型,写法是0 8 * * 1

我先把常见场景列一个速查表:

含义Cron 表达式
每小时整点执行0 * * * *
每天凌晨 2 点 30 分执行30 2 * * *
每周一 8 点执行0 8 * * 1
每月 1 号 0 点执行0 0 1 * *
每个工作日 9 点执行0 9 * * 1-5
每 15 分钟执行一次*/15 * * * *

Schedule Trigger 节点在配置 cron 时,通常是把表达式作为一个固定字符串填在界面里。如果你希望通过工作流的计算结果来“动态决定下一次执行时间”,那是做不到直接用表达式的,因为调度器在启动时就需要知道 cron 表达式,它不会在每次执行完后再重新求值。

那动态调度怎么办?我有两个替代方案。第一个是“Wait 节点循环法”:用一个 Code 节点计算出下一次等待的毫秒数,再交给 Wait 节点等待,执行完后又回到同一个判断逻辑。第二个是“外部触发法”:由外部定时系统(比如操作系统的 cron)按固定节奏调用 n8n 的 Webhook,Webhook 入口再根据当前业务参数决定是否继续执行。这两个方案我都用过,后者更稳定,也更容易监控。

顺带说一句,定时任务里经常要计算“昨天”“上周一”“本月第一天”这类时间。表达式里可以用new Date()配合时间戳来做。比如批量推理要处理昨天 0 点到 24 点的数据,可以这样写起始时间:

{{ new Date(new Date().setHours(0,0,0,0) - 86400000).toISOString() }}

这个表达式先拿到今天的 0 点,减去一天的毫秒数,得到昨天 0 点,再转成 ISO 字符串。虽然看起来绕,但效果很稳定。

3. 复杂数据转换实战:从嵌套 JSON 到干净特征

3.1 数组扁平化、去重与统计

很多机器学习 API 接收的输入不是单条数据,而是一个数组。比如你要把用户过去 30 天的行为日志聚合成特征向量,再传给模型。上游数据库查询节点返回的数据可能长这样:

[ { "action": "view", "count": 3 }, { "action": "click", "count": 5 }, { "action": "view", "count": 2 }, { "action": "purchase", "count": 1 } ]

而模型要求的输入是固定长度的数组:[总访问次数, 总点击次数, 总购买次数]。这时候就需要做两件事:按类型分组求和,把结果按固定顺序排列。

n8n 表达式里的 JavaScript 数组方法是完整的,mapfilterreduce都能用。下面这个表达式可以算“总 view 次数”:

{{ $json.filter(item => item.action === "view").reduce((sum, item) => sum + item.count, 0) }}

但是注意,$json在 n8n 的普通表达式里通常代表“当前这条数据”。当上游节点返回的是数组时,表达式的$json默认可能是数组本身,也可能需要你用$input$items()来访问完整的列表。这一点在不同版本里行为不完全一致,最稳妥的做法是先加一个日志节点输出看看结构,再写表达式。

我的经验是:如果你需要对“整个数组”做跨行聚合,直接用 Code 节点可能更清晰,因为表达式的$json语义容易把人绕晕。但如果你只是对“当前这条数据的某个字段”做数组内部的变换,表达式写起来反而更快。

比如当前这条数据是一个对象,对象里有个tags数组字段,你希望把它去重后拼成逗号分隔的字符串,这个表达式就可以直接写在输出字段里:

{{ [...new Set($json.tags)].join(",") }}

展开运算符把数组展开到 Set 里去重,再转回数组,最后 join 成字符串。这个写法简洁,而且不需要额外节点。

3.2 嵌套 JSON 抽取、字段改名与正则清洗

接下来是嵌套 JSON。外部 API 返回的数据往往裹了很多层,比如:

{ "status": "ok", "data": { "result": { "prediction": 0.9234, "meta": { "model_version": "v2", "latency_ms": 35 } } } }

如果你只是想拿到prediction值,表达式可以写成{{ $json.data.result.prediction }}。但难点在于:不是每条返回都有data.result。如果外部服务报错了,返回的结构可能变成:

{ "status": "error", "error": { "code": 500, "message": "timeout" } }

这时候直接访问$json.data.result.prediction就会报错。我的处理习惯是:先判断是否存在,再决定取值。表达式里可以写三元表达式:

{{ $json.data?.result?.prediction ?? $json.error?.message ?? "unknown" }}

?.是可选链,遇到空值返回 undefined 而不是抛异常;??是空值合并,前面是 null 或 undefined 时就取后面。这样写既安全又简短。如果你面对的是不熟悉 n8n 表达式的同事,我建议把这个写法多注释说明一下,因为?.??看起来确实有点抽象。

字符串清洗也是高频需求。机器学习模型对输入文本通常很敏感,比如不允许有换行符、不允许有重复空格、大小写必须统一。这个用表达式可以轻松处理:

{{ $json.text.trim().replace(/\s+/g, " ").toLowerCase() }}

这里的replace(/\s+/g, " ")会把所有连续空白字符(包括换行、Tab)替换成单个空格,配合trim()去掉首尾空格,最后统一转小写。如果还想做更复杂的清洗,比如去掉特殊字符,可以换成replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "")

有一个小坑要提醒:n8n 表达式里写正则时,反斜杠\的转义规则和普通 JavaScript 不太一样。你在表达式编辑器里写\s时,编辑器可能会把它当成合法的正则字符;但如果表达式是通过外部 JSON 配置传入的,需要写成\\s才能转义成功。遇到正则没生效的问题,先检查是不是反斜杠层数不对。

3.3 日期、编码与安全转换(以及为什么签名计算不应该用表达式)

数据处理还有一个常见场景:日期格式统一、Base64 编码、URL 编码、字符串与 JSON 互转。这些操作在 n8n 表达式里都有对应的实现方式。

日期格式统一是模型服务里很常见的需求。比如上游给的日期是时间戳、ISO 字符串、甚至是"2025/03/18"这种格式,而模型特征里要求统一用YYYY-MM-DD。你可以用new Date()先解析,再手动拼接:

{{ new Date($json.date).toISOString().slice(0, 10) }}

这个表达式先把日期解析成标准 ISO 字符串,然后取前 10 个字符,得到2025-03-18。简单直接。缺点是时区问题:toISOString()用的是 UTC 时间,如果你的业务在东八区,凌晨的数据会被算到前一天。这种情况下,我更推荐用 Code 节点配合时间库,或者先用一个专门处理日期的节点。

JSON 与字符串互转也很常用。当你需要把整个对象传给下游节点时,有时对方要求的是一个 JSON 字符串而不是对象:

{{ JSON.stringify($json.data) }}

反过来,当你接收到的是一个 JSON 字符串,需要取其中一个字段时:

{{ JSON.parse($json.rawBody).model_version }}

JSON.stringifyJSON.parse是两个最基础也是最有用的函数,几乎每个复杂工作流都会用到。但要注意:如果字符串本身不是合法 JSON,JSON.parse会直接抛异常。所以稳妥的做法是先判断是否能解析,或者用 try/catch 逻辑把它包起来。纯表达式里没法写 try/catch,这种情况下我会把整段逻辑挪到 Code 节点。

最后说一个很多人踩过的坑:给外部 API 做 HMAC 签名。有一些模型服务会在请求头里校验签名,很多人试图在表达式里完成签名计算,但 n8n 表达式上下文里并没有内置的 crypto 函数,你很难在双大括号内直接算出一个 SHA256 HMAC。我踩过这个坑后,就养成了规则:凡是涉及加密、签名、复杂哈希的,直接用 Crypto 节点或者 Code 节点,不要硬塞进表达式。

Crypto 节点可以很方便地生成 HMAC,只要选择算法、填好密钥和消息就行。Code 节点里也可以用 Node.js 的 crypto 模块。表达式擅长的是“计算和转换”,不是“密码学操作”。

4. 高频报错与排查技巧实录

4.1 常见报错成因与解决

表达式写多了,总会遇到报错。我把自己遇到最多的几种情况整理成一张表,方便你排查时对照:

报错现象常见原因解决思路
Cannot read properties of undefined访问了不存在的字段,或上游节点输出结构和预期不一致?.可选链,或在前面加日志节点确认结构
Invalid expression双大括号语法错误、括号不匹配、非法字符把表达式拆开,先写最小片段测试
输出结果是默认文本表达式被当成纯字符串,没有用双大括号包住在参数值里写{{ }},不要在 JSON 的 key 位置写表达式
时间结果差一天使用了 UTC 相关方法检查toISOString()getTime()是否受时区影响
正则替换没生效反斜杠转义层级的问题尝试把\s改为\\s,或改用 Code 节点
表达式求值顺序不对没有注意运算符优先级,字符串拼接和数字相加混在一起加括号明确优先级,数值字段显式用Number()

还有一个很隐蔽的问题,我之前反复遇到:表达式里用引号时容易跟外层的引号冲突。在 n8n 的 JSON 参数里,字符串值用双引号包裹,如果你的表达式内部也用了双引号字符串,就会导致 JSON 解析出错。解决办法是表达式内部尽量用单引号。比如{{ $json.text.split("、")[0] }}这种写法在 JSON 参数里很危险,建议改成{{ $json.text.split('、')[0] }}

4.2 从日志到结构:一套可复制的排查流程

表达式报错最气人的地方在于,信息量太少。好在排查流程是可以标准化的,我现在的习惯是四步走。

第一步,先看输入。在报错节点前面加一个日志节点,把上游输出完整打印出来。很多问题根源根本不是表达式写错,而是你以为的字段名和实际字段名不一样。比如数据库查询结果里字段名带了大写,你写的是小写,自然取不到值。

第二步,简化表达式。把你的长表达式先替换成一个短表达式,比如{{ JSON.stringify($json) }},确认最基础的取值没问题,再逐步把处理逻辑加回去。这样做的好处是把变量一个个隔离出来,不至于同时面对多个问题。

第三步,分段测试。n8n 的表达式编辑器支持实时预览,选一个测试数据,看看表达式结果是什么。如果结果和预期差得很远,就在表达式里多用几个临时函数查看中间值。虽然没法直接断点调试,但分段拼出中间结果是可以做到的。

第四步,固定基线。每次改完表达式,记录一份“输入示例 + 期望输出 + 实际输出”。我自己维护了一个测试数据集合,专门用来验证表达式改动。首次调试我建议用真实数据跑一遍,然后把这个输入保存下来,放在一个固定的测试 Webhook 里,随时可以重放。

排查时还有一个非常实用的技巧:如果你不确定某个表达式在 n8n 里的具体行为,可以先在浏览器控制台里用 JavaScript 模拟一遍。因为 n8n 表达式核心是 JavaScript,除了一部分 n8n 特有的对象(比如$json$node)之外,纯 JS 的部分在控制台里跑的结果跟在 n8n 里基本一致。

5. 把表达式写得像代码一样可维护

5.1 命名与分层:好的表达式也应“可读”

工作流维护成本一大半来自看不懂的表达式。我见过那种写了几百字符、全是括号和链式调用的表达式,跑起来完全正常,但三个月后回去看,根本不知道当初在算什么。所以我给自己定了几个朴素规则。

规则一:表达式只做“一步复杂”。如果一次转换需要拆成两步,就中间用一个 Set 节点过渡,而不是硬把两步合在同一个表达式里。比如先抽取字段、再清洗字符串,分两个节点各写一个表达式,虽然节点多了,但每一步都能单独测试。

规则二:字段命名要贴近业务。上游节点在导出数据时,如果字段名是abc,后面表达式里写$json.a看着就头大。我习惯在数据入口处用 Set 节点做一次“翻译”,把a改成user_ageb改成purchase_count。这相当于给数据做了一层 DTO 映射,后面所有表达式都基于清晰字段名书写。

规则三:常用配置集中管理。把工作流的模型版本、阈值、开关、URL 这类参数,统一放在工作流最前面的“配置区”节点里。后期改参数只需要到这个节点去改,表达式里全部用引用。这样做还有一个好处:你可以在配置区里写注释说明每个参数的作用,这对团队协作尤其重要。

5.2 什么时候该用 Code 节点而不是表达式

这个边界很多人把握不好。我的经验是:表达式擅长“参数级求值和轻量转换”,Code 节点擅长“完整的数据处理流程”。如果你发现一个表达式已经长得像天书,或者你需要写 for 循环、try/catch、多步中间变量,那就停下来,换成 Code 节点。

具体来说,优先级这样判断:单个字段的取值、拼接、格式化,用表达式;对数组做跨行聚合、对多字段做复杂清洗、需要加密签名,用 Code 节点;需要读取文件、调外部 SDK、做异步操作,想都别想用表达式,直接上 Code 节点。

比如前面说的“把多行日志聚合成特征向量”,虽然用表达式能写出来,但可读性很差。同样的逻辑用 Code 节点写,就是几行清晰的 JavaScript。在我看来,工作流的目标是先保证“人看得懂”,其次才是“节点尽量少”。

5.3 我习惯的脚手架:先日志、后表达式、再固化

最后分享一下我在团队里推的脚手架。每当我开始搭一个新的机器学习相关工作流,都会在一开始先铺一层“日志 + 示例数据”。具体操作是:把真实或者模拟的输入数据存放在一个 Set 节点里,后面所有表达式都围绕这组固定数据来开发和验证。

工作流跑通后,再把这组示例数据抽出来,作为自动化测试的输入。以后每次改动表达式,先喂一遍示例数据,确认输出没变,再放到真实生产链路里跑。这个方法帮我挡掉了大量“改一个字段导致下游全挂”的蠢错误。

再配合 n8n 的版本管理,整个表达式的演进过程都有迹可循。谁改了什么、为什么改,都不至于靠记忆。

最后再分享一个小技巧:在表达式编辑器里,大多数时候你可以直接点选上游字段自动生成表达式,而不需要手敲$node[...]路径。手敲容易漏掉.json这一层,点选反而不会。我第一次用 n8n 时总觉得点选生成的代码又长又啰嗦,后来发现那是因为它把完整路径都写清楚了,反而更可靠。现在我的习惯是先点选生成,再在可读性允许的范围内手动精简。

说到底,n8n 表达式是工作流里最不起眼却也最值得花时间熟练掌握的部分。它决定了你的工作流是“一改参数就崩”,还是“换环境、切模型、调阈值都云淡风轻”。希望这篇文章里这些踩坑总结和实操写法,能让你在搭机器学习自动化流程时少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询