第 4 步 · 多 Provider 抽象

同一套 agent 逻辑,换一个配置就能接不同厂商 —— 这是"工程味道"的一步

配套可运行项目:f:\myagent | 前置:已完成 Step 1~3(最小调用 → 主循环 → 工具调用)

本步的心智转变:先写死,后抽象

前三步我们把 URL、模型名、API Key 直接写死在 llm.ts 里——那是故意的。这一步才把它们抽出来。

为什么 task 1~3 不一开始就配置化? 因为当时每个值只出现一次,抽象(建接口、拆文件、拼 URL)只会增加理解成本,没有任何收益。规则是:一个值出现一次就写死;出现"换厂商"这类变化需求时,才值得抽象。task 4 就是那个"变化需求出现"的时刻——现在抽象,理由才成立。这个顺序叫"先写死、后抽象"(也叫 YAGNI:不需要的功能先不做)。
本步要做的三件事 ① 新建 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)是厂商给的,不能凭想象写。记不住就回到那条总口诀:上网络的键名 = 协议定;只在你代码里出现的 = 你定
4

多 Provider 抽象(换配置不换代码)

目标:把"会随厂商变化的东西"集中到一处,让 askLLM 只认接口、不认厂商。

4.1 新建 src/config.ts:ProviderConfig 接口 + provider 实例

这是本步唯一的新文件。它只做一件事:定义"一个 AI 厂商需要哪些配置",并提供一个从环境变量读取的实例。

src/config.ts
为什么需要接口(interface)而不是直接导出一个对象?契约先行:接口声明了"厂商必须提供什么",任何对象只要形状匹配就能当配置用——这为将来支持"多个厂商配置并存"(一个对象一个厂商)留了门;② 类型即文档:读代码的人看接口就知道这个 agent 依赖哪些外部条件;③ 防止拼错:写 baseUrl(小写 u)会被 tsc 直接报错,因为接口里定义的是 baseURL。TS 的职责就是帮你抓住这种拼写不一致。
这里的名字是谁定的? ProviderConfigproviderbaseURLmodelapiKey —— 全部是【你自己起的】(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 → 系统提示找不到文件。

4.2 修改 src/llm.ts:删掉写死,改用 provider

只改三处,其他(LLMResponse、工具调用翻译层)全部不动:① key 的读取换成 provider.apiKey;② URL 改成 baseURL + /chat/completions 拼接;③ model 换成 provider.model

src/llm.ts(升级)
升级前:src/llm.ts(task 3)
为什么 URL 要拆成 baseURL + 路径两段? 因为 baseURL 是"每个厂商不同的部分"(火山、DeepSeek、OpenAI 各有一套),而 /chat/completions 是"所有 OpenAI 兼容厂商都相同的部分"(协议固定路径)。把"变的部分"和"不变的部分"拆开,正是抽象的意义:换厂商时只覆盖变的部分(ARK_BASE_URL),不变的部分永远不用管。反过来如果整串 URL 都写死在代码里,换厂商就得改代码——那这一步就白做了。
"键名" vs "值",再看一眼 请求体里的 model(键名)是协议定的;provider.model(字段名)是你起的;而 provider.model(如 "deepseek-v4-flash-ga-260731")来自环境变量,换厂商就改它。baseURL 同理:字段名你定,值(URL 字符串)厂商定。

4.3 主循环、工具代码:一行不改(这就是抽象成功的证明)

这一步不需要agent.tstools.tstypes.ts。它们的 import 链是:agent.ts → llm.tsagent.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 是协议形状,与厂商无关
为什么"主循环零改动"是抽象成功的判据? 这是接口抽象的验收标准:换 Provider 后主循环一行不改还能跑 = 抽象成功。反过来,如果换厂商要改 agent.ts,说明"变化点"没有封住,抽象就失败了。抽象的价值就在这:让"经常变的东西"(厂商配置)和"几乎不变的东西"(主循环逻辑)解耦,改前者时绝不碰后者。

4.4 更新 .env.example:新增两个可选变量

新加了 ARK_BASE_URLARK_MODEL(都是可选的,不配就用 config.ts 默认值)。"换厂商"就是在这里改,而不是改代码。

.env.example
升级前:.env.example(task 1)
为什么默认值要写两遍(.env.example 里注释一遍 + config.ts 里 ?? 一遍)? 这两个地方的作用不同:.env.example给人类看的模板(说明"你可以在这里填什么");config.ts 里的 ?? 默认值给程序用的兜底(.env 没配时程序仍有值可用)。两处允许不一致——比如 .env.example 提示你填 A,config.ts 默认 B,只要 .env 里真配了 A,程序就用 A。这就是"配置优先级":.env > 代码默认值

4.5 运行验收 + 换厂商演示

终端 · 运行命令
验收标准(来自课程大纲) ① 只改 .env 就能切到另一个 OpenAI 兼容厂商;② 主循环、工具调用代码零改动;③ 能用自己的话讲清"为什么先写死、后抽象"的顺序是对的。三条都满足,task 4 通关。
常见坑 ① 换厂商后 404 / 401:先检查 .env 的三个变量是否都改对了(尤其 ARK_MODEL 要用对方厂商的模型 ID,ARK_BASE_URL 要按对方文档写,不是所有厂商都是 /v1 结尾);② 工具在对方厂商不可用:天气/计算工具是我们自己的本地函数,与厂商无关,但对方模型的"工具调用"能力参差不齐,部分小模型不会用工具——换模型后工具失效不一定是代码问题;③ 忘了复制 .env.example.envnode --env-file=.env 找不到文件会直接报错。
?

字段来源速查表(task 4 新增:配置相关)

前三章的速查表在 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 速查表
一句话记住 task 4 "变的进配置,不变的留代码"。哪个是变的?厂商的地址、模型、密钥。哪个是不变的?主循环、工具、消息形状。把它们分开,换厂商就是改一行 .env
← Home