数据截至 (上游 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)
先给几个数字,好知道这层有多厚:
| 东西 | 数量 | 出处 |
|---|---|---|
| 工具注册表条目 | 3774 | apps/sim/tools/registry.ts:5542 起的 tools 映射 |
| 集成块注册表条目 | 302 | apps/sim/blocks/registry-maps.ts:370 的 BLOCK_REGISTRY |
| 块定义文件 | 276 | apps/sim/blocks/blocks/ 目录 |
| 模型 Provider | 21 | apps/sim/providers/registry.ts:31 的 providerRegistry |
| 错误抽取器 | 18 | apps/sim/tools/error-extractors.ts:66 的 ERROR_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 |
executeProviderRequest | provider 门面:选执行器、算钱、BYOK 归零 | apps/sim/providers/index.ts:164 |
providerRegistry | 21 个 provider 的静态表 | apps/sim/providers/registry.ts:31 |
anthropic/openai core.ts | 两段真正的工具调用循环 | apps/sim/providers/anthropic/core.ts、apps/sim/providers/openai/core.ts |
executeTool | 单个工具执行的统一管线 | apps/sim/tools/index.ts:1513 |
tools 注册表 | 3774 个工具定义 | apps/sim/tools/registry.ts:5542 |
| 沙箱 | 跑用户写的 JS/Python | apps/sim/lib/execution/isolated-vm.ts、e2b.ts |
2.3 主线走一遍(不进代码)
AgentBlockHandler.execute(agent-handler.ts:217)的九步,顺序本身就是设计:
- 先剔掉连不上的 MCP 工具(
filterUnavailableMcpTools,:155)——不能让一个挂掉的 MCP 服务器污染整张工具表。 - 再校验权限(
validateToolPermissions,:140):用了 MCP/自定义工具就查企业版开关。 - 解析结构化输出格式、确定模型、校验模型 provider 是否被允许(
:73-76)。 - 装配工具(
formatTools,:207)。 - 装配技能(
:85-94):把技能目录塞进系统提示,并挂一个load_skill工具。 - 装配消息(
buildMessages,:593),再挂附件、按 provider 能力做 base64 水化。 - 拼
ProviderRequest(buildProviderRequest,:888)。 - 调 provider(
:959),里面是那个循环。 - 处理响应:流式包一层记忆持久化(
wrapStreamForMemoryPersistence,:1066),非流式直接落库。
3. 核心原理(逐个机制)
3.1 工具装配:四种来源,一张表
要解决的小问题
模型只认一种东西:一组 {name, description, JSON Schema}。但 Sim 的工具有四种完全不同的来源,得先归一。
四种来源
| 来源 | 用户在画布上干了什么 | 装配函数 | 结果 id 形如 |
|---|---|---|---|
| 集成块 | 勾了「Gmail / Slack / …」某个操作 | transformBlockTool(apps/sim/providers/utils.ts:646) | gmail_send |
| 自定义工具 | 自己写了 schema + 一段 JS | createCustomTool(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 | 画布上的表单 | 只排除 hidden | apps/sim/tools/params.ts:587 |
createLLMToolSchema | 模型 | 排除 hidden、user-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)、envVars、workflowVariables、blockData、_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 token | applyTokenWindow(: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:750、openai/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)判定。
两家在「轮完之后」的收尾不一样,这个差异是真实的:
| 强制工具全用完后 | 出处 | |
|---|---|---|
| Anthropic | tool_choice = undefined(整个字段删掉) | anthropic/core.ts:828 |
| OpenAI | tool_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:42 的 ModelCapabilities 声明了每个模型支不支持 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.defineProperty 把 output.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),两头对得上。