首页 > 教程攻略 > ai教程 >06-LLM 多协议抽象的 Protocol 管道

06-LLM 多协议抽象的 Protocol 管道

来源:互联网 时间:2026-07-15 18:43:39
好的,没问题。作为一名在LLM工程化领域摸爬滚打多年的老手,我看过太多“为抽象而抽象”的代码了。但今天要聊的这个结构,是真正把“抽象”用对了地方,解决了一个棘手的实际问题:如何优雅地管理多个LLM提供商的API差异。 我们来直接切入正题,看看这个名为`@opencode-ai/llm`的包,它的核心设计思路。 ### Protocol:定义API的“语义契约” 这个包最核心的洞见其实很简单:**把“这个API长什么样”和“请求发到哪儿、怎么认证”这两件事彻底分开**。前者是由`Protocol`负责的“语义契约”,它只关心三件事: 1. 如何把通用的`LLMRequest`,转换成每个服务商(Provider)自己的原生请求体。 2. 这个请求体必须满足什么数据格式(Schema)。 3. 如何把服务商返回的流式响应,解码回我们通用的`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发完所有数据块),用最终状态冲刷出剩余的事件。 #### 完整流水线:从请求到用户事件 把上面这些串联起来,一个完整的请求-响应流程就是这样: 1. **编译阶段**:`LLMRequest` 经过 `applyCachePolicy` 和 `resolveRequestOptions` 处理后,交给 `route.body.from` 构建出原始的 `Body`。 2. **校验阶段**:用 `body.schema` 对 `Body` 进行校验,确保其符合契约。 3. **预备阶段**:`route.prepareTransport` 将校验后的 `Body` 和请求信息,打包成Transport层所需的数据(比如HTTP请求对象)。 4. **执行阶段**:`Transport` 开始发送请求,接收响应流,并通过 `Framing` 将字节流切成一个个 `Frame`。 5. **解码阶段**:每个 `Frame` 经过 `stream.event` Schema解码,变成 `Event`。 6. **翻译阶段**:每个 `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? 这一步是检验设计是否优秀的试金石。来看看它有多简单: 1. **第一步,定义 `OpenAIChat.protocol`**:它定义了Chat Completions的完整语义契约,包括`body.schema`、`body.from`和流式状态机。 2. **第二步,创建 `OpenAICompatibleChat.route`**:它直接复用 `OpenAIChat.protocol`,**零改动**。只修改了`route.id`以防冲突,并指定了`Endpoint`和`Framing`。 3. **第三步,为每个Provider创建专属Route**:通过一个 `define(profile)` 工厂函数,为每个Provider覆盖 `Endpoint`(baseURL)和 `Auth`(bearer token)。 ```typescript // 代码示例: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 的四步流水线 1. **resolveRequestOptions**:合并三层默认值(Route级 -> Model级 -> 请求级),得到完整的请求对象。 2. **applyCachePolicy**:根据缓存策略,在正确的位置注入缓存提示(hint)。 3. **body.from + Schema校验**:调用Protocol的`from`函数构建Provider原生请求体,然后用`body.schema`对其进行校验。**这一步是关键的编译期闸门**,如果`from`函数有bug,会在这里就失败,而不是等到请求发出去后收到Provider的400错误。 4. **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),错误处理可以基于类型做决策,缓存策略可以统一管理。 核心的设计哲学可以概括为: 1. **Protocol是“API语义”的唯一来源**:一个Protocol定义了API的全部语义知识。部署(URL、Auth、Transport)是与之正交的、可替换的维度。这使得Protocol成为一种可复用的语义资产。 2. **compile是“类型安全”的编译边界**:在body和Transport之间插入Schema校验,把bug捕获在编译期。compile不执行的设计,让请求预览成为免费的副产品。 3. **缓存策略是“协议无关”的策略层**:`auto`策略在编译边界注入语义提示,Protocol的`lowering`函数自然翻译为线格式标记。两层分离让缓存策略可以统一演进,而不需要改动每个Protocol。 4. **错误是“类型化决策”的输入**:10种 `Reason` 加上 `retryable` getter,让上层可以用类型开关做不同策略。Executor的预输出重试是这一层的默认消费者。 5. **正交组合优于继承**:DeepSeek不继承 `OpenAIChat`,而是 `OpenAIChat.protocol + DeepSeek.endpoint + bearer.auth`。这避免了继承层次的爆炸,用M+N个正交维度覆盖所有组合。