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

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

对应问题 Q6:Protocol<Body, Frame, Event, State> 四类型参数管道、Route.make() 四轴正交组合、compile() 编译边界、cache-policy.ts inline hint 注入、executor.ts 类型化错误映射与预输出重试。 勘察依据:packages/llm/src/route/protocol.tsroute/client.tsroute/executor.tscache-policy.tsroute/endpoint.tsroute/auth.tsroute/framing.tsroute/transport/http.tsprotocols/openai-chat.tsprotocols/anthropic-messages.tsprotocols/openai-compatible-chat.tsproviders/openai-compatible.tsschema/errors.tsschema/events.tsprovider-error.ts

一、Protocol:一个模型服务族的语义 API 契约

@opencode-ai/llm 包的核心设计目标是:将"这个 API 长什么样"(语义契约)与"请求发到哪里、用什么认证、怎么传输"(部署契约)正交分离。Protocol 是前者的载体------它只 owns 三件事:公共 LLMRequest 如何变成 provider 原生请求体、请求体必须满足什么 schema、流式响应如何解码回公共 LLMEvent

1.1 四类型参数的定义

ts 复制代码
// 36:43:opencode/packages/llm/src/route/protocol.ts
export interface Protocol<Body, Frame, Event, State> {
  /** Stable id for the wire protocol implementation. */
  readonly id: ProtocolID
  /** Request side: schema for the provider-native body and how to build it. */
  readonly body: ProtocolBody<Body>
  /** Response side: streaming state machine. */
  readonly stream: ProtocolStream<Frame, Event, State>
}

四个类型参数精确对应管道的四个阶段:

类型参数 管道阶段 语义 生命周期
Body 请求体 provider 原生请求体候选。Route.makebody.schema 校验后 JSON 编码发出 每次 compile 构建
Frame 传输帧 流式响应的一个分帧单元。SSE 是一段 JSON data: 字符串;AWS event stream 是一个解析后的二进制帧 每个分帧周期
Event 原生事件 从一个 Frame 经 stream.event schema 解码后的 provider 原生事件 每帧一个
State 解析状态 贯穿 stream.step 的累加器,将事件序列翻译为 LLMEvent 序列 每次响应一个

1.2 请求侧:ProtocolBody

ts 复制代码
// 45:50:opencode/packages/llm/src/route/protocol.ts
export interface ProtocolBody<Body> {
  /** Schema for the validated provider-native body sent as the JSON request. */
  readonly schema: Schema.Codec<Body, unknown>
  /** Build the provider-native body from a common `LLMRequest`. */
  readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
}

ProtocolBody 有两个字段:

  • schemaBody 类型的 JSON Codec------它既是类型来源 (TypeScript 通过 schema 推断 Body),也是运行时校验器compile 用它 decodeUnknown 确保构建的 body 满足契约)
  • from:把公共 LLMRequest 降级(lowering)为 provider 原生 body 的 Effect 函数。这是唯一 知道公共消息如何映射到 provider 线格式的地方------provider 的 quirks 被锁在这里,不泄漏到 LLMRequest

1.3 响应侧:ProtocolStream

ts 复制代码
// 52:63:opencode/packages/llm/src/route/protocol.ts
export interface ProtocolStream<Frame, Event, State> {
  /** Schema for one decoded streaming event, decoded from a transport frame. */
  readonly event: Schema.Codec<Event, Frame>
  /** Initial parser state. Called once per response with the resolved request. */
  readonly initial: (request: LLMRequest) => State
  /** Translate one event into emitted `LLMEvent`s plus the next state. */
  readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
  /** Optional request-completion signal for transports that do not end naturally. */
  readonly terminal?: (event: Event) => boolean
  /** Optional flush emitted when the framed stream ends. */
  readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
}

ProtocolStream 是一个流式状态机,五个字段对应五个阶段:

  • eventSchema.Codec<Event, Frame>------从一个传输帧解码出原生事件的 schema。类型上 Frame → Event,是 ProtocolBody.schemaBody ↔ unknown)在响应侧的镜像
  • initial:每个响应调用一次,传入 resolved request,返回初始 State。这通常是 ToolStream.empty() + Lifecycle.initial() 的组合
  • step:核心翻译器------输入 (当前 State, 一个 Event),输出 [下一个 State, 要发射的 LLMEvent 数组]。这是把 provider 的流式语义(OpenAI 的 delta、Anthropic 的 content_block_delta)翻译为公共事件(text-delta、tool-input-delta)的唯一位置
  • terminal:可选------对于不自然结束的传输(如 WebSocket),标记哪个事件表示完成
  • onHalt:可选------当分帧流结束时(如 OpenAI 发完所有 chunk),用最终 State 冲刷出剩余事件(如 Anthropic 没有 onHalt,而 OpenAI Chat 用它发射 finish 事件)

1.4 完整流水线:request → body → frames → events → LLMEvent

将四个类型参数和两块(Body / Stream)组合,完整的请求-响应流水线如下:

bash 复制代码
        ┌─────────────────────────────────────────────────────────────┐
        │                    compile() 编译阶段                         │
        │                                                            │
  LLMRequest ──► applyCachePolicy() ──► resolveRequestOptions()       │
        │                                    │                        │
        │                           route.body.from(resolved)          │
        │                                    │                        │
        │                              Body (候选)                      │
        │                                    │                        │
        │                     Schema.decodeUnknown(body.schema)        │
        │                                    │                        │
        │                              Body (已校验)                    │
        │                                    │                        │
        │                        route.prepareTransport(body)          │
        │                                    │                        │
        │                              Prepared (HTTP 预备)             │
        └────────────────────────────────────┼────────────────────────┘
                                             │
        ┌────────────────────────────────────▼────────────────────────┐
        │              streamPrepared() 流式执行阶段                    │
        │                                                            │
     Prepared ──► transport.frames(prepared, runtime)                 │
                        │                                            │
                   Stream<Frame>  ◄── Framing.sse / bedrock-event     │
                        │                                            │
                Stream.mapAccumEffect                                │
                   ┌────┴────────────────────────┐                   │
                   │  decodeEvent(frame)          │  ← stream.event   │
                   │  → Event                     │     schema 解码     │
                   └────┬────────────────────────┘                   │
                        │                                            │
                   stream.step(state, event)                         │
                        │                                            │
                   [nextState, LLMEvent[]]                           │
                        │                                            │
                   Stream<LLMEvent>  ──► 公共事件流                    │
        └─────────────────────────────────────────────────────────────┘

类型流向不变量LLMRequest → Body(经 body.from),Body → unknown(经 body.schema 编码),HTTP 传输后 Uint8Array → Frame(经 Framing.frame),Frame → Event(经 stream.event 解码),Event + State → LLMEvent[](经 stream.step)。每一步的类型转换都有对应的 Schema.Codec 站岗------schema 既是编译期的类型推断来源,也是运行时的校验闸门。

二、Route.make():四轴正交组合

Route.make 是协议层的核心构造器。它把四个正交的部署维度 组合成一个可执行的 Route:一个 Protocol + 一个 Endpoint + 一个 Auth + 一个 Framing(+ 可选 headers/defaults)。这个分离让 DeepSeek、TogetherAI 等只需复用 OpenAIChat.protocol,零代码即可接入。

2.1 Route 的完整结构

ts 复制代码
// 36:53:opencode/packages/llm/src/route/client.ts
export interface Route<Body, Prepared = unknown> {
  readonly id: string
  readonly provider?: ProviderID
  readonly protocol: ProtocolID
  readonly endpoint: Endpoint<Body>
  readonly auth: AuthDef
  readonly transport: Transport<Body, Prepared, unknown>
  readonly defaults: RouteDefaults
  readonly body: RouteBody<Body>
  readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
  readonly model: (input: RouteMappedModelInput) => Model
  readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
  readonly streamPrepared: (
    prepared: Prepared,
    request: LLMRequest,
    runtime: TransportRuntime,
  ) => Stream.Stream<LLMEvent, LLMError>
}

Route 持有四轴的绑定结果(protocol/endpoint/auth/transport),加上两个运行时方法(prepareTransport + streamPrepared)和一个可变性方法(with 用于 patch)。

2.2 四轴正交模型

bash 复制代码
   ┌───────────────────────────────────────────────────────┐
   │                Route.make(input)                      │
   │                                                      │
   │   ┌─────────────┐  ┌──────────────┐                  │
   │   │  Protocol   │  │   Endpoint   │                  │
   │   │ (语义契约)  │  │ (URL 在哪)   │                  │
   │   │ Body/Frame/ │  │ baseURL+path │                  │
   │   │ Event/State │  │ +query       │                  │
   │   └──────┬──────┘  └──────┬───────┘                  │
   │          │                │                          │
   │   ┌──────▼──────┐  ┌──────▼───────┐                  │
   │   │    Auth     │  │   Framing    │                  │
   │   │ (怎么认证)  │  │ (怎么分帧)   │                  │
   │   │ bearer/none │  │ sse/binary   │                  │
   │   │ /header/... │  │              │                  │
   │   └──────┬──────┘  └──────┬───────┘                  │
   │          │                │                          │
   │          └───────┬────────┘                          │
   │                  ▼                                    │
   │          HttpTransport.httpJson                        │
   │          (默认 transport = httpJson)                   │
   │                  │                                    │
   │                  ▼                                    │
   │               Route<Body, Prepared>                   │
   └───────────────────────────────────────────────────────┘

四轴的职责边界:

接口 回答的问题 示例
Protocol Protocol<Body,Frame,Event,State> 我说的是什么 API? OpenAI Chat / Anthropic Messages / Gemini / Bedrock Converse
Endpoint Endpoint<Body> 请求发到哪个 URL? baseURL + path(可为函数,URL 可嵌入 model id/region)
Auth Auth 怎么认证? bearer / header / none / custom,支持 orElse/andThen 组合
Framing Framing<Frame> 响应字节流怎么切成帧? sse(SSE data: 行)/ AWS event stream(二进制长度前缀帧)

2.3 Route.make 的两个重载

ts 复制代码
// 303:339:opencode/packages/llm/src/route/client.ts
export function make<Body, Prepared, Frame, Event, State>(
  input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
// ...
export function make<Body, Frame, Event, State>(
  input: MakeInput<Body, Frame, Event, State>,
): Route<Body, HttpTransport.HttpPrepared<Frame>>
export function make<Body, Prepared, Frame, Event, State>(
  input: MakeInput<Body, Frame, Event, State> | MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> | Route<Body, HttpTransport.HttpPrepared<Frame>> {
  if ("transport" in input) return makeFromTransport(input)
  const protocol = input.protocol
  return makeFromTransport({
    id: input.id,
    provider: input.provider,
    protocol,
    endpoint: input.endpoint,
    auth: input.auth,
    headers: input.headers,
    transport: HttpTransport.httpJson({ framing: input.framing }),
    defaults: input.defaults,
  })
}

两个重载的区别在于第四轴的提供方式:

  • MakeInput(常用) :调用方只提供 framingmake 自动用 HttpTransport.httpJson({ framing }) 构造默认 HTTP transport。适用于绝大多数 HTTP+SSE 的 provider
  • MakeTransportInput(高级) :调用方直接提供完整 Transport<Body, Prepared, Frame>。适用于 WebSocket(如 OpenAI Responses WebSocket Route)或自定义传输

无论哪个重载,最终都走 makeFromTransport,保证两条路径的 build() 逻辑一致。

2.4 makeFromTransport:Route 的构建工厂

makeFromTransport 内部的 build() 函数完成了四轴到运行时方法的绑定:

ts 复制代码
// 247:301:opencode/packages/llm/src/route/client.ts
  const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
    const route: Route<Body, Prepared> = {
      id: routeInput.id,
      provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
      protocol: protocol.id,
      endpoint: routeInput.endpoint,
      auth: routeInput.auth ?? Auth.none,
      transport: routeInput.transport,
      defaults: routeInput.defaults ?? {},
      body: protocol.body,
      with: (patch: RoutePatch<Body, Prepared>) => { /* ... 深拷贝+合并 ... */ },
      model: (input) => makeRouteModel(route, input),
      prepareTransport: (body, request) =>
        routeInput.transport.prepare({ /* ... endpoint/auth/encodeBody/headers ... */ }),
      streamPrepared: (prepared, request, runtime) => {
        // frames → decode → step → LLMEvent
      },
    } satisfies Route<Body, Prepared>
    return route
  }

两个运行时方法的关键实现:

prepareTransport ------把 Body + LLMRequest 交给 transport 的 prepare,后者组装 URL(Endpoint.render)、叠加 headers、应用 Auth、JSON 编码 body,产出 Prepared(HTTP 场景是 HttpClientRequest + Framing)。

streamPrepared------完整的帧→事件翻译管道:

ts 复制代码
// 279:295:opencode/packages/llm/src/route/client.ts
      streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
        const route = `${request.model.provider}/${request.model.route.id}`
        const events = routeInput.transport
          .frames(prepared, request, runtime)
          .pipe(
            Stream.mapEffect(decodeEvent(route)),
            protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
          )
        return events.pipe(
          Stream.mapAccumEffect(
            () => protocol.stream.initial(request),
            protocol.stream.step,
            protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
          ),
          Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
        )
      },

管道三步:

  1. transport.frames() ------执行 HTTP 请求,把响应字节流经 Framing.frame 切成 Stream<Frame>
  2. Stream.mapEffect(decodeEvent) ------每帧用 protocol.stream.event schema 解码成 Event
  3. Stream.mapAccumEffect(initial, step) ------以 initial 为初始 State,逐个 Event 调 step,产出 Stream<LLMEvent>

decodeEventmakeFromTransport 顶部通过 Schema.decodeUnknownEffect(protocol.stream.event) 预编译一次,避免每个帧重复编译 schema。

2.5 DeepSeek/TogetherAI 如何复用 OpenAIChat.protocol

正交分离的直接收益是:provider 只需覆盖 Endpoint + Auth 两轴,Protocol 和 Framing 原样复用

第一步OpenAIChat.protocol 定义了 chat completions 的语义契约(body schema + streaming state machine):

ts 复制代码
// 481:493:opencode/packages/llm/src/protocols/openai-chat.ts
export const protocol = Protocol.make({
  id: ADAPTER,                    // "openai-chat"
  body: {
    schema: OpenAIChatBody,
    from: fromRequest,
  },
  stream: {
    event: Protocol.jsonEvent(OpenAIChatEvent),
    initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
    step,
    onHalt: finishEvents,
  },
})

第二步OpenAICompatibleChat.route 原样复用这个 protocol,只改 route id 防止与原生 OpenAI 冲突:

ts 复制代码
// 17:22:opencode/packages/llm/src/protocols/openai-compatible-chat.ts
export const route = Route.make({
  id: ADAPTER,                    // "openai-compatible-chat"
  protocol: OpenAIChat.protocol,  // ← 原样复用,零改动
  endpoint: Endpoint.path("/chat/completions"),
  framing: Framing.sse,
})

第三步openai-compatible.tsdefine(profile) 工厂为每个 provider 覆盖 Endpoint(baseURL)+ Auth(bearer),生成专属 Route:

ts 复制代码
// 38:52:opencode/packages/llm/src/providers/openai-compatible.ts
const define = (profile: OpenAICompatibleProfile) => {
  const configureProfile = (input: FamilyModelOptions = {}) => {
    const facade = configure({
      ...input,
      baseURL: input.baseURL ?? profile.baseURL,   // ← 只改 baseURL
      provider: profile.provider,
    })
    return {
      id: ProviderID.make(profile.provider),
      model: facade.model,
      configure: configureProfile,
    }
  }
  return configureProfile()
}

export const deepseek = define(profiles.deepseek)     // baseURL = "https://api.deepseek.com/v1"
export const togetherai = define(profiles.togetherai)  // baseURL = "https://api.together.xyz/v1"
export const cerebras = define(profiles.cerebras)       // baseURL = "https://api.cerebras.ai/v1"

configure 内部通过 route.with({ endpoint: { baseURL }, auth: AuthOptions.bearer(...) }) patch 一个新 Route------因为 with 是不可变拷贝,原 OpenAICompatibleChat.route 不受影响,每个 provider 拿到自己绑定的 Route 实例。

复用度量 :DeepSeek/TogetherAI/Cerebras/Groq/Fireworks/DeepInfra/Baseten 七个 provider 共享同一份 OpenAIChat.protocol(~500 行 body schema + lowering + stream parser),每个 provider 只需 3 行 define(profile)。如果没有四轴正交,每个 provider 要复制全部 500 行------这正是 protocol.ts 注释所说的"This separation is what lets DeepSeek, TogetherAI, Cerebras, etc. all reuse OpenAIChat.protocol without forking 300 lines per provider"。

三、compile():编译边界

compileLLMClient 的核心内部函数,是公共 LLMRequest 与 provider 原生世界之间的编译边界------它把请求编译成"已校验的 body + 已预备的 transport 数据",但不执行传输。

3.1 compile 的四步流水线

ts 复制代码
// 344:359:opencode/packages/llm/src/route/client.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)

  return {
    request: resolved,
    route,
    body,
    prepared,
  }
})

四步:

  1. resolveRequestOptions(request)------合并三层默认值(route defaults → model defaults → request 字段),产出 resolved request
  2. applyCachePolicy(resolved)------根据 cache policy 注入 inline cache hints(详见第四节)
  3. route.body.from(resolved)Schema.decodeUnknown(body.schema) ------调用 protocol 的 lowering 函数构建 provider body,然后用 body schema 校验它。ProviderShared.validateWith 包装了 decode,失败时产出 InvalidProviderOutput 类型化错误
  4. route.prepareTransport(body, resolved) ------把已校验的 body + request 交给 transport 的 prepare,产出 transport 私有的 Prepared 数据(HTTP 场景是组装好的 HttpClientRequest + Framing

3.2 resolveRequestOptions:三层默认值合并

ts 复制代码
// 167:180:opencode/packages/llm/src/route/client.ts
const resolveRequestOptions = (request: LLMRequest) => {
  const routeDefaults = request.model.route.defaults
  const modelDefaults = request.model.defaults
  const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
  return LLMRequest.update(request, {
    generation: generation ?? new GenerationOptions({}),
    providerOptions: mergeProviderOptions(
      routeDefaults.providerOptions,
      modelDefaults?.providerOptions,
      request.providerOptions,
    ),
    http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
  })
}

三层优先级从低到高route.defaults(协议级默认)→ model.defaults(模型级默认)→ request.*(请求级显式)。mergeGenerationOptionsfindLast 取最后一个非 undefined 值(后者覆盖前者),mergeProviderOptions/mergeHttpOptions 做深合并(JSON object 递归合并)。

3.3 编译边界的设计含义

compile 是"编译"而非"执行",它有三个关键设计约束:

约束一:编译不执行传输compile 产出 { request, route, body, prepared } 四元组,但 prepared 只是 transport 的预备数据(HTTP 是 HttpClientRequest 对象),网络请求尚未发出。这让 LLMClient.prepare() 可以安全地暴露给调试 UI 和请求预览------用户可以看到"即将发什么"而不实际发送。

约束二:body schema 是编译期闸门body.from 产出候选 body 后,Schema.decodeUnknown(body.schema) 立即校验。这意味着 protocol 实现者的 lowering 逻辑如果有 bug(如遗漏 required 字段、类型不匹配),会在 compile 阶段就失败,而不是等请求发出去后才在 provider 端报 400。错误类型是 InvalidProviderOutputReason------"provider 输出无效",因为这是 protocol 自己产出的 body 不合法。

约束三:cache policy 在 body 构建前注入applyCachePolicyresolveRequestOptions 之后、body.from 之前执行,这样 lowering 函数拿到的 resolved request 已经带了 cache hints,可以在构建 body 时自然地把 hints 翻译为 wire markers(如 Anthropic 的 cache_control)。这是一个"在正确位置注入,让后续自然处理"的设计模式。

3.4 prepare 与 stream 如何消费 compile 的产出

compile 的产出被三个公共方法消费:

ts 复制代码
// 361:408:opencode/packages/llm/src/route/client.ts
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
  const compiled = yield* compile(request)
  return new PreparedRequest({
    id: compiled.request.id ?? "request",
    route: compiled.route.id,
    protocol: compiled.route.protocol,
    model: compiled.request.model,
    body: compiled.body,
    metadata: { transport: compiled.route.transport.id },
  })
})

const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
  Stream.unwrap(
    Effect.gen(function* () {
      const compiled = yield* compile(request)
      return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
    }),
  )

const generateWith = (stream: Interface["stream"]) =>
  Effect.fn("LLM.generate")(function* (request: LLMRequest) {
    const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
    const response = LLMResponse.complete(state)
    if (response) return response
    return yield* ProviderShared.eventError(/* ... "stream ended without a terminal finish event" */)
  })
  • prepare:只调 compile,不执行。返回 PreparedRequest(包含 body + route/protocol/model 元数据)
  • stream:调 compile 后用 Stream.unwrap 把 Effect 转为 Stream,然后执行 streamPrepared(含传输 + 分帧 + 解码 + 状态机)。Stream.unwrap 让 compile 的 Effect 在 Stream 首次拉取时执行,实现 lazy 语义
  • generate:在 stream 基础上用 Stream.runFold 归约成 LLMResponse。归约器是 LLMResponse.reduce(定义在 schema/events.ts),它把事件流组装成 Message + usage + finishReason

四、cache-policy.ts:inline hint 注入

applyCachePolicy 是 compile 的第一步,负责根据请求的 cache 策略自动在正确的 part 上注入 CacheHint,让后续的 lowering 函数自然处理。

4.1 为什么只对 anthropic-messages 和 bedrock-converse 注入

ts 复制代码
// 39:42:opencode/packages/llm/src/cache-policy.ts
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these --- emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
ts 复制代码
// 99:101:opencode/packages/llm/src/cache-policy.ts
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
  if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
  // ...
}

原因在于不同 provider 的缓存机制不同

Provider 缓存机制 是否尊重 inline hint
Anthropic Messages 显式 cache_control: { type: "ephemeral" } 标记在 content block 上 ✅ 是------wire format 有 inline marker 字段
Bedrock Converse 显式 cachePoint 标记在 body 中 ✅ 是------wire format 有 inline marker
OpenAI Chat/Responses 隐式 prefix caching------自动缓存请求前缀,无需 marker ❌ 否------wire format 无 marker 字段
Gemini 隐式 + out-of-band CachedContent API ❌ 否------用独立 API 管理缓存

对 OpenAI/Gemini 注入 hints 是"无害但无意义的"(harmless but pointless)------lowering 函数会忽略 CacheHint 字段。因此 applyCachePolicy 在第一行就短路返回,跳过整个策略 pass,避免对消息数组做无谓的遍历和拷贝。

4.2 auto 策略的三个断点

ts 复制代码
// 18:23:opencode/packages/llm/src/cache-policy.ts
const AUTO: CachePolicyObject = {
  tools: true,
  system: true,
  messages: "latest-user-message",
}

默认 "auto" 策略在三个位置放置 cache 断点:

  1. tools: true------最后一个 tool definition 上。tools 在缓存层级中最高(tool 列表变化频率最低)
  2. system: true------最后一个 system part 上。system prompt 在一次会话中通常不变
  3. messages: "latest-user-message"------最近一条 user message 的最后一个 text part 上

这个设计的经济学依据在注释中写明:Anthropic 5 分钟缓存的写入成本是 1.25x base,读取成本是 0.1x base------单次复用即已盈利。在 Agent 循环中,一次 user message 会触发多轮 assistant/tool 来回,这些来回都共享同一前缀,在 "latest user message" 处设断点让每一轮的 intra-turn API call 都命中缓存。

4.3 策略解析与断点注入

ts 复制代码
// 33:37:opencode/packages/llm/src/cache-policy.ts
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
  if (policy === undefined || policy === "auto") return AUTO
  if (policy === "none") return NONE
  return policy
}
ts 复制代码
// 104:111:opencode/packages/llm/src/cache-policy.ts
  const hint = makeHint(policy.ttlSeconds)
  const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
  const system = policy.system ? markLastSystem(request.system, hint) : request.system
  const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages

  if (tools === request.tools && system === request.system && messages === request.messages) return request
  return LLMRequest.update(request, { tools, system, messages })

解析规则:

  • undefined"auto"(默认开启缓存,数学上划算)
  • "auto" → tools + system + latest-user-message
  • "none" → 不做自动放置(但手动 CacheHint 仍然流转)
  • 对象形式 → 精确按调用方指定的位置

注入逻辑尊重手动放置------markLastTool/markLastSystem/markMessageAt 都检查目标 part 是否已有 cache 字段,有则跳过。这保证用户的显式控制不被覆盖。

消息策略的三种模式

ts 复制代码
// 85:97:opencode/packages/llm/src/cache-policy.ts
const markMessages = (
  messages: ReadonlyArray<Message>,
  strategy: NonNullable<CachePolicyObject["messages"]>,
  hint: CacheHint,
): ReadonlyArray<Message> => {
  if (messages.length === 0) return messages
  if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
  if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
  const start = Math.max(0, messages.length - strategy.tail)
  let next = messages
  for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
  return next
}
  • "latest-user-message":在最后一条 user message 的最后一个 text part 上标记
  • "latest-assistant":在最后一条 assistant message 上标记
  • { tail: N }:在最后 N 条 message 上各标记一个断点

4.4 注入后的流转路径

applyCachePolicy 注入 CacheHint 后,hints 通过 LLMRequest 的 part 字段流转到 protocol 的 lowering 函数。以 Anthropic 为例:

ts 复制代码
// 243:251:opencode/packages/llm/src/protocols/anthropic-messages.ts
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
  if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
  if (breakpoints.remaining <= 0) {
    breakpoints.dropped += 1
    return undefined
  }
  breakpoints.remaining -= 1
  return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}

Anthropic 的 lowering 函数读取每个 part 的 cache 字段,通过 cacheControl 翻译为 wire 格式的 cache_control: { type: "ephemeral", ttl: "5m"|"1h" }。同时管理 Anthropic 的 4 断点上限 ------ANTHROPIC_BREAKPOINT_CAP = 4,超过时按 tools → system → messages 的失效顺序丢弃(tools 层级最高优先保留),并 log warning。

这样 applyCachePolicy(protocol 无关)+ cacheControl(protocol 特定)形成两层:第一层在编译边界注入语义 hints,第二层在 lowering 时翻译为 wire markers 并管理 provider 限制。

五、executor.ts:类型化错误映射与预输出重试

RequestExecutor 是 LLM 网关的 HTTP 传输层。它把 Effect 的 HttpClient 包裹成类型化的 LLMError 产出器,并实现"仅预输出重试"(pre-output retry)------只在流开始前重试,流开始后不再重试。

5.1 类型化错误体系

executor.ts 不自己定义错误类型,而是消费 schema/errors.ts 中定义的 10 种 LLMErrorReason。每种 Reason 都有 retryable getter 标记是否可重试:

ts 复制代码
// 160:172:opencode/packages/llm/src/schema/errors.ts
export const LLMErrorReason = Schema.Union([
  InvalidRequestReason,      // 400/404/409/413/422 --- retryable: false
  NoRouteReason,             // 无路由 --- retryable: false
  AuthenticationReason,     // 401/403 --- retryable: false
  RateLimitReason,          // 429 --- retryable: true
  QuotaExceededReason,      // 429 + quota --- retryable: false
  ContentPolicyReason,      // content_filter --- retryable: false
  ProviderInternalReason,    // 5xx --- retryable: true
  TransportReason,          // 网络层 --- retryable: false
  InvalidProviderOutputReason, // provider 输出无效 --- retryable: false
  UnknownProviderReason,    // 未知状态 --- retryable: false
]).pipe(Schema.toTaggedUnion("_tag"))
ts 复制代码
// 174:192:opencode/packages/llm/src/schema/errors.ts
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
  module: Schema.String,
  method: Schema.String,
  reason: LLMErrorReason,
}) {
  override readonly cause = this.reason

  get retryable() {
    return this.reason.retryable
  }

  get retryAfterMs() {
    return "retryAfterMs" in this.reason ? this.reason.retryAfterMs : undefined
  }
  // ...
}

LLMErrorTaggedErrorClass,其 retryableretryAfterMs 代理到 reason。这让 executor 的重试逻辑可以统一查询 error.retryable 而不需要 switch reason 类型。

5.2 HTTP 状态码到 Reason 的映射

statusReason 是映射的核心------输入 HTTP 状态码 + 响应体,输出对应的 LLMErrorReason

ts 复制代码
// 225:275:opencode/packages/llm/src/route/executor.ts
const statusReason = (input: {
  readonly status: number
  readonly message: string
  readonly retryAfterMs?: number | undefined
  readonly rateLimit?: HttpRateLimitDetails | undefined
  readonly http: HttpContext
}) => {
  const body = input.http.body ?? ""
  if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
    return new ContentPolicyReason({ message: input.message, http: input.http })
  }
  if (input.status === 401) {
    return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
  }
  if (input.status === 403) {
    return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
  }
  if (input.status === 429) {
    if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
      return new QuotaExceededReason({ message: input.message, http: input.http })
    }
    return new RateLimitReason({
      message: input.message,
      retryAfterMs: input.retryAfterMs,
      rateLimit: input.rateLimit,
      http: input.http,
    })
  }
  if (
    input.status === 400 ||
    input.status === 404 ||
    input.status === 409 ||
    input.status === 413 ||
    input.status === 422
  ) {
    return new InvalidRequestReason({
      message: input.message,
      classification: isContextOverflow(body) ? "context-overflow" : undefined,
      http: input.http,
    })
  }
  if (input.status >= 500 || retryableStatus(input.status)) {
    return new ProviderInternalReason({
      message: input.message,
      status: input.status,
      retryAfterMs: input.retryAfterMs,
      http: input.http,
    })
  }
  return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
}

映射逻辑按优先级排列:

HTTP 状态码 Reason retryable 特殊逻辑
任何 + body 含 content_policy/safety ContentPolicyReason false body 正则匹配优先于状态码
401 AuthenticationReason (kind="invalid") false ---
403 AuthenticationReason (kind="insufficient-permissions") false ---
429 + body 含 quota_exceeded QuotaExceededReason false 配额耗尽不可重试
429 (其他) RateLimitReason true 携带 retryAfterMs + rateLimit 详情
400/404/409/413/422 InvalidRequestReason false 检测 isContextOverflow(body) → classification="context-overflow"
500+ 或 429/503/504/529 ProviderInternalReason true 携带 retryAfterMs
其他 UnknownProviderReason false 兜底

两个值得注意的设计

  1. ContentPolicy 优先于状态码 :即使状态码是 400,如果 body 含 content_filter/safety,优先归类为 ContentPolicyReason。这让上层可以根据"内容政策"做差异化处理(如提示用户修改 prompt 而非重试)
  2. Context overflow 检测 :400/413 等错误会检查 body 是否匹配 context overflow 的正则模式(isContextOverflow)。匹配则给 InvalidRequestReasonclassification: "context-overflow",这让上层(如 core 的 compaction 逻辑)可以精确检测"上下文溢出"而非通用"无效请求"

5.3 context overflow 的模式匹配

provider-error.ts 定义了 22 个正则模式匹配各 provider 的 context overflow 消息:

ts 复制代码
// 4:28:opencode/packages/llm/src/provider-error.ts
const patterns = [
  /prompt is too long/i,
  /input is too long for requested model/i,
  /exceeds the context window/i,
  /input token count.*exceeds the maximum/i,
  /tokens in request more than max tokens allowed/i,
  // ... 共 22 个模式 ...
  /model_context_window_exceeded/i,
]

export const isContextOverflow = (message: string) =>
  patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)

export const isContextOverflowFailure = (failure: unknown) =>
  failure instanceof LLMError
    ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
    : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"

isContextOverflow 覆盖了 Anthropic(prompt is too long)、OpenAI(maximum context length)、Gemini、Bedrock 等各 provider 的不同措辞。isContextOverflowFailure 则用于在流式事件中检测 ProviderErrorEvent------因为 context overflow 也可能在流中途发生(provider 先开始流再发现超长)。

5.4 传输层错误的归一化

HTTP 传输层(DNS/连接/超时)的错误也需要归一化为 LLMErrortoHttpError 把 Effect HttpClientError 转为 TransportReason

ts 复制代码
// 307:343:opencode/packages/llm/src/route/executor.ts
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
  const transportError = (input: { /* ... */ }) =>
    new LLMError({
      module: "RequestExecutor",
      method: "execute",
      reason: new TransportReason({
        message: input.message,
        kind: input.kind,
        url: input.request ? redactUrl(input.request.url) : undefined,
        http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
      }),
    })

  if (Cause.isTimeoutError(error)) {
    return transportError({ message: error.message, kind: "Timeout" })
  }
  if (!HttpClientError.isHttpClientError(error)) {
    return transportError({ message: "HTTP transport failed" })
  }
  const request = "request" in error ? error.request : undefined
  if (error.reason._tag === "TransportError") {
    return transportError({
      message: error.reason.description ?? "HTTP transport failed",
      kind: error.reason._tag,
      request,
    })
  }
  return transportError({
    message: `HTTP transport failed: ${error.reason._tag}`,
    kind: error.reason._tag,
    request,
  })
}

TransportReasonretryable 为 false------传输层错误不自动重试(因为可能是 DNS/证书等持久性故障)。但超时(Cause.isTimeoutError)有专门的 kind: "Timeout" 标记,上层可以据此判断是否值得重试。

5.5 预输出重试策略

ts 复制代码
// 345:364:opencode/packages/llm/src/route/executor.ts
const retryDelay = (error: LLMError, attempt: number) => {
  if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS))
  return Random.nextBetween(
    Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS),
    Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS),
  ).pipe(Effect.map((delay) => Math.round(delay)))
}

const retryStatusFailures = <A, R>(
  effect: Effect.Effect<A, LLMError, R>,
  retries = MAX_RETRIES,
  attempt = 0,
): Effect.Effect<A, LLMError, R> =>
  Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect<A, LLMError, R> => {
    if (!error.retryable || retries <= 0) return Effect.fail(error)
    return retryDelay(error, attempt).pipe(
      Effect.flatMap((delay) => Effect.sleep(delay)),
      Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)),
    )
  })

重试参数:MAX_RETRIES = 2(最多重试 2 次,共 3 次请求)、BASE_DELAY_MS = 500MAX_DELAY_MS = 10_000

退避策略

  • 如果错误携带 retryAfterMs(如 RateLimitReasonRetry-After header 解析),优先使用它,但封顶 MAX_DELAY_MS
  • 否则用指数退避 + 抖动BASE_DELAY_MS * 2^attempt,乘以 0.8~1.2 的随机抖动,封顶 MAX_DELAY_MS。抖动避免多个客户端同步重试(thundering herd)

"仅预输出重试"的语义

ts 复制代码
// 366:380:opencode/packages/llm/src/route/executor.ts
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
  Service,
  Effect.gen(function* () {
    const http = yield* HttpClient.HttpClient
    const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
      Effect.gen(function* () {
        const redactedNames = yield* Headers.CurrentRedactedNames
        return yield* http
          .execute(request)
          .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
      })
    return Service.of({
      execute: (request) => retryStatusFailures(executeOnce(request)),
    })
  }),
)

retryStatusFailures 包裹的是 executeOnce------即一次完整的 HTTP 请求 (发请求 + 读状态码 + 解析 body + 映射为 LLMError 或返回 response)。重试发生在这个层面:如果 executeOnce 失败(返回 LLMError),且 error.retryable 为 true,则延迟后重试整个请求。

关键设计 :重试只在"获取 HTTP Response"阶段发生。一旦 executeOnce 成功返回 response(status < 400),retryStatusFailures 就结束了------后续的 transport.frames() 读取响应流不再被重试包裹。这意味着:

  • ✅ 请求被 rate limit → 自动重试(因为 executeOnce 失败)
  • ✅ 请求 503 → 自动重试(因为 executeOnce 失败)
  • ❌ 流开始后 provider 中途断开 → 不重试(因为已经过了 executeOnce,进入 transport.frames()
  • ❌ 流中途的 provider-error 事件 → 不重试(因为这是 stream 层面的 LLMEvent,不是 LLMError)

这个设计是正确的------流已经开始后重试会导致重复输出(用户已经看到部分文本),而预输出阶段的重试对用户透明(什么都没输出过)。

5.6 敏感信息脱敏

executor 还负责错误中的敏感信息脱敏,确保 LLMErrorHttpContext 不会泄漏 API key:

ts 复制代码
// 48:54:opencode/packages/llm/src/route/executor.ts
const SENSITIVE_NAME_SOURCE =
  "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
const SHORT_QUERY_NAME = /^(key|sig)$/i
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")

脱敏分两层:

  1. 结构脱敏 :用正则匹配 body/query 中所有看起来像敏感字段名(authorization/token/secret/key 等)的 JSON 字段和 query 参数,替换为 <redacted>
  2. 字面脱敏 :收集请求中实际发送的 secret 值(header 里的 Bearer token、query 里的 ?key=...),在响应 body 中搜索替换------防止 provider 把 secret 回显到错误消息中

URL 中的敏感 query 参数也被脱敏(redactUrl),request-id 提取支持 5 种 header 变体(x-request-id/request-id/x-amzn-requestid/x-amz-request-id/x-goog-request-id/cf-ray),兼容各 provider 的 request id 惯例。

5.7 Rate Limit 详情的解析

ts 复制代码
// 112:148:opencode/packages/llm/src/route/executor.ts
const rateLimitDetails = (headers: Record<string, string>, retryAfter: number | undefined) => {
  const limit: Record<string, string> = {}
  const remaining: Record<string, string> = {}
  const reset: Record<string, string> = {}

  Object.entries(headers).forEach(([name, value]) => {
    const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1]
    if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value)

    const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1]
    if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value)

    const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1]
    if (openaiReset) return addRateLimitValue(reset, openaiReset, value)

    const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name)
    if (!anthropic) return
    if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value)
    if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value)
    return addRateLimitValue(reset, anthropic[1], value)
  })
  // ...
  return new HttpRateLimitDetails({ retryAfterMs: retryAfter, limit, remaining, reset })
}

rateLimitDetails 解析两种 rate limit header 风格:

  • OpenAI 风格x-ratelimit-limit-requestsx-ratelimit-remaining-tokensx-ratelimit-reset-requests
  • Anthropic 风格anthropic-ratelimit-requests-limitanthropic-ratelimit-requests-remaininganthropic-ratelimit-requests-reset

两种风格的 limit/remaining/reset 按 dimension(requests/tokens)分组存入 HttpRateLimitDetails,附带 retryAfterMs(从 Retry-After/retry-after-ms header 解析)。这些详情附加到 RateLimitReason 上,让上层可以精确展示"还剩多少 quota、何时重置"。

六、LLM 网关的类型化设计范式

6.1 设计范式总结

OpenCode 的 LLM 网关体现了一个完整的类型化设计范式,可以概括为五层:

bash 复制代码
┌──────────────────────────────────────────────────────────────┐
│  第五层:类型化错误(10 种 LLMErrorReason + retryable getter)│
│  schema/errors.ts --- 每种错误自带可重试性 + HTTP 上下文       │
└───────────────────────────┬──────────────────────────────────┘
                            │ statusReason() 映射
┌───────────────────────────▼──────────────────────────────────┐
│  第四层:传输执行(executor.ts --- HTTP + 重试 + 脱敏)        │
│  预输出重试(MAX_RETRIES=2)+ 敏感信息脱敏 + rate limit 解析  │
└───────────────────────────┬──────────────────────────────────┘
                            │ transport.frames() + streamPrepared()
┌───────────────────────────▼──────────────────────────────────┐
│  第三层:编译边界(compile --- cache policy + body + schema)  │
│  resolveRequestOptions → applyCachePolicy → body.from →       │
│  schema.decode → prepareTransport                             │
└───────────────────────────┬──────────────────────────────────┘
                            │ Route.make() 四轴组合
┌───────────────────────────▼──────────────────────────────────┐
│  第二层:部署组合(Route = Protocol + Endpoint + Auth +      │
│  Framing/Transport)--- 四轴正交,DeepSeek 只改 Endpoint+Auth  │
└───────────────────────────┬──────────────────────────────────┘
                            │ Protocol.make()
┌───────────────────────────▼──────────────────────────────────┐
│  第一层:语义契约(Protocol<Body,Frame,Event,State>)        │
│  body.schema + body.from + stream.event + stream.step         │
└──────────────────────────────────────────────────────────────┘

6.2 关键设计不变量

不变量 位置 约束
Protocol ≠ 部署 protocol.ts:20-24 Protocol 不知道 URL、header、auth scheme------这些是 Route.make 的部署关切
四轴正交 client.ts:321-339 Protocol/Endpoint/Auth/Framing 独立可替换,DeepSeek/TogetherAI 只覆盖 Endpoint+Auth
编译不执行 client.ts:344-359 compile 产出 { body, prepared } 但不发送网络请求------prepare() 安全暴露给调试
body schema 是闸门 client.ts:348-350 body.from 后立即 schema.decodeUnknown 校验,lowering bug 在 compile 阶段就失败
cache policy 先于 lowering client.ts:345 applyCachePolicy 在 body.from 之前注入 hints,让 lowering 自然翻译为 wire markers
inline hints 仅 Anthropic/Bedrock cache-policy.ts:42 OpenAI/Gemini 隐式缓存,注入 hints 无害但无意义,短路跳过
错误类型化 errors.ts:160-192 10 种 Reason 各有 retryable getter,executor 统一查询不 switch
预输出重试 executor.ts:353-364 重试只包裹 executeOnce(获取 response),流开始后不重试
敏感信息脱敏 executor.ts:48-55 结构脱敏 + 字面脱敏双层,防 provider 回显 secret
context overflow 检测 provider-error.ts:4-28 22 个正则覆盖各 provider 措辞,classification="context-overflow"

6.3 与 AI SDK 的对比

OpenCode 的 LLM 网关设计相比 Vercel AI SDK(@ai-sdk/*core 依赖 18 个 @ai-sdk/* provider)有几个关键差异:

维度 OpenCode @opencode-ai/llm Vercel AI SDK
协议抽象 Protocol<Body,Frame,Event,State> 四类型参数管道 无显式 Protocol 类型,每个 provider 自己实现
部署正交 Route.make 四轴正交,DeepSeek 3 行接入 每个 @ai-sdk/* 包独立实现,无共享 protocol
请求编译 compile() 显式编译边界(schema 校验 + transport 预备) 无编译概念,直接 streamText
缓存策略 cache-policy.ts 协议无关 auto 注入 各 provider 各自处理,无统一策略
错误类型化 10 种 LLMErrorReason + retryable getter ProviderError 无细分 reason
重试策略 预输出重试(只在获取 response 阶段) 通常无内置重试或各 provider 自行实现

OpenCode 的设计更重、更显式,但换来的是:新增 provider 的边际成本极低 (只需 Endpoint+Auth patch)、错误处理可类型化决策 (上层可以 if (error.reason._tag === "RateLimit") 做不同策略)、缓存策略统一管理(agent 循环自动获得 prompt caching 收益)。

6.4 设计哲学

  1. Protocol 是"API 语义"的单一来源:一个 protocol 定义了"这个 API 长什么样"的全部知识------body 怎么构建、stream 怎么解析。部署(URL/auth/transport)是正交的、可替换的维度。这让 protocol 成为可复用的语义资产,而非一次性的 provider 实现
  2. compile 是"类型安全"的编译边界:在 body.from 和 transport 之间插入 schema 校验,让 protocol 实现者的 lowering bug 在编译期(而非运行时 400 错误)就被捕获。compile 不执行的设计让请求预览成为免费的副产品
  3. cache policy 是"协议无关"的策略层:auto 策略在编译边界注入语义 hints,protocol 的 lowering 自然翻译为 wire markers。两层分离让缓存策略可以统一演进而不动每个 protocol
  4. 错误是"类型化决策"的输入:10 种 Reason + retryable getter 让上层可以用类型 switch 做不同策略(context-overflow → compaction,rate-limit → 退避重试,authentication → 提示用户)。executor 的预输出重试是这一层的默认消费者
  5. 正交组合优于继承 :DeepSeek 不继承 OpenAIChat,而是 OpenAIChat.protocol + DeepSeek.endpoint + bearer.auth。这避免了继承层次的爆炸(M×N 个组合),用 M+N 个正交维度覆盖所有组合
相关推荐
慕容白 MU1 小时前
开源一个基于LVGL的图层管理框架(LMF)
开源·lvgl·图层管理框架
2601_956414141 小时前
2026免费论文降重工具推荐:合规实用工具选型指南
人工智能·自然语言处理
拾起_3691 小时前
04-V2 双循环与 V1 的核心差异
人工智能·开源
拾起_3691 小时前
05-Event Sourcing 事件系统
人工智能·开源
精神底层1 小时前
AI 学习笔记:研究方法的演变
人工智能·笔记·学习
拾起_3692 小时前
03-V1 主循环的完整生命周期
人工智能·开源
科技大视界2 小时前
投资AI项目,传统尽调不够用了——李章虎律师拆解算法、数据、算力三大雷区
人工智能·算法·数据挖掘
纳米体育数据2 小时前
体育数据API接口怎么选?纳米数据一站式接入18+项目,助力产品快速上线
人工智能
SL-staff2 小时前
(十九)「JVS-Rules规则引擎 V2.5」— 简单评分卡节点
人工智能·低代码·规则引擎·jvs-rules·决策流·信用评分卡·简单评分卡节点