跳转到内容

模型 Provider / LLM 调用

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度较难
预计阅读50 分钟
  • 章节 ID:04-llm-provider
  • 章节摘要:从已准备好的模型上下文出发,理解 provider client、system/options/tools/headers、消息兼容转换、native 回退与统一 LLMEvent。
  • 教程版本:v1.18.16
  • 源码基线:a3647eb025c7615159d417dcc49fc39fdaeba65b
  • 章节元数据:/versions/v1-18-16/data/chapters.json
  • 源码映射:/versions/v1-18-16/data/source-map.json
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/llm/ai-sdk.ts
  • packages/opencode/src/session/llm/native-runtime.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/provider/transform.ts
  • packages/llm/src/protocols/index.ts

本章以 OpenCode 源码版本 v1.18.16(提交 a3647eb025c7)为证据基线。我们追踪一条典型 AI SDK 路径:session 已准备好消息、system 内容和工具;当 native runtime 未启用或不支持当前 provider 时,OpenCode 怎样构造请求、调用 provider,再把流还原为统一事件?这条路径由源码证明,但不是一次真实网络请求录屏。

学完这一章,你应该能够:

  1. 画出 SessionProcessor -> LLM.stream -> Provider -> native/AI SDK -> LLMEvent 的适配地图。
  2. 区分 provider 元数据、language model client、请求参数与 provider-specific transform。
  3. 沿源码解释 system、messages、tools、options 与 headers 怎样合成一次模型请求。
  4. 说明为什么同一份内部消息在发给不同 provider 前仍需要归一化。
  5. 判断 native runtime 何时回退、工具何时被过滤、异常事件怎样进入统一错误通道。
  6. 把“稳定内核 + 边界适配器”的方法迁移到自己的多模型 Agent。

OpenCode 的 LLM 层是一座双向翻译桥:向外把统一的 session 消息、system、工具和策略翻译成某个 provider 接受的请求;向内把不同 runtime 的流翻译成统一 LLMEvent

本章只回答一个问题:

为什么切换 provider 不应该迫使 agent loop 重写一遍?

因为 loop 只依赖 LLM.stream(input): Stream<LLMEvent>;provider SDK 的创建、请求兼容和原始事件差异都被关在 LLM/Provider 边界内。核心合同见 packages/opencode/src/session/llm.ts

  • native runtime 已成为清晰的 opt-in adapter:先由 LLMNativeRuntime.stream 判断支持度,不支持就带原因回退 AI SDK。
  • AI SDK 路径仍调用 streamText,再由 LLMAISDK.toLLMEventsfullStream 归一化;agent loop 不需要知道当前选中了哪条 runtime。
  • provider transform 的职责继续扩大,包含 reasoning variant、provider options、schema 与 token 上限等兼容逻辑,更说明这些差异不应进入 loop。
native runtime 与 AI SDK 回退 packages/opencode/src/session/llm.ts:224-282

runtime 选择发生在 LLM 边界内部。

224      // Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It225      // either returns a ready LLMEvent stream or a concrete fallback reason.226      if (flags.experimentalNativeLlm) {按条件进入分支。227        const native = LLMNativeRuntime.stream({228          model: input.model,选择模型或 provider。229          provider: item,选择模型或 provider。230          auth: info,231          llmClient,232          messages: prepared.messages,233          tools: prepared.tools,234          toolChoice: input.toolChoice,235          temperature: prepared.params.temperature,236          topP: prepared.params.topP,237          topK: prepared.params.topK,238          maxOutputTokens: prepared.params.maxOutputTokens,239          providerOptions: prepared.params.options,选择模型或 provider。240          headers: prepared.headers,241          abort: input.abort,242        })243        if (native.type === "supported") {按条件进入分支。244          yield* Effect.logInfo("llm runtime selected", {Effect 异步工作流。245            "llm.runtime": "native",246            "llm.provider": input.model.providerID,选择模型或 provider。247            "llm.model": input.model.id,248          })249          return {返回给上一层。250            type: "native" as const,251            stream: native.stream,252          }253        }254        yield* Effect.logInfo("llm runtime selected", {Effect 异步工作流。255          "llm.runtime": "ai-sdk",256          "llm.provider": input.model.providerID,选择模型或 provider。257          "llm.model": input.model.id,258          "llm.native_unsupported_reason": native.reason,259        })260        yield* Effect.logInfo("native runtime unavailable; falling back to ai-sdk", {Effect 异步工作流。261          providerID: input.model.providerID,选择模型或 provider。262          modelID: input.model.id,选择模型或 provider。263          "session.id": input.sessionID,264          small: (input.small ?? false).toString(),265          agent: input.agent.name,266          mode: input.agent.mode,267          reason: native.reason,268        })269      }270271      yield* Effect.logInfo("llm runtime selected", {Effect 异步工作流。272        "llm.runtime": "ai-sdk",273        "llm.provider": input.model.providerID,选择模型或 provider。274        "llm.model": input.model.id,275      })276      // Default runtime path: AI SDK owns provider execution and tool dispatch;277      // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.278      return {返回给上一层。279        type: "ai-sdk" as const,280        result: streamText({向模型发起请求。281          onError(error) {282            bridge.fork(
两条 runtime 汇入统一事件流 packages/opencode/src/session/llm.ts:368-380

native 直接返回统一流,AI SDK 经过事件适配器。

368            if (result.type === "native") return result.stream按条件进入分支。369370            // Adapter seam: both runtimes expose the same LLMEvent stream. Native371            // already returns one; AI SDK streams are converted here.372            const state = LLMAISDK.adapterState()373            return Stream.fromAsyncIterable(result.result.fullStream, (e) =>返回给上一层。374              e instanceof Error ? e : new Error(String(e)),375            ).pipe(376              Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),377              Stream.flatMap((events) => Stream.fromIterable(events)),378            )379          }),380        ),

2. 为什么“调用模型”远不止一个 HTTP 请求

Section titled “2. 为什么“调用模型”远不止一个 HTTP 请求”

最简聊天请求看起来像:

messages -> provider -> text

coding agent 的真实请求还包含:

  • agent prompt、环境 system 与用户自定义 system;
  • 当前模型能力、variant、温度、topP、token 上限;
  • 几十个带 JSON Schema 和 execute 回调的工具;
  • OAuth、API key、base URL、provider options 与 headers;
  • text、reasoning、tool call、tool result、usage、finish reason 等流事件。

更麻烦的是,各 provider 对空消息、tool-call id、附件模态、缓存标记和 options namespace 的要求并不一致。若 agent loop 直接理解这些差异,它会迅速变成 provider 条件分支的墙。

3. 先画地图:三种稳定合同夹住变化

Section titled “3. 先画地图:三种稳定合同夹住变化”
SessionProcessor
|
| LLM.StreamInput
v
LLM.Service
|
+--> Provider.getLanguage(model) ---> provider SDK / model client
|
+--> ProviderTransform ------------> messages/options/schema 兼容
|
+--> native runtime (支持时)
| |
| +-----------------------> LLMEvent
|
+--> AI SDK streamText (默认回退)
|
v
LLMAISDK.toLLMEvents ----------> LLMEvent
|
v
SessionProcessor
稳定合同变化被放在哪里
session -> LLMStreamInputsession 如何准备历史,不泄漏给 provider
ProviderModelInfogetLanguageSDK 包、认证、base URL、model client
LLM -> sessionLLMEventAI SDK/native 原始流事件被适配掉

通用 Agent 内核只需要“输入一份模型上下文,返回一条事件流”;OpenCode 的 provider catalog、插件 hooks、native 实验路径、GitLab workflow 与大量兼容规则是产品层。

1function stream(input: StableLLMInput): Stream<LLMEvent> {定义一段可复用逻辑。2  return scopedStream(async (abort) => {返回给上一层。3    const language = await provider.getLanguage(input.model)选择模型或 provider。4    const system = buildSystem(input)5    const params = mergeModelAgentVariantOptions(input)6    const tools = filterDisabledTools(input)7    const messages = adaptMessages(input.messages, input.model)89    const native = tryNative({ language, system, messages, tools, abort })10    if (native.supported) return native.stream按条件进入分支。1112    const result = streamText({向模型发起请求。13      model: language, system, messages, tools, ...params, abortSignal: abort,选择模型或 provider。14    })15    return mapEvents(result.fullStream, toLLMEvent)返回给上一层。16  })17}

这是从真实实现抽出的教学骨架。OpenCode 还要加入 auth、config、plugin hooks、telemetry、headers、tool-call repair 和 provider-specific options。

5.1 Provider.Model 是能力与寻址信息,不是客户端

Section titled “5.1 Provider.Model 是能力与寻址信息,不是客户端”

Provider.Model 保存 model id、provider id、API package/id/url、能力、上下文限制、价格、options、headers 和 variants。来源:packages/opencode/src/provider/provider.ts

它回答“这个模型是什么、能做什么、怎样找到 SDK”,但不能直接发请求。

Provider.getLanguage(model) 返回 AI SDK 的 LanguageModelV3,并按 providerID/modelID 缓存。它通过 resolveSDK 得到 provider factory,再调用自定义 model loader 或 sdk.languageModel(model.api.id)。来源:packages/opencode/src/provider/provider.ts

可以把它类比为 Spring 中由配置创建并缓存的 client bean。类比边界是:不同 provider SDK 在运行时动态选择,并不都实现一个由 OpenCode 自己定义的 Java interface。

5.3 ProviderTransform 是兼容层,不是 provider registry

Section titled “5.3 ProviderTransform 是兼容层,不是 provider registry”

它负责消息、options、temperature、token 上限和 tool schema 等转换。registry 决定“用谁”,transform 解决“对方接受什么形状”。

5.4 LLMEvent 是 session 层唯一需要懂的输出语言

Section titled “5.4 LLMEvent 是 session 层唯一需要懂的输出语言”

AI SDK 的 text-deltatool-callfinish-step 等事件经 LLMAISDK.toLLMEvents 转成统一事件;native runtime 原生返回同一事件类型。来源:packages/opencode/src/session/llm/ai-sdk.tspackages/opencode/src/session/llm/native-runtime.ts

6. 追一条典型源码旅程:从稳定输入到 AI SDK 事件

Section titled “6. 追一条典型源码旅程:从稳定输入到 AI SDK 事件”

假设 Agent 核心循环已经准备好:

  • 当前 user message;
  • 当前 agent 与 model;
  • 转成 ModelMessage[] 的会话历史;
  • system 内容数组;
  • 当前轮可用 tools。

我们只追 native 未启用或返回 unsupported 后的 AI SDK 分支。

6.1 第一站:StreamInput 划定 session 与 provider 的边界

Section titled “6.1 第一站:StreamInput 划定 session 与 provider 的边界”
1export type StreamInput = {定义数据结构约束。2  user: MessageV2.User会话消息片段结构。3  sessionID: string4  parentSessionID?: string5  model: Provider.Model选择模型或 provider。6  agent: Agent.Info7  permission?: Permission.Ruleset8  system: string[]9  messages: ModelMessage[]10  small?: boolean11  tools: Record<string, Tool>12  retries?: number13  toolChoice?: "auto" | "required" | "none"14}

来源:packages/opencode/src/session/llm.ts

注意这里已经是 ModelMessage[],不是数据库原始 parts。message-to-model 的领域转换属于 session/message 层;本章从这个稳定边界开始。

6.2 第二站:并发取得 client、配置、provider 与认证

Section titled “6.2 第二站:并发取得 client、配置、provider 与认证”

LLM.run 使用 Effect.all(..., { concurrency: "unbounded" }) 同时获取:

  1. provider.getLanguage(input.model)
  2. 全局 config;
  3. provider info;
  4. 当前 provider auth。

来源:packages/opencode/src/session/llm.ts

这些读取互不依赖,因此并发可以缩短首个 token 前的准备时间。失败仍会进入同一个 Effect 错误通道。

6.3 第三站:Provider 把配置变成 language model

Section titled “6.3 第三站:Provider 把配置变成 language model”

resolveSDK 从 provider options 开始,解析 base URL 中的变量,补 API key 与 model headers,再对 { providerID, npm, options } 求 hash 作为 SDK 缓存 key。来源:packages/opencode/src/provider/provider.ts

若 package 在 bundled provider 表中,直接加载内置 factory;否则通过 Npm.add 获取入口或加载 file:// provider,再查找 create* factory。来源:packages/opencode/src/provider/provider.ts

随后 getLanguage 再按 ${providerID}/${model.id} 缓存具体 model client。来源:packages/opencode/src/provider/provider.ts

两级缓存解决不同复用粒度:相同 provider 配置可共享 SDK,同一模型可复用 language model。缓存键与初始化行为由源码证明;性能收益是结构上的设计解释。

6.4 第四站:system prompt 先组合,再允许插件修改

Section titled “6.4 第四站:system prompt 先组合,再允许插件修改”

system 的基础顺序是:

agent.prompt(若有)否则 provider system prompt
+ input.system
+ last user message 上的 system

来源:packages/opencode/src/session/llm.ts

然后触发 experimental.chat.system.transform。若插件扩展出多个 system 项,且 header 没被改动,代码把剩余项重新合并,维持两段结构以利缓存。来源:packages/opencode/src/session/llm.ts

OpenAI OAuth 是特殊分支:system 被写入 options.instructions,messages 中不再前置 system message;GitLab workflow 也有独立处理。普通路径则把 system 数组转换成 role: "system" 的 messages。来源:packages/opencode/src/session/llm.ts

6.5 第五站:options 不是覆盖,而是有顺序的合并

Section titled “6.5 第五站:options 不是覆盖,而是有顺序的合并”

普通请求先从 ProviderTransform.options(...) 得到 provider/model 默认值,再依次深合并:

base options
<- model.options
<- agent.options
<- selected variant

越靠后的层优先级越高。来源:packages/opencode/src/session/llm.ts

接着 chat.params hook 可以修改 temperature、topP、topK、maxOutputTokens 与 options;chat.headers hook 单独修改 headers。来源:packages/opencode/src/session/llm.ts

为什么 parameters 与 headers 分两个 hook?代码没有写设计说明;从接口形状可解释为分别治理模型行为与传输元数据,这是明确标注的设计解释。

6.6 第六站:工具要经过两次筛选与稳定排序

Section titled “6.6 第六站:工具要经过两次筛选与稳定排序”

resolveTools 合并 agent/session permission,移除被 disabled 的工具,也尊重 user message 上旧 tools[k] === false 的覆盖。来源:packages/opencode/src/session/llm.ts

之后工具按名称排序:

1const sortedTools = Object.fromEntries(2  Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),3)

来源:packages/opencode/src/session/llm.ts

这不是决定模型会调用谁,而是稳定工具声明顺序。对 GitHub Copilot 的“历史含 tool call、当前无工具”特殊情况,源码还注入永不应调用的 _noop 兼容工具。来源:packages/opencode/src/session/llm.ts

OpenCode 自有 provider 会加入 project/session/request/client headers;其他 provider 获得 session affinity、可选 parent session id 与 User-Agent。最后再合并 model headers 和 plugin headers。来源:packages/opencode/src/session/llm.ts

这里能确认合并顺序:后面的 input.model.headers 与 plugin headers 可覆盖前面的同名字段。是否应该允许覆盖某个具体 header,要结合配置安全策略判断,源码本身只证明当前行为。

6.8 第八站:native 先试,unsupported 就回退

Section titled “6.8 第八站:native 先试,unsupported 就回退”

只有 experimentalNativeLlm 开启时才尝试 native runtime。native 当前检查 provider id、SDK package、OAuth 与 API key;不支持时返回带原因的 unsupportedLLM.run 记录原因并继续 AI SDK。来源:packages/opencode/src/session/llm.tspackages/opencode/src/session/llm/native-runtime.ts

native 支持时直接返回 Stream<LLMEvent>;否则进入下一站。packages/llm/src/protocols/index.ts 显示 native LLM 包对外组织了 Anthropic、Bedrock、Gemini、OpenAI Chat/Compatible/Responses 等协议模块,但仅凭这个 index 不能推断每个模型此刻都走 native 分支。

6.9 第九站:AI SDK streamText 发出真正请求

Section titled “6.9 第九站:AI SDK streamText 发出真正请求”

AI SDK 分支调用 streamText,主要参数包括:

  • temperature/topP/topK/providerOptions;
  • activeTools/tools/toolChoice;
  • maxOutputTokens、abortSignal、maxRetries;
  • messages、headers 与 language model;
  • telemetry 与 tool-call repair。

来源:packages/opencode/src/session/llm.ts

关键的一层在 wrapLanguageModel middleware:请求真正发出前,ProviderTransform.message(...) 再处理 prompt。来源:packages/opencode/src/session/llm.ts

这说明内部 ModelMessage[] 是稳定语义,不保证已经符合每家 provider 的所有格式限制。

6.10 第十站:ProviderTransform 修复“语义相同、格式不兼容”

Section titled “6.10 第十站:ProviderTransform 修复“语义相同、格式不兼容””

ProviderTransform.message 的总入口依次处理不支持的附件 part、消息归一化、部分 provider 的缓存标记,以及 providerOptions key 映射。来源:packages/opencode/src/provider/transform.ts

normalizeMessages 中可见的真实兼容例子包括:

  • 清理非法 surrogate 字符;
  • Anthropic/Bedrock 过滤空内容;
  • Claude tool-call id 字符清理;
  • 特定 Anthropic SDK 调整 tool-call 与非 tool 内容顺序。

来源:packages/opencode/src/provider/transform.ts

不要把这些规则背成永恒标准。它们明显依赖 provider/SDK 行为,升级时必须用当前源码和集成测试重新验证。

6.11 第十一站:原始流被翻译为 LLMEvent

Section titled “6.11 第十一站:原始流被翻译为 LLMEvent”

LLM.stream 为本次调用创建 scoped AbortController。AI SDK 路径把 result.fullStream 转成 Effect Stream,再逐个交给 LLMAISDK.toLLMEvents。来源:packages/opencode/src/session/llm.ts

adapter 维护 step、当前 text/reasoning id 与 tool call id 到名称的映射。典型转换包括:

start-step -> LLMEvent.stepStart
text-delta -> LLMEvent.textDelta
tool-call -> LLMEvent.toolCall
tool-result -> LLMEvent.toolResult
finish-step -> LLMEvent.stepFinish
finish -> LLMEvent.finish
error -> Effect.fail

来源:packages/opencode/src/session/llm/ai-sdk.tspackages/opencode/src/session/llm/ai-sdk.ts

到这里,provider 的职责结束;统一事件继续交回 SessionProcessor 落成 message parts。

7. 五个不能忽略的失败与兼容分支

Section titled “7. 五个不能忽略的失败与兼容分支”

Provider.getModel 在 provider/model 缺失时返回 ModelNotFoundError,并尽量提供 suggestions。来源:packages/opencode/src/provider/provider.tsresolveSDK 初始化异常则包装成 InitError。来源:packages/opencode/src/provider/provider.ts

unsupported 只是说明当前请求不走 native;随后会回退 AI SDK。只有 native 已选中后的 stream failure 或 AI SDK error 才是本次模型调用错误。

ProviderTransform.message 先调用 unsupportedParts;它会把不支持的附件变成可读错误文本,提示模型告知用户。来源:packages/opencode/src/provider/transform.ts。这是降级语义,不是让 provider 接收它不支持的二进制。

AI SDK 分支先尝试把工具名转为 lowercase 并匹配现有工具;仍不匹配时把调用改写给 invalid 工具,并携带原工具名与错误。来源:packages/opencode/src/session/llm.ts

这让无效工具调用进入可观察的工具失败路径,而不是在 provider 边界静默消失。

scoped AbortController 在 stream scope 释放时 abort,并作为 abortSignal 传入 AI SDK;native 工具包装同样转发 abort。来源:packages/opencode/src/session/llm.tspackages/opencode/src/session/llm/native-runtime.ts

8. OpenCode 的选择:替代方案与代价

Section titled “8. OpenCode 的选择:替代方案与代价”
设计问题OpenCode 的选择可选做法收益与代价
loop 看什么输出统一 LLMEventloop 直接处理各 SDK eventsession 稳定;需要维护 adapter
provider client 如何创建元数据驱动、动态 factory、缓存每家 provider 手写固定 client扩展灵活;初始化与缓存键复杂
兼容规则放哪里ProviderTransform 边界集中处理污染 message domain model内部语义干净;transform 容易膨胀
runtime 如何演进native 支持时使用,否则 AI SDK 回退一次性切换实现可渐进迁移;两条路径都要验证
配置怎样覆盖base -> model -> agent -> variant -> plugin单层全局 options灵活;最终参数来源不再单一

表中选择由源码直接证明;收益与代价是设计解释。

9. TypeScript / Effect:只补四个阅读障碍

Section titled “9. TypeScript / Effect:只补四个阅读障碍”

这是工具名到 AI SDK Tool 的映射,不是 Java 的 record。可类比 Map<String, Tool>,但运行时是普通对象。

LLM.Service 声明能力接口,live/defaultLayer 组合 Auth、Config、Provider、Plugin 等依赖。可以类比 DI container 提供的 bean graph;不同点是依赖和错误在 Effect 类型中显式组合。

9.3 discriminated union 的 runtime 选择

Section titled “9.3 discriminated union 的 runtime 选择”

native.type === "supported" 后,TypeScript 知道存在 stream;否则存在 reason。它是轻量的 result union,类似 Java sealed result。

一个 AI SDK event 可能映射为零个或多个 LLMEvent,所以先 effectful 转换为数组,再把数组摊平成事件流。

方法一:在边界两侧定义稳定语言

Section titled “方法一:在边界两侧定义稳定语言”

输入用统一 request model,输出用统一 domain event;provider adapter 只负责翻译。

验证问题:新增 provider 时,SessionProcessor 是否完全不用修改?

方法二:把兼容修形集中在最后一公里

Section titled “方法二:把兼容修形集中在最后一公里”

领域消息保持统一,发送前才处理 provider 的空消息、id、附件与 options 规则。

验证问题:某 provider 修复 bug 后,你能否只删除一条 transform,而不迁移历史消息?

方法三:让新 runtime 可回退、可观测

Section titled “方法三:让新 runtime 可回退、可观测”

先做 capability/status 判断,记录选择与 unsupported reason,再回退成熟路径。

验证问题:native 覆盖不足时,用户得到的是透明回退还是硬失败?日志能否说明选了哪条路?

不要看上文,补全:

SessionProcessor 给 LLM 的稳定输入叫 ______。Provider 先把 model 元数据变成 ______。system 由 ______ 组合,options 按 ______ 的顺序覆盖。消息发送前经 ______ 修形。native 不支持时回退到 ______,原始流最后被翻译成 ______。

如果你的解释里仍出现“不同 provider 由 agent loop 分支处理”,请回看 packages/opencode/src/session/llm.ts

  1. 入门:列出 StreamInput 中与 provider 无关的五个字段。
  2. 进阶:从 provider.getLanguage 追到 resolveSDK,画出两级缓存键。
  3. 辨析:比较 ProviderTransform.messageLLMAISDK.toLLMEvents 的方向。
  4. 失败路径:分别说明 model not found、native unsupported、AI SDK error 最终走向哪里。

为 mini agent 写两个假 provider adapter:一个输出自定义 delta 事件,一个输出 chunk 事件;两者都必须转换成相同 DomainLLMEvent,业务 processor 不允许出现 provider 名称判断。

12. 最后复盘:桥接完成,但模型怎样获得行动能力

Section titled “12. 最后复盘:桥接完成,但模型怎样获得行动能力”

本章主链是:

稳定 StreamInput
-> provider client + system/options/tools/headers
-> provider-specific message transform
-> native 或 AI SDK stream
-> 统一 LLMEvent

你现在应该能回答中心问题:切换 provider 不重写 agent loop,是因为变化被夹在稳定输入与统一事件之间。

下一章还有一个关键缺口:LLM 请求里的 tools 从哪里来?description、schema 与真正的 execute 如何绑定,权限和输出截断又在哪一层发生?接下来进入“Tool 调用系统”。