数据截至 (上游 commit 7cdd13a1beec)
节点生态:LLM 调用、84 个内置节点与插件系统
30 秒导读: 前几章讲的是"图怎么被调度、控制流怎么撑起分支循环";这一章讲图里每个方块自己在干什么。 重点看两件事:一是最重的那条路径——Chat 节点如何把"组装消息 → 流式拉取 → 出错重试 → 算 token 与花费" 一口气做完;二是 Rivet 怎么用一张注册表 + 一个插件回调,把 84 个内置节点和一堆外部模型/服务 统一挂进来。
本章承接:引擎(02) 讲"谁来 process",控制流(03)
讲"control-flow-excluded 怎么控分支"。这里不重复调度与控制流,只讲节点内部和扩展机制。
1. 先建立直觉:节点是什么、Chat 节点为什么最重
节点 = 图里一个会算东西的方块。 引擎按拓扑顺序把上游算好的值喂给它,它 process 一把,吐出
新的值给下游。绝大多数节点都很轻(拼字符串、抽 JSON、走个 if)。
Chat 节点是整个货架里工程含量最高的一支。 因为"调一次大模型"远不止发个 HTTP 请求:
- 把用户给的乱七八糟输入(字符串 / 消息 / 消息数组)归一成一串 chat message;
- 一边流式收增量、一边实时回传给 UI(你在画布上看到字一个个蹦出来就是它);
- 网络抖动 / 限流 / 超时要自动重试,还不能把用户已经看到的进度弄丢;
- 收完要数 token、按模型价目表算钱,从一个
cost端口吐出去。
所以本章把 Chat 节点单独拎出来精讲,其余 80 多个节点只做分类点名,细节指回源码。
一句类比:把 Chat 节点当成"带自动重拨、带跑马灯、带计价器的电话机"——你只管说要打给谁, 它负责拨通、断线重拨、把对方的话一个字一个字念给你听、挂机后给你一张话费单。
2. 顶层全景:一次 Chat 调用怎么转
Chat 节点的实现被拆成两层:薄薄的 ChatNodeImpl(节点外壳)+ 厚厚的 ChatNodeBase(真正干活)。
ChatNodeImpl 的每个方法几乎都是原样转发给 ChatNodeBase:
真源码:packages/core/src/model/nodes/ChatNode.ts:61-63 的 ChatNodeImpl.process 只有一行——
return ChatNodeBase.process(this.data, this.chartNode, inputs, context)。为什么要拆?因为像
chatLoopNode 这类节点要复用同一套 LLM 逻辑,把逻辑放进 ChatNodeBase 这个普通对象(不是类),
谁都能 import 来调。
怎么读下面这张图: 从上到下是一次
process的时间顺序;虚线框是"会重复发生"的重试圈。
输入端口 (prompt / systemPrompt / model / functions / …)
│
▼
① 归一化输入 getInputOrData(...) 逐个取参数
getChatNodeMessages 把 prompt 归一成 ChatMessage[],system prompt 塞最前
│ ChatNodeBase.ts:1573 / :1609-1617
▼
② 查模型表 openaiModels[model] → maxTokens / cost / displayName
(查不到就用兜底 Custom) ChatNodeBase.ts:993-1002
│
▼
③ (可选) 客户端数 token context.tokenizer.getTokenCountForMessages(...)
超限就砍 maxTokens 并 warn ChatNodeBase.ts:1033-1051
│
▼
┌─ ④ retry(...) 重试圈 ──────────────────────────────────┐
│ p-retry 包住整个请求 ChatNodeBase.ts:1094 │
│ │ │
│ ▼ │
│ streamChatCompletions(...) 流式拉增量 :1253 │
│ │ │
│ ▼ for await (chunk of chunks) │
│ 拼 response / function_calls :1286-1384 │
│ │ │
│ ▼ 每 ~100ms 节流一次 │
│ context.onPartialOutputs?.(output) 跑马灯回传 :1278 │
│ │ │
│ 出错 → onFailedAttempt 判断要不要重拨 :1499-1563 │
└─────────────────────────────────────────────────────────┘
│ 流收完
▼
⑤ 结算 算 token → getCostForTokens → cost 端口
ChatNodeBase.ts:1448-1478
│
▼
输出端口 (response / function-calls / all-messages / responseTokens / cost / usage / duration)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
ChatNodeImpl | 节点外壳:端口定义、UI、create,process 转发给 base | nodes/ChatNode.ts:13 |
ChatNodeBase | 真正的处理逻辑,被 Chat / ChatLoop 复用 | nodes/ChatNodeBase.ts:102 |
getChatNodeMessages | 把杂乱输入归一成 ChatMessage[] | nodes/ChatNodeBase.ts:1573 |
streamChatCompletions | 对 OpenAI 兼容端点发流式请求,yield 每个 chunk | utils/openai.ts:512 |
openaiModels | 模型 → maxTokens / 每千 token 单价 表 | utils/openai.ts:27 |
getCostForTokens | (tokenCount/1000) * 单价 | nodes/ChatNodeBase.ts:1622 |
3. 核心机制:Chat 节点这条最重的路径
3.1 消息组装——把"什么都能接"归一成消息数组
要解决的小问题: prompt 端口的类型被声明成 ['chat-message','chat-message[]']
(ChatNodeBase.ts:263-269),但实际用户可能连了个字符串、字符串数组、单条消息、消息数组,甚至别的。
节点必须把它们全部收拢成一个 ChatMessage[]。
思路: 用 ts-pattern 的 match 做模式匹配,一种输入形状对应一条归一规则;都不匹配就尽力
coerce 成字符串当 user 消息。
真实实现 getChatNodeMessages @ nodes/ChatNodeBase.ts:1576-1607:
// 示意,非源码:重点看"每种输入形状 → 归一成 ChatMessage[]"
let messages = match(prompt)
.with({ type: 'chat-message' }, p => [p.value]) // 单条 → 包成数组
.with({ type: 'chat-message[]' }, p => p.value) // 已是数组 → 原样
.with({ type: 'string' }, p => [{ type: 'user', message: p.value }])
.with({ type: 'string[]' }, p => p.value.map(v => ({ type: 'user', message: v })))
.otherwise(p => /* 尽力 coerce 成 string / chat-message */ [...]);
关键细节:system prompt 单独走一条端口,并强制塞到最前。 如果消息里已有一条 system 消息,
先把它删掉再把新的 system prompt 插到 [0],避免出现两条系统消息(ChatNodeBase.ts:1609-1617)。
3.2 流式增量 + onPartialOutputs 回传——画布上的跑马灯
要解决的小问题: 大模型回一大段要几秒到几十秒,不能让用户干等。要边收边把"目前收到的半截答案" 推给 UI 显示。
思路: streamChatCompletions 是个异步生成器(async function*),内部用 fetchEventSource
读 SSE,把每个 data 行 JSON.parse 后 yield 出来(utils/openai.ts:512-561)。请求体里强制带上
stream: true 和 stream_options: { include_usage: true }(utils/openai.ts:532-537)——后者让
最后一个 chunk 带上 token 用量,省得客户端自己数。
节点侧用 for await 消费,每个 chunk 把 delta.content 往对应 choice 的数组里追加,
delta.tool_calls 则按 index 累加 name / arguments 字符串,并尝试实时 JSON.parse 出
lastParsedArguments(ChatNodeBase.ts:1296-1335)。
巧妙处——节流回传,别把 UI 冲垮。 每来一个 chunk 都回传会太频繁,于是包了一层节流:
真实实现 nodes/ChatNodeBase.ts:1277-1284:
// 示意,非源码:距上次回传超过阈值(默认 100ms)才真正回传一次
let throttleLastCalledTime = Date.now();
const onPartialOutput = (output) => {
const now = Date.now();
if (now - throttleLastCalledTime > (context.settings.throttleChatNode ?? 100)) {
context.onPartialOutputs?.(output); // 推给 UI / 上层 Subgraph
throttleLastCalledTime = now;
}
};
流循环结束后,还会无条件再回传一次最终结果,确保最后一帧不被节流吞掉
(context.onPartialOutputs?.(output) @ nodes/ChatNodeBase.ts:1387)。
这也解释了
useAsGraphPartialOutput这个开关(默认true,ChatNodeBase.ts:125): 它决定本节点的流式增量要不要透传给"调用本图的 Subgraph 节点",让父图也能看到跑马灯。
3.3 p-retry 重试——断线自动重拨,但不乱重拨
要解决的小问题: 网络会抖、会限流(429)、会超时(408)。要自动重试,但对"我们自己写错了" (4xx 参数错误)这种不该重试的错要立刻抛出。
思路: 用 p-retry 把整个"发请求 + 收流"包起来(import retry from 'p-retry' @ ChatNodeBase.ts:28,
调用点 ChatNodeBase.ts:1094)。退避参数几乎是"永不放弃"式:
| 参数 | 值 | 含义 |
|---|---|---|
forever | true | 一直重试 |
retries | 10000 | 上限一万次 |
maxRetryTime | 1000*60*5 | 但最多重试 5 分钟 |
factor | 2.5 | 指数退避倍率 |
minTimeout / maxTimeout | 500 / 5000 | 退避 0.5s 起、封顶 5s |
randomize | true | 加抖动,避免同时重拨 |
以上见 nodes/ChatNodeBase.ts:1490-1498。
关键是 onFailedAttempt 里的分诊逻辑(ChatNodeBase.ts:1499-1563):
signal.aborted→ 直接抛Aborted,不再重试;- 网络类错误(
fetch failed/terminated)→return,即"吞掉错误,继续重试"; - 429 限流 / 408 超时 → 还有重试次数就顺便把"正在重试…"当作 response 回传给 UI再重试
(
ChatNodeBase.ts:1535-1557),用户不会以为卡死; - 其它 4xx(400–499)→ 这是我们自己请求写错了,立刻抛出,重试没意义。
3.4 计 token 与算钱——从 tokenizer 到 cost 端口
两处都要 token 数: 一是发请求前防止超模型上下文上限,二是收完后算钱。
发请求前(可选,客户端算)。 只有关掉 useServerTokenCalculation 时才在客户端数:
context.tokenizer.getTokenCountForMessages(messages, functions, tokenizerInfo)
(ChatNodeBase.ts:1034)。默认 tokenizer 是 GptTokenizerTokenizer
(integrations/GptTokenizerTokenizer.ts)。数出来若 >= openaiModel.maxTokens 直接报错;
若"输入 + maxTokens"超限,则把 maxTokens 砍到剩余额度的 95% 并加一条 warning
(ChatNodeBase.ts:1036-1050)。
模型价目表 openaiModels。 每个模型一条记录,形如(utils/openai.ts:27-38):
// 示意,非源码:每千 token 单价(prompt/completion 分开计)
'gpt-5': {
maxTokens: 400000,
cost: { prompt: 1.25e-6, completion: 10e-6 },
displayName: 'GPT-5',
supported: { parallelFunctionCalls: true },
},
查不到的自定义模型有兜底:maxTokens 用 overrideMaxTokens ?? 8192、cost 全 0
(ChatNodeBase.ts:993-1002)——即"自定义模型不计价"。
收完后算钱。 优先用服务器回的 usage(prompt/completion tokens),否则用客户端数的。
成本公式极简:
真实实现 getCostForTokens @ nodes/ChatNodeBase.ts:1622-1624:
export function getCostForTokens(tokenCount: number, costPerThousand: number) {
return (tokenCount / 1000) * costPerThousand; // 每千 token 单价 × 千 token 数
}
prompt cost + completion cost 之和写进 cost 端口(ChatNodeBase.ts:1453-1478),
同时 usage 端口带上 prompt_cost / completion_cost / total_cost 明细。
一个诚实的坑(inferred):
utils/openai.ts:42里gpt-5-mini的 prompt 单价写成0.25 - 6(等于-5.75),显然是把0.25e-6手误成减法。这会让该模型的 prompt 成本算成负数 。 只是价目表里的一个笔误,不影响主流程逻辑。
3.5 两条出口:流式 vs 非流式
不是所有请求都能流式。ChatNodeBase.ts:1139 判断:o1-beta 模型(o1-preview/o1-mini)或开了音频输出
走非流式的 chatCompletions(utils/openai.ts:487,一次性 fetch + response.json());其余走
流式 streamChatCompletions。两条出口最后都写同样的输出端口并 Object.freeze(output) 后塞进模块级
cache(ChatNodeBase.ts:100,受 data.cache 开关控制,:1129-1133)。
4. 84 个内置节点的功能地图
先说清数量:packages/core/src/model/nodes/ 下有 84 个 .ts 文件,但其中 ChatNodeBase.ts
是被复用的共享实现、不是节点本身;registerBuiltInNodes 里实际 .register(...) 的是
83 个节点类型(model/Nodes.ts:253-338)。下面按"这节点在 agent 工作流里干的活"分类点名——
只归类、不展开,单节点细节请按文件名进源码看。
文本 / prompt 组装:
| 节点 | 干 什么 | 文件 |
|---|---|---|
| Text | 文本模板 / 插值 | TextNode.ts |
| Prompt | 造一条 chat-message(system/user/assistant) | PromptNode.ts |
| Assemble Prompt / Assemble Message | 把多条片段拼成消息序列 | AssemblePromptNode.ts / AssembleMessageNode.ts |
| Trim Chat Messages | 按 token 预算裁剪历史 | TrimChatMessagesNode.ts |
| Chunk | 按 token 切块 | ChunkNode.ts |
| Chat / Chat Loop | LLM 调用 / 带循环的对话 | ChatNode.ts / ChatLoopNode.ts |
逻辑控制(本章不展开,详见 03):
IfNode / IfElseNode / MatchNode / CompareNode / CoalesceNode / LoopControllerNode /
LoopUntilNode / RaceInputsNode / AbortGraphNode / DelayNode / PassthroughNode。
数据处理与抽取:
| 节点 | 干什么 | 文件 |
|---|---|---|
| Extract JSON | 从文本里抠出 JSON | ExtractJsonNode.ts |
| Extract YAML | 抠 YAML | ExtractYamlNode.ts |
| Extract Regex | 正则抽取 | ExtractRegexNode.ts |
| Extract Object Path | 按路径取字段 | ExtractObjectPathNode.ts |
| Extract Markdown Code Blocks | 抠 markdown 代码块 | ExtractMarkdownCodeBlocksNode.ts |
| To JSON / To YAML / To Markdown Table / To Tree | 序列化成各格式 | ToJsonNode.ts 等 |
| Evaluate / Hash | 表达式求值 / 哈希 | EvaluateNode.ts / HashNode.ts |
数组与对象:
ArrayNode / ObjectNode / FilterNode / JoinNode / SliceNode / SplitNode / ShuffleNode /
PopNode / DestructureNode / NumberNode / BooleanNode / RandomNumberNode。
图组合(把子图当函数用):
| 节点 | 干什么 | 文件 |
|---|---|---|
| Graph Input / Graph Output | 子图的入参 / 出参端口 | GraphInputNode.ts / GraphOutputNode.ts |
| Subgraph | 内联调用另一张图 | SubGraphNode.ts |
| Call Graph / Graph Reference / List Graphs | 动态按引用调图 / 引用图 / 列图 | CallGraphNode.ts 等 |
| Context / Get Global / Set Global | 读上下文 / 读写全局变量 | ContextNode.ts / GetGlobalNode.ts / SetGlobalNode.ts |
| Raise Event / Wait For Event / Delegate Function Call | 事件与函数调用委派 | RaiseEventNode.ts 等 |
数据集与向量(RAG 的料):
| 节点 | 干什么 | 文件 |
|---|---|---|
| Create / Load / Append / Replace Dataset | 数据集增改 | CreateDatasetNode.ts 等 |
| Get Dataset Row / Get All Datasets | 读取 | GetDatasetRowNode.ts / GetAllDatasetsNode.ts |
| Dataset Nearest Neighbors | 数据集内近邻检索 | DatasetNearestNeigborsNode.ts |
| Get Embedding | 取向量 | GetEmbeddingNode.ts |
| Vector Store / Vector Nearest Neighbors | 向量库写入 / 检索 | VectorStoreNode.ts / VectorNearestNeighborsNode.ts |
IO(伸手到外部世界):
HttpCallNode(HTTP 调用)/ ReadFileNode / ReadDirectoryNode / ReadAllFilesNode(文件读取)/
ExternalCallNode(调宿主注入的函数)/ UserInputNode(要人输入)/ DocumentNode / ImageNode /
AudioNode / PlayAudioNode / CronNode / CommentNode。
代码 / MCP(下面 §5 单讲): CodeNode、MCPDiscoveryNode、MCPToolCallNode、MCPGetPromptNode。
5. 两类"能跑外部逻辑"的节点
5.1 Code 节点——沙箱里跑 JS
做什么: 让用户在节点里写一段 JS,拿到 inputs 对象、返回带类型的 outputs
(nodes/CodeNode.ts:44-55 的默认代码示范了这个约定)。
执行不在节点里做,而是委托给 context.codeRunner(nodes/CodeNode.ts:184-197),并把几个能力开关
一起传下去:allowFetch / allowRequire / allowRivet / allowProcess / allowConsole
(CodeNode.ts:22-31)。这几个开关就是沙箱边界——决定这段代码能不能上网、能不能 require。
真实的执行器 IsomorphicCodeRunner @ integrations/CodeRunner.ts:26:它按开关把 console / fetch /
Rivet 等对象拼进参数列表,然后用 new AsyncFunction(...argNames) 把用户代码编译成一个异步函数再调
(CodeRunner.ts:72-74)。
// 示意,非源码:只有被允许的能力才会被注入进作用域
const argNames = ['inputs']; const args = [inputs];
if (options.includeFetch) { argNames.push('fetch'); args.push(fetch); }
if (options.includeRivet) { argNames.push('Rivet'); args.push(Rivet); }
// require / process 在这个执行器里直接拒绝(需要 Node executor)
const AsyncFunction = async function(){}.constructor;
const fn = new AsyncFunction(...argNames, code);
return await fn(...args);
边界要点:
- 这是
Function构造器执行,不是真隔离的 VM——同源代码能访问被注入的对象。真正的安全隔离靠 运行时选哪个执行器(见 05)。 includeRequire/includeProcess在 Isomorphic 执行器里直接抛错"requires the Node executor" (CodeRunner.ts:37-44)——浏览器端跑不了。- 想彻底禁掉动态代码,宿主可换成
NotAllowedCodeRunner,它对任何代码都抛 "Dynamic code execution is disabled"(CodeRunner.ts:80-90)。
5.2 MCP 节点——把 Rivet 接到 MCP 服务器
做什么: 通过 [Model Context Protocol] 发现并调用外部工具/prompt。三个节点分工:
MCPDiscoveryNode(列服务器上的工具)、MCPToolCallNode(调工具)、MCPGetPromptNode(取 prompt)。
和 Code 节点一样,节点本身不实现协议,而是委托给 context.mcpProvider
(nodes/MCPToolCallNode.ts:268,没配就报 "MCP Provider not found")。它支持两种传输:
| 传输 | 触发条件 | 调用 |
|---|---|---|
http | 有 serverUrl(且必须含 /mcp) | context.mcpProvider.httpToolCall(...) MCPToolCallNode.ts:284 |
stdio | 用本地 MCP 配置里的 serverId | context.mcpProvider.stdioToolCall(...) MCPToolCallNode.ts:298 |
MCPProvider 接口本身定义在 integrations/mcp/MCPProvider.ts:135(getHTTPTools / getStdioTools /
httpToolCall / stdioToolCall 等),具体实现由宿主提供。
模式已经很清楚了:凡是"要碰外部世界 / 要执行外部逻辑"的节点(Code、MCP、Dataset、Vector、 Embedding、Chat 的 endpoint),节点只描述意图,真正的能力由
InternalProcessContext上的 provider 注入。 这让同一张图在浏览器、Node、测试里能换不同实现跑——详见 05。
6. 插件系统:用一个回调把外部模型挂进注册表
内置节点覆盖不了所有厂商(Anthropic、Google、Hugging Face…),Rivet 用插件扩展。核心就三个东西: 一个接口、一张注册表、一个取密钥的函数。
6.1 RivetPlugin 接口——插件长什么样
真实定义 model/RivetPlugin.ts:4-24,关键字段:
| 字段 | 作用 |
|---|---|
id | 全局唯一标识 |
name | UI 里显示名 |
register? | 一个回调:(register) => { register(someNode); ... },插件在这里把自己的节点交出去 |
configSpec? | 配置项声明(API key、endpoint 等),UI 据此生成表单 |
contextMenuGroups? | 往右键"新建节点"菜单里加分组 |
一个最小插件长这样(真实的 anthropicPlugin @ plugins/anthropic/plugin.ts:4-30):
// 示意,非源码:结构对齐真实的 anthropicPlugin
export const anthropicPlugin: RivetPlugin = {
id: 'anthropic',
name: 'Anthropic',
register: (register) => { register(chatAnthropicNode); }, // 把节点交出去
configSpec: {
anthropicApiKey: {
type: 'secret',
label: 'Anthropic API Key',
pullEnvironmentVariable: 'ANTHROPIC_API_KEY', // 允许从环境变量兜底
},
// anthropicApiEndpoint: { type: 'string', default: 'https://api.anthropic.com/v1', ... }
},
};
6.2 registerPluginNode——插件节点怎么进注册表
注册表是 NodeRegistration。它对内置节点有 register(model/NodeRegistration.ts:26),对插件节点有
registerPluginNode(model/NodeRegistration.ts:54)。
关键在 registerPlugin(model/NodeRegistration.ts:105-110):它就是调用插件的 register 回调,并把
"每次收到一个节点定义就 registerPluginNode 进来"这件事作为参数传进去:
真实实现 model/NodeRegistration.ts:105-110:
registerPlugin(plugin: RivetPlugin) {
if (plugin.register) {
plugin.register((definition) => this.registerPluginNode(definition, plugin));
}
this.#plugins.push(plugin);
}
registerPluginNode 内部会把插件节点定义包成一个 PluginNodeImplClass 子类,存进 #infos / #implsMap,
并把 plugin 一起记下(NodeRegistration.ts:76-88)——这一步很重要,因为节点运行时要靠它找回
"我属于哪个插件",才能读到该插件的配置(下面 6.3)。重复的 node type 会直接抛 Duplicate node type
(NodeRegistration.ts:62)。
内置的 8 个插件在 plugins.ts:20-30 汇总成一张 plugins 表:
anthropic / autoevals / assemblyAi / pinecone / huggingFace / gentrace / openai / google。
它们各自的 nodes/ 目录里放着要挂的节点(如 anthropic 的 ChatAnthropicNode、openai 的
Assistant/Thread 系列、pinecone 的向量节点)。
6.3 getPluginConfig——从设置或环境变量取密钥
节点运行时不直接碰全局设置,而是通过 context.getPluginConfig(name) 拿配置(例如
ChatAnthropicNode.ts:536 的 context.getPluginConfig('anthropicApiKey'))。这个 context 方法在
GraphProcessor.ts:1640 处绑定到了 getPluginConfig(plugin, settings, name),并且已经把"当前节点属于
哪个插件"绑进去了。
取值优先级(真实实现 utils/getPluginConfig.ts:3-32):
① 该插件没在 configSpec 里声明 name → 返回 undefined(未知配置项)
② settings.pluginSettings[plugin.id][name] 有值(字符串) → 用它 ← UI 里填的
③ 否则看 configSpec[name].pullEnvironmentVariable → 取 settings.pluginEnv[envName] ← 环境变量兜底
④ 都没有 → undefined
// 示意,非源码:先查 UI 设置,再退回环境变量
const spec = plugin.configSpec?.[name];
if (!spec) return undefined;
const fromSettings = settings.pluginSettings?.[plugin.id]?.[name];
if (typeof fromSettings === 'string' && fromSettings) return fromSettings;
const envName = spec.pullEnvironmentVariable === true ? name : spec.pullEnvironmentVariable;
if (envName && settings.pluginEnv?.[envName]) return settings.pluginEnv[envName];
return undefined;
为什么这么设计: 开发时你可以只 export ANTHROPIC_API_KEY=... 就跑(靠 ③);上生产再在 UI/settings
里显式配(靠 ②)。同一份图不用改就能在两种场景切换。
7. 巧妙之处(可借鉴)
- 薄外壳 + 厚 base,逻辑可复用。
ChatNodeImpl.process一行转发给ChatNodeBase.process(ChatNode.ts:61),让chatLoopNode等能直接复用整套 LLM 逻辑,不必继承一棵深类树。 - 重试里顺手回传"正在重试…"。 限流/超时时把提示文字当
response推给 UI(ChatNodeBase.ts:1535-1557), 用户永远知道系统在动,而不是白屏干等。 - 节流回传 + 结束补一帧。 平时按 ~100ms 节流(
ChatNodeBase.ts:1278-1284),流结束再无条件回传一次 (:1387),兼顾流畅与"最后一帧不丢"。 - 能力按开关注入,而非默认全给。 Code 节点的
allowFetch/allowRequire/...(CodeNode.ts:22-31)+ 执行器实现(CodeRunner.ts)让沙箱边界显式、可控、可替换。 - 插件配置"UI 优先、环境变量兜底"。 一个
getPluginConfig同时服务开发便利与生产显式配置 (getPluginConfig.ts:3-32)。
8. 边界与局限(诚实)
- Chat 节点深度绑定 OpenAI 兼容形状。 消息、
tools、response_format、cost 都按 OpenAI 的字段建模 (ChatNodeBase.ts+utils/openai.ts);别家模型(Anthropic/Google)只能走插件节点另起一套,不是复用 Chat 节点。 openaiModels价目表是硬编码快照,会过时且可能有笔误。 例如gpt-5-mini的 prompt 单价被写成0.25 - 6(utils/openai.ts:42),算出来是负数;自定义模型一律按 0 计价(ChatNodeBase.ts:996-1001)。 cost 端口只作参考,别当账单。- Code 节点默认执行器不是强隔离。
IsomorphicCodeRunner用AsyncFunction执行,靠"不注入能力"限制 而非真 VM 沙箱(CodeRunner.ts:72-74);要安全得靠宿主换执行器。 - MCP / Dataset / Vector / Embedding 等节点没有 provider 就直接报错(如
MCPToolCallNode.ts:268), 它们的可用性完全取决于宿主注入了什么。
9. 横向对比(同组其它章)
- 谁来调
process、拉取式数据流怎么转 → 02 引擎。 control-flow-excluded怎么撑起分支/循环/竞速(本章 §4 的逻辑控制类节点)→ 03 控制流。- provider(tokenizer / codeRunner / mcpProvider)从哪来、图怎么嵌进产品跑 → 05 运行时与测试。
- 节点、数据值、图模型的词汇表 → 01 数据模型。
10. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| Chat 节点外壳(转发) | packages/core/src/model/nodes/ChatNode.ts | ChatNodeImpl / chatNode |
| Chat 处理主逻辑 | packages/core/src/model/nodes/ChatNodeBase.ts | ChatNodeBase.process |
| 消息归一化 | packages/core/src/model/nodes/ChatNodeBase.ts | getChatNodeMessages |
| 成本公式 | packages/core/src/model/nodes/ChatNodeBase.ts | getCostForTokens |
| 流式请求(异步生成器) | packages/core/src/utils/openai.ts | streamChatCompletions |
| 非流式请求 | packages/core/src/utils/openai.ts | chatCompletions |
| 模型价目/上限表 | packages/core/src/utils/openai.ts | openaiModels |
| 内置节点注册链 | packages/core/src/model/Nodes.ts | registerBuiltInNodes |
| Code 节点 | packages/core/src/model/nodes/CodeNode.ts | CodeNodeImpl |
| 代码执行器(沙箱) | packages/core/src/integrations/CodeRunner.ts | IsomorphicCodeRunner / NotAllowedCodeRunner |
| MCP 工具调用节点 | packages/core/src/model/nodes/MCPToolCallNode.ts | MCPToolCallNodeImpl.process |
| MCP provider 接口 | packages/core/src/integrations/mcp/MCPProvider.ts | MCPProvider |
| 插件接口 | packages/core/src/model/RivetPlugin.ts | RivetPlugin |
| 节点注册表 | packages/core/src/model/NodeRegistration.ts | registerPlugin / registerPluginNode |
| 取插件配置 | packages/core/src/utils/getPluginConfig.ts | getPluginConfig |
| 内置插件汇总 | packages/core/src/plugins.ts | plugins |
| 示例插件 | packages/core/src/plugins/anthropic/plugin.ts | anthropicPlugin |