.agents/issue/workflow-variable-replace-cpu-blocking-analysis.md
本分析只关注工作流变量替换里的 CPU 阻塞 问题,尤其是 Node.js 主线程被同步字符串扫描、正则替换、JSON 序列化卡住的风险。
当前先只考虑三个低风险优化点:
replaceVariablereplaceEditorVariablegetNodeRunParams暂不处理:
目标不是完全避免 replace,而是减少无意义的变量遍历、无意义的 JSON.stringify 和重复全文扫描。
变量替换目前的主要风险不是单次 String.prototype.replace,而是:
JSON.stringify。runtimeVariables。这些操作都发生在主线程。如果变量数量大、节点数量多、某些变量值很大,就可能造成 event loop 卡顿。
replaceVariable文件:
packages/global/common/string/tools.ts主要调用点:
packages/service/core/workflow/dispatch/tools/textEditor.tspackages/global/core/workflow/runtime/utils.tspackages/service/core/workflow/dispatch/ai/chat.tspackages/service/core/workflow/dispatch/ai/utils.ts当前逻辑概要:
export function replaceVariable(text: any, obj: Record<string, any>, depth = 0) {
if (typeof text !== 'string') return text;
const replacements: { pattern: string; replacement: string }[] = [];
for (const key in obj) {
const val = obj[key];
const formatVal = valToStr(val);
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
replacements.push({
pattern: `{{${escapedKey}}}`,
replacement: formatVal
});
}
replacements.forEach(({ pattern, replacement }) => {
result = result.replace(new RegExp(pattern, 'g'), () => replacement);
});
}
这个函数是变量替换的公共入口,被多个工作流路径复用。当前实现是“变量驱动”:
遍历所有变量 -> 每个变量格式化 -> 每个变量扫描全文替换
风险点:
{{xxx}},也会进入完整逻辑。obj 里的所有变量。valToStr 遇到 object 会同步 JSON.stringify。复杂度接近:
变量数量 * 文本长度 + 所有变量 stringify 成本
更合理的复杂度应接近:
文本长度 + 实际出现变量数量 + 实际出现变量 stringify 成本
改成“模板驱动”:
扫描模板里实际出现的 placeholder -> 只解析命中的变量
建议逻辑:
if (typeof text !== 'string') return text;
if (!text.includes('{{')) return text;
return text.replace(/{{([^}]+)}}/g, (match, key) => {
if (!(key in obj)) return match;
return valToStr(obj[key]);
});
实际实现需要继续保留现有语义:
$1、$&、$' 等特殊字符时,不能被 replace 当作替换语法。while + depth 处理嵌套变量,每轮没有变化时提前退出。建议结构:
let result = text;
let depth = 0;
while (depth <= MAX_REPLACEMENT_DEPTH && result.includes('{{')) {
let changed = false;
result = result.replace(/{{([^}]+)}}/g, (match, key) => {
if (!(key in obj)) return match;
const replacement = valToStr(obj[key]);
if (replacement === match) return match;
changed = true;
return replacement;
});
if (!changed) break;
depth++;
}
这样做的原因:
changed 判断兜底。JSON.stringify。TextEditor 节点会直接调用公共 replaceVariable:
const textResult = replaceVariable(text, {
...customVariables,
...variableState.toRuntimeRecord()
});
当前 TextEditor 还有一个额外成本:
Object.keys(customVariables).forEach((key) => {
let val = customVariables[key];
if (typeof val === 'object') {
val = JSON.stringify(val, null, 2);
}
customVariables[key] = val;
});
这会提前 stringify 所有 customVariables 里的 object,即使模板没有引用对应变量。
第一阶段可以先不单独重构 TextEditor,但公共 replaceVariable 优化后,TextEditor 仍建议同步做一个低风险调整:
customVariables。variableState.toRuntimeRecord() 后续可结合第 3 点一起 lazy 化。这仍属于第 1 点的调用点优化,不单独扩展为新的优化范围。
风险相对低,但需要测试覆盖:
undefined、null、object、array。$1、$&、$'、$` 。已有测试位置:
packages/global/test/common/string/tools.test.tsreplaceEditorVariable文件:
packages/global/core/workflow/runtime/utils.ts当前逻辑中,处理 {{$node.output$}} 前会先执行:
text = replaceVariable(text, variables);
随后再处理节点引用:
const variablePattern = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g;
const matches = [...text.matchAll(variablePattern)];
replaceEditorVariable 是 workflow input 处理的核心函数。它的问题主要在两个方面。
第一,普通变量替换会先对完整 variables 调用公共 replaceVariable:
{{$node.output$}},也会先处理普通变量。variables 中有大对象,可能触发无意义 JSON.stringify。replaceVariable 优化而缓解,但这里仍应加快速路径。第二,节点引用替换目前是:
matchAll 收集引用 -> 生成 replacements -> 每个 replacement 扫描全文
如果同一段文本里有多个节点引用,会重复扫描。
建议分两层优化。
第一层:快速跳过。
if (typeof text !== 'string') return text;
if (text === '') return text;
if (!text.includes('{{')) return text;
第二层:避免对完整变量表做无意义处理。
普通变量:
replaceVariable。{{key}} 时读取 variables[key]。节点引用:
replace(variablePattern, callback)。nodeId 和 outputId。formatVariableValByType 和 valToStr。示意:
result = result.replace(variablePattern, (match, nodeId, id) => {
const variableVal = resolveNodeOutput(nodeId, id);
if (shouldSkipCircularReference(variableVal, `${nodeId}.${id}`)) {
return match;
}
return valToStr(variableVal);
});
需要重点保护兼容行为:
{{key}} 和 {{$node.output$}} 混合出现。已有测试位置:
packages/global/test/core/workflow/runtime/utils.test.tsgetNodeRunParams文件:
packages/service/core/workflow/dispatch/index.ts当前逻辑概要:
const runtimeVariables = this.data.variableState.toRuntimeRecord();
node.inputs.forEach((input) => {
let value = replaceEditorVariable({
text: input.value,
nodesMap: this.runtimeNodesMap,
variables: runtimeVariables
});
value = getReferenceVariableValue({
value,
nodesMap: this.runtimeNodesMap,
variables: runtimeVariables,
isReferenceVal: nodeInputIsReference(input)
});
params[input.key] = valueTypeFormat(value, input.valueType);
});
这是 workflow 的调度热路径。每个节点运行前都会执行。
当前成本来源:
variableState.toRuntimeRecord()。toRuntimeRecord() 会把变量 Map 转成普通对象。replaceEditorVariable。replaceEditorVariable,只是函数内部再返回。节点数量多时,这些成本会被放大。
将:
const runtimeVariables = this.data.variableState.toRuntimeRecord();
改成按需获取:
let runtimeVariables: Record<string, unknown> | undefined;
const getRuntimeVariables = () => {
runtimeVariables ??= this.data.variableState.toRuntimeRecord();
return runtimeVariables;
};
只有在以下情况才调用:
getReferenceVariableValue。{{ 的字符串,需要 replaceEditorVariable。调度层先判断:
const rawValue = input.value;
const needTextReplace = typeof rawValue === 'string' && rawValue.includes('{{');
不满足时不调用 replaceEditorVariable。
如果 nodeInputIsReference(input) 为 true,可以优先调用:
getReferenceVariableValue({
value: input.value,
nodesMap: this.runtimeNodesMap,
variables: getRuntimeVariables(),
isReferenceVal: true
});
这样纯引用值不需要先走文本替换。
需要注意:
风险主要来自兼容性:
建议补充 dispatch 层单测,覆盖:
toRuntimeRecord()。{{key}} 变量仍能替换。{{$node.output$}} 变量仍能替换。replaceVariable优先原因:
JSON.stringify。完成标准:
while + depth 替代递归调用自身。customVariables object。replaceEditorVariable优先原因:
完成标准:
getNodeRunParams优先原因:
完成标准:
runtimeVariables 按需构造。{{ 字符串跳过文本替换。为了确认优化效果,建议在后续实现中保留或增加轻量观测:
replaceVariable 耗时replaceEditorVariable 耗时toRuntimeRecord() 耗时这些观测用于确认是否仍存在主线程同步热点。
第一阶段只做 1、2、3:
replaceVariable 从变量驱动改成模板驱动。replaceEditorVariable 增加快速跳过,并减少重复全文扫描。getNodeRunParams lazy 构造 runtimeVariables,并跳过不需要替换的 input。这三项都属于低风险热路径优化,目标是减少无意义 CPU 消耗,不改变 HTTP JSON body、Laf 等兼容风险更高的逻辑。
本次实现按上述三个低风险优化点落地,并额外把字符串同步处理上限改成系统级配置。
replaceVariable 实际优化已将公共变量替换从“变量驱动”改成“模板驱动”:
{{ 的字符串直接返回。/\{\{([^}]+)\}\}/g 扫描模板中的 placeholder。valToStr。JSON.stringify。while + max depth 替代递归调用。undefined 转空字符串、null 转 "null"、object JSON 化等兼容语义。复杂度从接近:
变量数量 * 文本长度 + 所有变量 stringify 成本
降为接近:
文本长度 + 实际命中 placeholder 数量 + 实际命中变量 stringify 成本
因此 obj key 越多、文本实际引用越少,新方案优势越明显。
replaceEditorVariable 实际优化已做的调整:
{{ 的文本直接返回。replaceVariable 处理普通变量,避免未引用变量 stringify。matchAll + 每个引用 replace 全文 改成单轮 replace 扫描。nodeId.outputId 使用 cache。VARIABLE_NODE_ID、节点 output、节点 input 引用、Map/object nodesMap 等兼容行为。getNodeRunParams 实际优化已抽出 getWorkflowNodeRunParams 便于单测,并在调度热路径做 lazy 化:
runtimeVariables 延迟到实际需要变量替换或引用解析时才调用 variableState.toRuntimeRecord()。{{ 的静态字符串不进入文本替换。pluginInput、dynamic input、childrenNodeIdList、httpJsonBody 和 valueTypeFormat 行为。TextEditor 不再预先遍历并 stringify 所有 customVariables。当前通过 lazy Proxy 保留旧优先级和格式化行为:
JSON.stringify(val, null, 2)。原有 checkStrOversize 写死 100,000,000 字符。本次改成系统级环境变量:
SYSTEM_MAX_STRING_LENGTH_M=100
含义:
1 表示 1,000,000 字符。1 ~ 100,由 serviceEnv 初始化时校验;非法值直接启动失败。100,也就是 100,000,000 字符。@fastgpt/global 移到 @fastgpt/service,内部直接使用 serviceEnv 初始化后导出的 SYSTEM_MAX_STRING_LENGTH;调用方不再传递 maxStringLength。@fastgpt/global 只保留纯工具和 workflow runtime 数据格式化逻辑,避免 global 反向依赖 service。部署模板、Helm values 和中文/英文环境变量文档已同步。
使用独立 node --expose-gc 脚本对比旧逻辑和新逻辑,结果取多轮中位数。测试重点是大字符串、变量多但实际引用少、以及大对象未引用/少量引用场景。
| 场景 | 旧方案 | 新方案 | 提升 |
|---|---|---|---|
| 5MB,无占位符,1000 个 primitive 变量 | 80.89ms | 0.09ms | 880x |
| 5MB,引用 1 个 primitive,1000 个变量 | 99.28ms | 21.90ms | 4.53x |
| 5MB,引用 10 个 primitive,1000 个变量 | 161.39ms | 60.67ms | 2.66x |
| 场景 | 旧方案 | 新方案 | 提升 | stringify 次数(旧/新) |
|---|---|---|---|---|
| 10MB,引用 1 个 primitive,1000 个变量 | 167.33ms | 2.33ms | 71.7x | 0 / 0 |
| 10MB,引用 5 个 primitive,1000 个变量 | 170.61ms | 2.23ms | 76.4x | 0 / 0 |
| 10MB,引用 1 个 10KB object,1000 个 object 变量 | 171.96ms | 2.29ms | 75.2x | 1000 / 1 |
| 10MB,引用 5 个 10KB object,1000 个 object 变量 | 174.64ms | 2.19ms | 79.9x | 1000 / 5 |
100,000,000 字符约等于 95.37MB ASCII 文本。该长度是默认上限,超过才会被 checkStrOversize 拦截。
| 场景 | 旧方案 | 新方案 | 提升 | stringify 次数(旧/新) |
|---|---|---|---|---|
| 100M,无占位符,1000 个 primitive 变量 | 1638.16ms | 1.64ms | 998.88x | 0 / 0 |
| 100M,稀疏 1 个 primitive 引用,1000 个变量 | 1614.71ms | 25.75ms | 62.71x | 0 / 0 |
| 100M,稀疏 5 个 primitive 引用,1000 个变量 | 1734.82ms | 27.57ms | 62.92x | 0 / 0 |
| 100M,稀疏 1 个 10KB object 引用,1000 个 object 变量 | 1636.83ms | 29.17ms | 56.11x | 1000 / 1 |
结论:
本次改动需要重点关注以下兼容点:
undefined、null、object、array 的输出是否与旧逻辑一致。$1、$&、$'、$` 时是否按字面量输出。replaceEditorVariable 的节点 output/input 引用是否保持旧行为。SYSTEM_MAX_STRING_LENGTH_M 是否按 M 单位在 serviceEnv 初始化时校验为 1 ~ 100。