跳到主内容
星链API

AgentHub架构解析:LLM Provider适配器模式与重试策略实战

人工智能8,878
AgentHub架构解析:LLM Provider适配器模式与重试策略实战

构建 Agent 平台时,一个绕不开的工程问题摆在面前:ReAct 循环、工具调度、记忆管理等核心逻辑,不应该和任何 LLM 供应商的 SDK 调用形态、返回结构耦死。现实中,今天用 Anthropic,明天可能接一个兼容 OpenAI 协议的国产模型,后天可能要同时接两家让不同 Agent 各用各的。如果这些差异渗透进 Runner 和 Loop 层,每换一次供应商都是一次伤筋动骨的改造。

AgentHub 的 Provider 层正是为了解决这个问题而设计的。它用一个抽象基类 BaseProvider 搭配一组统一类型,把 Agent 核心和供应商 SDK 隔开。这篇文章将拆解这套设计的关键决策、代码实现与局限。

一、适配器模式:不引入这层会怎样

AgentHub 的 Runner 里只有一行核心调用:

const response = await this.provider.complete(system, history, tools, signal);

this.provider 的类型是 BaseProvider,Runner 不知道背后是 Claude 还是其他供应商。这是典型的适配器模式 —— 把供应商特有的 SDK 调用格式、返回块数组结构、重试策略封装进一个类,Agent 核心保持供应商无关,依赖抽象而非具体实现。

不加这层的后果很具体:换供应商要改 Runner 里所有 client.messages.create 调用点;返回格式解析会散落在 Runner 各处;重试逻辑在每个调用点重复编写。有了 Provider 层,换供应商的改动范围收敛为:新写一个 XxxProvider extends BaseProvider、在 resolveModel 的协议分支里加一路、配置里加一个槽位。AgentRunnerAgentLoopToolRegistryGatewayCore 一行不改。

如果目标供应商兼容 OpenAI 的 /chat/completions 协议 ——DeepSeek、GLM 官方 API 都属于这一类 —— 连新类都不用写,直接加一个 api: openai 的槽位即可。

二、统一类型系统:贯穿多层的 “通用语言”

Provider 层在 base.ts 中定义了一组统一类型。LLMMessage 的 role 只有 userassistant,刻意排除了 system,因为 Anthropic API 不接受 system 作为消息 role,system 走单独参数传递。ContentBlock 是一个联合类型,涵盖 TextBlockImageBlockDocumentBlockToolUseBlockToolResultBlock,其中工具调用和工具结果靠 tool_use_id 关联。

LLMResponse 包含 contenttool_callsstop_reasoninput_tokensoutput_tokens 五个字段。这组类型的价值在于:它们不止 Provider 在用,而是贯穿多层的 “通用语言”——ToolDefinitionToolRegistry.definitions() 产出,经 Provider 翻译给 LLM;stop_reason 返回给 Runner 判断 ReAct 循环是继续还是终止;input_tokens 返回给 Loop 判断是否触发 L2 Consolidator 压缩历史。

如果不做统一,SDK 的 MessageParam / Tool 类型就会渗透进 Runner、Loop、Consolidator,换供应商时这些层全得改。统一类型把 SDK 类型挡在 Provider 边界内,是 “换供应商只改 Provider” 这个承诺成立的前提。

三、AnthropicProvider:双向翻译与防御性设计

AnthropicProvidercomplete() 方法承担两个方向的翻译。

入参方向,统一类型转 SDK 格式时,system 单独传给 SDK 的 system 字段,不进 messages 数组;messages.map(toMessageParam) 把纯字符串 content 直接传,块数组 content 转成 SDK 块格式;tools as unknown as Tool[] 因统一类型和 SDK 类型结构兼容但 TypeScript 定义不同,用双重断言绕过类型检查。

返回方向,SDK 的 msg.content 是块数组,一轮输出可能同时含 text 块和 tool_use 块。Provider 将它们分别提取 —— 文本拼成 content 字符串,工具调用收集成 tool_calls 数组,Runner 拿到的是干净的两个字段。

有两个容易被忽略的防御细节。一是构造 client 时设 authToken: null,因为 Anthropic SDK 默认会从环境变量读 ANTHROPIC_AUTH_TOKEN 作为认证 token,可能和显式传入的 apiKey 冲突,显式设 null 禁掉这层隐式读取。二是缺 key 时的可操作报错 ——guardApiKey() 在缺 key 时抛出 “LLM API Key 未配置:请在配置页填写 API Key 后重试”,替代 SDK 原生的 “Could not resolve authentication method” 这类缺乏操作指引的报错。

max_tokens 被硬编码为 8096,代码即 8096,疑为 8192 的笔误。这个上限的作用是防止 LLM 失控输出超长回复。值得注意的是,它和槽位配置中的 timeout_per_step 是两回事,后者限制的是调用时长而非输出长度,而且目前实际没有接通。

四、重试策略:只重试 “等一下就会好” 的错误

AnthropicProvider 的重试逻辑有明确的设计取舍:

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; 
}

只重试 429 和 529,因为这两类错误属于 “瞬时可恢复”—— 限流和服务端过载的成因通常是服务端压力太大,等一下就好。重试太密只会加重压力,指数退避(2 / 4 / 8 秒,最多 3 次)给服务端喘息时间,也提高重试命中率。

signal.aborted 优先于重试判断。if (signal?.aborted) throw err 排在重试逻辑之前:用户发 /stop 取消时,即使当前遇到的是可重试错误,也要立即抛 AbortError,不能再走 2/4/8 秒的退避。这是把 “用户意图” 置于 “自动容错” 之上的优先级设计。

流式路径没有重试。streamComplete() 直接消费 SDK 事件流,中途出错不会重试。原因是流已经开始往客户端推 token,重跑一次要么重复推送、要么需要复杂的缓冲对账,收益撑不起复杂度。

一个真实的坑是:两个 Provider 的退避序列实际不一致。AnthropicProvider 是先算 delay 再 attempt++(2/4/8 秒);OpenAICompatProviderfetchWithRetry 里是先 attempt++ 再算 delay = 2000 * 2^attempt,实际退避成了 4/8/16 秒。行为没错,但和 Anthropic 侧的节奏差了一倍。教训很明确:同样的重试逻辑在两个类里各写一遍,细节必然漂移。更稳的做法是把重试策略抽成共享函数,或者至少用同一组测试钉住行为。

五、OpenAICompatProvider:另一套双向翻译

OpenAICompatProvider 覆盖 OpenAI /chat/completions 协议,对上实现同一个 BaseProvider 接口,Runner 完全无感。和 AnthropicProvider 的关键差异体现在几个层面。

不用 SDK,裸 fetch。直接 POST ${baseURL}/chat/completions,带 Authorization: Bearer 头。没有 authToken: null 那种防御,因为根本没有 SDK 会去偷读环境变量。

格式转换是另一套双向翻译。入参侧,assistant 的 tool_use 块转 tool_calls 数组,input 对象要 JSON.stringify 成 arguments 字符串;tool_result 块拆成独立的 role: "tool" 消息,靠 tool_call_id 关联。对比 Anthropic 侧,tool_result 是 user 消息里的一个块,system 是单独字段而非 messages 数组的第一条消息。返回侧,finish_reason 映射回 stop_reasontool_calls → tool_uselength → max_tokens、其他 → end_turn

重试条件更宽。isRetryable 放行 429、5xx 以及 fetch 网络失败的 TypeError—— 裸 fetch 没有 SDK 的错误类型体系,只能用 status 码和异常类型判。

流式是手写 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 核心一行没改。

六、槽位制装配:per-agent 可覆盖、缺省继承

AgentHub 经历了一次架构演进:从 “一个全局 agent 实例” 变成 “多个可配置 agent 实例并存”。Provider 装配也跟着从 “全局注册表” 演进成 “槽位表 + 直建函数”。

resolveModel(agentModel, agentProvider) 的解析语义分三层:槽位取 agent.json 的 provider,缺省时回退到 llm.default_provider,再缺省取 providers 第一项;模型取 agent.json 的 model,缺省用槽位默认 model;协议由槽位自声明的 api: anthropic | openai 决定实例化哪个类。

配错处理分两级,边界很清晰:Agent 指向不存在的槽位(配置重构删了旧槽位后存量 agent.json 的典型场景)只 warn 降级到默认槽位 —— 抛错会让 loadOne → loadAll 全挂、拖垮整个网关启动;但槽位本身缺失或没配 model 直接抛错,不做静默 fallback。

每个 Agent 在 AgentRegistry.loadOne 里各调一次 resolveModel,实例化自己专属的 provider 实例。子 Agent 的继承条件:写了 model 或 provider 就新建实例,两个都不写才直接复用父 Agent 的 provider 实例。聊天窗的模型选择器也配套了 —— 读 /api/configllm.providers 出下拉,选中的 model/provider 作为 model_hint / provider_hint 随消息上行,当回合临时实例化 provider + AgentLoop。

这种设计对多模型 API 网关的实践有参考意义。以星链 API 为例,它在路由层聚合多家模型供应商,而 AgentHub 的槽位制解决的是应用层的 per-Agent 供应商选择问题 —— 两者在不同的抽象层级上各自处理 “多供应商” 这一核心诉求。

七、职责边界与当前局限

Provider 层的职责很窄:单次 LLM 调用、重试、格式翻译。以下都不归它管:system prompt 组装归 ContextBuilder、历史文件块处理和 413 兜底归 Loop、ReAct 循环归 Runner、记忆压缩触发归 Loop、工具执行归 Runner + ToolRegistry、错误兜底成文案归 Loop。这种窄职责正是它可替换的前提。

当前有几个如实的局限。

  1. ProviderRegistry 类是死代码,定义还在但全代码库已无任何地方实例化或调用它,装配完全走 resolveModel 直建了。
  2. timeout_per_step 配置项没接通 ——schema 里有默认 30 秒,但两个 Provider 都没接,AnthropicProvider 的 complete 里既没 setTimeout 也没传 SDK 的 timeout 选项,OpenAICompatProvider 的 fetch 同样没有超时控制,单步超时目前只能靠外层 GatewayCore 的会话级锁超时兜底。
  3. 多处 as unknown as 双重断言(tools as unknown as Tool[] 等)因统一类型和 SDK 类型结构兼容但 TS 定义不同而绕过检查,运行时对但类型安全打了折扣。改进方向是给统一类型和 SDK 类型写显式映射函数替掉双重断言。
  4. 两个 Provider 的重试逻辑各写一份,细节已经开始漂移。改进方向是抽共享的重试工具函数,用同一组测试钉住行为。

小结

AgentHub 的 Provider 层用 BaseProvider 抽象 + LLMMessage / ToolDefinition / LLMResponse 统一类型把 Agent 核心和供应商 SDK 隔开。AnthropicProvider 做双向翻译(system 单独传、返回块数组分别提取 text 与 tool_use)、只对 429/529 做指数退避重试(2/4/8 秒最多 3 次,signal.aborted 优先不重试,流式不重试)、把取消 signal 透传给 SDK 中断 HTTP。OpenAICompatProvider 以裸 fetch 覆盖 OpenAI 协议,流式手写 SSE 解析。装配侧采用 “providers 槽位表 + resolveModel 直建”,每个 Agent 各持独立 provider 实例,支持 per-agent 覆盖与继承。

这套设计的核心价值不在于代码有多复杂,而在于把一个频繁变化的维度 ——LLM 供应商 —— 收敛到一个可替换的边界内。当协议适配和重试策略被封装在 Provider 层,Agent 核心就可以专注于 ReAct 循环、工具调度和记忆管理这些真正与业务逻辑相关的部分。对于需要统一管理多家 LLM 供应商调用的场景,无论是应用层的 Provider 抽象还是网关层的路由聚合,核心思路是一致的:用稳定的接口隔离易变的实现。

了解更多: https://xinglianapi.com/

LLMAPI集成Agent框架星链api代码实战

Related

相关文章推荐