跳到主要内容

数据截至 (上游 commit e741923f72c3)

Agent 块、模型 Provider 与工具层

30 秒导读: 画布上的一个 Agent 块被调度到之后,Sim 要做三件事——把用户勾选的工具编译成模型能读的 schema、把提示词和历史拼成消息数组、把这一切丢进某个模型 Provider 里的工具调用循环。这一章讲完这条从「块配置」到「真实 HTTP 请求 / 沙箱进程」的全程。

本章是本组里最像传统 agent 实现的一层。前面几章讲的是编排(调度引擎子流程与变量),这一章讲的是编排之下、单个 Agent 块内部的那台发动机。


1. 这是什么(零基础也能懂)

1.1 一句话定义

Agent 块 = 一次「带工具的模型对话」的完整执行单元。 用户在画布上配置模型、提示词、勾几个工具;运行时这个块自己完成「问模型 → 模型要调工具 → 真去调 → 把结果喂回模型 → 再问」的整轮循环,直到模型不再要工具为止。

1.2 它要解决什么问题

一个能用的 agent 平台,必须同时回答四个很不一样的问题:

问题白话Sim 的答案在哪
模型从哪来OpenAI / Anthropic / Bedrock / 本地 Ollama 都要能接apps/sim/providers/registry.ts 的 21 个 provider
工具从哪来内置集成、用户自己写的代码、外接 MCP 服务器、技能文档AgentBlockHandler.formatTools
谁来填参数有些参数用户在画布上填死,有些必须模型现编ParameterVisibility 四值协议
工具怎么真跑大多数是 HTTP,少数要跑用户代码executeTool + 两套代码沙箱

1.3 规模感(as of 38c088a8)

先给几个数字,好知道这层有多厚:

东西数量出处
工具注册表条目3774apps/sim/tools/registry.ts:5542 起的 tools 映射
集成块注册表条目302apps/sim/blocks/registry-maps.ts:370BLOCK_REGISTRY
块定义文件276apps/sim/blocks/blocks/ 目录
模型 Provider21apps/sim/providers/registry.ts:31providerRegistry
错误抽取器18apps/sim/tools/error-extractors.ts:66ERROR_EXTRACTORS

一个块可以带多个工具(一个「操作」对应一个工具 id),所以块数远小于工具数。

3774 是 tools 映射的键数。数这张表容易少数:有 28 条键名太长被格式化成两行(sportmonks_football_* 系列最典型),用 grep -c '^ name: value$' 这类单行模式只会数到 3746。

1.4 用起来什么样

用户视角只有一个块:选 claude-sonnet-4-6,写一句系统提示,勾上「Gmail 发信」和一个自己写的 custom-tool,连线跑起来。

模型视角则是一份被自动生成的工具清单。下面这段是示意,非源码,演示 Agent 块最终喂给模型的东西长什么样:

// 示意,非源码:一次 provider 请求的骨架
{
provider: 'anthropic',
model: 'claude-sonnet-4-6',
messages: [
{ role: 'system', content: '你是客服助手…' },
{ role: 'user', content: '把这封投诉转给售后' },
],
tools: [
// 用户已经在画布上填死 to/from 的参数,这里不会出现在 schema 里
{ id: 'gmail_send', parameters: { type: 'object', properties: { subject: {}, body: {} } } },
{ id: 'custom_翻译', parameters: {} },
],
}

重点看 tools[].parameters 里少了什么 —— 用户填过的参数被剔掉了,只留下必须由模型现编的那几个。这就是 §3.2 要讲的可见性协议。

1.5 一句话直觉

把 Agent 块当成一台带外设的主机:Provider 层是 CPU 插槽(换 CPU 不换主板),工具层是 IO 总线(所有外设一个协议),沙箱是那块专门跑不可信代码的隔离卡。


2. 顶层全景(它大概怎么转)

2.1 三层结构

怎么读这张图:自上而下是一次调用的下沉方向,中间那层的循环箭头是关键——工具层会被反复调用,最多 20 轮。

┌────────────────────────────────────────────────────────┐
│ Agent 块层 executor/handlers/agent/ │
│ · 装配工具(四种来源合成一张表) │
│ · 装配消息(提示词 + 记忆 + 技能清单) │
│ · 装配请求 → ProviderRequest │
└────────────────────────┬───────────────────────────────┘
│ executeProviderRequest(providerId, req)
┌────────────────────────▼───────────────────────────────┐
│ Provider 层 providers/ │
│ · 选执行器、解 API key、注入结构化输出指令 │
│ · 工具调用循环(≤ MAX_TOOL_ITERATIONS = 20): │
│ 问模型 ─→ 有 tool_use? ─→ 并发执行 ─→ 回喂 │
│ ▲ │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────┬───────────────────────────────┘
│ executeTool(id, params)
┌────────────────────────▼───────────────────────────────┐
│ 工具层 tools/ │
│ 权限 → 托管 key → OAuth → 发请求 → 重试 → 后处理 │
│ ├─ 内部 /api/* 普通 fetch │
│ ├─ 外部 URL SSRF 防护 + IP 钉死 │
│ ├─ MCP 转 /api/mcp/tools/execute │
│ └─ 用户代码 isolated-vm 或 E2B 沙箱 │
└────────────────────────────────────────────────────────┘

2.2 部件一句话职责

部件干什么文件
AgentBlockHandler块级总装:工具、消息、请求、响应apps/sim/executor/handlers/agent/agent-handler.ts
Memory会话记忆的取/种/追加,含三种截断策略apps/sim/executor/handlers/agent/memory.ts
skills-resolver技能的渐进式披露(先给目录、按需 load)apps/sim/executor/handlers/agent/skills-resolver.ts
executeProviderRequestprovider 门面:选执行器、算钱、BYOK 归零apps/sim/providers/index.ts:164
providerRegistry21 个 provider 的静态表apps/sim/providers/registry.ts:31
anthropic/openai core.ts两段真正的工具调用循环apps/sim/providers/anthropic/core.tsapps/sim/providers/openai/core.ts
executeTool单个工具执行的统一管线apps/sim/tools/index.ts:1513
tools 注册表3774 个工具定义apps/sim/tools/registry.ts:5542
沙箱跑用户写的 JS/Pythonapps/sim/lib/execution/isolated-vm.tse2b.ts

2.3 主线走一遍(不进代码)

AgentBlockHandler.execute(agent-handler.ts:217)的九步,顺序本身就是设计:

  1. 先剔掉连不上的 MCP 工具(filterUnavailableMcpTools,:155)——不能让一个挂掉的 MCP 服务器污染整张工具表。
  2. 再校验权限(validateToolPermissions,:140):用了 MCP/自定义工具就查企业版开关。
  3. 解析结构化输出格式、确定模型、校验模型 provider 是否被允许(:73-76)。
  4. 装配工具(formatTools,:207)。
  5. 装配技能(:85-94):把技能目录塞进系统提示,并挂一个 load_skill 工具。
  6. 装配消息(buildMessages,:593),再挂附件、按 provider 能力做 base64 水化。
  7. ProviderRequest(buildProviderRequest,:888)。
  8. 调 provider(:959),里面是那个循环。
  9. 处理响应:流式包一层记忆持久化(wrapStreamForMemoryPersistence,:1066),非流式直接落库。

3. 核心原理(逐个机制)

3.1 工具装配:四种来源,一张表

要解决的小问题

模型只认一种东西:一组 {name, description, JSON Schema}。但 Sim 的工具有四种完全不同的来源,得先归一。

四种来源

来源用户在画布上干了什么装配函数结果 id 形如
集成块勾了「Gmail / Slack / …」某个操作transformBlockTool(apps/sim/providers/utils.ts:646)gmail_send
自定义工具自己写了 schema + 一段 JScreateCustomTool(agent-handler.ts:864)custom_<标题>
MCP 工具连了一台 MCP 服务器并勾了工具buildMcpTool(agent-handler.ts:1367)mcp-<serverId>-<toolName>
技能勾了若干技能文档buildLoadSkillTool(skills-resolver.ts:160)load_skill

装配前先做一次过滤:usageControl === 'none' 的工具直接丢掉(agent-handler.ts:700),因为「none」的语义是「这一轮别用它」。

集成块当工具:两次转译

transformBlockTool 干的事比名字重:它要把块定义 + 用户选的操作翻译成一个具体工具的 LLM schema

// providers/utils.ts:536-556(节选)
if ((blockDef.tools?.access?.length || 0) > 1) {
if (selectedOperation && blockDef.tools?.config?.tool) {
toolId = blockDef.tools.config.tool({ ...block.params, operation: selectedOperation })

一个块通常挂多个工具(tools.access),tools.config.tool() 是块自己写的「按 operation 选工具」的路由函数。选中之后再走 createLLMToolSchema 生成给模型看的 schema。

还有三个不显然的细节都在 transformBlockTool 里:

  • 唯一 id 后缀:同一个块类型被拖两次、指向不同资源时,id 必须区分开。所以 workflow_executor 会拼上 workflowId、knowledge_* 拼 knowledgeBaseId、table_* 拼 tableId(providers/utils.ts:600-617)。
  • 子工作流借名:workflow_executor 会去抓被调工作流的名字和描述,用它们覆盖工具名/描述(:603-612)——模型看到的是「发票审批流」,不是「workflow_executor」。
  • paramsTransform 延迟到执行期:类型强转、canonical 参数选择都封进一个闭包(:625-666),执行时才跑。原因见 §3.2 末尾。

MCP:缓存优先,发现兜底

MCP(Model Context Protocol,外接工具服务器的标准协议)工具最麻烦,因为 schema 在别人家服务器上。Sim 的策略是分层降级:

一批 MCP 工具


有 tool.schema 缓存?

是 ├──────────► createMcpToolFromCachedSchema ← 零网络往返

否 ▼
processMcpToolsWithDiscovery
│ 按 serverId 分组,每台服务器只连一次

discoverMcpToolsForServer ── GET /api/mcp/tools/discover
│ 失败且是 session/400/404 → sleep(100) 再试一次

该服务器整组工具丢弃(不阻断其它服务器)
  • 缓存路径在 processMcpToolsBatched(agent-handler.ts:1040):有 tool.schema 就直接用,完全不连 MCP 服务器
  • 发现路径按 serverId 分组,Promise.all 并发,单台失败只丢那台(:412-426)。
  • 重试判据窄得刻意:只有错误信息里含 session / 400 / 404 才重试(isRetryableError,:510-513),对应「MCP 会话过期」这一类可自愈故障。
  • 更早一层还有可用性过滤:filterUnavailableMcpTools 直接查 mcpServers.connectionStatus === 'connected'(:186-190);查库失败时反而放行所有工具(:191-196)——宁可让工具调用失败,也不让一次 DB 抖动把整个块变哑。

3.2 「谁来填参数」:一套四值可见性协议

要解决的小问题

一个 gmail_send 工具有十几个参数。哪些该让用户在画布上填死、哪些必须模型现编、哪些是系统内部值不能给任何人看?

协议本身

// apps/sim/tools/types.ts:75-79
export type ParameterVisibility =
| 'user-or-llm' // 用户可填,不填则模型必须生成
| 'user-only' // 只有用户能填
| 'llm-only' // 只有模型能填(计算值)
| 'hidden' // 谁都看不见

同一份 ToolConfig.params 会被投影成两张不同的表:

投影函数给谁看排除什么位置
createUserToolSchema画布上的表单只排除 hiddenapps/sim/tools/params.ts:587
createLLMToolSchema模型排除 hiddenuser-only以及用户已经填了值的参数tools/params.ts:647

关键的一句在 createLLMToolSchema:

// tools/params.ts:578-590(节选)
if (isNonEmpty(userProvidedParams[paramId])) continue
if (param.visibility === 'user-only') continue
if (param.visibility === 'hidden') continue

「用户填过就不给模型看」这条,让同一个工具在不同块上暴露出不同宽度的接口:填死了收件人,模型就只能写正文。

MCP 与自定义工具走的是同一逻辑的简化版 filterSchemaForLLM(tools/params.ts:865),从 schema 里删掉用户已填的属性,并同步把它从 required 里摘掉。

执行期再合并

模型给回参数后,要和用户填的那半边合起来:

// providers/utils.ts:1277-1285(节选)
let toolParams = mergeToolParameters(tool.params || {}, llmArgs)
if (tool.paramsTransform) {
toolParams = tool.paramsTransform(toolParams)
}

mergeToolParameters(已拆到 tools/merge-params.ts:77)的规则:以模型参数为底,用户参数覆盖,但空值不参与覆盖——用户清空过的字段不许把模型的值压掉。inputMapping 例外,走深合并。

prepareToolExecution(providers/utils.ts:1254)最后再挂上一圈系统上下文:_context(workflowId/workspaceId/userId/callChain)、envVarsworkflowVariablesblockData_toolSchema。这些以下划线开头的键就是「hidden」参数的实际载体。

为什么 paramsTransform 必须延迟? 因为块级的类型强转(如 Number(x))如果在序列化期就跑,会把 <Block.output> 这类动态引用字符串直接毁掉。所以转换被封成闭包,等变量解析完再执行——这一点和第 3 章的变量解析是同一个约束的两面。

3.3 消息装配:八步、以及记忆从哪来

buildMessages(agent-handler.ts:1438)是一段有明确编号的流水线,注释里 1-8 步写得很清楚。挑三个有意思的:

其一,原生记忆的「种 / 取 / 追」三态。 第一次跑时把输入消息种进去(seedMemory),之后每次只取历史 + 追加本轮新用户消息(:607-633)。判重靠 executionId 打标:

// agent-handler.ts:623-626(节选)
const userMessageInThisRun = memoryMessages.some(
(m) => m.role === 'user' && m.executionId === ctx.executionId
)
if (!userMessageInThisRun) {}

这解决的是「同一次执行里 Agent 块被重入」时的重复写入。

其二,系统消息强制归位。 addSystemPrompt(:841)不仅把系统消息挪到 0 号位,还会倒序扫一遍删掉后面所有系统消息(:865-872)——历史里混进来的第二条 system 会被丢弃并打 warn。

其三,技能用「目录 + 按需加载」而不是全塞。 buildSkillsSystemPromptSection(skills-resolver.ts:136)只把技能的 name/description 拼成一段 XML 塞进系统消息,正文一律不给;模型觉得需要时调 load_skill,executeTool 里有一条专门的短路分支去取全文(tools/index.ts:951-972)。这是典型的渐进式披露,省的是上下文。

三种记忆截断策略并列在 Memory.fetchMemoryMessages(memory.ts:15):

memoryType截断依据实现
conversation模型上下文窗口 × 利用率applyContextWindowLimit(memory.ts:152)
sliding_window最近 N 条applyWindow(:121)
sliding_window_tokens最近 N tokenapplyTokenWindow(:130)

applyTokenWindow 从后往前累加,有一条保底:如果第一条(最新那条)就超预算,也照样留下(:141-144)——宁可超也不能返回空。

落库用的是 Postgres jsonb 追加,一条 SQL 完成 upsert:

// memory.ts:238
data: sql`${memory.data} || ${JSON.stringify([sanitizedMessage])}::jsonb`,

3.4 工具调用循环:20 轮上限与强制工具轮转

这是本章的心脏。Anthropic 与 OpenAI 各有一份实现,结构几乎平行。

循环骨架

payload(含 tools / tool_choice)


┌─► ① 调模型
│ │
│ ├─ 没有 tool_use / tool_call ─→ break ─→ 收尾(直接返回 / 转流式)
│ │
│ ▼
│ ② 并发执行本轮全部工具(Promise.allSettled)
│ │
│ ▼
│ ③ 把 tool_use + tool_result 写回消息
│ │
│ ▼
│ ④ 重算 tool_choice(强制工具轮转)
│ │
└──────┴─ iterationCount++,仍 < 20 就再来一轮

上限是全局常量:

// providers/index.ts:29
export const MAX_TOOL_ITERATIONS = 20

Anthropic 的循环在 providers/anthropic/core.ts:598(非流式)与 :497(流式-带工具),OpenAI 的在 providers/openai/core.ts:649

并发与容错

同一轮里模型可能要求调多个工具,一律并发,用 Promise.all 收口(anthropic/core.ts:750openai/core.ts:750;每个工具 promise 内部自捕获,所以 all 不会整轮炸)。单个工具抛异常不会炸掉整轮,而是变成一条结构化的错误结果喂回模型:

// anthropic/core.ts:739-745(节选)
resultContent = { error: true, message: result.error || 'Tool execution failed', tool: toolName }

模型看得见自己的工具失败了 —— 这是让它有机会改参数重试的前提。

Anthropic 的消息批处理

Anthropic 协议要求 tool_use 和 tool_result 各自成块。Sim 的做法是一轮只加两条消息:

// anthropic/core.ts:1052-1067(节选)
currentMessages.push({ role: 'assistant', content: [...thinkingBlocks, ...toolUseBlocks] })
currentMessages.push({ role: 'user', content: toolResultBlocks })

注意 thinkingBlocks 被原样保留(:1044-1049)——扩展思考模式下,思考块必须跟着 assistant 消息一起回传,否则推理链会断。

强制工具轮转

用户可以把某个工具标成 usageControl: 'force'prepareToolsWithUsageControl(providers/utils.ts:919)会把第一个 force 工具变成 tool_choice,并把全部 force 工具 id 记进 forcedTools(:974-1022)。三家协议格式不同,这里就地分叉:Anthropic 用 {type:'tool', name}、Google 用 functionCallingConfig.mode='ANY'、其余用 {type:'function', function:{name}}

然后每一轮结束时轮转到下一个:

// anthropic/core.ts:1087-1097(节选)
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'tool', name: remainingTools[0] }
} else {
nextPayload.tool_choice = undefined
}

「用过了吗」由 checkForForcedToolUsage(providers/anthropic/utils.ts:134)→ trackForcedToolUsage(providers/utils.ts:1051)判定。

两家在「轮完之后」的收尾不一样,这个差异是真实的:

强制工具全用完后出处
Anthropictool_choice = undefined(整个字段删掉)anthropic/core.ts:828
OpenAItool_choice = 'auto'openai/core.ts:824

还有一条 Anthropic 独有的约束:思考模式与强制工具不兼容。开了 thinking 就只允许 auto/none,所有轮转逻辑都被 !thinkingEnabled 挡住(anthropic/core.ts:810-835),payload 装配时也只放行 none(:410-418)。

流式与非流式:两条几乎平行的路

Anthropic 一个文件里有三条分支,判据是「要不要流」和「有没有工具」:

分支条件行为位置
纯流式stream && 无工具直接开流anthropic/core.ts:489
静默工具 + 末尾流stream && !streamToolCalls工具轮先非流式跑完,再单独发一次流式请求输出最终答案:434:762
纯非流式其余循环跑完直接返回:836

第二条分支的收尾很有意思:工具循环结束后并不是把已有文本返回,而是拿累积的 currentMessages 再发一次带 stream:true 的请求(:755-765),这样用户看到的最终回答是逐字出来的。代价是多一次模型调用。

一处真实的不对称(值得当坑记): 静默工具分支里调 executeTool(toolName, executionParams, { signal })(:529),而纯非流式分支调的是 executeTool(toolName, executionParams, { skipPostProcess: true, signal })(:939)。也就是说同一个工具在流式和非流式下,postProcess 跑不跑是不一样的。代码里没有注释解释这个差异。

OpenAI 侧走的是 Responses API,消息累积形式不同——工具结果作为 function_call_output 条目 push 进 currentInput(openai/core.ts:800-804),而不是拼 message。

Azure 的一处特判

旧版对 Azure OpenAI「tools + response_format 不能同请求」做过延迟格式变通(工具轮结束后再单发一次)。这套 deferredTextFormat 特判已在上游删除:现在结构化输出直接写进 basePayload.text.format(openai/core.ts:222-241),不再有 Azure 分支,也没有跳过末尾流式的逻辑。

3.5 Provider 门面:换 CPU 不换主板

executeProviderRequest(providers/index.ts:130)是所有 provider 的唯一入口,它做的是跨 provider 的公共事:

请求进来

├─ getProviderExecutor(providerId) registry.ts:52,查表
├─ sanitizeRequest index.ts:31,按模型能力抹掉不支持的参数
├─ getApiKeyWithBYOK index.ts:146,workspace 自带 key 优先
├─ 结构化输出 → 追加进 systemPrompt index.ts:175-195
├─ 大文件附件上传 / 挂远端 URL index.ts:197-198
├─ provider.executeRequest(...) ← 各家自己的循环
└─ 算钱:calculateCost / BYOK 归零 / 加工具成本

sanitizeRequest 是「能力表驱动」的apps/sim/providers/models.ts:42ModelCapabilities 声明了每个模型支不支持 temperature / reasoningEffort / verbosity / thinking,不支持的参数在这里被置 undefined,而不是让下游各家自己判:

// providers/index.ts:35-49(节选)
if (model && !supportsTemperature(model)) sanitizedRequest.temperature = undefined
if (model && !supportsThinking(model)) sanitizedRequest.thinkingLevel = undefined

BYOK(Bring Your Own Key)的计费归零很讲究。用户用自己的 key 时不该被平台按托管价计费,但流式响应的 cost 是在回调里、函数返回之后才写进去的。于是 zeroCostForBYOK(index.ts:96)用 Object.definePropertyoutput.cost 变成一个只读 getter,setter 只吸收 toolCost:

// providers/index.ts:113-119(节选)
Object.defineProperty(output, 'cost', {
get: () => (toolCost > 0 ? { ...ZERO_COST, toolCost, total: toolCost } : ZERO_COST),
set: (value) => { if (value?.toolCost) toolCost = value.toolCost },

同时还要把 trace 里已经写好的 timeSegments[].cost 一并抹掉(zeroModelSegmentCosts,:78),否则上层汇总 span 时会把毛价重新加回来——注释里直说了这个坑。

流式响应对象的组装被抽成了 createStreamingExecution(apps/sim/providers/streaming-execution.ts:108),各 provider 只提供「流本身」和一个 drain 回调,timing 收尾统一由 finalizeTiming 处理。

工具 schema 的协议差异被压缩到一个 56 行的小文件里:

// apps/sim/providers/tool-schema-adapter.ts:46-55
export function adaptAnthropicToolSchema(tool) {
return { name: tool.id, description: tool.description,
input_schema: { type: 'object', properties:, required:} }
}

注意 name 用的是 tool.id 而非 tool.name——循环里回查工具也是按 id(anthropic/core.ts:640),两头对得上。

3.6 executeTool:一个工具的完整一生

executeTool(tools/index.ts:1478)是所有工具的唯一执行入口,不管调用者是 Agent 块、Copilot 还是别的块。

executeTool(toolId, params, options)

├─ normalizeToolId 剥掉资源后缀 workflow_executor_<uuid> → workflow_executor
├─ assertPermissionsAllowed 工具级黑名单 + mcp/custom/skill 分类闸门 :941
├─ 分流
│ ├─ load_skill ──► 直接返回技能正文,不发请求 :951
│ ├─ isCustomTool ─► getToolAsync 从库里取定义 :974
│ ├─ isMcpTool ──► executeMcpTool 并 return :983
│ └─ 其余 ──► getTool 查 3774 条注册表 :994
├─ validateRequiredParametersAfterMerge 只校验 user-or-llm 的必填 :1004
├─ injectHostedKeyIfNeeded BYOK → 托管 key 轮询,拿不到就 429/503 :1016
├─ OAuth:凭据 → POST /api/auth/oauth/token 换 accessToken :1035
├─ 执行
│ ├─ tool.directExecution 本地直执行,不发 HTTP :1144
│ └─ executeToolRequest 发请求(托管 key 时套 executeWithRetry) :1197
├─ tool.postProcess 可选二次加工(可再调 executeTool) :1225
├─ processFileOutputs 文件型输出落对象存储 :1237
├─ 托管 key 计费 + 指标 :1244
└─ postProcessToolOutput 剥掉 __ 开头的内部字段 :1257

托管 key:平台代付的那条路

Sim 云上帮用户垫 Exa/Serper 之类的 key。injectHostedKeyIfNeeded(:226)的优先级链是:

  1. 用户自己在参数里填了 key → 不动,不计费。
  2. workspace 配了 BYOK → 用它,不计费(:247-259)。
  3. 都没有 → 从 {PREFIX}_1..N 里轮询一把托管 key,标记 __usingHostedKey,计费

拿不到 key 的两种失败被明确区分:被本工作区限流抛 429 并带 retryAfterMs,一把 key 都没配抛 503(:278-306)。

托管 key 路径外面还套了一层重试:executeWithRetry(:425)对 429/503(以及 401/403 但 message 里含 quota/rate limit 的,见 isRateLimitError,:369)做指数退避。退避跑完还不行,最后再挣扎一次——重新排队拿一把新的托管 key再试(reacquireAfterRetriesExhausted,:449-457reacquireHostedKey,:330)。设计意图注释里写得很直白:上游的限额可能比我们自己的更紧。

计费是「按次」或「自定义函数」两种:

// tools/index.ts:516-526(节选)
case 'per_request': return { cost: pricing.cost }
case 'custom': { const result = pricing.getCost(params, response);}

算出的钱塞进 output.cost,但内部中转字段(__costDollars 这类 __ 前缀键)在返回前被 stripInternalFields 剥掉(:618)。自定义工具例外——它的输出是用户自己定义的,不做剥离(postProcessToolOutput,:631)。

发请求:内外两条路

executeToolRequest(:1526)按目标 URL 分叉:

内部路由(/api/…)外部 URL
fetch普通 fetch + AbortController 超时secureFetchWithPinnedIP
安全注入内部 JWT(addInternalAuthIfNeeded,:1421)validateUrlWithDNS 再把 IP 钉死(防 DNS rebinding / SSRF)
调用链透传 SIM_VIA_HEADER,防自调用成环仅同源时透传

两边共享同一套重试:getRetryConfig(:1453)默认只重试幂等方法(GET/HEAD/PUT/DELETE),退避带抖动(calculateBackoff,:1489),尊重 Retry-After 但超过 maxDelayMs 就放弃重试(:1765-1771)。

有一个省流的细节:如果这一轮已经确定要重试,就不读响应体,直接 body.cancel()(shouldRetryWithoutReadingBody,:1509,用于 :1652-1666)。

请求体和响应体各有 10MB 硬顶(:679-680),超了给的是人话错误而不是 Next.js 的 413(validateRequestBodySize,:698)。

失败时的错误消息由 18 个抽取器组成的注册表负责翻译。工具可以在 errorExtractor 字段里点名用哪个(确定性),不点名就按顺序全试一遍(tools/error-extractors.ts:555 extractErrorMessage)。

MCP 工具的执行

executeMcpTool(:1999)不走注册表,而是把 id 拆成 serverId + toolName(apps/sim/lib/mcp/utils.ts:211),POST 到 /api/mcp/tools/execute。参数来源有两种形态,靠一个系统参数黑名单区分:

// tools/index.ts:2048-2051(节选)
toolArguments = Object.fromEntries(
Object.entries(params).filter(([key]) => !MCP_SYSTEM_PARAMETERS.has(key))
)

黑名单里是 serverId/toolName/_context/envVars/blockData 这类(:822-833)——Agent 块调 MCP 时参数是平铺的,只能靠排除法把业务参数捞出来。

3.7 代码执行沙箱:两套引擎、一个路由

自定义工具和 Function 块最终都落到 POST /api/function/execute(apps/sim/app/api/function/execute/route.ts:1824)。这个路由要在两套沙箱之间做选择。

选择规则

// app/api/function/execute/route.ts:1497-1500
const useE2B =
isE2bEnabled &&
!isCustomTool &&
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))

翻译成话:

情况走哪为什么
自定义工具(任何语言)isolated-vm恒定短路,!isCustomTool
PythonE2B(未开启则直接报错)本地没有 Python 运行时
import 的 JSE2Bisolated-vm 没有 npm 包
纯 JSisolated-vm快、无外部依赖
参数里含大值引用强制 isolated-vm,否则报错大值只在本进程可解引用(:1502-1506)

isolated-vm:进程池 + V8 隔离

executeInIsolatedVM(lib/execution/isolated-vm.ts:1350)背后是一个自建的 worker 进程池,配置全部可用环境变量覆盖(:114-140):池大小默认 4、单 worker 并发 2500、队列上限 10000、单 worker 执行 200 次后回收。

公平性做了两层:进程内的按 owner 加权轮转队列(ownerStates + queuedOwnerRing,:206-207),以及跨实例的 Redis 分布式租约(tryAcquireDistributedLease,:347)。租约拿不到分两种:超限直接拒(给用户「并发太多」的话术),Redis 不可用则降级为只靠本地限流(:1360-1364)。

真正的隔离在 isolated-vm-worker.cjs:

// apps/sim/lib/execution/isolated-vm-worker.cjs:202-204
isolate = new ivm.Isolate({ memoryLimit: 128 })
if (executionId !== undefined) activeIsolates.set(executionId, isolate)
context = await isolate.createContext()

128MB 的 V8 隔离堆,里面什么宿主对象都没有。用户代码需要的能力靠三根桥接进去:

形式通向哪位置
console.logivm.Callback父进程 stdout 缓冲:209-219
fetchivm.Reference + IPC父进程的受控 fetch(有 URL 长度、响应体字节/字符上限):241-266
brokerivm.Reference + IPC宿主能力(读写工作区文件等):268-296

用户代码编译时被起名 user-function.js(:438),就是为了让栈里的行号可被定位。

错误行号还原

两套沙箱都会给用户代码加包装,所以报错行号天然是错的。两边各有一套「减掉偏移」的还原:

isolated-vm 路径 E2B 路径
───────────────── ────────────────────────────
报错行 line 报错行(Python "Cell In[N], line X"
│ 或 JS "(行:列)")
▼ │
减 prependedLineCount ▼
(自定义工具的参数解构行数) 减 prologueLineCount + wrapperLines
│ (JS 包装 3 行 / Python 包装 1 行)
▼ │
取源码该行内容拼进消息 ▼
clamp 到用户代码实际行数,再拼

E2B 侧在 formatE2BError(route.ts:299),常量在 :54-55;isolated-vm 侧在 route.ts:1760-1766。最终产出的都是 Line 7: `const x = foo()` - TypeError: … 这种能直接看的消息。


4. 巧妙之处(可以偷的技术)

  1. 能力表驱动的参数消毒。 与其在每个 provider 里写「这个模型不支持 temperature」,不如把能力声明进模型表,入口处统一抹掉(providers/index.ts:31 + providers/models.ts:42)。加模型只改数据不改代码。

  2. 用 getter 冻结一个未来才会被写入的字段。 BYOK 计费归零遇到「值在回调里才写」的时序难题,答案是 Object.defineProperty 把 setter 变成过滤器(providers/index.ts:113)。比到处传 flag 干净。

  3. schema 投影而非 schema 分裂。 同一份 params 定义,按可见性投影出「给人的表单」和「给模型的 schema」两份(tools/params.ts:587:541)。避免了两套 schema 漂移。

  4. 降级永远朝「多给一点」的方向。 MCP 可用性查库失败时放行全部工具(agent-handler.ts:638-650);分布式租约 Redis 挂了就退回本地限流(isolated-vm.ts:1410)。基础设施抖动不该让功能消失。

  5. 重试判据窄而具体。 MCP 发现只在 session/400/404 时重试;HTTP 只重试幂等方法;托管 key 限流才走指数退避。没有「万能重试」。

  6. 工具失败以数据形式回喂模型。 错误变成 {error:true, message, tool} 进对话(anthropic/core.ts:739-745),而不是抛出去终止循环。模型因此能自己改参数重来。

  7. 技能的渐进式披露。 系统提示里只放技能目录,正文靠 load_skill 按需拉(skills-resolver.ts:136 + tools/index.ts:951)。上下文只为真正用到的技能付费。

  8. 同一个执行入口服务所有调用方。 Agent 块、Copilot、MCP 块都走 executeTool,所以权限、限流、计费、SSRF 防护只需要写一遍。


5. 边界与局限(诚实版)

  • 20 轮是硬上限,且没有「达到上限」的显式信号。 到顶后只是补一次 trace 富化(anthropic/core.ts:875-884),返回结构里没有专门的截断标记;调用方只能从 timing.iterations 反推。
  • 两份工具循环是复制粘贴的兄弟。 Anthropic 与 OpenAI 各一份,且 Anthropic 内部流式/非流式又各一份。前面提到的 skipPostProcess 不对称就是这种结构的自然产物。
  • 强制工具轮转是「一轮一个」的粗粒度。 多个 force 工具时按数组顺序依次强制,无法表达「这两个必须同时调」。
  • MCP 工具 id 靠 - 分割解析(lib/mcp/utils.ts:211),serverId 固定取前两段。工具名里含 - 没问题(用 slice(2).join('-') 兜住),但格式约定很脆。
  • 10MB 是全局硬顶,请求体和响应体都是(tools/index.ts:679-680)。大结果必须走文件引用而不是内联。
  • isolated-vm 里没有 npm。 想 import 就只能上 E2B;而 E2B 未启用时,Python 和带 import 的 JS 直接报错(route.ts:1484-1494)。
  • 自定义工具永远不上 E2B(route.ts:1499!isCustomTool),代码里没有注释说明这个约束的理由。

6. 与本组其它章的关系

想知道什么去哪章
Agent 块是怎么从画布变成可执行节点的01-canvas-to-dag.md
谁决定这个 Agent 块此刻该跑02-scheduler-and-edges.md
<Block.output> 这类引用是怎么解析成真值的03-subflows-and-variables.md
工具跑到一半要等人批准怎么办05-pause-resume-and-triggers.md
让 AI 帮你搭出这个 Agent 块的那半边06-copilot-mothership.md
全局概览index.md

7. 横向对比:同类平台把「模型 + 工具」放在哪一层

同货架的 workflow-builder 子库都要回答本章 §1.2 那四个问题,但抽象放的位置很不一样。总览见总库的分支 E:AI Agent Reference — 领域地图与原理综述

下表的兄弟项目结论依据各自子库 doc(不是本次重读它们的源码),每格都附了可跳的章节。

项目模型调用被抽在哪工具/能力从哪来换来的代价
Sim(本章)独立的 Provider 层,21 家统一进 executeProviderRequest,参数消毒由模型能力表驱动四种来源归一成一张工具表:3774 条静态注册 + MCP 运行期发现注册表巨大(自带 minimal 变体);工具循环 Anthropic/OpenAI 各写一份
Rivet收在节点里——Chat 节点自己做消息组装、流式、重试、计费84 个内置节点 + 插件 registerPluginNode 挂进同一张节点注册表(rivet/04)加模型 = 加节点或插件,没有跨模型的统一入口
Dify模型实例在建图那一刻DifyNodeFactory 塞进节点构造函数(dify/03)节点真正的干活能力在另一个进程的插件守护进程,主体只是它的 HTTP 客户端(dify/06)多一跳进程边界;能力的版本与主体解耦,也更难同步调试
Langflow不设专门一层——模型和工具都是同一种东西:一个 Python 组件类,Input/Output 声明反射成 UI(langflow/01)加能力 = 加一个组件类参数契约由反射决定,没有 Sim 那种「谁能填这个参数」的显式协议

共性与分歧: 四家都得把「模型」和「外部系统」接进同一张图,分歧只在统一层放在哪——Sim 抽成进程内的一层(Provider + Tool),Rivet 下沉进节点,Dify 外推到另一个进程。判据其实很实际:有没有一件事必须在唯一的地方做。 Sim 有三件——托管 key 计费、SSRF 防护、参数可见性投影,所以它必须有 executeTool 这个唯一入口(§4 第 8 条);Dify 把干活外包出去,换来的是能力可以独立发版。


8. 代码地图(导航索引)

路径约定: 本节给克隆根相对的全路径(apps/sim/…);正文里的引用为省字,首次出现给全路径,同一节内的重复引用只写文件名或 :行号

Agent 块层

主题文件符号
块执行主流程apps/sim/executor/handlers/agent/agent-handler.tsAgentBlockHandler.execute
工具装配总入口同上formatTools
自定义工具同上createCustomTool / fetchCustomToolById
MCP 缓存优先装配同上processMcpToolsBatched / createMcpToolFromCachedSchema
MCP 发现兜底同上processMcpToolsWithDiscovery / discoverMcpToolsForServer / buildMcpTool
工具权限与可用性同上validateToolPermissions / filterUnavailableMcpTools
消息装配同上buildMessages / addSystemPrompt / addUserPrompt
请求装配与响应处理同上buildProviderRequest / processProviderResponse / processStructuredResponse
流式记忆持久化同上wrapStreamForMemoryPersistence
会话记忆apps/sim/executor/handlers/agent/memory.tsMemory.fetchMemoryMessages / applyTokenWindow / appendMessage
技能渐进披露apps/sim/executor/handlers/agent/skills-resolver.tsresolveSkillMetadata / buildSkillsSystemPromptSection / buildLoadSkillTool
结构化输出解析apps/sim/executor/handlers/shared/response-format.tsparseResponseFormat
块级常量apps/sim/executor/constants.tsAGENT / BlockType / MCP

Provider 层

主题文件符号
provider 门面apps/sim/providers/index.tsexecuteProviderRequest / MAX_TOOL_ITERATIONS / sanitizeRequest / zeroCostForBYOK
provider 注册表apps/sim/providers/registry.tsproviderRegistry / getProviderExecutor
核心类型apps/sim/providers/types.tsProviderRequest / ProviderResponse / ProviderToolConfig / ProviderId
Anthropic 工具循环apps/sim/providers/anthropic/core.tsexecuteAnthropicProviderRequest / buildThinkingConfig
Anthropic 强制工具判定apps/sim/providers/anthropic/utils.tscheckForForcedToolUsage / createReadableStreamFromAnthropicStream
OpenAI 工具循环apps/sim/providers/openai/core.tsexecuteResponsesProviderRequest
块→工具转译apps/sim/providers/utils.tstransformBlockTool / prepareToolsWithUsageControl / trackForcedToolUsage / prepareToolExecution / calculateCost
模型能力与定价apps/sim/providers/models.tsPROVIDER_DEFINITIONS / ModelCapabilities
流式响应装配apps/sim/providers/streaming-execution.tscreateStreamingExecution
工具 schema 适配apps/sim/providers/tool-schema-adapter.tsadaptOpenAIChatToolSchema / adaptAnthropicToolSchema
附件处理apps/sim/providers/attachments.tssupportsFileAttachments / shouldUseLargeFilePath / buildAnthropicMessageContent

工具层

主题文件符号
工具执行主管线apps/sim/tools/index.tsexecuteTool / executeToolRequest / executeMcpTool
托管 key 与限流同上injectHostedKeyIfNeeded / reacquireHostedKey / executeWithRetry / calculateToolCost
输出清洗与体积限制同上postProcessToolOutput / stripInternalFields / validateRequestBodySize
工具类型协议apps/sim/tools/types.tsToolConfig / ParameterVisibility / ToolResponse / OAuthConfig / ToolRetryConfig / ToolHostingConfig
参数 schema 投影apps/sim/tools/params.tscreateLLMToolSchema / createUserToolSchema / filterSchemaForLLM / mergeToolParameters
请求格式化与校验apps/sim/tools/utils.tsformatRequestParams / validateRequiredParametersAfterMerge / createToolConfig
工具注册表apps/sim/tools/registry.tstools
错误抽取apps/sim/tools/error-extractors.tsERROR_EXTRACTORS / extractErrorMessage

沙箱层

主题文件符号
沙箱路由与行号还原apps/sim/app/api/function/execute/route.tsPOST / formatE2BError / createUserFriendlyErrorMessage
isolated-vm 池与公平队列apps/sim/lib/execution/isolated-vm.tsexecuteInIsolatedVM / tryAcquireDistributedLease
V8 隔离与能力桥接apps/sim/lib/execution/isolated-vm-worker.cjsivm.Isolate / __fetchRef / __brokerRef
E2B 沙箱apps/sim/lib/execution/e2b.tsexecuteInE2B / executeShellInE2B