数据截至 (上游 commit dad6f5196773)
模型运行时:一套代码接住几十家供应商与它们各异的流
30 秒导读:
packages/model-runtime是 LobeHub 的「供应商适配层」。上面这层只会说一句话——runtime.chat(payload);下面接的是 82 家供应商,各有各的 SDK、各有各的参数名、各有各的流事件格式。这一章讲它怎么把「各异」压成「统一」。
本章只讲模型运行时本身。消息和工具怎么被拼成 payload,见 上下文工程;拼好的指令谁来调度、工具怎么执行,见 运行时内核 与 工具体系。
1. 这是什么(零基础也能懂)
一句话定义: 一个「模型供应商翻译层」——把 LobeHub 内部统一的请求对象翻译成某一家供应商的 API 调用,再把那家返回的流翻译回统一的事件流。
它解决什么问题
假设你在做一个聊天应用,今天接 OpenAI,明天用户说要用 Claude,后天有人自建了 Ollama,大后天老板说要接通义千问。四家的差异不是「换个 URL」这么简单:
| 差异点 | OpenAI | Anthropic | Google Gemini | Ollama |
|---|---|---|---|---|
| 请求 SDK | openai | @anthropic-ai/sdk | @google/genai | ollama |
| 思考内容在哪 | delta.reasoning_content | 独立的 thinking_delta 事件 | parts[].thought === true | message.thinking |
| 工具调用分片 | delta.tool_calls[] 增量 | input_json_delta 逐段 JSON | functionCall 整块 | 整块 |
| 用量字段名 | prompt_tokens | input_tokens | usageMetadata | 无 |
| 引用/来源 | delta.annotations | citations_delta | grounding metadata | 无 |
如果每接一家就在业务层写一遍 if (provider === 'xxx'),代码会烂掉。model-runtime 就 是那个「只烂在一个地方」的地方。
它能做什么
- chat —— 聊天补全,返回一个 SSE 流的
Response。 - embeddings / textToSpeech / transcribe —— 向量化、TTS、语音转写。
- createImage / createVideo —— 文生图、文生视频(含 webhook 与轮询)。
- generateObject —— 结构化输出(JSON Schema),不支持原生 schema 的家用 tool calling 模拟。
- models / pullModel —— 拉取该供应商的模型列表;本地模型(Ollama)还能下载模型并回传进度流。
用起来什么样
// 示意,非源码
// 1. 按 provider id 拿到一个 runtime(内部查 runtimeMap 表)
const runtime = ModelRuntime.initializeWithProvider('deepseek', {
apiKey: process.env.DEEPSEEK_API_KEY,
});
// 2. 用统一的 payload 发起对话,拿回的是一个 SSE 流 Response
const response = await runtime.chat({
messages: [{ content: '你好', role: 'user' }],
model: 'deepseek-reasoner',
temperature: 0.7,
});
// 重点看:换成 'anthropic' / 'google' / 'ollama',上面这两行一个字都不用改
真实入口是 ModelRuntime.initializeWithProvider(packages/model-runtime/src/core/ModelRuntime.ts:503)。
一句话直觉
把它当作万国插头转换器 + 传送带整流器:
- 「插头」这一半负责下行——把统一 payload 翻译成某家的请求格式;
- 「整流」这一半负责上行——把各家五花八门的流事件,整成同一种传送带上的标准零件。
两半的接口分别是 LobeRuntimeAI(下行)和 StreamProtocolChunk(上行)。
2. 顶层全景(它大概怎么转)
一张图看清一次 chat
从上往下是请求(下行),从下往上是响应(上行);中间那条虚线是「统一 ↔ 方言」的分界。
上层业务(agent-runtime / apps/server)
│ runtime.chat(payload) ▲ SSE 事件流(统一协议)
▼ │
┌───────────────────────────────────────────────────────────┐
│ ModelRuntime ── 门面:生命周期钩子(预算/计费/追踪) │
└───────────────────────────────────────────────────────────┘
│ runtimeMap[provider] ▲
▼ │
┌───────────────────────────────────────────────────────────┐
│ 某个 LobeXxxAI 实例(由工厂生成) │
│ ① handlePayload 统一 payload → 该家方言 │
│ ② 调 SDK │
│ ③ XxxStream 该家流事件 → StreamProtocolChunk │
└───────────────────────────────────────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 分界线 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ ▲
▼ 供应商方言 │ 供应商原生流
OpenAI / Anthropic / Gemini / Bedrock / Ollama ...
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
LobeRuntimeAI | 全可选方法的接口,是「一个 runtime 能干什么」的唯一契约 | core/BaseAI.ts:29 |
ModelRuntime | 门面类,包一层生命周期钩子后转发给具体 runtime | core/ModelRuntime.ts:141 |
providerRuntimeMap | provider id → runtime 类的静态查找表(82 个 key) | runtimeMap.ts:85 |
createOpenAICompatibleRuntime | 主力工厂,给 OpenAI 兼容协议的家用 | core/openaiCompatibleFactory/index.ts:335 |
createAnthropicCompatibleRuntime | 第二工厂,给 Messages API 协议的家用 | core/anthropicCompatibleFactory/index.ts:451 |
createRouterRuntime | 同一 provider 下按模型/baseURL 再分流 + 失败回退 | core/RouterRuntime/createRuntime.ts:237 |
protocol.ts | 统一流协议的类型与三段管线工具 | core/streams/protocol.ts |
各家 *Stream | 把某家原生流事件翻译成 StreamProtocolChunk | core/streams/{anthropic,openai,google,qwen,spark,ollama}... |
usageConverters/ | 各家 usage 字段 → 统一 ModelUsage + 算钱 | core/usageConverters/ |
errors/ | 错误码表 + 分类学 + 上游报文模式匹配 | errors/ |
model-bank | 静态模型卡片库(上下文窗口、能力、定价) | packages/model-bank/src/aiModels/ |
主线走一遍(不进代码)
- 上层调
ModelRuntime.chat(payload)。 ModelRuntime先跑beforeChat钩子(比如预算检查,不够就抛),再把onChatFinal钩子注入到回调里。- 转发给具体 runtime 的
chat。 - 具体 runtime 做四件事:归一化工具 schema →
handlePayload翻译成方言 → 调 SDK → 把返回流交给对应的*Stream。 *Stream走三段管线,吐出 SSE 文本流。StreamingResponse包成Content-Type: text/event-stream的Response返回。- 流结束时,
createCallbacksTransformer的flush汇总全文/思考/工具/用量,触发onFinal→ 落库、计费。