# 模型 Provider / LLM 调用

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

## 0. 本章学习目标

学完这一章，你应该能够：

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。

## 1. 一句话讲明白

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:39-62`。

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

最简聊天请求看起来像：

```text
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. 先画地图：三种稳定合同夹住变化

```text
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 -> LLM | `StreamInput` | session 如何准备历史，不泄漏给 provider |
| Provider | `Model`、`Info`、`getLanguage` | SDK 包、认证、base URL、model client |
| LLM -> session | `LLMEvent` | AI SDK/native 原始流事件被适配掉 |

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

## 4. 最小机制：先看 15 行请求桥

```ts
function stream(input: StableLLMInput): Stream<LLMEvent> {
  return scopedStream(async (abort) => {
    const language = await provider.getLanguage(input.model)
    const system = buildSystem(input)
    const params = mergeModelAgentVariantOptions(input)
    const tools = filterDisabledTools(input)
    const messages = adaptMessages(input.messages, input.model)

    const native = tryNative({ language, system, messages, tools, abort })
    if (native.supported) return native.stream

    const result = streamText({
      model: language, system, messages, tools, ...params, abortSignal: abort,
    })
    return mapEvents(result.fullStream, toLLMEvent)
  })
}
```

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

## 5. 读源码前，只分清四个角色

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

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

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

### 5.2 language model 是可调用客户端

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

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

### 5.3 `ProviderTransform` 是兼容层，不是 provider registry

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

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

AI SDK 的 `text-delta`、`tool-call`、`finish-step` 等事件经 `LLMAISDK.toLLMEvents` 转成统一事件；native runtime 原生返回同一事件类型。来源：`packages/opencode/src/session/llm/ai-sdk.ts:61-252`、`packages/opencode/src/session/llm/native-runtime.ts:16-18`。

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

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

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

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

### 6.1 第一站：`StreamInput` 划定 session 与 provider 的边界

```ts
export type StreamInput = {
  user: MessageV2.User
  sessionID: string
  parentSessionID?: string
  model: Provider.Model
  agent: Agent.Info
  permission?: Permission.Ruleset
  system: string[]
  messages: ModelMessage[]
  small?: boolean
  tools: Record<string, Tool>
  retries?: number
  toolChoice?: "auto" | "required" | "none"
}
```

来源：`packages/opencode/src/session/llm.ts:39-52`

<!-- source-ref path="packages/opencode/src/session/llm.ts" lines="39-62" title="LLM 层的稳定输入输出合同" note="先看边界需要哪些数据，再看 provider 细节怎样被关在实现内部。" -->

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

### 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:85-107`。

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

### 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:1508-1561`。

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

<!-- source-ref path="packages/opencode/src/provider/provider.ts" lines="1508-1561" title="provider options 与 SDK 缓存键" note="区分 provider SDK 实例缓存和下一段的 language model 缓存。" -->

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

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

### 6.4 第四站：system prompt 先组合，再允许插件修改

system 的基础顺序是：

```text
agent.prompt（若有）否则 provider system prompt
  + input.system
  + last user message 上的 system
```

来源：`packages/opencode/src/session/llm.ts:109-124`。

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

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

### 6.5 第五站：options 不是覆盖，而是有顺序的合并

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

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

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

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

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

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

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

之后工具按名称排序：

```ts
const sortedTools = Object.fromEntries(
  Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),
)
```

来源：`packages/opencode/src/session/llm.ts:204-225`

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

### 6.7 第七站：headers 最后汇合

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:330-350`。

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

### 6.8 第八站：native 先试，unsupported 就回退

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

<!-- source-ref path="packages/opencode/src/session/llm/native-runtime.ts" lines="39-80" title="native runtime 支持检查与请求入口" note="unsupported 是正常能力判断，不等同于模型请求失败。" -->

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

### 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:395-468`。

<!-- source-ref path="packages/opencode/src/session/llm.ts" lines="395-468" title="AI SDK 请求组装" note="先按参数类别阅读；不要把这段误认成 agent loop。" -->

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

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

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

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

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

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

来源：`packages/opencode/src/provider/transform.ts:58-234`。

<!-- source-ref path="packages/opencode/src/provider/transform.ts" lines="58-151" title="消息归一化的第一批兼容规则" note="这些规则证明统一内部语义仍需要 provider 边界修形。" -->

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

### 6.11 第十一站：原始流被翻译为 `LLMEvent`

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

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

```text
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.ts:9-18`、`packages/opencode/src/session/llm/ai-sdk.ts:61-252`。

<!-- source-ref path="packages/opencode/src/session/llm/ai-sdk.ts" lines="191-233" title="tool call/result/error 的事件翻译" note="session processor 只看到统一 name、id、result 与 error，不再依赖 AI SDK 事件形状。" -->

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

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

### 7.1 provider 或 model 不存在

`Provider.getModel` 在 provider/model 缺失时返回 `ModelNotFoundError`，并尽量提供 suggestions。来源：`packages/opencode/src/provider/provider.ts:1655-1677`。`resolveSDK` 初始化异常则包装成 `InitError`。来源：`packages/opencode/src/provider/provider.ts:1646-1648`。

### 7.2 native unsupported 不是请求失败

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

### 7.3 模型不支持某种附件

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

### 7.4 tool call 名称错误会尝试修复

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

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

### 7.5 取消必须穿透到请求

scoped `AbortController` 在 stream scope 释放时 abort，并作为 `abortSignal` 传入 AI SDK；native 工具包装同样转发 abort。来源：`packages/opencode/src/session/llm.ts:471-480`、`packages/opencode/src/session/llm/native-runtime.ts:98-116`。

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

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

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

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

### 9.1 `Record<string, Tool>`

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

### 9.2 `Layer` 与 `Context.Service`

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

### 9.3 discriminated union 的 runtime 选择

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

### 9.4 `Stream.mapEffect` + `flatMap`

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

## 10. 可以带走的方法

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

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

验证问题：新增 provider 时，SessionProcessor 是否完全不用修改？

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

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

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

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

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

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

## 11. 费曼复述与练习阶梯

### 11.1 60 秒复述

不要看上文，补全：

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

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

### 11.2 读源码练习

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

### 11.3 小实现

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

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

本章主链是：

```text
稳定 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 调用系统”。
