A38第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

模型适配与迁移

模型适配与迁移 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

读完本课你能:解释「统一业务接口」如何把提供方差异挡在一个文件里;切换模型提供方时,按消息、工具、流式、参数四张核对单逐项确认差异;用连接测试和固定样例回归证明迁移没有破坏功能。

问题场景

你用的模型服务宣布涨价,换一家的念头出现了。第一反应是「把代码里的请求全部改一遍」——这意味着聊天、工具调用、流式解析到处都要动。而本站的做法是:业务代码根本不知道提供方是谁,切换只改三个环境变量。这个差距就是适配层设计的价值,也是本课要讲清的东西。

核心解释

本站的隔离层就是 lib/ai/adapter.ts:对外暴露 streamChatCompletion、ChatMessage、AiError 这套自有接口,对内才写 OpenAI 兼容的协议细节。业务代码(如 app/api/ai/chat/route.ts)只 import 这套接口,从不碰具体提供方。切换提供方 = 改 AI_BASE_URL / AI_API_KEY / AI_MODEL 三个环境变量(lib/ai/config.ts 读取),代码零改动——前提是新提供方兼容 OpenAI chat/completions 协议,且你核对过下面四张单子:

消息层:role 是否都认(system/user/assistant/tool);工具结果回传要不要 tool_call_id;数组型 content(图文混合,A37)是否支持。

工具层:tool_calls 的流式增量怎么拼——本站按 index 累积 id/name/arguments 分片(toolAccum);finish_reason 的取值是 "tool_calls" 还是别的词。

流式层:SSE 行格式与 [DONE] 结尾是否一致;多字节中文被分包切断时 TextDecoder 流式解码是否照常(这是协议通用行为,但有的网关会改分帧)。

参数层:max_tokens 还是 max_completion_tokens;temperature 范围;stream_options.include_usage 支持与否——不支持就拿不到用量,A34 的账本直接变空。

错误处理为什么稳?因为 classifyHttpError 按状态码分类,状态码语义是各家公认约定,比解析错误文案可靠得多:

// lib/ai/adapter.ts(节选):按状态码分类,不猜文案
export function classifyHttpError(status: number, bodyText: string): AiError {
  if (status === 401 || status === 403)
    return new AiError("not_configured", "密钥无效或无权限,请检查 AI 配置。", status);
  if (status === 429)
    return new AiError("rate_limit", "请求频率或额度受限,稍后再试。", status);
  if (status === 402)
    return new AiError("quota", "账户余额或额度不足。", status);
  return new AiError(
    "model_error",
    `模型服务返回 ${status}:${bodyText.slice(0, 200)}`,
    status
  );
}

示例拆解

一次完整迁移的执行顺序(每步有验证):

# 1. 备份当前配置,改三个环境变量(.env.local)
#    AI_BASE_URL=https://新提供方地址  AI_API_KEY=sk-xxx  AI_MODEL=新模型名

# 2. 连接测试:最便宜的真相
curl -s -X POST localhost:3000/api/ai/status
#    期待 {"ok":true,...,"latencyMs":...};失败会带 category

# 3. 核对四张单子:重点看流式工具调用与 usage 是否照常落 ai_call 表

# 4. 回归:A31 固定样例对比新旧模型输出 + 核心路径
node scripts/e2e-core.mjs

关键点:连接测试先行——POST /api/ai/status 用一条最小请求验证「地址、密钥、模型名」三件套,比直接在聊天里试错便宜;用量表是迁移探针——ai_call 有没有新记录、prompt_tokens 是否为 null,立刻暴露 include_usage 的支持差异;回归对齐 A33——固定样例的通过率变化就是迁移的质量报告。

动手任务

  1. 跑实验区:两家「提供方」的流式报文形状不同,归一化之后输出完全一致——体会「差异挡在适配层」。
  2. 本机把 AI_MODEL 改成一个不存在的名字,重启后请求 /api/ai/status,确认错误类别是 model_error 且提示可读,改回来。
  3. 写下你的核对单:对照本课四张单子,逐项标注「已核对 / 本站未用到」。

常见错误

  • 业务代码直接拼上游 URL:绕过适配层,迁移时每处都要找出来改,漏一处就是一个隐性 bug。
  • 只测文本对话就切完:工具调用和流式是差异重灾区,不回归这两样等于没测(A15-A17 的能力会静默坏掉)。
  • 忽略 usage 缺失:新提供方不支持 include_usage,账本悄悄变空,几周后才发现没数据可复盘。
  • 按错误文案写判断:文案各家不同还随时改,按状态码分类才稳定。

检查与复盘

  • 能画出「业务代码 → adapter → 上游」的分层,说出切换提供方要改什么、不用改什么
  • 四张核对单(消息/工具/流式/参数)各能说出至少一个真实差异点
  • 做过一次(哪怕是模拟的)迁移:连接测试与回归检查都有结果记录

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1故障定位较难

bug 现场:按迁移流程把提供方从 A 换到 B(改了 AI_BASE_URL / AI_API_KEY / AI_MODEL 三个环境变量),连接测试通过、文本聊天正常。一周后发现 ai_call 表新增记录的 prompt_tokens / completion_tokens 全是 null,账本空了一周。最可能的原因与遗漏的核对项?

// lib/ai/adapter.ts(节选)
body: JSON.stringify({
  model: cfg.model,
  messages: opts.messages,
  stream: true,
  stream_options: { include_usage: true },
}),
// onDone: (usage) => {
//   if (usage) { /* 写入 ai_call */ }
// }

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...