# packages/llm：模型协议层

> Schema 优先的 @opencode-ai/llm 如何用 Protocol、Endpoint、Auth、Framing 四轴组装路由，统一 LLMEvent 事件流与提示缓存，opencode 怎样按开关接入，以及下一代 @opencode-ai/ai 的设计草案。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/llm-package
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`packages/llm`（包名 `@opencode-ai/llm`）是 OpenCode 自己写的模型协议层，`src/` 下约 9500 行，目标是在 AI SDK 之外提供一条自己可控的调用链路。README 第一句就是定位：

> Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.

## 边界：只管一次 provider turn

它刻意做得很窄：工作区内只依赖 `@opencode-ai/schema`，外部运行时依赖是 `effect`，外加 Bedrock 要用的 `@smithy/eventstream-codec`、`@smithy/util-utf8` 与 `aws4fetch`；包标着 `private`，尚未单独发布。`AGENTS.md` 要求它与会话无关——鉴权、权限、插件、遥测头和运行时选择都留在 `packages/opencode/src/session/llm.ts`。`LLMClient.stream()` 与 `generate()` 都**只发一次 provider 请求**，不管会话历史，也不自动循环执行工具。

## 最小用法

```ts
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")

const request = LLM.request({
  model,
  system: "You are concise.",
  prompt: "Say hello in one short sentence.",
  generation: { maxTokens: 40 },
})

const program = Effect.gen(function* () {
  const response = yield* LLMClient.generate(request)
  console.log(response.text)
})
```

（`packages/llm/README.md:10`）

先配置提供方门面（facade），再选模型；选出的 `Model` 携带一条可执行的**路由**（route），请求就按它执行。

## Route：四根正交的轴

`Route.make()` 的注释（`packages/llm/src/route/client.ts:306`）把一次部署拆成四问：

| 轴 | 回答 | 例子 |
| --- | --- | --- |
| Protocol | 说的是哪种 API | 请求体构造与校验、流事件解码、事件到 `LLMEvent` 的状态机 |
| Endpoint | 发到哪里 | `baseURL` 加路径；路径可以是函数，Gemini、Bedrock 把模型 ID 拼进 URL |
| Auth | 怎样鉴权 | Bearer、`x-api-key` 请求头、Bedrock 的 SigV4 签名 |
| Framing | 字节怎样切帧 | SSE，或 Bedrock 的 AWS 二进制事件流 |

其下还有一层 Transport 作为 IO 模板，OpenAI Responses 因此能把 HTTP 换成 WebSocket 而不动协议。拆轴的收益在 OpenAI 兼容路由上最明显——整个协议原样复用：

```ts
export const route = Route.make({
  id: ADAPTER,
  protocol: OpenAIChat.protocol,
  endpoint: Endpoint.path("/chat/completions"),
  framing: Framing.sse,
})
```

（`packages/llm/src/protocols/openai-compatible-chat.ts:17`）

DeepSeek、Groq、Together、Fireworks、Cerebras、DeepInfra、Baseten 都只是在它上面配一个 `baseURL`。现有路由：

| 路由 id | 端点 |
| --- | --- |
| `openai-chat` | `/chat/completions` |
| `openai-responses` | `/responses`，另有 WebSocket 版 |
| `openai-compatible-chat` | `/chat/completions`，`baseURL` 由门面指定 |
| `anthropic-messages` | `/messages` |
| `gemini` | `/models/<id>:streamGenerateContent?alt=sse` |
| `bedrock-converse` | `/model/<id>/converse-stream`，AWS 事件流 |

## 编译：从 LLMRequest 到一次 HTTP 请求

```ts
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
  const resolved = applyCachePolicy(resolveRequestOptions(request))
  const route = resolved.model.route

  const body = yield* route.body
    .from(resolved)
    .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
  const prepared = yield* route.prepareTransport(body, resolved)
  // ...
})
```

（`packages/llm/src/route/client.ts:344`，删减后）

三组选项（`generation`、`providerOptions`、`http`）按“路由默认、模型默认、请求”由低到高合并，再套用缓存策略；协议生成原生请求体并用 Schema 校验，不支持的内容在本地就失败；Transport 生成真正的请求，Auth 在此写入请求头。`RequestExecutor` 对 429、503、504、529 与其他 5xx 最多重试 2 次，优先遵循 `retry-after`。响应帧逐个解码，经协议的 `step` 状态机变成 `LLMEvent`；`LLMClient.prepare()` 只编译不发送，便于测试。

## LLMEvent 与提示缓存

所有协议流出同一个标签联合（`packages/llm/src/schema/events.ts:209`），共 16 种：`step-start`、`step-finish`，`text-*`、`reasoning-*` 的 start、delta、end，工具参数流式到达的 `tool-input-*`，解析完成的 `tool-call`，以及 `tool-result`、`tool-error`，终结于 `finish` 或 `provider-error`。`Usage` 同时给出含缓存的总量与互不重叠的分项，注释强调下游永远不必做减法；`generate()` 把事件流折叠成 `LLMResponse`，等不到终结事件就报错。

提示缓存默认 `"auto"`：在最后一个工具定义、最后一段 system、最近一条用户消息上各放一个断点。最近一条用户消息是关键：一次用户输入会展开成多轮 assistant 与工具往返，它们共享这段前缀。注释算过账，Anthropic 5 分钟缓存写入按 1.25 倍计价、读取按 0.1 倍，复用一次就回本。只有 `anthropic-messages` 与 `bedrock-converse` 两个路由真正写出标记，OpenAI 与 Gemini 由服务端隐式缓存，直接跳过；`cache: "none"` 可以关闭，手工写的 `CacheHint` 总会保留。相比[提供方适配层](https://daiw.org/manual/opencode/provider-transform)里 V1 写死的断点位置，这里的策略成了可配置的数据。

## ToolRuntime：只执行一次调用

`ToolRuntime.dispatch(tools, call)`（`packages/llm/src/tool-runtime.ts:23`）按 `parameters` Schema 解码输入、执行、再按 `success` Schema 编码输出。未知工具、输入不合法或处理器返回 `ToolFailure` 都变成 `tool-error` 加一个错误 `tool-result`，让模型下一轮自行纠正，其他异常则让流失败。它不追加历史、不计步数、不发起下一轮。

## opencode 如何接入

默认仍走 AI SDK；设了 `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` 后，`session/llm.ts` 每次请求先问 `native-runtime.ts` 能否走原生：

```ts
const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
  return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
  return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
```

（`packages/opencode/src/session/llm/native-runtime.ts:54`）

另外，OAuth 凭据只放行带自定义 `fetch` 的 OpenAI（即 ChatGPT 订阅），且必须拿得到 API key；不满足就回退 AI SDK，同一会话里的不同请求可以走不同路径。通过后，`native-request.ts` 按 `npm` 选门面，把 AI SDK 形状的消息降级成 `LLMRequest`；工具仍归 opencode 执行，每个本地 `tool-call` 交给 `ToolRuntime.dispatch()` 在 `FiberSet` 里并发执行。AI SDK 路径则由 `session/llm/ai-sdk.ts` 把 `fullStream` 转成同样的 `LLMEvent`，所以[处理器](https://daiw.org/manual/opencode/processor)不关心走的是哪条路，详见[模型调用层](https://daiw.org/manual/opencode/llm-bridge)。

<Callout type="warn">
`session/llm/AGENTS.md` 说 `OPENCODE_EXPERIMENTAL=true` 也能开启原生路径、OpenAI OAuth 一律回退；但 `runtime-flags.ts` 只读 `OPENCODE_EXPERIMENTAL_NATIVE_LLM`，`native-runtime.ts` 也已放行带 `fetch` 覆盖的 OpenAI OAuth。以代码为准。
</Callout>

## 下一代：@opencode-ai/ai（设计草案）

同目录的 `DESIGN.md` 开篇声明是讨论稿，签名只是示意，尚未实现。它打算改名 `@opencode-ai/ai`，与现有 API 彻底决裂。最关键的一刀是拆开两种语义：

> This separation is load-bearing:
>
> - `generate` / `stream`: complete Model Run
> - `generateTurn` / `streamTurn`: one Provider Turn

将来的 `LLM.generate` 表示**完整运行**：自动执行本地工具并继续请求，默认最多 20 轮，触顶算成功结果 `max-turns`；`generateTurn`、`streamTurn` 严格只发一次请求、从不执行本地工具。草案明确要求 OpenCode 迁到后者，自己掌控持久化、权限、工具结算与续跑。此外，`Route` 将藏到可执行的 `Model` 之后，包内附带 models.dev 生成快照。眼下的 `@opencode-ai/llm` 仍是“每次一个 turn、调用方自己循环”的形态。

下一篇：[配置系统](https://daiw.org/manual/opencode/config)——`opencode.json` 从哪些位置加载、如何合并与替换变量，本部分反复出现的 `provider`、`model` 等字段都在那里汇合。
