理解 Protocol:API 的“语义契约”
这个包最核心的洞见其实很简单:把“这个 API 长什么样”和“请求发到哪儿、怎么认证”这两件事彻底分开。前者是由 Protocol 负责的“语义契约”,它只关心三件事:
- 如何把通用的
LLMRequest,转换成每个服务商(Provider)自己的原生请求体。 - 这个请求体必须满足什么数据格式(Schema)。
- 如何把服务商返回的流式响应,解码回我们通用的
LLMEvent。
四类型参数,定义一条完整管道
Protocol 接口用了四个泛型参数,它们精确地映射了数据流经管道时的四个阶段:
| 类型参数 | 管道阶段 | 干什么的? | 生命周期 |
|---|---|---|---|
Body |
请求体 | 构建好的 Provider 原生请求体。Route.make 会用 body.schema 校验后,编码成 JSON 发出去。 |
每次请求都构建一个 |
Frame |
传输帧 | 流式响应的一个最小单元。比如 SSE 协议里的一段 data: 字符串,或是 AWS Event Stream 里的一个二进制帧。 |
每个分帧都是一个 |
Event |
原生事件 | 从一个 Frame 里,经过 stream.event 这个 Schema 解码后,得到的 Provider 原生事件。 |
每帧解析出一个 |
State |
解析状态 | 贯穿 stream.step 的累加器,它的任务是把一系列 Provider 原生事件,翻译成我们想要的 LLMEvent 序列。 |
每次响应一个 |
请求侧:ProtocolBody
ProtocolBody 只有两个字段,但职责清晰:
schema: 这是Body类型的 JSON 编解码器(Codec)。它既是 TypeScript 的类型推断来源,也是运行时的校验器。compile阶段会用它来确保构建出来的请求体是合法的。from: 这是一个函数,负责把通用的LLMRequest“降级”为 Provider 的原生请求体。这里就是封存每个 Provider 奇技淫巧的地方,所有 Provider 的怪异之处都被锁在这个函数里,不会污染到上层通用的LLMRequest。
响应侧:ProtocolStream
ProtocolStream 是一个流式状态机,它由五个字段构成,对应五个处理阶段:
event: 一个 Schema 编解码器,负责从传输帧解码出原生事件。类型上就是Frame → Event。initial: 每次响应开始时被调用一次,传入解析后的请求,返回一个初始的State。step: 这是核心翻译器。它输入(当前State, 一个Event),输出[下一个State, 要发射的LLMEvent数组]。这是唯一一个把 Provider 的流式语义(比如 OpenAI 的 delta,Anthropic 的 content_block_delta)翻译成我们统一事件(text-delta, tool-input-delta)的地方。terminal: 可选的,用于标记那些不会自然结束的传输(比如 WebSocket),告诉状态机哪个事件表示“结束了”。onHalt: 可选的,当分帧流结束时(比如 OpenAI 发完所有数据块),用最终状态冲刷出剩余的事件。
完整流水线:从请求到用户事件
把上面这些串联起来,一个完整的请求-响应流程就是这样:
- 编译阶段:
LLMRequest经过applyCachePolicy和resolveRequestOptions处理后,交给route.body.from构建出原始的Body。 - 校验阶段:用
body.schema对Body进行校验,确保其符合契约。 - 预备阶段:
route.prepareTransport将校验后的Body和请求信息,打包成 Transport 层所需的数据(比如 HTTP 请求对象)。 - 执行阶段:
Transport开始发送请求,接收响应流,并通过Framing将字节流切成一个个Frame。 - 解码阶段:每个
Frame经过stream.eventSchema 解码,变成Event。 - 翻译阶段:每个
Event进入stream.step状态机,结合当前的State,产出最终的LLMEvent流。
每一步的类型转换都有对应的 Schema.Codec 把关,既是编译期的类型来源,也是运行时的校验闸门。
Route.make():四轴正交组合的艺术
Route.make 是协议层的核心构造器。它把四个完全正交的部署维度组合成一个可执行的 Route:一个 Protocol + 一个 Endpoint + 一个 Auth + 一个 Framing。这种分离带来的最大好处是:DeepSeek、TogetherAI 这类兼容 OpenAI 的厂商,只需要复用 OpenAIChat.protocol,零代码即可接入。
Route 的完整结构
一个 Route 持有四个轴的绑定结果,加上两个运行时方法(prepareTransport + streamPrepared)和一个可变性方法(with)。
四轴正交模型
这四个轴的职责边界非常清晰:
- Protocol:定义“我说的是什么 API?”(例如:OpenAI Chat / Anthropic Messages)
- Endpoint:定义“请求发到哪个 URL?”(例如:
baseURL+path) - Auth:定义“怎么认证?”(例如:
bearer/header/none) - Framing:定义“响应字节流怎么切成帧?”(例如:
sse/ AWS Event Stream)
两个重载:常用与高级
Route.make 提供了两个重载:
MakeInput(常用):你只需要提供framing,它自动帮你构造默认的 HTTP Transport。适用于绝大多数 HTTP+SSE 的 Provider。MakeTransportInput(高级):你需要提供完整的Transport。这适用于 WebSocket 或自定义传输协议。
DeepSeek/TogetherAI 如何复用 OpenAIChat.protocol?
这一步是检验设计是否优秀的试金石。来看看它有多简单:
- 第一步,定义
OpenAIChat.protocol:它定义了 Chat Completions 的完整语义契约,包括body.schema、body.from和流式状态机。 - 第二步,创建
OpenAICompatibleChat.route:它直接复用OpenAIChat.protocol,零改动。只修改了route.id以防冲突,并指定了Endpoint和Framing。 - 第三步,为每个 Provider 创建专属 Route:通过一个
define(profile)工厂函数,为每个 Provider 覆盖Endpoint(baseURL)和Auth(bearer token)。
// 代码示例:DeepSeek 和 TogetherAI 的接入
export const deepseek = define(profiles.deepseek) // baseURL = "https://api.deepseek.com/v1"
export const togetherai = define(profiles.togetherai) // baseURL = "https://api.together.xyz/v1"
注意,configure 内部是通过 route.with({ ... }) 来生成一个新 Route 的,这是一个不可变拷贝,所以原始的 route 不会被影响,每个 Provider 都拿到自己专属的 Route 实例。
衡量一下复用效果:DeepSeek、TogetherAI、Cerebras、Groq 等七个 Provider,共享同一份约500行的 OpenAIChat.protocol。每个 Provider 只需要3行 define(profile) 代码。如果没有四轴正交,每个 Provider 都得复制这500行代码。这正是这套设计的威力所在。
compile():编译边界,而非执行边界
compile 是 LLMClient 的内部核心函数,它是公共 LLMRequest 与 Provider 原生世界之间的一个编译边界。它的任务是编译,而不是执行。
compile 的四步流水线
- resolveRequestOptions:合并三层默认值(Route 级 -> Model 级 -> 请求级),得到完整的请求对象。
- applyCachePolicy:根据缓存策略,在正确的位置注入缓存提示(hint)。
- body.from + Schema 校验:调用 Protocol 的
from函数构建 Provider 原生请求体,然后用body.schema对其进行校验。这一步是关键的编译期闸门,如果from函数有 bug,会在这里就失败,而不是等到请求发出去后收到 Provider 的400错误。 - prepareTransport:把校验好的
body和请求对象交给 Transport,准备 Transport 所需的私有数据(比如 HTTP 请求对象)。
编译边界的设计含义
compile 是“编译”而非“执行”,它有三个关键设计约束:
- 编译不执行:
compile只是产出数据,不发送网络请求。这让LLMClient.prepare()可以安全地暴露给调试工具,让用户预览“即将发什么”而不实际发送。 - Schema 是编译期闸门:
body.from产出 body 后,立即用 Schema 校验。这意味着 Protocol 实现者的逻辑 bug(比如遗漏必填字段)会在编译阶段就被捕获,错误类型是“Provider 输出无效”,而不是等到运行时再报错。 - 缓存策略先于 body 构建:
applyCachePolicy在body.from之前执行,这样降级函数拿到的请求已经带上了缓存提示,可以自然地将其翻译为线格式标记。这是一个“在正确位置注入,让后续自然处理”的设计模式。
总结:一个类型化的 LLM 网关设计范式
OpenCode 的 LLM 网关设计展示了一个完整的、强调类型安全的范式。它比像 Vercel AI SDK 这样的方案更重、更显式,但换来的好处是实实在在的:新 Provider 的接入成本极低(只需改 Endpoint 和 Auth),错误处理可以基于类型做决策,缓存策略可以统一管理。
核心的设计哲学可以概括为:
- Protocol 是“API 语义”的唯一来源:一个 Protocol 定义了 API 的全部语义知识。部署(URL、Auth、Transport)是与之正交的、可替换的维度。这使得 Protocol 成为一种可复用的语义资产。
- compile 是“类型安全”的编译边界:在 body 和 Transport 之间插入 Schema 校验,把 bug 捕获在编译期。compile 不执行的设计,让请求预览成为免费的副产品。
- 缓存策略是“协议无关”的策略层:
auto策略在编译边界注入语义提示,Protocol 的lowering函数自然翻译为线格式标记。两层分离让缓存策略可以统一演进,而不需要改动每个 Protocol。 - 错误是“类型化决策”的输入:10 种
Reason加上retryablegetter,让上层可以用类型开关做不同策略。Executor 的预输出重试是这一层的默认消费者。 - 正交组合优于继承:DeepSeek 不继承
OpenAIChat,而是OpenAIChat.protocol + DeepSeek.endpoint + bearer.auth。这避免了继承层次的爆炸,用 M+N 个正交维度覆盖所有组合。
