Python · 架构 · LLM-Bridge

一个 OpenAI 端点,后面三种完全不同的进程

LLM-Bridge 复盘:对外是一种整齐的 OpenAI 格式,对内是 SDK 消息流、行分隔 JSON、纯文本三头野兽。归一化都发生在适配层里。

LLM-Bridge 对外只有一种形状:OpenAI Chat Completions。可对内,三个后端吐出来的东西没有一样是像的——

  • claude 是 Agent SDK 的一串异步消息对象(AssistantMessageStreamEventResultMessage);
  • codex 是子进程 stdout 上的行分隔 JSON,一行一个事件;
  • agy 是子进程 stdout 上的纯文本,没有任何结构。

把这三头野兽收敛成同一种整齐输出,是适配层唯一的工作。这篇讲这层归一化具体归在哪。

一个基类,三种实现

每个 provider 是 providers/ 下的一个文件,都继承 BaseProvider,对外只暴露三个方法:

class BaseProvider:
    async def complete(self, request) -> ChatCompletionResponse: ...
    async def stream(self, request) -> AsyncIterator[ChatCompletionChunk]: ...
    async def list_models(self) -> list[ModelInfo]: ...

不管底层是 SDK 还是子进程,进来的都是 OpenAI 的 ChatCompletionRequest,出去的都是 OpenAI 的 Response / Chunk。路由层只跟这三个方法打交道,永远不知道底下是 SDK 的消息对象还是一坨纯文本。差异必须在这层被吃掉,一个字节都不许漏到上面去。

入:三种 prompt 拼法

OpenAI 的 messages 是带 role 的结构化数组。三个后端里,只有 claude 的 SDK 有独立的 system prompt 通道,另外两个只吃一整段文本。于是归一化的第一步,是把结构化 messages 拍成各家要的形状:

  • claudesystem 抽出来单独走 SDK 的 system_prompt,其余按 user / assistant 拼成一段;
  • codex / agy:全部拍平成一段,system[System Instructions] 前缀、assistant 加 [Previous Assistant Response] 前缀,让模型还能分清谁说的。

这里有个诚实的丢弃:因为是 chat-only,带 role="tool" 的消息会被直接拍掉,而不是假装能处理。丢弃是明确的设计决定,不是 bug——文档里写清楚了。

出:把三种流收敛成一种 chunk

输出这一侧,三家的流事件长得完全不同,但最终都只调三个构造器:make_role_chunk(开头的 role 帧)、make_content_chunk(一段文本增量)、make_final_chunk(收尾,带 finish_reason 和 usage)。一个 StreamState 串起同一条流的 id 和模型名。

区别只在「从各自的原生事件里,怎么抠出那段文本」:

  • claude:SDK 的 StreamEvent 里,content_block_delta 事件的 delta.text_delta 就是增量文本;
  • codex:行分隔 JSON 里,type=="item.completed"item.type=="agent_message"text 字段;turn.completed 表示结束;
  • agy:没有事件流,直接 stdout.read(4096) 一块块读,读到什么就往外吐什么。

所以有个诚实的差别值得写在文档里:claude 和 codex 是 token 级流式,agy 是 chunk 级——它的 CLI 只给纯文本,网关没法凭空造出 token 边界,只能按读到的块转发。能做到什么粒度,取决于底层给你什么,装不出来。

连「列个模型」都没有统一答案

你以为「列出可用模型」是最简单的一步,结果三家给的答案形态都不一样:

  • claude:CLI 根本没有 list-models 命令。配了 Anthropic API Key 就走免费的 Models API(只用来列模型,绝不用于推理),带 1 小时缓存;没配就退回一份硬编码的 fallback 清单;
  • codex:读 CLI 自己维护的 ~/.codex/models_cache.json——那是它在自己运行时刷新的缓存,是 Codex 最接近「列模型接口」的东西,还得按 visibility=="list" 过滤掉内部模型;
  • agy:最规矩,agy models 全动态拿,再建一张 slug↔显示名的映射(Claude Sonnet 4.6 (Thinking)claude-sonnet-4.6-thinking)。

对外这些差异又被抹平:统一成 provider/model 格式,外加一层别名(fableopussonnethaikugemini-proflash),让常用模型能一个词点到。适配层的活儿,一半是转发,一半是替上层把「三家各有各的怪」这件事扛下来。

诚实的空位:agy 没有 token 计数

usage(输入/输出 token 数)也是三家不齐:claude 从 ResultMessage.usage 拿,codex 从 turn.completed 事件的 usage 拿,agy——纯文本输出,根本没有 token 计数

这时候的选择是:留空,返回一个空的 UsageInfo(),而不是拿字符长度估一个假数糊上去。一个编出来的 token 数,比没有 token 数更坏——它看起来像真的,会被下游当真用。适配层能抹平的是格式,抹不平的是底层根本不存在的信息;后者只能如实留白。

小结

对外一种 OpenAI 形状,对内三种进程,归一化全压在适配层:

  • 一个基类三种实现complete / stream / list_models 三个方法,差异不许漏到路由层;
  • 入口拍平结构:结构化 messages → 各家 prompt,chat-only 就明确丢掉 role="tool"
  • 出口收敛成 chunk:三种原生流 → 三个统一构造器,粒度取决于底层给到 token 还是纯文本;
  • 连列模型都不统一:Models API / 本地缓存 / 动态命令三条路,对外统一成 provider/model + 别名;
  • 抹不平的就留白:agy 没 token 计数就返回空,绝不编一个假的。

一句话:适配层的价值,不在于让三家看起来一样,而在于诚实地处理它们本来就不一样的地方。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。