跳到主要内容

多供应商 LLM 网关(@cline/llms)

本章讲什么: Cline 要同时支持 Anthropic、OpenAI、Google、Bedrock、本地端点等十几家供应商。这章讲 @cline/llms 怎么把它们抹平成一个统一的 stream(),以及网关在交给内核循环前做的两件公共事:上下文窗口裁剪reasoning 参数归一化

1. 它要解决的小问题

每家供应商的 API 形状都不同:Anthropic 的 messages 格式、OpenAI 的 chat completions、Bedrock 的封装……如果内核循环(01 章)要直接对接这些,会被各家差异淹没。

所以中间插一层网关:内核只认一个 AgentModel.stream(request) 接口,各家差异由网关内部消化。 内核拿到的永远是统一的事件流(text-delta / tool-call-delta / usage / finish)。

2. 三层抽象

内核循环 网关适配 供应商实现
AgentRuntime ──stream(request)──▶ GatewayModelAdapter ──▶ DefaultGateway.stream ──▶ anthropic.ts / openai.ts / ...
(只认 AgentModel) (把 AgentModelRequest (查注册表、解析模型、 (各家真实 SDK 调用,
翻成 GatewayStreamRequest) 算 maxTokens、调 provider) 吐统一事件)
部件职责符号
AgentModel内核眼里的「模型」,只有一个 stream()@cline/shared 接口
GatewayModelAdapter把内核的 AgentModelRequest 翻成网关请求gateway.ts:53
DefaultGateway注册表 + 解析 + 上下文裁剪,分发给具体 providergateway.ts:212
vendor 实现各家真实 API 调用providers/vendors/anthropic.ts

供应商是注册进来的:DefaultGateway 构造时装内置供应商(BUILTIN_PROVIDER_REGISTRATIONS,gateway.ts:222-234),也接受自定义注册和按 provider 的配置。createGateway(config)(gateway.ts:322)是工厂入口。

3. 关键活儿一:上下文窗口裁剪

模型有上下文上限(context window)。如果输入 prompt 已经很大,还按用户设的 maxTokens 要求输出,可能超窗报错。网关在 resolveGatewayRequestMaxTokens(gateway.ts:168)里把输出上限「夹」到安全值:

// 示意,非源码 —— 提炼自 gateway.ts:168-210 的取最小值逻辑
const caps = [requestedMaxTokens]; // ① 用户要的
if (model.maxOutputTokens) caps.push(model.maxOutputTokens); // ② 模型硬上限
if (model.contextWindow) {
// ③ 上下文里还剩多少:总窗 - 估算的输入 - 给格式化留的余量(1024)
const remaining = model.contextWindow - estimatedInputTokens - RESERVE;
if (remaining <= 0) { onContextOverflow(...); return undefined; } // 装不下,警告
caps.push(remaining);
}
return Math.max(1, Math.min(...caps)); // 取三者最小,至少留 1

重点看那个 remaining 计算。 它不是简单截断,而是:

  • 估算输入 token 有多大(estimateRequestInputTokens,gateway.ts:146 —— 把 systemPrompt + messages + tools 序列化后估)。
  • 减掉一个固定余量 GATEWAY_OUTPUT_RESERVE_TOKENS = 1024(gateway.ts:22),给供应商的格式化开销、工具 schema 开销、tokenizer 误差留空间。注释说这是故意略微高估输入,宁可保守。
  • 三个上限取最小:用户想要的、模型能给的、上下文还剩的。

如果连输入都装不下(remaining <= 0),触发 onContextOverflow 回调打一条 warn 日志(gateway.ts:288),而不是硬塞过去等供应商报错。

4. 关键活儿二:reasoning 参数归一化

「思考模式 / reasoning effort」各家叫法不一:有的用 thinking: boolean,有的用 reasoningEffort: low/medium/high,有的用 budgetTokensGatewayModelAdapter.stream(gateway.ts:60)把这些杂乱的输入归一成一个统一的 reasoning 对象:

// 示意,非源码 —— 提炼自 gateway.ts:61-95
// 优先用结构化的 request.options.reasoning;
// 否则从散落的 thinking / reasoningEffort / thinkingBudgetTokens 拼一个出来
reasoning: requestedReasoning ?? legacyReasoning ?? defaults.reasoning

这样上层(SessionRuntime / 用户)无论用哪种写法,网关都能转成具体供应商认得的形式。

5. 流式输出统一

具体 provider 的 stream() 返回各自的流,网关用 toAsyncIterable(gateway.ts:318)统一成异步可迭代,内核就能用 for await 消费(对上 01 章 §4 的流处理)。DefaultGateway.stream(gateway.ts:272)的流程:解析模型 → 创建 provider 实例 → 算 maxTokens → 调 provider.stream → 包成 async iterable。

6. 巧妙之处

  • 上下文裁剪「取最小 + 留余量」:不依赖供应商报错才发现超窗,而是主动估算并保守留 1024 token 余量(gateway.ts:22, 190-202)。
  • reasoning 三态兼容:同时吃结构化和散字段写法(gateway.ts:61-95),让上层不必关心各家差异。
  • 供应商可插拔:全靠注册表(GatewayRegistry),内置一份、用户能加自定义,关掉内置也行(builtins: false,gateway.ts:222)。

7. 边界与局限

  • token 估算是字符数近似(estimateTokens(length)),不是真 tokenizer —— 所以才故意高估留余量,极端情况下仍可能误差。
  • 网关本身不做重试 / 限流 —— 那些在更上层或 provider 中间件(providers/middleware)里。
  • maxTokens 只有在用户显式传了正有限值时才裁剪;没传就交给供应商默认(gateway.ts:283)。

8. 横向对比

把模型供应商抽象成统一 stream() 网关,是编码 agent 的通用做法 —— 它让「换模型」变成换一行 modelId。Cline 的特色是把上下文窗口管理也放进网关公共层,而不是散在每个 provider 里。

9. 代码地图

主题文件路径符号名
网关本体sdk/packages/llms/src/providers/gateway.tsDefaultGatewaycreateGateway
内核侧适配sdk/packages/llms/src/providers/gateway.tsGatewayModelAdapter
上下文裁剪sdk/packages/llms/src/providers/gateway.tsresolveGatewayRequestMaxTokensestimateRequestInputTokens
供应商注册表sdk/packages/llms/src/providers/registry.tsGatewayRegistry
各家实现sdk/packages/llms/src/providers/vendors/anthropic.tsopenai.tsbedrock.tsgoogle.ts