Commit 03dd9c00 by Archer Committed by GitHub

perf: runtime performance (#6665)

* perf: runtime performance

* add stringify trace

* remove trace val

* remove trace val

* remove logger

* remove logger

* add test

* add log
parent 22348594
# 工作流 CPU 阻塞模块分析
> 从 `packages/service/core/workflow/dispatch/index.ts` 入口出发,排查所有**同步占用 CPU、阻塞整个进程**的模块。
Node.js 单线程模型下,CPU 阻塞指:在当前调用栈未让出事件循环(无 `await`)的情况下执行大量计算,导致其他请求无法被处理。
---
## 一、`WorkflowQueue` 构造函数——图算法批量同步执行
**文件**: `packages/service/core/workflow/dispatch/index.ts:348`
每次创建工作流实例时,构造函数**同步**依次执行:
```ts
constructor(...) {
// 1. O(E) 构建边索引
this.edgeIndex = WorkflowQueue.buildEdgeIndex({ runtimeEdges });
// 2. O(N+E) DFS 边分类 ← 递归,全同步
// 3. O(N+E) Tarjan SCC ← 递归,全同步
// 4. O(N²) BFS per node ← 每个节点一次 BFS 回溯
this.nodeEdgeGroupsMap = WorkflowQueue.buildNodeEdgeGroupsMap({ ... });
}
```
三个算法全部是纯同步的 CPU 密集计算,无任何 `await` 让出点。
---
## 二、Tarjan SCC 算法——递归 DFS,无让出
**文件**: `packages/service/core/workflow/utils/tarjan.ts:31`
```ts
function tarjan(nodeId: string) {
// ...
for (const edge of outEdges) {
if (!discoveryTime.has(targetId)) {
tarjan(targetId); // ⚠️ 同步递归,无 await
}
}
}
for (const node of runtimeNodes) {
tarjan(node.nodeId); // 对每个未访问节点启动递归
}
```
**问题**
- 纯同步递归,执行期间完全占用 Event Loop。
- 节点数 N 较大(如 100+ 节点)时,递归深度 = 工作流拓扑深度,调用栈可能很深。
- 同文件 `classifyEdgesByDFS` 也是完全相同的递归 DFS 结构,与 Tarjan 串行执行,等于一次工作流启动 **做两遍图遍历**
---
## 三、`findBranchHandle`——每节点一次 BFS,合计 O(N²)
**文件**: `packages/service/core/workflow/dispatch/index.ts:543`
```ts
private static buildNodeEdgeGroupsMap(...) {
runtimeNodes.forEach((targetNode) => {
// 对每个节点的每条边,调用 findBranchHandle
const branchGroups = this.groupEdgesByBranch(nonBackEdges, ...);
});
}
private static findBranchHandle(edge, ...) {
const queue = [{ nodeId: edge.source, ... }];
while (queue.length > 0) {
// BFS 向上回溯,最坏遍历所有节点 ← 纯同步
const inEdges = edgeIndex.byTarget.get(nodeId) || [];
for (const inEdge of inEdges) {
queue.push({ nodeId: inEdge.source, ... });
}
}
}
```
**问题**
- `buildNodeEdgeGroupsMap` 在构造函数中对每个节点调用,每次调用又做一次 BFS。
- 最坏复杂度 O(N × (N + E)),对于 100 节点、200 边的工作流约为 30000 次循环迭代,全同步。
---
## 四、`replaceEditorVariable`——每节点每输入做正则+递归,全同步
**文件**: `packages/global/core/workflow/runtime/utils.ts:372`
每次节点运行前,`getNodeRunParams` 对每个 input 都调用:
```ts
node.inputs.forEach((input) => {
// 每个 input 都调用一次 replaceEditorVariable
let value = replaceEditorVariable({
text: input.value,
nodes: this.data.runtimeNodes, // 传入所有节点
variables: this.data.variables
});
value = getReferenceVariableValue({ value, nodes, variables });
});
```
`replaceEditorVariable` 内部:
```ts
// 1. 全局正则匹配,提取所有变量引用
const matches = [...text.matchAll(variablePattern)];
for (const match of matches) {
// 2. nodes.find() O(N) 线性扫描
const node = nodes.find((node) => node.nodeId === nodeId);
// 3. 每个变量编译一次新 RegExp ← 正则编译有 CPU 开销
replacements.push({ pattern: `\\{\\{\\$${escapedNodeId}...`, replacement: formatVal });
}
// 4. 如果有嵌套变量,递归调用自身(最多 depth=10)
if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) {
result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 });
}
```
**问题**
- **每次 `nodes.find()`** 是 O(N) 线性扫描,完全没有缓存。一个节点有 10 个 input、每个 input 引用 5 个变量、工作流有 50 个节点 → **2500 次 O(N) 扫描**
- **每个变量引用** `new RegExp(pattern)` 一次,正则编译有 CPU 成本。
- **最多 10 层递归**,每层都重复上述过程。
- 整个函数链(`replaceEditorVariable` + `getReferenceVariableValue`)全同步,**每个节点运行前都会触发,且节点越多调用越频繁**
---
## 五、`getReferenceVariableValue`——O(N) 数组扫描,无缓存
**文件**: `packages/global/core/workflow/runtime/utils.ts:297`
```ts
const node = nodes.find((node) => node.nodeId === sourceNodeId); // O(N)
return node.outputs.find((output) => output.id === outputId)?.value; // O(outputs)
```
**问题**
- 每次调用都线性扫描整个 `nodes` 数组。
-`replaceEditorVariable` 频繁调用(每个变量引用一次)。
- `nodes` 数组在运行时不会变化(节点结构固定),却没有预建索引,每次都从头扫。
---
## 汇总
| 位置 | 函数 | 复杂度 | 触发时机 | 是否有让出点 |
|------|------|--------|---------|------------|
| `dispatch/index.ts` 构造函数 | `buildEdgeIndex` | O(E) | 每次工作流启动 | ❌ 无 |
| `utils/tarjan.ts` | `classifyEdgesByDFS` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 |
| `utils/tarjan.ts` | `findSCCs (tarjan)` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 |
| `dispatch/index.ts` | `buildNodeEdgeGroupsMap` + `findBranchHandle` | O(N²) | 每次工作流启动 | ❌ 无 |
| `runtime/utils.ts` | `replaceEditorVariable` | O(N × inputs × depth) | 每节点运行前 | ❌ 无 |
| `runtime/utils.ts` | `getReferenceVariableValue` | O(N) per call | 每 input 一次 | ❌ 无 |
**最严重的场景**:大型工作流(100+ 节点)并发启动时,构造函数中的图算法(第一~四项)全部同步执行,每个请求都会独占 Event Loop 若干毫秒,并发时互相堆叠,导致明显卡顿。
**最高频的场景**:Agent 节点有大量工具调用时,每轮工具调用后节点重新 resolve 触发下游节点的参数注入,`replaceEditorVariable` 被高频调用,且每次都对所有节点做线性扫描。
......@@ -6,6 +6,7 @@ description: 'FastGPT V4.14.10 更新说明'
## 注意
1. 代码沙盒镜像名变更: `{{hub}}/fastgpt-sandbox` -> `{{hub}}/fastgpt-code-sandbox`
2. 系统工具部分头像,移除了 icon,都转用图片链接,如果丢失了头像,可以重新更新一次系统工具(卸载再安装,或者直接导入 pkg 覆盖)
## 🚀 新增内容
......@@ -15,6 +16,8 @@ description: 'FastGPT V4.14.10 更新说明'
## ⚙️ 优化
1. 工作流 runtime,减少计算复杂。
2. 增加一些对于大变量的计算限制,避免计算复杂度过高导致线程阻塞。
## 🐛 修复
......
......@@ -220,7 +220,7 @@
"document/content/docs/self-host/upgrading/4-14/4140.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-26T16:35:07+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-27T12:01:02+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
......
import crypto from 'crypto';
import { customAlphabet } from 'nanoid';
import path from 'path';
import { getErrText } from '../error/utils';
export const checkStrOversize = (str: string, size = 1e8) => {
if (str.length > size) {
return true;
}
return false;
};
/* check string is a web link */
export function strIsLink(str?: string) {
......@@ -30,7 +38,25 @@ export const valToStr = (val: any) => {
if (val === undefined) return '';
if (val === null) return 'null';
if (typeof val === 'object') return JSON.stringify(val);
if (typeof val === 'object') {
try {
const start = Date.now();
const res = JSON.stringify(val);
if (Date.now() - start > 1000) {
console.warn('Slow JSON.stringify', {
duration: Date.now() - start,
valLength: res.length
});
}
return res;
} catch (error) {
console.error('Failed to stringify value', { error });
return `Failed to stringify value: ${getErrText(error)}`;
}
}
return String(val);
};
......@@ -41,6 +67,9 @@ export function replaceVariable(
depth = 0
) {
if (typeof text !== 'string') return text;
if (checkStrOversize(text)) {
throw new Error('Text length exceeds 100,000,000 characters.');
}
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
......
......@@ -99,6 +99,7 @@ export type ChatDispatchProps = {
export type ModuleDispatchProps<T> = ChatDispatchProps & {
node: RuntimeNodeItemType;
runtimeNodes: RuntimeNodeItemType[];
runtimeNodesMap: Map<string, RuntimeNodeItemType>;
runtimeEdges: RuntimeEdgeItemType[];
params: T;
......
import json5 from 'json5';
import { replaceVariable, valToStr } from '../../../common/string/tools';
import { checkStrOversize, replaceVariable, valToStr } from '../../../common/string/tools';
import { ChatRoleEnum } from '../../../core/chat/constants';
import type { ChatItemType } from '../../../core/chat/type';
import type { NodeOutputItemType } from './type';
......@@ -296,17 +296,16 @@ export const filterWorkflowEdges = (edges: RuntimeEdgeItemType[]) => {
*/
export const getReferenceVariableValue = ({
value,
nodes,
nodesMap,
variables
}: {
value?: ReferenceValueType;
nodes: RuntimeNodeItemType[];
nodesMap: Record<string, RuntimeNodeItemType> | Map<string, RuntimeNodeItemType>;
variables: Record<string, any>;
}) => {
if (!value) return value;
// handle single reference value
if (isValidReferenceValueFormat(value)) {
const resoleValue = (value: [string, string | undefined]) => {
const sourceNodeId = value[0];
const outputId = value[1];
......@@ -316,12 +315,17 @@ export const getReferenceVariableValue = ({
}
// 避免 value 刚好就是二个元素的字符串数组
const node = nodes.find((node) => node.nodeId === sourceNodeId);
const node = nodesMap instanceof Map ? nodesMap.get(sourceNodeId) : nodesMap[sourceNodeId];
if (!node) {
return value;
}
return node.outputs.find((output) => output.id === outputId)?.value;
};
// handle single reference value
if (isValidReferenceValueFormat(value)) {
return resoleValue(value as [string, string | undefined]);
}
// handle reference array
......@@ -330,15 +334,12 @@ export const getReferenceVariableValue = ({
value.length > 0 &&
value.every((item) => isValidReferenceValueFormat(item))
) {
const result = value.map<any>((val) => {
return getReferenceVariableValue({
value: val,
nodes,
variables
});
});
return result.flat().filter((item) => item !== undefined);
return value
.map<any>((val) => {
return resoleValue(val as [string, string | undefined]);
})
.flat()
.filter((item) => item !== undefined);
}
return value;
......@@ -368,20 +369,42 @@ export const formatVariableValByType = (val: any, valueType?: WorkflowIOValueTyp
return val;
};
// 模块级 RegExp 缓存,避免每次变量替换都重新编译正则
const _replaceRegexCache = new Map<string, RegExp>();
const _MAX_REGEX_CACHE_SIZE = 5000;
const _getCachedRegex = (pattern: string): RegExp => {
let re = _replaceRegexCache.get(pattern);
if (!re) {
if (_replaceRegexCache.size >= _MAX_REGEX_CACHE_SIZE) {
_replaceRegexCache.clear();
}
re = new RegExp(pattern, 'g');
_replaceRegexCache.set(pattern, re);
}
return re;
};
// replace {{$xx.xx$}} variables for text
export function replaceEditorVariable({
text,
nodes,
nodesMap,
variables,
depth = 0
}: {
text: any;
nodes: RuntimeNodeItemType[];
nodesMap: Record<string, RuntimeNodeItemType> | Map<string, RuntimeNodeItemType>;
variables: Record<string, any>; // global variables
depth?: number;
}) {
const getNode = (nodeId: string) => {
return nodesMap instanceof Map ? nodesMap.get(nodeId) : nodesMap[nodeId];
};
if (typeof text !== 'string') return text;
if (text === '') return text;
if (checkStrOversize(text)) {
throw new Error('Text length exceeds 100,000,000 characters.');
}
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
......@@ -398,10 +421,10 @@ export function replaceEditorVariable({
if (typeof value !== 'string') return false;
// Check if the value contains the target variable pattern (direct self-reference)
const selfRefPattern = new RegExp(
`\\{\\{\\$${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\$\\}\\}`,
'g'
const selfRefPattern = _getCachedRegex(
`\\{\\{\\$${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\$\\}\\}`
);
selfRefPattern.lastIndex = 0;
return selfRefPattern.test(value);
};
......@@ -431,7 +454,7 @@ export function replaceEditorVariable({
return variables[id];
}
// Find upstream node input/output
const node = nodes.find((node) => node.nodeId === nodeId);
const node = getNode(nodeId);
if (!node) return;
const output = node.outputs.find((output) => output.id === id);
......@@ -439,7 +462,13 @@ export function replaceEditorVariable({
// Use the node's input as the variable value(Example: HTTP data will reference its own dynamic input)
const input = node.inputs.find((input) => input.key === id);
if (input) return getReferenceVariableValue({ value: input.value, nodes, variables });
if (input) {
return getReferenceVariableValue({
value: input.value,
nodesMap,
variables
});
}
})();
// Check for direct circular reference
......@@ -461,13 +490,20 @@ export function replaceEditorVariable({
}
// Apply all replacements
replacements.forEach(({ pattern, replacement }) => {
result = result.replace(new RegExp(pattern, 'g'), replacement);
});
for (const { pattern, replacement } of replacements) {
if (checkStrOversize(result)) {
console.warn('Text length exceeds 100,000,000 characters.');
break;
}
const re = _getCachedRegex(pattern);
re.lastIndex = 0;
result = result.replace(re, () => replacement);
}
// If we made replacements and there might be nested variables, recursively process
if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) {
result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 });
result = replaceEditorVariable({ text: result, nodesMap, variables, depth: depth + 1 });
}
return result || '';
......
......@@ -37,7 +37,7 @@ const buildHttpRequest = ({
const replaceVariables = (text: string) => {
return replaceEditorVariable({
text,
nodes: [],
nodesMap: new Map(),
variables: params
});
};
......
......@@ -749,7 +749,7 @@ export class WorkflowQueue {
if (runningNodePromises.size > 0) {
// 当上一个节点运行结束时,立即运行下一轮
await Promise.race(runningNodePromises).catch((error) => {
logger.error('Workflow race error', { error });
logger.error('Workflow race error', { chatId: this.data.chatId, error });
});
} else {
// 理论上不应出现此情况,防御性退回到让出进程
......@@ -852,14 +852,14 @@ export class WorkflowQueue {
// replace {{$xx.xx$}} and {{xx}} variables
let value = replaceEditorVariable({
text: input.value,
nodes: this.data.runtimeNodes,
nodesMap: this.runtimeNodesMap,
variables: this.data.variables
});
// replace reference variables
value = getReferenceVariableValue({
value,
nodes: this.data.runtimeNodes,
nodesMap: this.runtimeNodesMap,
variables: this.data.variables
});
......@@ -899,6 +899,7 @@ export class WorkflowQueue {
retainDatasetCite: this.data.retainDatasetCite,
node,
runtimeNodes: this.data.runtimeNodes,
runtimeNodesMap: this.runtimeNodesMap,
runtimeEdges: this.data.runtimeEdges,
params,
mode
......
......@@ -69,7 +69,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
responseChatItemId,
variables,
node,
runtimeNodes,
runtimeNodesMap,
histories,
params: {
system_httpMethod: httpMethod = 'POST',
......@@ -110,7 +110,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
const replaceStringVariables = (text: string) => {
return replaceEditorVariable({
text,
nodes: runtimeNodes,
nodesMap: runtimeNodesMap,
variables: allVariables
});
};
......@@ -178,7 +178,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
if (httpContentType === ContentTypes.json) {
httpJsonBody = replaceJsonBodyString(
{ text: httpJsonBody },
{ variables, allVariables, runtimeNodes }
{ variables, allVariables, runtimeNodesMap }
);
return json5.parse(httpJsonBody);
}
......@@ -306,10 +306,10 @@ export const replaceJsonBodyString = (
props: {
variables: Record<string, any>;
allVariables: Record<string, any>;
runtimeNodes: RuntimeNodeItemType[];
runtimeNodesMap: Map<string, RuntimeNodeItemType>;
}
) => {
const { variables, allVariables, runtimeNodes } = props;
const { variables, allVariables, runtimeNodesMap } = props;
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
......@@ -405,15 +405,20 @@ export const replaceJsonBodyString = (
return variables[id];
}
// Find upstream node input/output
const node = runtimeNodes.find((node) => node.nodeId === nodeId);
const node = runtimeNodesMap.get(nodeId);
if (!node) return;
const output = node.outputs.find((output) => output.id === id);
if (output) return formatVariableValByType(output.value, output.valueType);
const input = node.inputs.find((input) => input.key === id);
if (input)
return getReferenceVariableValue({ value: input.value, nodes: runtimeNodes, variables });
if (input) {
return getReferenceVariableValue({
value: input.value,
nodesMap: runtimeNodesMap,
variables
});
}
})();
const formatVal = valToStr(variableVal, isInQuotes);
......
......@@ -105,7 +105,7 @@ function getResult(
condition: IfElseConditionType,
list: ConditionListItemType[],
variables: Record<string, any>,
runtimeNodes: RuntimeNodeItemType[]
runtimeNodesMap: Map<string, RuntimeNodeItemType>
) {
const listResult = list.map((item) => {
const { variable, condition: variableCondition, value, valueType } = item;
......@@ -114,7 +114,7 @@ function getResult(
const conditionLeftValue = getReferenceVariableValue({
value: variable,
variables,
nodes: runtimeNodes
nodesMap: runtimeNodesMap
});
const conditionRightValue =
......@@ -122,7 +122,7 @@ function getResult(
? getReferenceVariableValue({
value: value as ReferenceItemValueType,
variables,
nodes: runtimeNodes
nodesMap: runtimeNodesMap
})
: value;
......@@ -135,7 +135,7 @@ function getResult(
export const dispatchIfElse = async (props: Props): Promise<Response> => {
const {
params,
runtimeNodes,
runtimeNodesMap,
variables,
node: { nodeId }
} = props;
......@@ -144,7 +144,7 @@ export const dispatchIfElse = async (props: Props): Promise<Response> => {
let res = IfElseResultEnum.ELSE as string;
for (let i = 0; i < ifElseList.length; i++) {
const item = ifElseList[i];
const result = getResult(item.condition, item.list, variables, runtimeNodes);
const result = getResult(item.condition, item.list, variables, runtimeNodesMap);
if (result) {
res = getElseIFLabel(i);
break;
......
......@@ -25,14 +25,14 @@ export const dispatchUpdateVariable = async (props: Props): Promise<Response> =>
chatConfig,
params,
variables,
runtimeNodes,
runtimeNodesMap,
workflowStreamResponse,
externalProvider,
runningAppInfo
} = props;
const { updateList } = params;
const nodeIds = runtimeNodes.map((node) => node.nodeId);
const nodeIds = Array.from(runtimeNodesMap.keys());
const result = updateList.map((item) => {
const variable = item.variable;
......@@ -55,7 +55,7 @@ export const dispatchUpdateVariable = async (props: Props): Promise<Response> =>
typeof item.value?.[1] === 'string'
? replaceEditorVariable({
text: item.value?.[1],
nodes: runtimeNodes,
nodesMap: runtimeNodesMap,
variables
})
: item.value?.[1];
......@@ -65,7 +65,7 @@ export const dispatchUpdateVariable = async (props: Props): Promise<Response> =>
return getReferenceVariableValue({
value: item.value,
variables,
nodes: runtimeNodes
nodesMap: runtimeNodesMap
});
}
})();
......@@ -75,15 +75,14 @@ export const dispatchUpdateVariable = async (props: Props): Promise<Response> =>
if (varNodeId === VARIABLE_NODE_ID) {
variables[varKey] = value;
} else {
const node = runtimeNodesMap.get(varNodeId);
// Other nodes
runtimeNodes
.find((node) => node.nodeId === varNodeId)
?.outputs?.find((output) => {
if (output.id === varKey) {
output.value = value;
return true;
}
});
node?.outputs?.find((output) => {
if (output.id === varKey) {
output.value = value;
return true;
}
});
}
return value;
......
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import type {
ApiKeyHealthResponseType
} from '@fastgpt/global/openapi/support/openapi/api';
import {
ApiKeyHealthParamsSchema
} from '@fastgpt/global/openapi/support/openapi/api';
import type { ApiKeyHealthResponseType } from '@fastgpt/global/openapi/support/openapi/api';
import { ApiKeyHealthParamsSchema } from '@fastgpt/global/openapi/support/openapi/api';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { useIPFrequencyLimit } from '../../../../../../../packages/service/common/middle/reqFrequencyLimit';
......
......@@ -41,7 +41,7 @@ describe('replaceJsonBodyString', () => {
const mockProps = {
variables: mockVariables,
allVariables: mockAllVariables,
runtimeNodes: mockRuntimeNodes
runtimeNodesMap: new Map(mockRuntimeNodes.map((node) => [node.nodeId, node]))
};
describe('Basic variable replacement functionality', () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment