AgentHub 的 Provider 层:让 Agent 核心与 LLM 供应商解耦
本文是 AgentHub 后端架构系列的第 9 篇,对照源码
src/providers/讲解。前面章节讲了 Agent 怎么跑循环、怎么管记忆,这一篇讲循环里那行"调 LLM"的调用背后------供应商适配、重试、取消、多模型装配。
引言
Agent 核心逻辑(ReAct 循环、工具调度、记忆管理)不应该和任何一个 LLM 供应商的 SDK 调用形态、返回结构耦死。今天用 Anthropic,明天可能要接一个兼容 OpenAI 协议的国产模型,后天可能要同时接两家让不同 agent 各用各的------如果这些差异渗透进 Runner 和 Loop,每换一次供应商都是一次伤筋动骨。
Provider 层就是挡在中间的适配层 :用一个含 complete()(非流式)和 streamComplete()(流式,逐 token 产出 StreamEvent)两个抽象方法的抽象类 BaseProvider,加一组统一类型(LLMMessage/ToolDefinition/LLMResponse),把 Agent 核心和供应商 SDK 隔开;AnthropicProvider 负责双向翻译(统一类型 ⇄ Anthropic SDK 格式)、只对限流/过载做指数退避重试、把取消 signal 透传给 SDK 中断 HTTP。装配侧则是"providers 槽位表 + resolveModel(agentModel, agentProvider) 直建":每个槽位自持 api_key/base_url/model/协议(api: anthropic|openai),按协议二选一实例化,每个 agent(甚至聊天窗的每一回合)各持自己的 provider 实例,不同 agent 指不同槽位即可接不同供应商账号。
一、为什么要有这层------适配器模式,不加会怎样
Agent Loop / Runner 不直接调 Claude API,而是通过 provider 调。回顾 ReAct 循环,Runner 里只有一行:
csharp
// AgentRunner.run
const response = await this.provider.complete(system, history, tools, signal);
this.provider 是 BaseProvider 类型,Runner 不知道背后是 Claude 还是别的。这是经典的适配器模式------把"供应商特有"的东西(SDK 调用格式、返回块数组结构、重试策略)封装进一个类,Agent 核心保持供应商无关,依赖抽象不依赖具体。
不加这层会怎样(反向论证这层的价值):
- 换供应商要改 Runner 里所有
client.messages.create调用点; - 返回格式解析(SDK 的块数组 → content / tool_calls)会散落在 Runner 里,得全改;
- 重试逻辑要在每个调用点重复写。
有了 Provider 层,这些都被收敛到一个类里。换供应商的改动范围:
bash
├─ 新写一个 XxxProvider extends BaseProvider
│ 实现 complete():统一类型 → Xxx SDK 格式 → 调 Xxx API → 翻译回 LLMResponse
├─ resolveModel 的协议分支里加一路(现状是 if/else 两分支:
│ api === "anthropic" → AnthropicProvider,否则 → OpenAICompatProvider)
└─ 配置的 llm.providers 加一个槽位,配 api_key/base_url/model/api
AgentRunner / AgentLoop / ToolRegistry / GatewayCore 一行不改
若对方兼容 OpenAI /chat/completions 协议(DeepSeek/GLM 官方 API 这类),连新类都不用写------OpenAICompatProvider 已把这条路走通,直接加一个 api: openai 的槽位即可(见第七节)。
二、统一类型系统------全系统的"通用语言"
Provider 层在 base.ts 定义了一组统一类型,Runner 和所有 Provider 都用这组类型,而不是 Anthropic SDK 的 MessageParam/Tool:
typescript
// 消息:role 只有 user/assistant,没有 system------system 单独传
interface LLMMessage {
role: "user" | "assistant";
content: string | ContentBlock[];
}
// 内容块:一条消息可含多种块
type ContentBlock =
| TextBlock | ImageBlock | DocumentBlock // 文本 / 图片 / PDF
| ToolUseBlock | ToolResultBlock; // 工具调用 / 工具结果(靠 tool_use_id 关联)
// 工具定义:给 LLM 看的 schema,ToolRegistry.definitions() 产出这个
interface ToolDefinition {
name: string;
description: string;
input_schema: { type: "object"; properties: ...; required?: string[] };
}
// LLM 返回
interface LLMResponse {
content: string; // 文本回复
tool_calls: ToolCall[]; // 要调的工具
stop_reason: "end_turn" | "tool_use" | "max_tokens"; // 终止原因
input_tokens: number; // 输入 token(L2 Consolidator 用)
output_tokens: number;
}
这组类型是可替换性的支点。因为 Runner 只认这些类型,换供应商时新 Provider 只需在内部把它们翻译成自家格式,Runner 一行不改。而且它们不止 Provider 在用,是贯穿多层的"通用语言":
- ToolDefinition :ToolRegistry 的
definitions()产出 → Provider 翻译给 LLM; - LLMMessage:Runner 累积的历史 → Provider 翻译给 LLM;
- stop_reason :Provider 返回 → Runner 判 ReAct 终止(
tool_use继续循环,其他结束); - input_tokens:Provider 返回 → Loop 判 L2 Consolidator 触发(超阈值就压缩历史);
- tool_calls :Provider 返回 → Runner 调
toolRegistry.execute执行工具。
如果不统一、各层直接用 SDK 类型,那么 SDK 的 MessageParam/Tool 就会渗透进 Runner、Loop、Consolidator,换供应商时这些层全得改,隔离作用就漏了。统一类型把 SDK 类型挡在 Provider 边界内,是"换供应商只改 Provider"这个承诺能成立的前提。
LLMMessage.role 刻意排除了 "system",因为 Anthropic API 不接受 system 作为消息 role,system 走单独参数------Provider 在接口层面就把这个约束表达出来了。
三、AnthropicProvider:双向翻译
complete() 干两个方向的翻译。流式版 streamComplete() 的翻译规则相同,只是把 SDK 的 content_block_start/delta/stop 事件聚合成统一 StreamEvent 序列供上层"边生成边显示"。下面以 complete() 为例。
入参翻译(统一类型 → SDK 格式):
kotlin
const msg = await this.client.messages.create(
{
model: this.model,
max_tokens: 8096, // 硬编码输出上限,见下
system, // ★ system 单独传,不进 messages 数组
messages: messages.map(toMessageParam), // LLMMessage → MessageParam
tools: tools as unknown as Tool[], // ToolDefinition → Tool(双重断言)
},
{ signal }, // signal 透传给 SDK
);
system单独传给 SDK 的system字段,不是消息数组里 role:system 的元素;messages.map(toMessageParam):纯字符串 content 直接传,块数组 content 转成 SDK 块格式;tools as unknown as Tool[]:统一类型和 SDK 类型结构兼容但 TS 定义不同,用双重断言绕过类型检查(运行时结构是对的,类型安全打了折扣------这是已知的技术债,见局限一节)。
返回翻译 (SDK 返回 → 统一 LLMResponse):
ini
const content = msg.content.filter(b => b.type === "text").map(b => b.text).join("");
const tool_calls = msg.content.filter(b => b.type === "tool_use").map(...);
const stop_reason = (msg.stop_reason ?? "end_turn") as ...;
return { content, tool_calls, stop_reason,
input_tokens: msg.usage.input_tokens, output_tokens: msg.usage.output_tokens };
关键点:msg.content 是块数组 ,一轮输出可能同时含 text 块和 tool_use 块(LLM 可以边回复边要求调工具)。Provider 把它们分别提取------文本拼成 content,工具调用收集成 tool_calls------Runner 拿到的是干净的两个字段,不用处理 SDK 的块数组结构。
两个防御细节:
一是构造 client 时设 authToken: null。Anthropic SDK 默认会从环境变量读 ANTHROPIC_AUTH_TOKEN 当认证 token,可能和显式传入的 apiKey 冲突(两套认证机制打架),显式设 null 禁掉,只用 apiKey 认证。
二是缺 key 的可操作报错 。构造时记录 missingKey = !apiKey,每次调用前 guardApiKey()------缺 key 时抛"LLM API Key 未配置:请在配置页填写 API Key 后重试"。这一步不白做:Anthropic SDK 在空 key 时抛的是"Could not resolve authentication method"这种天书,用户完全不知道该怎么办;换成带操作指引的报错,再配合 Loop 层对这类配置错误的原样透出(见 03-AgentLoop与ReAct循环 的错误兜底一节),用户第一次配错就能自己修好。
构造签名有第 4 参 name (槽位名,默认 "anthropic")------非 anthropic 槽位也走 Claude 协议时(比如某协议网关代理多模型,槽位 api: anthropic 但名字叫 glm/deepseek),日志里能区分是哪个槽位在调,不会全混成 "anthropic"。
max_tokens 硬编码 8096 :单次回复输出上限,不配置化。意义是防 LLM 失控输出超长回复(比如被 prompt injection 诱导输出整本书)。注意 8096 疑为 8192(8K)的笔误,但代码即 8096------一个小提醒:魔法数字上线前最好核对一遍,它跟槽位配置的 timeout_per_step(限调用时长)是两回事,而且 timeout_per_step 目前实际没接通(见局限)。
四、重试策略:只重试"等一下就会好"的错误
typescript
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000;
function isRetryable(err: unknown): boolean {
if (err instanceof RateLimitError) return true; // 429 限流
if (err instanceof InternalServerError && err.status === 529) return true; // 529 过载
return false;
}
// catch 块:
if (signal?.aborted) throw err; // ① 用户取消,优先,不重试
if (isRetryable(err) && attempt < MAX_RETRIES) { // ② 只重试 429/529
const delay = RETRY_BASE_MS * Math.pow(2, attempt); // 指数退避:2 / 4 / 8 秒
await new Promise(r => setTimeout(r, delay));
attempt++;
continue;
}
throw err; // ③ 不可重试 / 用尽 → 抛给 Loop
三个设计决策,每个都有明确理由:
为什么只重试 429 和 529 ------区分"瞬时可恢复"和"永久性"。RateLimitError(429 限流)、InternalServerError 且 status 529(服务端过载码)属于瞬时错误,等一下就好,重试有意义;401 认证失败、400 参数错、网络断这类重试也没用,直接抛。对永久性错误盲目重试只是浪费时间、放大延迟。
为什么用指数退避(2/4/8 秒) ------限流/过载的成因通常是"服务端压力太大",重试太密只会加重压力、降低成功率;逐次拉长间隔给服务端喘息时间,也提高重试命中率。最多 3 次,之后抛错。
为什么 signal.aborted 要优先于重试判断 ------if (signal?.aborted) throw err 排在重试逻辑之前 :用户发 /stop 取消时,即使当前遇到的是可重试错误,也要立即抛 AbortError,不能再走 2/4/8 秒的重试。否则用户按了停止还得干等几次退避才真正停下,取消形同虚设。这是把"用户意图"置于"自动容错"之上的优先级设计。
一个容易忽略的边界:流式路径没有重试。 上面的 while 循环只存在于非流式的 complete();streamComplete() 直接 for await 消费 SDK 事件流,中途出错不会重试。这和 Loop 层"流式下 413 不重试"是同一个取舍------流已经开始往客户端推 token,重跑一次要么重复推送、要么需要复杂的缓冲对账,收益撑不起复杂度。OpenAICompatProvider 稍微不同:它的重试包在 fetchWithRetry 里,覆盖"建立连接拿到响应头"这个阶段(流式/非流式共用),但一旦开始读 SSE 流,同样没有重试。给流式加重试不是不可以,但得先回答"已推的 token 怎么办"。
一个真实的坑:两个 Provider 的退避序列其实不一样。 AnthropicProvider 是先算 delay 再 attempt++(2/4/8 秒);OpenAICompatProvider 的 fetchWithRetry 里是先 attempt++ 再算 delay = 2000 * 2^attempt,实际退避成了 4/8/16 秒。行为没错(还是指数退避、还是 3 次),但和 Anthropic 侧的节奏差了一倍,文档里说"两边退避一致"就会翻车。教训:同样的重试逻辑在两个类里各写一遍,细节必然漂移------更稳的做法是把重试策略抽成共享函数,或者至少用同一组测试钉住行为。
重试用尽后错误去哪 :Provider 要么成功返回 LLMResponse,要么 throw------它自己不返回错误结果。抛出的异常从 provider.complete → Runner(不 catch,继续上抛)→ Loop 的 try/catch 兜住,按错误类型决定透出配置类提示还是通用文案(signal.aborted 则返回"已停止")。错误兜底统一在 Loop 层,Provider 只负责抛。
五、signal 透传------取消能真正中断 LLM 调用的物理实现
signal 从 GatewayCore 的 AbortController 出发,一路穿过 Loop、Runner,到 Provider 交给 SDK:
csharp
const msg = await this.client.messages.create({ ... }, { signal });
Provider 是 signal 这条链路的终点 :SDK 内部用 signal 取消正在进行的 HTTP 请求。用户发 /stop → ac.abort() → SDK 抛 AbortError → Provider catch 里 if (signal?.aborted) throw err 直接抛(不重试)→ Loop 兜成"已停止"。这就是"取消"能物理中断一个已经发出去的 LLM 请求的实现------在 SDK 层面 abort HTTP,而不只是设个标志位等它自然结束。
OpenAICompatProvider 那边同理:signal 塞进 fetch 的 RequestInit,fetch 在 abort 时抛错,fetchWithRetry 的 catch 先检查 signal?.aborted 再谈重试。
六、provider 装配:providers 槽位表 + per-agent 选槽位
AgentHub 经历过一次架构演进:从"一个全局 agent 实例"变成"多个可配置 agent 实例并存"。Provider 装配也跟着从"全局注册表"演进成"槽位表 + 直建函数"。
旧写法(单 agent 时代,已失效) :ProviderRegistry 是个 Map<name, BaseProvider>,启动时 register(new AnthropicProvider(...)),再按 config.llm.default_provider get() 出唯一 provider 给全局 AgentLoop 用。default_provider 配置项的原意是"注册多个供应商后改配置切默认"。
现在的写法 :装配路径是直建函数 resolveModel(src/providers/registry.ts),两参签名:
arduino
export function resolveModel(agentModel?: string, agentProvider?: string): BaseProvider {
const config = getConfig();
let providerName = agentProvider ?? config.llm.default_provider; // ① 选槽位
if (providerName && !config.llm.providers[providerName]) {
logger.warn("agent provider not configured, falling back to default", ...);
providerName = config.llm.default_provider; // agent 指向已删槽位:降级默认+warn
}
if (!providerName) providerName = Object.keys(config.llm.providers)[0]; // 留空=第一个条目(开箱即用)
const providerConfig = config.llm.providers[providerName];
if (!providerConfig) throw new Error(...); // 槽位本身缺失:抛错,不隐式降级
const providerModel = agentModel ?? providerConfig.model; // ② 选模型:agent 覆盖优先
if (!providerModel) throw new Error(...); // 顶层 llm.model 已删除,无全局兜底
// ③ 按槽位声明的协议二选一(if/else 两分支,无映射表)
if (providerConfig.api === "anthropic") {
return new AnthropicProvider(providerModel, providerConfig.api_key, providerConfig.base_url, providerName);
}
return new OpenAICompatProvider(providerName, providerModel, providerConfig.api_key, providerConfig.base_url, providerConfig.max_tokens);
}
三层解析语义:槽位 = agent.json 的 provider ?? llm.default_provider ?? providers 第一项;模型 = agent.json 的 model ?? 槽位默认 model(顶层 llm.model 字段已删除,provider 是唯一事实来源);协议 = 槽位自声明 api: anthropic|openai,决定实例化哪个类。另外每个槽位还有个 models 数组(可切换模型列表),供聊天窗下拉选择用------model 是默认值,models 是该槽位支持的其他可选模型。
配错处理分两级,注释里把理由写得很明白:agent 指向不存在的槽位(配置重构删了旧槽位后,存量 agent.json 的典型场景)只 warn 降级到默认槽位------抛错会让 loadOne → loadAll 全挂、拖垮整个网关启动;但槽位本身缺失或没配 model 直接抛错------不做旧版那种"静默 fallback 拿错 key/模型、难排查"的隐式降级。哪些错该宽、哪些错该严,取决于错误发生在"存量数据兼容"还是"当前配置本身"。
每个 agent 在 AgentRegistry.loadOne 里各调一次 resolveModel(def.model, def.provider),实例化自己专属的一个 provider 存进 AgentEntry.provider,并记下 providerName 供子 agent 继承判断和聊天窗 per-turn 覆盖用。def.model/def.provider 有值就用 agent 自己的,没写则 fallback 全局默认槽位------这正是整套多 agent 体系"per-agent 可覆盖、缺省继承全局"原则在 Provider 层的落地。子 agent 的继承条件:
ini
const model = subDef.model || subDef.provider
? resolveModel(subDef.model, subDef.provider ?? parentEntry.providerName)
: parentEntry.provider;
子 agent 写了 model 或 provider 就新建实例(只写 provider 时,model 取新槽位默认、provider 缺省继承父的 providerName);两个都不写才直接复用父 agent 的 provider 实例。
per-agent 化是做全了的 :凭证/地址/协议都随槽位走,不同 agent 指不同槽位即可接不同供应商账号;前端也配套了------聊天窗的模型选择器读 /api/config 的 llm.providers 出下拉(localStorage 记忆,选"默认"=不发覆盖),选中的 model/provider 作为 model_hint/provider_hint 随消息上行,当回合临时实例化 provider + AgentLoop(不改 agent 定义、不与并发请求互斥、配错降级 warn 回默认);agent 详情页改 model/provider 走 PUT /api/agents/:name,传 null 表示清除该字段、切回继承默认------这里有个实现细节:JSON.stringify 会丢弃 undefined 字段,所以协议上用 null 显式表达"删除"。
残留:
ProviderRegistry这个类的定义还留在registry.ts里,但全代码库已无任何地方实例化它或调它的register/get------装配完全走resolveModel直建了。注意与旧结论的差别:default_provider并不是残留,它已恢复实质作用,是槽位解析的主选择器(agentProvider ?? default_provider ?? 第一项);真正残留的只有这个类本身。
七、第二个 Provider:OpenAICompatProvider(api: openai)
槽位的 api 字段之所以存在,是因为 base_url 后面可能是两种协议:Anthropic messages(Claude 官方或某协议网关)或 OpenAI /chat/completions(DeepSeek/GLM 等官方 API)。OpenAICompatProvider(src/providers/openai-compat.ts)覆盖后者,对上实现同一个 BaseProvider 接口(含流式),Runner 完全无感。和 AnthropicProvider 的关键差异:
- 不用 SDK,裸 fetch :POST
${baseURL}/chat/completions,Authorization: Bearer <key>;没有authToken: null那种防御,因为根本没有 SDK 会去偷读环境变量。 - 格式转换是另一套双向翻译 :统一类型 ⇄ OpenAI 形态。入参侧
toOpenAIMessages:assistant 的 tool_use 块转tool_calls数组(input 对象要JSON.stringify成 arguments 字符串);tool_result 块拆成独立的role: "tool"消息 、靠tool_call_id关联(对比 Anthropic:tool_result 是 user 消息里的一个块);system 作为第一条role: "system"消息进 messages 数组(对比 Anthropic 的单独system字段)。返回侧finish_reason映射回 stop_reason:tool_calls → tool_use、length → max_tokens、其他 →end_turn。 - 重试条件更宽 :
isRetryable放行 429 / 5xx / fetch 网络失败的 TypeError------裸 fetch 没有 SDK 的错误类型体系,只能用 status 码和异常类型判(退避序列的差异见第四节那个坑)。 - max_tokens 可配 :取槽位的
max_tokens,默认 8192------AnthropicProvider 那边仍是硬编码 8096,两边不对称。 - 流式是手写 SSE 解析 :
streamComplete按行缓冲拆data:前缀、遇[DONE]收尾;delta.tool_calls按index累积 id/name/arguments 字符串,流结束后统一JSON.parse成 input------OpenAI 流式把工具调用的参数拆成 JSON 字符串碎片推,这是和 Anthropic 事件模型(content_block_start/input_json_delta)最不一样的地方。
设计意义 :两个 Provider 把"协议差异"完全封在层内,api 一个配置字段就切换协议栈------这是适配器模式价值的实证:接 GLM/DeepSeek 官方 API 时 Agent 核心一行没改。
全景图
vbnet
每个 AgentEntry 各持一个 provider = resolveModel(def.model, def.provider)
│ (按槽位 api 字段决定 AnthropicProvider / OpenAICompatProvider;下图以 AnthropicProvider 为例)
│
AgentRunner 调 provider.complete(system, messages, tools, signal)
│
▼
while (true) 重试循环(仅非流式;流式无重试,见第四节):
│
├─ 入参翻译:
│ system → SDK system 字段(单独传,非消息 role)
│ messages → MessageParam[](toMessageParam)
│ tools → Tool[](as unknown as 双重断言)
│ max_tokens: 8096(硬编码)
│
├─ client.messages.create({ ... }, { signal }) ← signal 终点,交给 SDK abort HTTP
│ │ 成功
│ ▼
│ 返回翻译:
│ content: filter text 块拼字符串
│ tool_calls: filter tool_use 块收集
│ stop_reason / input_tokens / output_tokens → LLMResponse ★
│
└─ catch (err):
├─ signal.aborted? → throw(优先,不重试,Loop 兜"已停止")
├─ isRetryable(429/529) && attempt < 3?
│ 是 → 指数退避 2/4/8 秒 → attempt++ → continue
│ 否 → throw(Loop 按错误类型兜底)
▼
抛给 Loop 统一兜底
八、职责边界:Provider 不管什么
Provider 层职责很窄------单次 LLM 调用 + 重试 + 格式翻译。以下都不归它管,这种窄职责正是它可替换的前提(换供应商时不用操心 prompt 怎么组装、历史怎么压缩,那些跟供应商无关):
| 事 | 谁管 | Provider 不管的原因 |
|---|---|---|
| system prompt 组装 | ContextBuilder | Provider 只传 system,不组装 |
| 历史文件块处理 / 413 兜底 | Loop | Provider 不知道文件块是啥 |
| ReAct 循环(多次调 complete) | Runner | Provider 只管单次调用 |
| 记忆压缩触发 | Loop | Provider 只返回 input_tokens,不判断 |
| 工具执行 | Runner + ToolRegistry | Provider 只返回 tool_calls,不执行 |
| 错误兜底成文案 | Loop | Provider 抛错,Loop 兜 |
九、当前的局限与可能的改进
如实说几个:
ProviderRegistry类是死代码 ------定义还在(registry.ts),但全代码库已无实例化/注册/获取调用,装配早改成resolveModel直建了。留着误导,该删。timeout_per_step配置项没接通 ------schema 里有(默认 30 秒),但两个 Provider 都没接:AnthropicProvider 的complete里既没 setTimeout 也没传 SDK 的 timeout 选项,OpenAICompatProvider 的 fetch 同样没有超时控制。单步超时目前只能靠外层 GatewayCore 的会话级锁超时兜底,不是真正的单步限时。改进方向:用AbortSignal.timeout(timeout_per_step)和外层 signal 合并,让单步真正可限时。- 多处
as unknown as双重断言 (tools as unknown as Tool[]等),因统一类型和 SDK 类型结构兼容但 TS 定义不同而绕过检查,运行时对但类型安全打折。改进方向:给统一类型和 SDK 类型写显式映射函数替掉双重断言。 - 两个 Provider 的重试逻辑各写一份,细节已经开始漂移(退避序列 2/4/8 vs 4/8/16,见第四节)。改进方向:抽共享的重试工具函数,用同一组测试钉住行为;顺带把"流式不重试"的边界也统一表达出来。
- agent.json 没有内联凭证字段 ------接新供应商必须先在
llm.providers声明槽位。这是有意的设计(凭证集中管理、不散落在各 agent 定义里),但"一键新增槽位"的配置页体验还有空间。
小结
Provider 层用 BaseProvider 抽象 + LLMMessage/ToolDefinition/LLMResponse 统一类型把 Agent 核心和供应商 SDK 隔开(适配器模式,依赖抽象不依赖具体);AnthropicProvider 做双向翻译(system 单独传、返回块数组分别提取 text 与 tool_use)、只对 429/529 做指数退避重试(2/4/8 秒最多 3 次,signal.aborted 优先不重试,流式不重试)、把取消 signal 透传给 SDK 中断 HTTP;装配侧是 llm.providers 槽位表 + resolveModel(agentModel, agentProvider) 直建------槽位自持 api_key/base_url/model/协议,api 字段二选一实例化 AnthropicProvider / OpenAICompatProvider(后者裸 fetch 走 /chat/completions,另做一套格式转换与 SSE 流式解析),每个 agent 各持一个实例、不同 agent 指不同槽位即可接不同供应商账号,聊天窗还能用 model_hint/provider_hint 做单轮覆盖。协议差异封在层内、统一类型跨层流转,是"换供应商只改 Provider"这个承诺的全部秘密。