news 2026/9/17 7:11:20

ai-memory 的 LLM Provider 有序故障转移链:从 `llm_fallbacks` 配置到熔断与健康观测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ai-memory 的 LLM Provider 有序故障转移链:从 `llm_fallbacks` 配置到熔断与健康观测

ai-memory 的 LLM Provider 有序故障转移链:从llm_fallbacks配置到熔断与健康观测

【免费下载链接】ai-memorySolution for long term memory for agent coding CLIs and to facilitate handoff between different agent vendors项目地址: https://gitcode.com/GitHub_Trending/ai/ai-memory

导读

ai-memory 通过一个Arc<dyn LlmProvider>抽象来驱动 bootstrap、consolidation、lint、review 等所有 LLM 依赖操作。当单一上游发生限流或宕机时,即使系统里还配置着另一个健康的 provider,任务依然会降级失败。本文围绕设计文档 docs/llm-provider-fallback.md(issue #648)展开,结合其在仓库中的完整实现,讲解如何通过[[llm_fallbacks]]配置一条有序的、opt-in 的 provider 故障转移链:只对瞬时故障(429 / 5xx / 超时 / 连接错误)切换候选,同时严格保留原始请求、JSON schema 与逻辑操作 id,并通过进程内熔断和被动健康上报让故障转移结果可观测。读完本文,你将掌握该链路的完整配置语法、失败判定策略、熔断语义以及从源码到测试的验证路径。

问题背景:为什么单一 provider 的 retry 不够

在引入故障转移之前,服务端为每个 LLM 操作只构造一个Arc<dyn LlmProvider>LlmProvidertrait 定义在 crates/ai-memory-llm/src/provider.rs,是整个工作区依赖的唯一 LLM 抽象,暴露四个入口点:

  • complete:普通文本补全;
  • complete_with_operation_id:携带既有逻辑操作 id 的文本补全;
  • complete_structured_raw:受 JSON schema 约束的结构化补全(原始 schema 进、原始值出);
  • complete_structured_raw_with_operation_id:结构化补全 + 操作 id。

原有的 retry 循环可以对同一个 provider重试瞬时失败,却无法在重试之间切换到第二个已配置的 provider。因此,一次上游限流或宕机,即使另一个 provider 完全健康,bootstrap、consolidation、lint、review 等操作也会一并降级。这正是设计文档开篇定义的 Problem。

设计目标与非目标

文档明确了五个 Goals:

  1. 仅在瞬时失败后切换:沿用既有的LlmError::is_transient()判定策略;
  2. 保留请求原貌:每个候选尝试都携带原始 request、JSON schema 与逻辑操作 id;
  3. 凭证安全:凭证仍在一次性配置加载中解析,不进入日志、status 载荷或持久化状态;
  4. 无全局超时策略:每个候选使用各自配置的请求超时;
  5. 可观测:通过被动的 provider-health 上报暴露“选中了哪个候选、是否发生过故障转移”。

同时明确 Non-goals:不为任意第三方 API 或 Command Code 集成做路由、不从其他应用数据库读凭证、不安装 provider CLI、不重试确定性失败(鉴权、非法请求、不支持的 schema、畸形响应),第一版实现也不改动 embeddings、hook 延迟、wiki/存储行为或 MCP schema。

配置形态:config.toml中的[[llm_fallbacks]]

顶层 LLM 字段(llm_provider/llm_model)仍是主 profile,故障转移链通过 TOML 数组段配置:

llm_provider = "opencode" llm_model = "mimo-v2.5-free" [[llm_fallbacks]] provider = "openai-compat" model = "poolside/laguna-s-2.1-free" base_url = "http://127.0.0.1:49375/v1" api_key_env = "AI_MEMORY_LOCAL_ROUTER_TOKEN" [[llm_fallbacks]] provider = "gemini" model = "gemini-3.5-flash" api_key_env = "GEMINI_API_KEY"

配置要点(均可在源码中得到印证):

  • provider:与llm_provider相同的 wire 名称集合。在 crates/ai-memory-cli/src/config.rs 的fallback_provider_config中,通过provider_choice_from_str解析,可选值包括anthropicopenaigeminiopenai-compatopenai-oauthcopilotanthropic-oauthopencode。无法识别的值直接报llm_fallbacks[i].provider=... is not one of ...,启动即失败。
  • model:候选模型 id,必须非空(llm_fallbacks[i].model must not be empty)。
  • base_url:可选。对openai-compat必填(源码build_provider对缺失 base_url 返回LlmError::NotConfigured)。从实现看,openai-compatopencode是仅有的两个“端点由运维决定”的方言(ProviderChoice::endpoint_is_operator_chosen()),其余 provider 都指向固定厂商主机。
  • api_key_env环境变量名,而非凭证明文——这是设计文档强调的“profile 携带 base URL 与凭证环境变量名、不把凭证值写进 config”。对需要 API key 的 provider(如gemini需要GEMINI_API_KEY)必须显式设置;仅当 provider 本身有原生凭证来源(OpenAI OAuth、Copilot、Anthropic OAuth)时可以省略。

启动期即校验,绝不留下“潜伏的故障转移”

设计文档强调:loader 校验每个 profile 并一次性解析全部凭证材料;凭证缺失、provider 非法或 profile 畸形都应在启动时失败,而不是等故障发生时才发现备份不可用。这一点在 crates/ai-memory-cli/src/config.rs 的Config::load中有完整实现:遍历每个llm_fallbacks条目,读取api_key_env指向的环境变量(缺失即bail),经fallback_provider_config校验后立即调用build_provider构造并丢弃,只为让畸形配置在启动阶段暴露。

尤其值得注意的细节:fallback_provider_auth与主 provider 的provider_auth刻意区分。主 provider 在未显式指定 key 时可以回退到其固定的环境变量(如ANTHROPIC_API_KEY),而 fallback profile 绝不隐式继承主 provider 的凭证——否则一个漏写api_key_env的 profile 会悄悄复用主 provider 的 key,从而绕过“启动即失败”的校验。原生凭证源(OAuth/Copilot)天然是进程级的,仍与主 provider 共享。

语义:append-only 有序链,暂无环境变量简写

初始语义是 append-only:主 provider 先跑,随后按声明顺序尝试 fallbacks。设计文档明确不新增环境变量简写,直到能无歧义地编码 profile 边界与凭证名——源码中也注释了 figment 无法从AI_MEMORY_*环境变量往返Vec<Struct>,且把多个 profile 的凭证名编码进单一环境变量会产生歧义。因此[[llm_fallbacks]]只能写在config.toml中。

实现边界:FallbackLlmProvider与薄 CLI

设计文档给出的数据流为:

Config::load -> primary ProviderConfig + fallback ProviderConfig values -> build_provider for each value -> FallbackLlmProvider(Vec<Candidate>) -> existing Arc<dyn LlmProvider> consumers

实现位于 crates/ai-memory-llm/src/fallback.rs:

  • Candidate:链上的单个候选,只保存provider&'static str)、modelString)与已构造的Arc<dyn LlmProvider>——绝不持有原始配置或凭证build_provider(定义在 crates/ai-memory-llm/src/factory.rs)依旧是每个候选唯一且仅有的构造路径。
  • FallbackLlmProvider:持有Vec<CandidateEntry>(每个 entry 额外带一个Mutex<CandidateState>记录成功/失败时间戳、错误类别与熔断截止),并实现LlmProvider的全部四个入口点。每个方法都按声明顺序遍历候选:circuit_open(i)为真则跳过;调用candidate.inner.<same_method>成功则record_success返回;失败则记录错误,若是瞬时错误继续下一个,否则立即返回。
  • 结构化调用把未改动的 schema 传给每个候选;operation-aware 调用把同一个LlmOperationId传给每个候选——保持调用方 retry 与幂等语义不变。
  • 测试 fallback.rs 中的all_four_methods_preserve_request_schema_and_operation_idScriptedLlm双桩验证:主候选瞬时失败后,次候选必须看到完全相同的请求(含max_tokens=42)、schema 与None / Some(op_id) / None / Some(op_id)的操作 id 序列。

CLI 保持薄层:Config::llm_provider_chain()(config.rs)返回None(未配置 LLM)、单个 provider(无 fallback,保持既有单 provider 行为),或包装了[primary, fallbacks...]FallbackLlmProvider。调用方拿到的仍然是Option<Arc<dyn LlmProvider>>。零-LLM 路径与既有单 provider 路径完全不受影响。

serve中,装配点位于 crates/ai-memory-cli/src/commands/serve.rs:llm_provider_chain()构造出 provider 后,再由ProviderHealth::wrap_llm_provider包上被动健康记录器。而llm-test命令(crates/ai-memory-cli/src/commands/llm_test.rs)走build_provider直接构造单个 provider,用于手动验证端点行为——这也呼应了文档交付序列第 4 步“针对先返回瞬时失败、再返回合法结构化补全的本地 OpenAI 兼容端点手动跑llm-test”。

失败策略与熔断状态

设计文档的失败分类表在实现中由LlmError::is_transient()(crates/ai-memory-llm/src/error.rs)精确承载:

失败类型切换下一候选?理由
429既有瞬时策略
5xx既有瞬时策略
超时 / 连接错误既有瞬时策略
400 / 401 / 403 / 404 / 422通常是请求、能力、模型或凭证配置问题
schema / 响应形状 / 反序列化错误相同输入必然再次确定性失败

源码中is_transient()的实现为:Provider { status, .. }status == 429 || (500..=599).contains(status),或Http(e)e.is_timeout() || e.is_connect();其余(Auth、Schema、Serde、UnexpectedShape、NotConfigured、AllCandidatesFailed)一律非瞬时。注意它覆盖了 Cloudflare 风格的52x520/524在测试中显式验证为瞬时)。设计文档与错误类型注释都强调:调用方必须保持 retry短且有界(几次尝试、间隔数秒),这不是 tenacity 式 8–128 秒退避的许可——源码注释明确引用了 cognee #2840 的教训。

熔断语义(源码CandidateState.circuit_open_until):

  • 候选被调用前先检查进程内熔断,以(provider, model)为键;
  • 瞬时错误将该候选的熔断打开一个有界冷却期CIRCUIT_COOLDOWN = Duration::from_secs(30)(30 秒),其他候选不受影响;
  • 一次成功响应立即关闭该候选的熔断(测试a_success_closes_an_open_circuit_immediately验证即使把circuit_open_until人为推到 600 秒后,一次record_success也会立刻清空);
  • 冷却期自然过期也会关闭熔断,无需等到下一次调用(测试an_elapsed_cooldown_closes_the_circuit_without_an_explicit_success);
  • 重启服务端清空所有熔断;初始实现没有持久化熔断状态,也没有独立的强制请求截止时间。

测试an_open_circuit_skips_only_its_candidate精确验证:主候选第一次瞬时失败后熔断打开,冷却期内第二次调用主候选的调用计数保持 1(完全跳过、不再尝试),次候选计数递增。

当所有合格候选都失败(或所有候选的熔断都打开)时,wrapper 返回有界的聚合错误LlmError::AllCandidatesFailed { attempted, summary }:只含 provider/model 标签与错误类别(可附带 HTTP 状态),绝不包含响应体或秘密。测试all_candidates_failing_returns_a_bounded_aggregate_error断言 summary 包含primary/m1secondary/m2500503,同时!summary.contains("secret body")attempted计数排除被跳过的熔断候选。

被动健康上报:故障转移结果可观测

设计文档要求“让选中的候选与故障转移结果通过被动 provider-health 上报可观测”,同时强调 status 只记录服务端已经发起过的调用,不去探测 fallback,也不触发后台恢复流量。

实现分两层:

  • ProviderHealth(crates/ai-memory-llm/src/health.rs)保留了顶层llm/embedding两个 role 的既有快照字段(兼容旧字段),新增llm_candidates: Vec<CandidateHealth>#[serde(default)],旧服务端响应也能被新 CLI 反序列化)。ProviderHealth额外持有一个Arc<Mutex<Option<Arc<dyn LlmProvider>>>>,仅在snapshot()时读取candidate_health()——普通 provider 返回空,FallbackLlmProvider返回有序候选列表。
  • CandidateHealth每个候选包含:provider 与 model 标签;last_selected(是否应答了最近一次完成的调用,由last_selected: Mutex<Option<usize>>提供);last_success_at/last_error_at时间戳;脱敏的last_error_status(HTTP 状态)与last_error_class(来自LlmError::class()的稳定短标签,如providerhttpauthschemaserdeunexpected-shapeall-candidates-failed);circuit_open_until(仅当冷却期确实未过期才上报,避免把过期未重置的时间戳误读为“仍打开”)。

健康快照中的脱敏是彻底的:只记录标签、时间戳、HTTP 状态与短错误类别,永不记录响应体或凭证。candidate_health的测试断言format!("{health:?}")不包含"must not leak"

status命令(crates/ai-memory-cli/src/commands/status.rs)中,当report.providers.llm_candidates非空时,会按声明顺序逐行渲染每个候选:标签、是否应答了最近一次调用、最近成功/失败时间、脱敏错误类别与状态、熔断打开截止时间。运维人员无需主动探测,即可从一次 status 调用中看到“当前实际由哪个候选在服务”“哪个候选最近失败过”“哪个候选正处于熔断冷却中”。

测试策略:从配置校验到链路语义

设计文档列出了 7 类测试,仓库中均已落地:

  1. 链序chain_tries_candidates_in_order_and_stops_at_first_success验证按声明顺序尝试并在首个成功处停止。
  2. 四入口语义保持all_four_methods_preserve_request_schema_and_operation_id(见上文)。
  3. 瞬时/确定性分流transient_statuses_advance_to_the_next_candidate[429, 500, 502, 503, 504]逐一断言会推进;deterministic_errors_stop_on_the_first_candidate400/401/403/404/422/Schema/Serde/UnexpectedShape断言次候选调用数为 0 且错误类别原样传播。
  4. 熔断an_open_circuit_skips_only_its_candidatea_success_closes_an_open_circuit_immediatelyan_elapsed_cooldown_closes_the_circuit_without_an_explicit_success
  5. 配置校验:在 crates/ai-memory-cli/src/config.rs 的测试模块中,通过load_with_toml直接喂 TOML 字符串(不读真实主目录)验证:空 provider / 空 model / 未知 provider 均被拒;openai-compat无 base_url 被拒;api_key_env指向的环境变量缺失被拒;fallback 不隐式继承主 provider 凭证(load_rejects_a_fallback_that_would_otherwise_inherit_the_primary_credential);以及无主 provider 时 fallback 仍合法llm_provider_chain返回None,因为链只通过它被消费)。
  6. 健康快照candidate_health_reports_labels_last_selected_and_redacted_errors断言候选标签、last_selected、脱敏错误类别与熔断字段,并验证渲染结果不含秘密。
  7. 单 provider 兼容llm_provider_chain_is_the_plain_provider_when_no_fallback_is_configured验证无 fallback 时返回普通 provider 且candidate_health()为空;llm_fallbacks_default_to_empty_and_no_behavior_change保证默认配置字节级无行为变化;llm_provider_chain_wraps_the_primary_and_every_fallback_in_order则端到端验证Config::load -> llm_provider_chain按序包装主 provider 与全部 fallbacks。

交付序列与落地现状

设计文档给出的交付序列:

  1. 新增 profile 解析、校验与测试——无 fallback profile 时零行为变化(已由llm_fallbacks_default_to_empty_and_no_behavior_change覆盖);
  2. 新增FallbackLlmProviderwrapper 与聚焦的 fake-provider 测试(fallback.rs 完整实现并附ScriptedLlm测试双桩);
  3. 接入serve、扩展被动健康并补充配置/状态文档与CHANGELOG.md条目(serve装配点见 serve.rs);
  4. 跑工作区 gates,并针对“先瞬时失败、后返回合法结构化补全”的本地 OpenAI 兼容端点手动执行llm-test

需要说明的是,设计文档本身是一份proposal(issue #648),而当前仓库已经完整实现了其中描述的机制,fallback.rs模块文档明确注明“Seedocs/llm-provider-fallback.md(issue #648) for the full design”——因此本文以上内容均以落地后的真实源码行为为准。

实战建议

  • 对自托管聚合端点(Ollama / vLLM / LM Studio / 本地路由器),将openai-compat作为 fallback 时务必同时给出base_urlapi_key_envapi_key_env指向的环境变量在服务启动时就必须已设置,否则进程直接拒绝启动。
  • 不要把需要鉴权的 provider 与主 provider 混用同一环境变量:fallback 的凭证是隔离解析的,漏配会在启动时报错而非宕机时才暴露。
  • 30 秒熔断冷却期是进程内、按(provider, model)粒度的:一次成功即可立即恢复该候选,无需等待冷却结束。
  • 观测入口是ai-memory status中的llm_candidates段:关注last_selected是否长期停留在 fallback 上、last_error_class/last_error_status是否反复出现、circuit_open_until是否持续非空——这些是被动采集的真实调用结果,不产生任何额外探测流量。
  • 若所有候选持续失败,错误为有界的llm fallback chain exhausted after N candidate(s): provider/model: class [status]; ...聚合信息,其中只含标签与错误类别,不含任何响应体或秘密。

【免费下载链接】ai-memorySolution for long term memory for agent coding CLIs and to facilitate handoff between different agent vendors项目地址: https://gitcode.com/GitHub_Trending/ai/ai-memory

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 7:08:12

从“11asff”到工程化:临时项目如何做成可维护的代码仓库

刚拿到“11asff”这个项目代号时&#xff0c;估计很多人都跟我一样愣了几秒。它既不像“cloud-native-platform”那样一眼看懂业务方向&#xff0c;也不像“pay-service”那样直接暴露系统职能&#xff0c;看上去就是随手在键盘上敲出来的一个随机字符串。但如果你在代码仓库里…

作者头像 李华
网站建设 2026/9/17 7:08:10

VSCode 转到定义失效排查:从语言模式到索引配置

1. 先别急着改配置&#xff1a;搞清"转到定义"到底是谁在干活上周帮同事看一个 C 项目&#xff0c;他抱怨 VScode 里按 F12 完全没反应&#xff0c;气得差点换回老 IDE。我过去看了一眼&#xff0c;右下角的语言模式赫然写着Plain Text——文件根本就没被当成 C 来解…

作者头像 李华
网站建设 2026/9/17 7:08:02

Git SSH免密配置实战:从密钥生成到clone与push全流程

很多人在用Git和GitHub Desktop的时候都遇到过这个场景&#xff1a;用HTTPS方式clone或者push&#xff0c;终端里反复弹窗要输入用户名和密码&#xff0c;一旦开启了双重认证还得去生成Personal Access Token&#xff0c;粘来粘去非常麻烦&#xff1b;换成GitHub Desktop倒是能…

作者头像 李华
网站建设 2026/9/17 7:08:02

SpringBoot+Vue3医疗挂号系统开发实践

1. 项目概述与背景作为一名经历过多次医疗系统开发的老码农&#xff0c;我深知传统医院挂号系统的痛点。记得去年陪家人去三甲医院就诊&#xff0c;早上6点排队取号&#xff0c;等到9点才挂上下午的号&#xff0c;这种体验促使我着手开发这套在线挂号系统。系统采用SpringBootV…

作者头像 李华
网站建设 2026/9/17 7:07:49

基于SpringBoot的招投标系统设计与实现

1. 项目背景与核心价值招投标系统作为企业采购和项目发包的重要工具&#xff0c;在工程建筑、IT服务、政府采购等领域有着广泛应用。传统招投标流程存在信息不对称、流程不透明、效率低下等问题&#xff0c;而基于SpringBoot的招投标系统能够有效解决这些痛点。这个毕业设计项目…

作者头像 李华