跳到主要内容

数据截至 (上游 commit d87b272aec54)

多协议模型层:一个接口接住 OpenAI / Anthropic / Gemini / Qwen

30 秒导读: 主循环(见 01 主循环)从头到尾只跟一个叫 ContentGenerator 的接口打交道,它长得像 Google Gemini SDK。真正连 OpenAI、Anthropic、DashScope 还是自家 Qwen OAuth,由一个枚举值决定;各家协议的差异、私有字段和坏行为,全部被关在这一层里消化掉。


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

一句话定义: 模型层是一个协议适配层——把「给模型发一段对话、拿回一段回复」这件事,抽象成一个固定接口,然后为每种模型 API 各写一个实现。

它解决什么问题。 你写了一个 coding agent,主循环大概是「组装消息 → 调模型 → 解析工具调用 → 执行 → 再调模型」。这套循环本身跟用哪个模型无关。但如果主循环里直接写 openai.chat.completions.create(...),那想换成 Claude 就得把循环重写一遍。

为什么这件事比想象中难。 不是「多写几个 if」的问题。真实的 OpenAI 兼容生态里,同一个 /v1/chat/completions 端点,不同厂商的行为能差出这些花样:

现实中的坑具体表现
工具参数是碎的一个 JSON 参数被切成几十个 SSE 片段,中途还可能被截断
思维链没有统一字段有的用 reasoning_content,有的用 reasoning,有的干脆把 <think> 标签混在正文里
内容格式挑食DeepSeek 不吃 content parts 数组,只吃纯字符串
错误伪装成正常响应限流错误以 finish_reason: "error_finish" 的正常 SSE 块返回,HTTP 状态码是 200
流会「装死」返回 200 之后不再发任何块,SDK 的 timeout 管不到
关思考的开关每家都不一样enable_thinking: false / thinking: {type:'disabled'} / reasoning.effort / extra_body.thinking.enabled

给谁用。 两类人:想换供应商的终端用户(改一份配置就行),以及想接入新厂商的贡献者(大多数情况下只需加一个 preset,不用写代码)。

用起来什么样。 用户视角就是一份 settings 里的模型供应商配置——挑一个 provider、填一个环境变量名。以 OpenRouter 为例(packages/core/src/providers/presets/openrouter.ts:13,openRouterProvider):

// 示意,非源码:这是 preset 的形状,用户只挑 id + 填环境变量
{
id: 'openrouter',
protocol: AuthType.USE_OPENAI, // 走 OpenAI 兼容那条路
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY', // 去这个环境变量里取 key
customHeaders: { 'HTTP-Referer': '...' } // 只有 header 差异 → 不用写 provider 类
}

一句话直觉。 把它当成电源转换头:主循环是只认一种插头的电器,转换头负责把各国插座(各家 API)转成那一种插头,并且顺手稳压(修流式坏数据)。


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

怎么读这张图: 从上往下是一次调用的下沉路径;到 createContentGenerator 处按 AuthType 分叉,四条支路殊途同归地实现同一个接口。

主循环 / GeminiChat(只认 Gemini 类型的请求与响应)


┌─────────────────────────────────┐
│ ContentGenerator(唯一契约) │ contentGenerator.ts:37
└─────────────────────────────────┘
│ createContentGenerator 按 AuthType 分叉
┌──────────┬───────┴────────┬──────────────┐
▼ ▼ ▼ ▼
openai anthropic gemini/vertex-ai qwen-oauth
│ │ │ │
▼ ▼ ▼ ▼
① 兼容管线 ② 原生 SDK ③ 原生 SDK ④ ①+动态令牌
pipeline Anthropic GoogleGenAI QwenContentGenerator

└─→ provider/ 按厂商打补丁(dashscope / deepseek / minimax / …)

每个部件一句话职责:

部件干什么在哪个文件
ContentGenerator5 个方法的接口,上层唯一认识的东西packages/core/src/core/contentGenerator.ts:37
AuthType五值枚举,决定走哪条支路packages/core/src/core/contentGenerator.ts:55
ContentGeneratorConfig一次调用需要的全部配置(key/baseUrl/超时/采样/思考开关…)packages/core/src/core/contentGenerator.ts:74
createContentGenerator工厂:校验配置 → 动态 import 对应实现 → 套上日志外壳packages/core/src/core/contentGenerator.ts:343
ContentGenerationPipelineOpenAI 兼容路径的主管线:建请求、发请求、转响应、兜错packages/core/src/core/openaiContentGenerator/pipeline.ts:279
OpenAICompatibleProvider厂商补丁接口:改 header、改 client、改请求体packages/core/src/core/openaiContentGenerator/provider/types.ts:26
ModelRegistry / resolveModelConfig把「用户配的模型/供应商」解析成一份带来源标注的 configpackages/core/src/models/modelRegistry.ts:86modelConfigResolver.ts:147
LoggingContentGenerator装饰器外壳:遥测、span、OpenAI 格式请求日志packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts:103

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

  1. 用户选定 auth 方式,Config.refreshAuth() 触发一次配置解析(packages/core/src/config/config.ts:2627)。
  2. resolveContentGeneratorConfigWithSources 校验并定稿一份 ContentGeneratorConfig(config.ts:2617)。
  3. createContentGeneratorauthType 造出具体实现,再包一层 LoggingContentGenerator(config.ts:2627)。
  4. 之后主循环每一轮只调 generateContentStream(request, promptId),其它的它一概不知道。

3. 契约本身:ContentGenerator 与 AuthType

这节讲什么: 整层的地基只有两个声明,很短,值得逐字看。

3.1 五个方法

接口定义在 packages/core/src/core/contentGenerator.ts:37(ContentGenerator):

generateContent(request, userPromptId): Promise<GenerateContentResponse>
generateContentStream(request, userPromptId): Promise<AsyncGenerator<...>>
countTokens(request): Promise<CountTokensResponse>
embedContent(request): Promise<EmbedContentResponse>
useSummarizedThinking(): boolean

三点值得注意:

  • 参数与返回值都是 @google/genai 的类型。 也就是说这套抽象没有自造中立协议,而是直接把 Gemini SDK 的数据结构选作「共同语言」。历史原因(qwen-code 从 gemini-cli fork 而来),但也是个务实选择:省掉一层翻译。
  • userPromptId 是接口的一部分。 它一路带到 DashScope 的 metadata.promptId(provider/dashscope.ts:250,buildMetadata),用于服务端会话追踪。
  • useSummarizedThinking() 是唯一一个「问能力」的方法。 只有 Gemini 返回 true(geminiContentGenerator.ts:303),OpenAI 与 Anthropic 实现都返回 false(openaiContentGenerator.ts:167anthropicContentGenerator.ts:340)——因为只有 Gemini 返回的是摘要过的思维,其它家给的是原始思维流。

3.2 五个 AuthType

packages/core/src/core/contentGenerator.ts:55(AuthType):

枚举值字面量走哪条实现
USE_OPENAIopenaicreateOpenAIContentGenerator
QWEN_OAUTHqwen-oauthQwenContentGenerator(继承 OpenAI 实现)
USE_GEMINIgeminicreateGeminiContentGenerator
USE_VERTEX_AIvertex-ai同上,靠 vertexai: true 区分
USE_ANTHROPICanthropiccreateAnthropicContentGenerator

分叉逻辑就在 createContentGenerator(contentGenerator.ts:343-421),四个分支都是动态 import()——所以启动时不会把三家 SDK 全加载进来。它还专门处理了一个真实场景:如果用户在后台被自动更新,动态 import 会抛 ERR_MODULE_NOT_FOUND,代码把它翻译成「请重启 Qwen Code」的人话(contentGenerator.ts:409-416,getModuleNotFoundError)。

最后一行是关键:不管走哪条分支,返回的都是被 LoggingContentGenerator 包过的对象(contentGenerator.ts:420)。


4. 配置从哪来:ContentGeneratorConfig 的三段解析

这节讲什么: 这个接口能「换模型不换循环」,一半功劳在配置层——把所有厂商差异表达成数据,而不是代码分支。

4.1 config 里有什么

ContentGeneratorConfig(contentGenerator.ts:74)是个大对象。按用途分成四组看更清楚:

组别字段说明
身份model apiKey apiKeyEnvKey baseUrl vertexai authType连谁、用什么凭据
传输timeout streamIdleTimeoutMs maxRetries retryErrorCodes proxy userAgent customHeaders网络行为
生成samplingParams reasoning contextWindowSize extra_body模型行为
兼容schemaCompliance enableCacheControl modalities splitToolMedia toolResultContentFormat专门用来绕各家的坑

其中四个字段在这一层的分量最重:

streamIdleTimeoutMs(contentGenerator.ts:88)——流式空转看门狗。 注释直说了为什么需要它:SDK 的 timeout 只覆盖「连接 + 首个响应」,一个返回了 200 然后再不发块的流是无界的。<= 0 表示关闭。

samplingParams(contentGenerator.ts:92)——逃生舱口。 它有一条 [key: string]: unknown 索引签名,意味着任何键都会原样上线。这样用户想给 GPT-5 系发 max_completion_tokens、给别家发 reasoning_effort,都不用等客户端发版。代价见 §5.1.3。

reasoning(contentGenerator.ts:104)——思考开关。 类型是 false | { effort?, budget_tokens? }effort 的取值里有个 'max',源码注释写明这是 DeepSeek 独有的扩展,Anthropic 那边只接受 low/medium/high,所以 Anthropic 实现会把它降级成 'high'(详见 §5.6)。

schemaCompliance(contentGenerator.ts:122)——工具 schema 方言。 只有两个取值:'auto'(原样透传)和 'openapi_30'(把现代 JSON Schema 降级到 OpenAPI 3.0)。降级逻辑在 packages/core/src/utils/schemaConverter.ts:18(convertSchema),典型动作是把 type: ["string","null"] 改写成 type: "string", nullable: true(schemaConverter.ts:48-54)。工具定义本身怎么来的,见 03 工具层

4.2 三段解析

配置不是一次成型的,而是三段接力:

用户输入的各种来源 解析器 产物
──────────────────────────────────────────────────────────────────────
--model / --openaiApiKey ┐
OPENAI_API_KEY 等环境变量 ├─→ ① resolveModelConfig ──→ 带来源标注的
settings.model.generationConfig │ (modelConfigResolver Partial<config>
modelProviders 里的 preset ┘ .ts:147)


② resolveContentGeneratorConfigWithSources
(contentGenerator.ts:187)
补 authType/proxy + 校验必填


③ createContentGenerator
(contentGenerator.ts:343)
→ 真正的 ContentGenerator 实例

resolveModelConfig(models/modelConfigResolver.ts:147) 是唯一的取值入口。优先级写在文件头注释里:modelProvider > CLI 参数 > 环境变量 > settings > 默认值。每个字段解析完都往 sources 里记一条「这个值从哪来的」(如 sources['model'] = cliSource('--model')),这在排查「我明明配了为什么不生效」时很有用。

AUTH_ENV_MAPPINGS(models/constants.ts:66)就是那张环境变量对照表——openai → OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL|QWEN_MODEL,anthropic → ANTHROPIC_*,依此类推。加一家新协议时,这里是要改的第一处。

resolveContentGeneratorConfigWithSources(contentGenerator.ts:187) 现在不做兜底取值了,只做两件事:补上 authTypeproxy 这两个「计算得来」的字段,然后调 validateModelConfig 校验。函数注释明确说 env fallback 已经上移到了统一 resolver,避免重复。

③ 校验规则(validateModelConfig,contentGenerator.ts:251) 很短,只有三条,但每条都有来由:

规则代码位置为什么
qwen-oauth 直接放行contentGenerator.ts:258它用动态令牌,构造时压根没有 key
其它 authType 必须有 apiKeymodelcontentGenerator.ts:263:287缺一个就抛带环境变量名提示的错
anthropic 必须显式给 baseUrlcontentGenerator.ts:297迁移自旧代码的硬约束

createContentGeneratorConfig(contentGenerator.ts:313)只是 ② 的一个丢掉 sources 的薄封装。

4.3 子代理怎么复用这套解析

多智能体(见 06 多智能体)里,一个子代理可能要跑在跟主进程不同的供应商上。这条路径复用同一套配置:buildAgentContentGeneratorConfig(models/content-generator-config.ts:44)继承父进程的传输类设置,但跨供应商时会把生成类字段全部清空(content-generator-config.ts:64-69),依据的清单是 MODEL_GENERATION_CONFIG_FIELDS(models/constants.ts:21)。理由很直白:父进程的 samplingParamsextra_body 对另一家 API 往往是非法字段。


5. 核心机制

5.1 OpenAI 兼容管线:一次请求的六道工序

它要解决的小问题: 把 Gemini 形状的请求翻译成 OpenAI 形状,发出去,再把响应翻译回来——并且在这条链上留出足够多的钩子,让厂商补丁能插进来。

流程(ContentGenerationPipeline,pipeline.ts:279):

executeWithErrorHandling (pipeline.ts:722)

├─① createRequestContext ......... 每次请求造一份"随行状态" :770
│ └─ 新建 StreamingToolCallParser(流式才建)
├─② buildRequest ................. 组装 OpenAI 请求体 :505
│ ├─ convertGeminiRequestToOpenAI(消息)
│ ├─ convertGeminiToolsToOpenAI(工具 + schemaCompliance)
│ ├─ provider.buildRequest(厂商补丁在这里插入)
│ └─ reasoning 关闭时的三种"关思考"写法
├─③ 抓包钩子 ..................... 日志看到的就是上线字节 :744
├─④ SDK 调用 ..................... chat.completions.create :217 / :256
├─⑤ 流式:看门狗 + 逐块转换 ....... processStreamWithLogging :309
└─⑥ 出错:EnhancedErrorHandler .... 统一兜底 :764

第③步的位置是承重的,源码注释专门标了:抓包必须在 buildRequest 之后、SDK 调用之前,否则日志里看到的就不是真正发出去的字节(pipeline.ts:836-840)。

5.1.1 流式与非流式的两个小细节

stream: true 时会顺手加 stream_options: { include_usage: true }(pipeline.ts:622),否则拿不到 token 用量。非流式时显式stream: false(pipeline.ts:625-628),注释说明原因:有些网关在字段缺失时默认走 SSE。

工具为空时不发 tools 字段而不是发 tools: [](pipeline.ts:632),因为有的 provider 会拒绝空数组。

5.1.2 关思考:一个开关,三种写法

reasoning: false(或单次请求的 thinkingConfig.includeThoughts === false)不是简单地「不发 reasoning 字段」——因为 DeepSeek V4+、qwen3 这类模型默认就在思考,不发字段等于默认开着,白白付延迟和费用。

实现在 pipeline.ts:653-708,动作有三个:

目标端发什么判定条件代码
DashScope 上的 qwen 系enable_thinking: false主机是 DashScope 上线模型名以 qwen 开头或等于 coder-modelpipeline.ts:679-686
DeepSeek 官方端点thinking: { type: 'disabled' }isDeepSeekHostname 为真pipeline.ts:705-707
通用删掉 reasoningreasoning_effort 两种形状总是pipeline.ts:693-698

这里有个容易看漏的正确性细节:门禁判定用的是 context.model(实际上线的模型名),不是 config 里的模型名。注释解释了为什么——单次请求可以覆盖模型,用 config 的模型名会两头都错(pipeline.ts:666-672)。

开启思考时反而更保守:buildReasoningConfig(pipeline.ts:784)不做任何值映射,直接把用户配的 reasoning 对象透传。注释列举了 5 个模型 5 种行为后得出结论——与其猜,不如原样传,让厂商语义保持完整。

5.1.3 采样参数:设了就全权接管

buildGenerateContentConfig(pipeline.ts:713)有一条硬规则:

if (configSamplingParams !== undefined) {
return { ...configSamplingParams }; // pipeline.ts:658-660
}

一旦用户设了 samplingParams,整个采样段就以它为准,逐字段兜底逻辑(temperature / top_p / max_tokens 等)全部跳过。同一条规则在 provider 层也认:applyOutputTokenLimit 一发现 samplingParams 存在就原样返回,不再注入 max_tokens 默认值(provider/default.ts:160-164)。

好处是逃生舱口彻底、可预测;代价是用户设了 samplingParams 就得自己写全,否则会掉进「服务端默认 max_tokens 很小 → 输出被截断」的坑。

5.2 流式空转看门狗:给「装死的流」设一个上限

它要解决的小问题: 服务端返回 200、开了 SSE,然后一个块都不再发。SDK 的 timeout 已经用完了它的职责(连接 + 首响应),这时候客户端会永远挂着。

思路: 在流的外面再套一层生成器,每次 next() 都跟一个定时器赛跑;块一到就重置定时器。

原理演示:

// 示意,非源码:看门狗的骨架
async function* withWatchdog(source, idleMs, abort) {
const it = source[Symbol.asyncIterator]();
while (true) {
const next = it.next();
const timer = new Promise((_, rej) =>
setTimeout(() => { abort(); rej(new Error('ETIMEDOUT')); }, idleMs)
);
const r = await Promise.race([next, timer]); // 谁先来听谁的
if (r.done) return;
yield r.value; // 有块 → 下一轮重新计时
}
}

真实实现: withStreamInactivityTimeout(pipeline.ts:218-275)。比示意多了三处必要的严谨:

  • 超时时先看父信号是否已被取消:是的话抛 AbortError(用户主动取消),否则才抛可重试的 StreamInactivityTimeoutError(pipeline.ts:233-248)。
  • 超时后那个被遗弃的 next() promise 会 reject,代码显式 .catch(() => {}) 吞掉,避免 unhandled rejection(pipeline.ts:258)。
  • timer.unref?.()(pipeline.ts:250)——不让这个定时器拖住 Node 进程退出。

关键细节: StreamInactivityTimeoutError 自带 code = 'ETIMEDOUT'(pipeline.ts:51),这是为了让上游的重试分类器把它当成普通的 socket 读超时。也正因如此,processStreamWithLogging 的 catch 里特意绕过 handleError——因为通用错误处理会把 code 抹掉,抹掉就不可重试了(pipeline.ts:505-514)。

超时值的解析优先级是「显式 config > QWEN_STREAM_IDLE_TIMEOUT_MS 环境变量 > 默认 120 秒」(resolveStreamIdleTimeoutMs,pipeline.ts:167;默认值在 openaiContentGenerator/constants.ts:5)。上限 MAX_STREAM_IDLE_TIMEOUT_MS(约 24.8 天,constants.ts:14)不是洁癖:setTimeout 对超过这个值的延迟会静默压缩成 1ms,等于看门狗立刻开火。

5.3 流式碎片 JSON 的容错拼装(本章最硬的一块)

它要解决的小问题: 模型要调一个工具,参数是 {"path":"src/a.ts","content":"..."}。这段 JSON 在 SSE 里被切成几十片;片段可能没有 id、index 可能撞车、末尾可能被 max_tokens 拦腰截断。

思路: 不要每来一片就试着 JSON.parse(那会疯狂失败)。改为自己做一个极简的 JSON 词法状态机,只跟踪「括号深度」,深度归零时才尝试解析。

为什么必须跟踪字符串状态: 因为 {"content": "function f() {"} 里那个 { 在字符串内部,不能计入深度。

原理演示:

// 示意,非源码:深度计数的核心四行
for (const ch of chunk) {
if (!inString) { // 只在字符串外数括号
if (ch === '{' || ch === '[') depth++;
else if (ch === '}' || ch === ']') depth--;
}
if (ch === '"' && !escape) inString = !inString; // 未转义的引号切换状态
escape = (ch === '\\' && !escape); // 反斜杠只对下一个字符生效
}
// depth === 0 且缓冲非空 → 这时候才值得 JSON.parse

真实实现: StreamingToolCallParser(streamingToolCallParser.ts:36)。它为每个工具调用 index 各存一份状态,四张 Map 一一对应上面四个变量:

字段存什么代码位置
buffers累积到现在的原始字符串streamingToolCallParser.ts:38
depths当前括号嵌套深度:40
inStrings当前是否在字符串字面量里:42
escapes下一个字符是否被转义:44

字符扫描循环本体在 streamingToolCallParser.ts:179-191,跟上面示意一模一样。

修复(repaired 标记)。 深度归零后 JSON.parse 仍失败时,如果状态显示「还在字符串里」,就补一个引号再试一次;成功则返回 { complete: true, value, repaired: true }(streamingToolCallParser.ts:206-217)。repaired 这个布尔值把「原样解析成功」和「我替它补了个引号」区分开,调用方可以据此判断可信度。ToolCallParseResult 的类型声明在 streamingToolCallParser.ts:15

三级降级。 流结束时 getCompletedToolCalls(streamingToolCallParser.ts:252)用三档策略兜底:

JSON.parse(buffer)
│ 失败

还在字符串里? → JSON.parse(buffer + '"')
│ 仍失败

safeJsonParse(buffer, {}) ← 兜到空对象,绝不让整轮崩掉

index 撞车怎么办。 有的 provider 会把不同工具调用塞进同一个 index。addChunk(streamingToolCallParser.ts:68)在收到带新 id 的片段时,会检查该 index 上是否已经躺着一个完整且 id 不同的调用;是的话就 findNextAvailableIndex() 换一个位置(:102:317)。反过来,没有 id 的续传片段落在一个已完成的 buffer 上时,用 findMostRecentIncompleteIndex() 找回它真正的归属(:129:356)。

最巧的一处:拿状态机当截断探测器。 hasIncompleteToolCalls()(streamingToolCallParser.ts:453)判断依据只有两条——depth > 0inString === true,任一为真就说明 JSON 被拦腰截断了。

它的价值在调用方:convertOpenAIChunkToGemini 在收到 finish 时先问一句这个,如果为真就把 provider 报的 finish_reason 强行改写成 'length'(converter.ts:1308:1329-1333)。源码注释点名了动机:DashScope/Qwen 有时输出明明被 max_tokens 截断了,却仍然报 "stop""tool_calls"。有了这个改写,下游才能正确地把 wasOutputTruncated 置位。

一个防呆约束: convertOpenAIChunkToGemini 开头就断言 requestContext.toolCallParser 必须存在,不存在直接抛错并在错误信息里教你怎么修(converter.ts:1219-1223)。因为 parser 是每条流一份的可变状态,复用会静默串数据。

5.4 思维标签解析:把 <think> 从正文里剥出来

它要解决的小问题: MiniMax 这类 provider 不用 reasoning_content 字段,而是把思维直接混在正文里,用 <think>…</think> 包着。而且标签本身也会被 SSE 切开——上一块结尾是 <thi,下一块开头才是 nk>

思路: 一个二值状态机(text / thought),加上跨块缓冲:当剩余文本是某个标签的前缀时就停下等下一块,别急着当正文吐出去。

真实实现: TaggedThinkingParser(taggedThinkingParser.ts:64)。三处设计值得看:

  • 开闭标签各两种写法:<think>/<thinking></think>/</thinking>(taggedThinkingParser.ts:15-16)。文件头注释说明这是故意允许交叉匹配的(<think>…</thinking> 合法),因为用的是模式开关而非标签栈,而 MiniMax 实际上一次响应只用一种。
  • 前缀检测 isPrefixOfAnyTag(taggedThinkingParser.ts:36)有个提前退出:剩余长度超过最长标签(11 个字符)就直接返回 false,不必切片——把最坏情况压回 O(1)(:45-49)。
  • 性能上,每次 parse 只做一次 toLowerCase() 生成小写副本,循环内不再重复分配(taggedThinkingParser.ts:73)。

关键细节: 流结束时如果还停在 thought 模式且缓冲非空(说明 </think> 从没来过),它会 debugLogger.warn 一条再把内容吐出来(taggedThinkingParser.ts:114-118)。注释把这个动作的目的写得很清楚:让这类静默丢数据的场景变得可观测

谁会启用它?由 provider 声明:MiniMaxOpenAICompatibleProvider.getResponseParsingOptions() 返回 { taggedThinkingTags: true }(provider/minimax.ts:41)。pipeline 只在「流式 + provider 声明了该选项」时才建 parser(pipeline.ts:877-880)。启用后,reasoning_content 那条抽取路径会被跳过,免得同一段思维被算两遍(converter.ts:1229-1232)。

5.5 provider/:按厂商打补丁的那一层

它要解决的小问题: 「OpenAI 兼容」是个谎言的委婉说法。得有个地方专门放各家的怪癖,且不能污染主管线。

接口只有三个必需方法(provider/types.ts:26,OpenAICompatibleProvider):buildHeaders() / buildClient() / buildRequest(),外加两个可选钩子 getResponseParsingOptions?()getRequestContextOverrides?()

选谁由一串 if 决定(openaiContentGenerator/index.ts:57,determineProvider),顺序是 DashScope → DeepSeek → MiMo → ModelScope → MiniMax → Mistral → Default。

六家补丁各自在干嘛:

Provider补的是什么坑关键代码
dashscope.ts缓存控制标记、vl_high_resolution_imagespreserve_thinking、会话 metadata;glm 无工具请求要把 content parts 拍平成字符串provider/dashscope.ts:175(buildRequest)、:262(addDashScopeCacheControl)
deepseek.ts只吃纯字符串 content;把 reasoning.effort 翻译成扁平的 reasoning_effortprovider/deepseek.ts:96:176(translateReasoningEffort)
minimax.ts声明「正文里有 <think> 标签」provider/minimax.ts:41
mistral.ts出站时剥掉非标准的 messages[].reasoning_contentprovider/mistral.ts:64(stripReasoningContent)
modelscope.ts非流式时删掉 stream_options(它不支持)provider/modelscope.ts:29
mimo.ts每个 assistant 消息补一个空的 reasoning_content;默认拆分工具返回的媒体provider/mimo.ts:46:63
default.ts兜底:User-Agent、customHeaders 合并、max_tokens 上限、qwen3 的 reasoning_content → reasoning 镜像provider/default.ts:49

贯穿全层的一条判定法则:主机名 vs 模型名,别混用。 这是这一层最值得抄走的经验,provider/deepseek.ts:36-66 用两个函数把它固化下来了:

isDeepSeekHostname(config) ← 严格:解析 URL 取 hostname,精确匹配
用于:改请求体形状的决定(reasoning_effort、thinking:disabled)
理由:猜错了就是给严格后端发它不认识的字段 → HTTP 400

isDeepSeekProvider(config) ← 宽松:hostname 命中 OR 模型名含 "deepseek"
用于:内容格式适配(content parts 拍平)
理由:自建部署(sglang/vllm)跑 DeepSeek 模型也有同样的格式约束

注释还点出了为什么必须解析 URL 而不是做子串匹配:朴素的 includes('api.deepseek.com') 会在 https://api.deepseek.com.evil.com/v1 上误判(provider/deepseek.ts:26-31)。同样的 URL 解析写法在 DashScope(dashscope.ts:55-63,注释还提到规避 ReDoS)、MiniMax(minimax.ts:31)、Mistral(mistral.ts:26)、ModelScope(modelscope.ts:16)里一致复现。

什么时候不该写 provider 类。 provider/README.md 给了明确门槛:只有请求级行为差异才配一个类;只差 HTTP header 的,在 preset 里写 customHeaders 就够了,DefaultOpenAICompatibleProvider.buildHeaders() 会自动合并(provider/default.ts:71-73)。OpenRouter 和 Requesty 就是这么接进来的——零代码。

一个容易看漏的细节: DashScope 的 shouldEnableCacheControl() 读的是 this.cliConfig.getContentGeneratorConfig()?.enableCacheControl(dashscope.ts:505),也就是运行时的实时值,而不是构造时捕获的那份。因为 qwen-oauth 路径上换模型会原地热更新这个字段,不重建 generator。

5.6 Anthropic 路径:一个不走 pipeline 的独立实现

这条路径为什么不复用 pipeline: Anthropic 的协议差异不在字段层面,而在结构层面——system 是独立顶层字段、内容块是 content_block_* 事件流、思考有 thinking 块和签名。硬塞进 OpenAI 管线得不偿失,所以 AnthropicContentGenerator(anthropicContentGenerator.ts:162)直接实现 ContentGenerator

四个值得学的设计:

① 凭据不能靠环境变量兜底。 构造 SDK 时,用不上的那一侧显式传 null 而不是省略(anthropicContentGenerator.ts:207-216)。注释把攻击面写得很清楚:SDK 的解构默认值只对 undefined 生效,省略字段会让 ANTHROPIC_API_KEY 回填进来;而 SDK 的鉴权解析优先 apiKey 而非 authToken,结果是一个同时在跑 Claude Code 的用户,会把真实的 Anthropic key 当作 X-Api-Key 发给第三方代理。显式 null 掐断了这条回填路径。

② 身份伪装是「捆绑」的,不是三个独立开关。 一个谓词 isAnthropicNativeBaseUrl(anthropicContentGenerator.ts:123)同时决定三件事:用 Bearer 还是 x-api-key、User-Agent 报 claude-cli 还是 QwenCode、发不发 x-app: cli。注释解释了为什么不拆成两个布尔量——拆了会掩盖耦合,诱使后来人只改一半(:173-177)。

③ beta header 从请求体反推,而不是从判定条件推。 buildPerRequestHeaders(anthropicContentGenerator.ts:393)扫描已经组装好的请求体来决定发哪些 anthropic-beta 标记;hasGlobalCacheScopeOnWire(:468)就是那个扫描器。好处是 header 和 body 共享唯一真相源,退化情况(空 system + 无工具 → 没东西挂 cache scope)自动不发多余的 beta。

'max' 降级带一次性告警。 resolveEffectiveEffort(anthropicContentGenerator.ts:665)在非 DeepSeek 主机上把 'max' 夹到 'high',并用 effortClampWarned 闩住只警告一次(:683-689)。这里刻意用严格的主机名判定——用宽松判定的话,一个叫 deepseek-clone 的模型跑在真 api.anthropic.com 上就能绕过降级,然后吃 400。

思考预算的阶梯(buildThinkingConfig,anthropicContentGenerator.ts:719):

effortbudget_tokens备注
low16 000
(未设/medium)32 000默认档
high64 000
max128 000只有 DeepSeek 主机能走到这一档

显式 budget_tokens 是逃生舱口,会先于 adaptive 分支检查(:748-753),免得在 Claude 4.6+ 上被静默丢弃。4.6+ 用 { type: 'adaptive' },判定是数值化的主/次版本比较而非单字符正则(modelSupportsAdaptiveThinking,:710),且故意不锚定 ^claude-,好让 bedrock/claude-opus-4-7vertex_ai/claude-sonnet-4-6@… 这类转售前缀也能命中。

token 用量归一化 单独成文件:buildAnthropicUsageMetadata(anthropicContentGenerator/usage.ts:41)。难点是 Anthropic 把 prompt 拆成 input_tokens / cache_read_input_tokens / cache_creation_input_tokens 三份,而 Anthropic 兼容代理常按 OpenAI 语义填。判别式选了 cache_creation > 0 作为主信号——注释记录了上一版的翻车方式:拿 inputTokens 跟两个缓存字段比大小,在长对话里 inputTokens 自然长过 cache_creation,于是某一刻会突然漏掉缓存部分,界面上表现为 prompt 大小「掉一下」。

空流兜底: processStreamWithEmptyFallback(anthropicContentGenerator.ts:1014)发现流结束了却既没内容也没 finish reason,就用同一个请求非流式重发一次,让真正的 provider 错误(通常是计费/配额)浮出来,而不是抛一句「stream ended without a finish reason」。

5.7 Gemini 路径:最薄的一层

GeminiContentGenerator(geminiContentGenerator.ts:28)几乎就是 GoogleGenAI 的转发器——因为接口的公共语言本来就是 Gemini 类型,不需要转换。它只做三件小事:

  • 合并 customHeaders(geminiContentGenerator.ts:40-57)。
  • 映射思考等级(buildThinkingConfig,:118):low → LOW,high | max → HIGH,其余 THINKING_LEVEL_UNSPECIFIED'max' 折到 HIGH 是因为 Gemini 没有更高档(:128-137)。
  • 剥掉不支持的字段与媒体(stripUnsupportedFields,:175):删 inlineData/fileData 里的 displayName;工具返回里的音视频换成一句说明文字(convertUnsupportedMediaToText,:258)。

采样参数在这里是有默认值的(temperature: 1topP: 0.95topK: 64,geminiContentGenerator.ts:84-94),跟 OpenAI 路径「不设就不发」的风格不同。

5.8 Qwen 自家鉴权:OAuth 设备流 + 跨进程令牌管理

它要解决的小问题: Qwen OAuth 用户没有 API key,只有一个会过期的 access token;而且用户可能同时开着好几个终端窗口,谁都可能触发刷新。

三个文件分工:

qwenOAuth2.ts ......... 拿到令牌(设备码流 + PKCE + 刷新)
sharedTokenManager.ts . 保管令牌(内存缓存 + 文件锁 + 跨进程一致)
qwenContentGenerator.ts 用令牌(每次请求前热替换 apiKey/baseURL)

① 拿令牌:RFC 8628 设备码流 + RFC 7636 PKCE。 generatePKCEPair()(qwenOAuth2.ts:70)生成 verifier(32 字节随机 base64url)和 challenge(SHA-256)。requestDeviceAuthorization(:348)换设备码,pollDeviceToken(:421)轮询,refreshAccessToken(:500)续期。

这里有个安全细节值得单独说:拿到设备授权响应后,代码只记录脱敏字段(okexpires_in),绝不打印完整 result(qwenOAuth2.ts:386-408)。注释说明理由——device_code 在授权有效期内等同于 bearer 凭据,一句 debug 级的 console.log(result) 就能把它写进 stderr/journald,绕过上层所有脱敏。凭据文件本身以 0o600 写盘(QWEN_CREDENTIAL_FILE_MODE,qwenOAuth2.ts:1072)。

② 保管令牌:单例 + 双层锁。 SharedTokenManager(sharedTokenManager.ts:120)是进程内单例(getInstance,:168),但要跟其它进程协调:

getValidCredentials(client, forceRefresh) :210

├─ checkAndReloadIfNeeded ← 别的进程刷过了吗?(比文件 mtime)
├─ 缓存里的 token 还有效? → 直接返回(留 30s 缓冲) :670

└─ 需要刷新 → performTokenRefresh :466
├─ ① 进程内:refreshPromise 去重,并发调用共享同一次刷新
├─ ② 跨进程:acquireLock 抢文件锁 oauth_creds.lock :701
├─ ③ 拿到锁后再查一次(可能别人刚刷完)→ 命中就直接用
└─ ④ 真刷新 → 原子写盘

文件锁的实现是 fs.writeFile(lockPath, lockId, { flag: 'wx' })(sharedTokenManager.ts:710)——wx 保证「文件已存在则失败」,这就是原子的 test-and-set。锁 ID 用 randomUUID() 而不是 PID,注释注明是出于安全考虑(:704)。陈旧锁(超过 LOCK_TIMEOUT_MS = 35 秒)通过先 rename 再 unlink 的两步清理,避免直接删除时的竞态(:722-733)。

LOCK_TIMEOUT_MS 是 35 秒而刷新超时是 30 秒,源码有一行注释解释这个数字关系:锁超时必须大于刷新超时,否则一次正在进行的刷新会被别人当成陈旧锁清掉(sharedTokenManager.ts:31-32)。

③ 用令牌:每次请求前热替换。 QwenContentGenerator(qwenContentGenerator.ts:27)继承 OpenAIContentGenerator,构造时固定用 DashScope provider。它把每个方法都包进 executeWithCredentialManagement(:124):

// 示意,非源码:核心就这么几行
const { token, endpoint } = await this.getValidToken();
this.pipeline.client.apiKey = token; // 直接改 SDK 客户端实例
this.pipeline.client.baseURL = endpoint; // endpoint 也是动态的
return await operation();
// 401/403 → 强制刷新一次再重试一遍

endpoint 也动态是因为服务端会在凭据里返回 resource_url,指向就近的区域端点;getCurrentEndpoint(qwenContentGenerator.ts:59)负责补协议头和 /v1 后缀。

它还覆盖了 shouldSuppressErrorLogging(:76),把鉴权类错误的日志压掉——因为这类错误是它自己要处理的(刷新后重试),刷屏没有意义。


6. 上下文窗口与输出上限

这节讲什么: 「这个模型能吃多少 token、最多吐多少」这个问题,被单独收在一个文件里。

packages/core/src/core/tokenLimits.ts 的对外面只有三个函数:

符号位置作用
normalizetokenLimits.ts:55归一化模型名:剥供应商前缀、切 |:、去日期/版本后缀
hasExplicitOutputLimit:235这个模型有没有明确定义的输出上限(区分「已知」和「用默认值」)
tokenLimit:262查上限,查不到就回落到默认值

默认值:输入 131 072、输出 32 000(tokenLimits.ts:11-12)。LIMITS 表里同时存着两类数字——2 的幂近似(128k = 131072)和厂商声明的十进制(200k = 200000),注释说明了这个区分(:35-38)。

normalize 里有两条硬编码的例外:qwen-{plus,flash,vl-max}-latestkimi-k2-<4位数字> 不剥后缀,因为对这些模型来说后缀是身份的一部分(tokenLimits.ts:71-76)。

hasExplicitOutputLimit 存在的意义applyOutputTokenLimit(provider/default.ts:157)里体现得最清楚——它决定了要不要「削」用户配的值:

情况行为
用户设了 4K,已知模型上限 64K用 4K(尊重用户)
用户设了 100K,已知模型上限 64K削到 64K(不然某些 API 直接 400)
用户设了 100K,未知模型用 100K(自建后端可能真支持)
用户没设QWEN_CODE_MAX_OUTPUT_TOKENS 环境变量,再回落到模型上限

区分「已知/未知」这一步是关键:对未知模型(部署别名、自建服务)不去猜、不去削。同样的四档逻辑在 Anthropic 侧独立复现了一遍(anthropicContentGenerator.ts:616-639)。

上下文窗口那一侧可以被 ContentGeneratorConfig.contextWindowSize 直接覆盖(contentGenerator.ts:124),压缩策略怎么用这个数字,见 05 上下文工程


7. 日志包装:装饰器模式的标准用法

LoggingContentGenerator(loggingContentGenerator.ts:103)实现 ContentGenerator、内部持有一个 ContentGenerator,是教科书式的装饰器。createContentGenerator 的最后一行把每个实现都包进去(contentGenerator.ts:420),所以上层拿到的永远是这个壳

它承担四件事:

  • 遥测事件:ApiRequestEvent / ApiResponseEvent / ApiErrorEvent
  • OpenTelemetry span:startLLMRequestSpan / endLLMRequestSpan,敏感属性(系统提示、模型输出)另有开关控制(areSensitiveSpanAttributesEnabled)。
  • OpenAI 格式的请求/响应日志:即使底层走的是 Anthropic 或 Gemini,也统一转成 OpenAI 形状落盘,方便对比(convertGeminiResponseToOpenAIForLogging,:843)。
  • 重试上下文快照:snapshotRetryMetadata() 必须在第一个 await 之前同步读取(loggingContentGenerator.ts:70-88)。原因写在注释里:流式路径返回的是 AsyncGenerator,真正迭代发生在 retryWithBackoff 已经返回、AsyncLocalStorage 帧已经退出之后;不提前快照就什么都读不到。

对于 OpenAI 路径,它拿到的请求体是真正上线的那份字节——靠的是 pipeline 里那个 openaiRequestCaptureContext 钩子(pipeline.ts:839),而不是自己重新拼一遍。


8. 巧妙之处(可以带走的技术)

① 用 JSON 深度状态机反推「输出被截断了」。 不新增探测机制,而是复用流式解析器已经维护的 depthinString——hasIncompleteToolCalls()(streamingToolCallParser.ts:453)为真就说明 JSON 被腰斩,进而覆盖 provider 撒谎的 finish_reason(converter.ts:1329)。零额外成本,纠正了一类服务端报告不可信的问题。

② 主机名判定与模型名判定分成两个函数,各管各的决定。 isDeepSeekHostname vs isDeepSeekProvider(provider/deepseek.ts:36:60),Anthropic 侧同构复现(anthropicContentGenerator.ts:56:83)。判定法则:改请求体形状用严判,改内容格式用宽判。误判成本不对称,判定强度就该不对称。

③ 显式 null 掐断 SDK 的环境变量回填。 anthropicContentGenerator.ts:207-216 那段。凭据泄漏路径往往藏在「库的默认行为」里,而不是自己的代码里。

④ beta header 扫描已组装的请求体,而不是重算判定条件。 hasGlobalCacheScopeOnWire(anthropicContentGenerator.ts:468)。凡是「声明」和「内容」必须一致的地方,让声明从内容推导,就消灭了两者漂移的窗口。

⑤ 用 wx 标志做跨进程原子锁。 fs.writeFile(path, id, { flag: 'wx' })(sharedTokenManager.ts:710)——不引入任何锁库,一行拿到 test-and-set;陈旧锁用 rename-then-unlink 两步清理避开竞态(:722-733)。

⑥ 抓包点的位置是承重的。 pipeline.ts:836-840 的注释把「必须在 X 之后、Y 之前」写成了代码契约。日志系统最常见的失效模式就是记录了一份「打算发送」而非「实际发送」的数据。

samplingParams 的全有全无语义。 要么完全不管(走逐字段兜底),要么完全交出(原样透传)。避免了「一半你的默认值、一半我的」这种最难排查的中间态。

⑧ 只有请求级差异才配一个 provider 类。 provider/README.md 立的这条规矩,让 OpenRouter、Requesty 这类只差 header 的供应商能零代码接入。抽象层最容易失控的地方就是「每来一个新东西就加一个类」。


9. 边界与局限

公共语言就是 Gemini 类型,不是中立协议。 接口签名直接用 @google/genaiGenerateContentParameters(contentGenerator.ts:38)。省了一层翻译,但也意味着 Gemini SDK 的类型演进会直接推到所有实现上。

embedContent 三家实现三种态度。 OpenAI 路径硬编码 text-embedding-ada-002(openaiContentGenerator.ts:147),Anthropic 直接抛「不支持」(anthropicContentGenerator.ts:337),只有 Gemini 是真转发。也就是说这个方法并不是真正跨协议可用的。

countTokens 在非 Gemini 路径上是估算。 OpenAI 和 Anthropic 实现都用 RequestTokenEstimator(字符数估计),失败时进一步降级到 字符数 / 4(openaiContentGenerator.ts:100-107anthropicContentGenerator.ts:326-327)。上下文占用的显示因此是近似值。

没有 usage 明细时的 70/30 拆分是拍脑袋的。 只拿到 total_tokens 时,代码按「输入 70%、输出 30%」估算拆分(converter.ts:1379-1387)。注释诚实地写了「typically」。好在减法保证两半加回去等于总数,不会因为分别取整而对不上。

provider 选择是一串 if,顺序敏感。 determineProvider(openaiContentGenerator/index.ts:57)按固定顺序试。DashScope 的判定尤其宽——baseUrl 为空时默认返回 true(dashscope.ts:49),任何 *.alibaba-inc.com / *.aliyun-inc.com 也算(:81-84)。这是刻意的设计(注释写了「保持通用,别把私有网关主机名硬编码进来」),但意味着在这些域名下部署非 DashScope 后端时会拿到不该有的补丁。

MiniMax 的后缀匹配是故意放宽的。 provider/minimax.ts:14-24 的注释自己承认:任何 *.minimaxi.com / *.minimax.io 子域都会启用 tagged thinking 解析,如果用户在这样的子域上架了非 MiniMax 后端,解析会误开。

思维标签解析器不是标签栈。 <think>…</thinking> 这种交叉配对被判为合法(taggedThinkingParser.ts:12-14),嵌套标签也不支持。对 MiniMax 的实际输出够用,对更复杂的格式不够。

repaired 标记只上报,不阻断。 补引号修复成功后会置 repaired: true(streamingToolCallParser.ts:212),但从这一层往上没有强制的处理策略——工具参数被「修」过之后照样会被执行。真正的拦截在权限层,见 04 安全护栏


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

主题文件路径(相对克隆根)关键符号
核心接口packages/core/src/core/contentGenerator.tsContentGeneratorAuthTypeContentGeneratorConfig
工厂与校验packages/core/src/core/contentGenerator.tscreateContentGeneratorcreateContentGeneratorConfigresolveContentGeneratorConfigWithSourcesvalidateModelConfig
OpenAI 入口packages/core/src/core/openaiContentGenerator/index.tscreateOpenAIContentGeneratordetermineProvider
OpenAI 实现packages/core/src/core/openaiContentGenerator/openaiContentGenerator.tsOpenAIContentGeneratorshouldSuppressErrorLogging
OpenAI 管线packages/core/src/core/openaiContentGenerator/pipeline.tsContentGenerationPipelinebuildRequestbuildReasoningConfigcreateRequestContexthandleChunkMerging
流式看门狗packages/core/src/core/openaiContentGenerator/pipeline.tswithStreamInactivityTimeoutStreamInactivityTimeoutErrorresolveStreamIdleTimeoutMsStreamContentError
协议转换packages/core/src/core/openaiContentGenerator/converter.tsconvertGeminiRequestToOpenAIconvertGeminiToolsToOpenAIconvertOpenAIChunkToGemininormalizeStreamingTextDeltacleanOrphanedToolCalls
碎片 JSON 拼装packages/core/src/core/openaiContentGenerator/streamingToolCallParser.tsStreamingToolCallParseraddChunkgetCompletedToolCallshasIncompleteToolCallsfindNextAvailableIndex
思维标签解析packages/core/src/core/openaiContentGenerator/taggedThinkingParser.tsTaggedThinkingParserparseTaggedThinkingTextisPrefixOfAnyTag
错误处理packages/core/src/core/openaiContentGenerator/errorHandler.tsEnhancedErrorHandlerisTimeoutError
provider 契约packages/core/src/core/openaiContentGenerator/provider/types.tsOpenAICompatibleProviderOpenAIRequestContextOverrides
provider 兜底packages/core/src/core/openaiContentGenerator/provider/default.tsDefaultOpenAICompatibleProviderapplyOutputTokenLimit
DashScope 补丁packages/core/src/core/openaiContentGenerator/provider/dashscope.tsDashScopeOpenAICompatibleProviderisDashScopeProvideraddDashScopeCacheControlbuildMetadata
DeepSeek 补丁packages/core/src/core/openaiContentGenerator/provider/deepseek.tsisDeepSeekHostnameisDeepSeekProvidertranslateReasoningEffortflattenContentParts
其它补丁provider/{minimax,mistral,modelscope,mimo}.tsprovider/README.mdMiniMaxOpenAICompatibleProviderstripReasoningContentModelScopeOpenAICompatibleProviderensureReasoningContentOnAssistantMessage
Anthropic 实现packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.tsAnthropicContentGeneratorresolveEffectiveEffortbuildThinkingConfigbuildPerRequestHeadersprocessStreamWithEmptyFallback
Anthropic 转换packages/core/src/core/anthropicContentGenerator/converter.tsAnthropicContentConverterconvertGeminiToolsToAnthropicinjectEmptyThinkingOnToolUseTurns
Anthropic 用量packages/core/src/core/anthropicContentGenerator/usage.tsbuildAnthropicUsageMetadata
Gemini 实现packages/core/src/core/geminiContentGenerator/GeminiContentGeneratorcreateGeminiContentGeneratorstripUnsupportedFields
Qwen OAuthpackages/core/src/qwen/qwenOAuth2.tsgeneratePKCEPairrequestDeviceAuthorizationpollDeviceTokenrefreshAccessTokengetQwenOAuthClient
跨进程令牌packages/core/src/qwen/sharedTokenManager.tsSharedTokenManagergetValidCredentialsperformTokenRefreshacquireLock
Qwen 生成器packages/core/src/qwen/qwenContentGenerator.tsQwenContentGeneratorexecuteWithCredentialManagementgetCurrentEndpoint
模型注册表packages/core/src/models/modelRegistry.tsModelRegistryresolveProviderProtocolmodelRegistryKey
配置解析packages/core/src/models/modelConfigResolver.tsresolveModelConfigresolveQwenOAuthConfigresolveGenerationConfig
字段清单packages/core/src/models/constants.tsMODEL_GENERATION_CONFIG_FIELDSPROVIDER_SOURCED_FIELDSAUTH_ENV_MAPPINGSQWEN_OAUTH_MODELS
子代理配置packages/core/src/models/content-generator-config.tsbuildAgentContentGeneratorConfigcreateRuntimeContentGeneratorViewresolveCredentialField
供应商 presetpackages/core/src/providers/all-providers.tspresets/ALL_PROVIDERSfindProviderByCredentialsopenRouterProvider
token 上限packages/core/src/core/tokenLimits.tstokenLimithasExplicitOutputLimitnormalizeDEFAULT_TOKEN_LIMIT
schema 降级packages/core/src/utils/schemaConverter.tsconvertSchemaSchemaComplianceMode
日志外壳packages/core/src/core/loggingContentGenerator/loggingContentGenerator.tsLoggingContentGeneratorloggingStreamWrappersnapshotRetryMetadata
常量packages/core/src/core/openaiContentGenerator/constants.tsDEFAULT_STREAM_IDLE_TIMEOUT_MSMAX_STREAM_IDLE_TIMEOUT_MSDEFAULT_DASHSCOPE_BASE_URL

接着读: 这些 ContentGenerator 被谁调用、一轮对话怎么走完,见 01 主循环;工具定义怎么产生、又怎么变成这一层看到的 tools 数组,见 03 工具层