同一套 agent 逻辑,换一个配置就能接不同厂商 —— 这是"工程味道"的一步
前三步我们把 URL、模型名、API Key 直接写死在 llm.ts 里——那是故意的。这一步才把它们抽出来。
src/config.ts:定义 ProviderConfig 接口 + 一个 provider 实例;② 修改 src/llm.ts:把写死的 URL / model / apiKey 换成读 provider;③ 主循环、工具代码一行不改。验收判据:只改 .env 就能切到另一个 OpenAI 兼容厂商(火山 / DeepSeek / OpenAI……),代码零改动。model 同时出现在两个地方:在请求体里它是协议键名(不能改,Step 3 速查表讲过);在 ProviderConfig 里它是你自己起的字段名(能改)。它们靠 model: provider.model 这一行建立映射。URL 也一样:baseURL 字段名是你起的,但它拼出来的值(https://.../api/plan/v3)是厂商给的,不能凭想象写。记不住就回到那条总口诀:上网络的键名 = 协议定;只在你代码里出现的 = 你定。目标:把"会随厂商变化的东西"集中到一处,让 askLLM 只认接口、不认厂商。
src/config.ts:ProviderConfig 接口 + provider 实例这是本步唯一的新文件。它只做一件事:定义"一个 AI 厂商需要哪些配置",并提供一个从环境变量读取的实例。
baseUrl(小写 u)会被 tsc 直接报错,因为接口里定义的是 baseURL。TS 的职责就是帮你抓住这种拼写不一致。ProviderConfig、provider、baseURL、model、apiKey —— 全部是【你自己起的】(TS 命名自由)。唯一要注意:model 字段的值最终会填进请求体的协议键名 model,所以"语义上"必须放模型 ID,但拼写层面没有强制。真正强制的是 ARK_BASE_URL / ARK_MODEL / ARK_API_KEY 这三个环境变量名要和 .env 文件、--env-file=.env 注入完全一致。ARK_APIKEY)→ 静默拿默认值空串,请求 401;② baseURL 忘记末尾不加 /,或加成了 /chat/completions 的完整 URL → 拼出 .../completions/chat/completions 404;③ 直接在 .env 里写值但忘了复制 .env.example → 系统提示找不到文件。src/llm.ts:删掉写死,改用 provider只改三处,其他(LLMResponse、工具调用翻译层)全部不动:① key 的读取换成 provider.apiKey;② URL 改成 baseURL + /chat/completions 拼接;③ model 换成 provider.model。
baseURL 是"每个厂商不同的部分"(火山、DeepSeek、OpenAI 各有一套),而 /chat/completions 是"所有 OpenAI 兼容厂商都相同的部分"(协议固定路径)。把"变的部分"和"不变的部分"拆开,正是抽象的意义:换厂商时只覆盖变的部分(ARK_BASE_URL),不变的部分永远不用管。反过来如果整串 URL 都写死在代码里,换厂商就得改代码——那这一步就白做了。model(键名)是协议定的;provider.model(字段名)是你起的;而 provider.model 的值(如 "deepseek-v4-flash-ga-260731")来自环境变量,换厂商就改它。baseURL 同理:字段名你定,值(URL 字符串)厂商定。这一步不需要动 agent.ts、tools.ts、types.ts。它们的 import 链是:agent.ts → llm.ts、agent.ts → tools.ts。因为 askLLM 的签名没变(还是 (messages, tools?)),只是它内部实现换了数据来源——调用方感知不到,也就无需改动。
| 文件 | 本步是否改动 | 原因 |
|---|---|---|
src/config.ts | 新增 | 唯一新文件:配置集中地 |
src/llm.ts | 改 3 处 | apiKey / URL / model 换数据源,接口签名不变 |
src/agent.ts | 零改动 | 只依赖 askLLM 的签名,不关心内部数据从哪来 |
src/tools.ts | 零改动 | 与厂商无关,纯本地逻辑 |
src/types.ts | 零改动 | Message / RawToolCall 是协议形状,与厂商无关 |
agent.ts,说明"变化点"没有封住,抽象就失败了。抽象的价值就在这:让"经常变的东西"(厂商配置)和"几乎不变的东西"(主循环逻辑)解耦,改前者时绝不碰后者。.env.example:新增两个可选变量新加了 ARK_BASE_URL 和 ARK_MODEL(都是可选的,不配就用 config.ts 默认值)。"换厂商"就是在这里改,而不是改代码。
.env.example 是给人类看的模板(说明"你可以在这里填什么");config.ts 里的 ?? 默认值 是给程序用的兜底(.env 没配时程序仍有值可用)。两处允许不一致——比如 .env.example 提示你填 A,config.ts 默认 B,只要 .env 里真配了 A,程序就用 A。这就是"配置优先级":.env > 代码默认值。
.env 就能切到另一个 OpenAI 兼容厂商;② 主循环、工具调用代码零改动;③ 能用自己的话讲清"为什么先写死、后抽象"的顺序是对的。三条都满足,task 4 通关。.env 的三个变量是否都改对了(尤其 ARK_MODEL 要用对方厂商的模型 ID,ARK_BASE_URL 要按对方文档写,不是所有厂商都是 /v1 结尾);② 工具在对方厂商不可用:天气/计算工具是我们自己的本地函数,与厂商无关,但对方模型的"工具调用"能力参差不齐,部分小模型不会用工具——换模型后工具失效不一定是代码问题;③ 忘了复制 .env.example 为 .env:node --env-file=.env 找不到文件会直接报错。前三章的速查表在 agent-tutorial.html。这里只列 task 4 新增/涉及的名字。判断口诀不变:上网络的键名 = 协议定;只在你代码里出现的 = 你定,但要对齐。
| 字段 / 值 | 能不能改 | 谁规定的 | 说明 |
|---|---|---|---|
路径后缀 /chat/completions | 不能改 | OpenAI 兼容协议 | 所有兼容厂商固定路径,拼在 baseURL 后面 |
ProviderConfig 的字段名:baseURL / model / apiKey | 自己起 | TypeScript / 你 | 只在你自己的代码里出现;model 的值会映射进协议键名 model |
环境变量名:ARK_BASE_URL / ARK_MODEL / ARK_API_KEY | 自己起 | 你自己 | 必须和 .env 文件、config.ts 里的引用拼写完全一致 |
baseURL 的值(URL 字符串) | 换厂商就改 | 厂商文档 | 火山标准版 /api/v3、套餐版 /api/plan/v3;DeepSeek 是 https://api.deepseek.com/v1……以厂商文档为准 |
model 的值(模型 ID) | 换厂商就改 | 厂商控制台 | 用对方账号里真实开通的模型 ID,否则 404 |
?? 默认值(如 ?? "...") | 自己起 | 你自己 | 兜底逻辑:.env 没配时用的值。优先级:.env > 默认值 |
请求体里 model / messages / tools 等键名 | 不能改 | OpenAI 兼容协议 | 与 task 3 相同,见 agent-tutorial.html 速查表 |
.env。