跳到主要内容

01 · 模型抽象层

这章讲清楚:Chatbox 怎么让“OpenAI / Claude / Gemini / Ollama …”在上层看起来是同一个东西,以及它在流式与重试上踩过的两个坑。

1.1 它要解决的小问题

上层的 agent 循环(第3章)只想说一句:“给我一个 chatStream(messages, options),你吐 chunk 我来拼。”它不想知道底下是哪家、用什么 SDK、推理字段叫什么。

所以需要一个基类,把所有供应商的差异吃进去,对外只暴露统一接口。这个基类是 AbstractAISDKModel(src/shared/models/abstract-ai-sdk.ts:133)。

1.2 思路:站在 Vercel AI SDK 肩膀上

Chatbox 没有自己手写各家 HTTP,而是用 Vercel AI SDK(ai 包)。AI SDK 已经把各家封装成 LanguageModelV3,并提供统一的 streamText()。Chatbox 的基类只做两件“AI SDK 之上”的事:

  1. 要求子类提供一个 getChatModel()——返回那个具体的 LanguageModelV3。各供应商的差异(host、key、header、beta 标志)全部塞进这一步。
  2. 把 AI SDK 的 fullStream 重新解析成 Chatbox 自己的消息模型 contentParts(文本 / 推理 / 工具调用 / 图片)。

子类的契约很小(abstract-ai-sdk.ts:159-163):

protected abstract getProvider(options): Pick<Provider, 'languageModel'> & ...
protected abstract getChatModel(options): LanguageModelV3

能力则由模型元数据声明,基类据此回答上层的能力问询(abstract-ai-sdk.ts:138-146):

public isSupportToolUse() {
return this.options.model.capabilities?.includes('tool_use') || false
}
public isSupportVision() { /* capabilities 含 'vision' */ }
public isSupportReasoning() { /* capabilities 含 'reasoning' */ }

注意 capabilities 来自供应商定义里的模型清单(第2章),例如 Claude 的每个模型都标了 ['vision','reasoning','tool_use']。上层据此门控工具(第4章)。

1.3 流式主路径:chatStream

chatStream()(abstract-ai-sdk.ts:217)是上层真正调用的入口。它是一个 async * 生成器,把 AI SDK 的 result.fullStream 的每个 chunk 透传出去,但额外混入了自定义的 status 事件(用来上报“正在重试”)。

这里有个值得看的工程细节:它要同时等“下一个流 chunk”和“下一个状态变化”,谁先来发谁。它用一个 StatusQueue + Promise.race 实现(abstract-ai-sdk.ts:297-331):

// 示意,非源码:同时竞速“流数据”和“状态变更”
const next = await Promise.race([
nextChunk.then((it) => ({ type: 'chunk', it })), // 模型吐的下一块
statusWait.promise.then(() => ({ type: 'status' })), // 重试状态变了
])
if (next.type === 'status') continue // 状态先到:把队列里的 status 发完再继续

非流式供应商也被统一进来:若 stream === false,用 simulateStreamingMiddleware() 把一次性响应伪装成流(abstract-ai-sdk.ts:224-229),上层无需区分。

1.4 精华一:计费安全的重试

这是全章最该带走的点。

问题: 网络请求失败时“自动重试”是常识。但对计费类 POST(向模型发一次生成请求)有个陷阱:如果服务端已经处理了请求、只是返回的连接断了,你一重试就被扣两次费

Chatbox 的做法: 把 AI SDK 默认的 2 次重试彻底关掉,自己只对“计费安全”的错误重试。判断标准很干脆——只有 429(还没处理就被限流)和 5xx(服务端自己崩了)才算安全(abstract-ai-sdk.ts:52-54):

function isRetryableStatusCode(code: number): boolean {
return code === 429 || (code >= 500 && code < 600)
}

这层重试用 ai-retrycreateRetryable 包住模型(abstract-ai-sdk.ts:248),并在每次重试时往 StatusQueue 推一个 retrying 状态(abstract-ai-sdk.ts:270-276),于是 UI 能显示“第 2/5 次重试…”。

而真正发起 streamText 时,maxRetries: 0 被刻意放在最后,代码注释明说“放最后,免得 provider 的 callSettings 不小心又把重试打开”(abstract-ai-sdk.ts:282-291)。图片生成也同理(paint(), abstract-ai-sdk.ts:360)。

这是一个典型的“正确但反直觉”的决定:默认重试在计费场景是 bug,不是 feature。

1.5 精华二:把流重新拆成 contentParts

AI SDK 的 fullStream 吐的是扁平的 TextStreamPart(text-delta / reasoning-delta / tool-call / tool-result / file / finish…)。Chatbox 要把它们累积成结构化的消息片段数组 contentParts,供 UI 渲染气泡。

这个解析有个细腻处:推理(reasoning)块的计时。模型先“想”再“说”,Chatbox 要显示“思考了 X 秒”。做法是:进入 reasoning 时记 startTime,一旦遇到 text-delta(开始正式输出)就 finalizeReasoningDuration() 把 duration 定下来(abstract-ai-sdk.ts:556-568):

// 示意,非源码:思考→输出 的瞬间,把“想了多久”定格
case 'text-delta':
finalizeReasoningDuration() // 用 now - startTime 算 duration
currentTextPart = appendText(...) // 从此进入“说”的阶段

还有个供应商兼容补丁:有的供应商会随正文返回空的 reasoning 块,会把正常文本割裂,所以 reasoning-deltatrim() 判空,空的直接丢(abstract-ai-sdk.ts:573-579,中文注释在源码里)。

注意:渲染进程的生成主线另有一份几乎等价的流解析器 stream-chunk-processor.ts(第3章会讲)。基类里的这套主要服务非流式补全路径 _callChatCompletion(abstract-ai-sdk.ts:750)。两份解析逻辑并存是这份代码的一个“重复”味道。

1.6 错误处理与脱敏

出错时基类做两件事:

  1. 图片输入不被支持 → 抛一个本地化的 ChatboxAIAPIError,还会按用户 IP 决定是否推荐 Chatbox AI(abstract-ai-sdk.ts:193-204)。
  2. HTML 错误页脱敏:当 502/503/504 网关返回一整页 HTML 时,不把这坨垃圾喂给上层,而是替换成一句 "Bad Gateway - The server returned an HTML error page…"(sanitizeResponseBody, abstract-ai-sdk.ts:636-648)。

1.7 边界

  • 基类不做 tool 的执行——它只把 tools 透传给 streamText,执行由 AI SDK 在 tool.execute 里跑(工具定义见第4章)。
  • 多步工具循环靠 stopWhen: stepCountIs(options.maxSteps || Number.MAX_SAFE_INTEGER)(abstract-ai-sdk.ts:282)——默认几乎不设上限,由会话设置决定。

1.8 代码地图

主题文件符号
基类与统一接口src/shared/models/abstract-ai-sdk.tsAbstractAISDKModel
流式入口(混入 status)src/shared/models/abstract-ai-sdk.tschatStream
计费安全重试判定src/shared/models/abstract-ai-sdk.tsisRetryableStatusCode / isRetryableStatusError
状态队列(race 实现)src/shared/models/abstract-ai-sdk.tsStatusQueue
流块→contentPartssrc/shared/models/abstract-ai-sdk.tsprocessStreamChunk(基类私有)
推理计时收尾src/shared/models/abstract-ai-sdk.tscreateOrUpdateReasoningPart / finalizeResult
HTML 错误脱敏src/shared/models/abstract-ai-sdk.tssanitizeResponseBody
模型接口契约src/shared/models/types.tsModelInterface / ModelStreamPart