diff --git a/fam-edge/config/config.yaml b/fam-edge/config/config.yaml index 20fc9b1..10cfbed 100644 --- a/fam-edge/config/config.yaml +++ b/fam-edge/config/config.yaml @@ -63,6 +63,13 @@ models: - "${GEMINI_API_KEY_2}" - "${GEMINI_API_KEY_3}" - "${GEMINI_API_KEY_4}" + # 每个 key 对应的 Google Cloud 项目名(顺序对应上面 api_key + extra_api_keys), + # 用于模型统计页展示是哪个项目在跑,不填则退回 "key1/key2/..." + key_labels: + - "智能摄像头-1" + - "智能摄像头-2" + - "智能摄像头-3" + - "智能摄像头-4" timeout: 600 # 模型级独立超时(最终值,不参与编排层 ×2 放大) # gemini-flash-lite 实测 ~22-34s,按用户要求放宽至 8 分钟(480s),避免大视频/排队时过早切断 diff --git a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py index cb2f508..d18c4ed 100644 --- a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py @@ -9,27 +9,32 @@ provider_name = "gemini" 整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON (本地不切片、不抽帧;Gemini 原生支持长视频) -多 Key 轮换(2026-08-21 新增,2026-08-22 改为真正均摊负载): 不同 Google Cloud -项目的 API Key 各自独立计费/配额,config 的 `api_key` 为主 Key,`extra_api_keys` -可以再配多个(各自项目的 Key)。outer loop 按 Key 顺序尝试,inner loop 才是原有 -的模型 fallback 链——因为 Gemini Files API 上传的文件只能被同一个 Key/项目引用, -换 Key 必须重新上传,所以每个 Key 都要重走一遍"上传 -> 模型链尝试 -> 删除"。 +多 Key 轮换(2026-08-21 新增,2026-08-22 改为均摊负载,2026-08-22 再改为真随机): +不同 Google Cloud 项目的 API Key 各自独立计费/配额,config 的 `api_key` 为主 +Key,`extra_api_keys` 可以再配多个(各自项目的 Key)。outer loop 按 Key 顺序 +尝试,inner loop 才是原有的模型 fallback 链——因为 Gemini Files API 上传的文件 +只能被同一个 Key/项目引用,换 Key 必须重新上传,所以每个 Key 都要重走一遍 +"上传 -> 模型链尝试 -> 删除"。 实测发现的问题: 原实现每次都从 api_keys[0] 开始试,只有 0 号 key 的所有模型全部 失败才会换下一个 key;但 flash-lite 兜底通常最终能成功,导致 0 号 key 几乎揽下 -全部流量,其余 3 个 key 常年闲置——完全没有起到分摊配额的作用。现在改为 -_rotated_keys():每次 analyze_video()/chat() 调用都从"上一次的下一个 key"开始 -试起,调用结束(无论成败)就把起点往后挪一位,多次调用下来自然把请求均匀摊到 -所有配置的 key 上,而不是"谁在前面谁扛所有流量"。 +全部流量,其余 3 个 key 常年闲置——完全没有起到分摊配额的作用。 -每次实际使用的 key 会以 "key{N}"(N 从 1 开始,对应 api_keys 里的原始下标)的 -形式拼进 model_calls.model 字段(如 "gemini-flash-latest·key2"),这样现有的 -按 provider+model 分组统计(fam-core ui_api.py 的 /api/ui/model-stats)不用改 -schema 就能天然按 key 拆开显示,不需要新增字段/新迁移。 +先改成过顺序轮转(每次从上次的下一个 key 起手),后应用户要求改为 +_rotated_keys():每次调用都用 random.randrange 随机抽一个起点(每个 key 命中 +概率均等 1/n),从起点开始按顺序把全部 key 过一遍(仍然保留"当前 key 全部模型 +失败就级联到下一个 key"的兜底逻辑,只是起点是真随机而不是顺序推进)。 + +每次实际使用的 key 会以 key_labels 里配置的标签(未配置则退回 "key{N}",N 从 1 +开始对应 api_keys 里的原始下标)拼进 model_calls.model 字段(如 +"gemini-flash-latest·智能摄像头-2"),这样现有的按 provider+model 分组统计 +(fam-core ui_api.py 的 /api/ui/model-stats)不用改 schema 就能天然按 key 拆 +开显示,不需要新增字段/新迁移。 """ import os import time import json +import random import requests from datetime import datetime, timezone, timedelta from typing import Dict, List, Optional @@ -54,15 +59,20 @@ class GeminiAdapter(BaseModelAdapter): m for m in config.get('fallback_models', []) if m and m != self.model_name] # 多 Key 轮换:主 key + extra_api_keys(各自独立项目/配额),去重保序 raw_keys = [config.get('api_key', '')] + list(config.get('extra_api_keys', []) or []) + # key_labels 按同样顺序配置每个 key 对应的 Google Cloud 项目名(如"智能摄像 + # 头-1"),用于 model_calls 统计里标注具体哪个项目在跑;不配置则退回 "key{N}" + raw_labels = list(config.get('key_labels', []) or []) seen = set() self.api_keys = [] - for k in raw_keys: + self.key_labels = [] + for i, k in enumerate(raw_keys): resolved = self._resolve_key(k) if resolved and resolved not in seen: seen.add(resolved) self.api_keys.append(resolved) + label = raw_labels[i] if i < len(raw_labels) and raw_labels[i] else f"key{len(self.api_keys)}" + self.key_labels.append(str(label)) self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法 - self._key_rotation_idx = 0 # 下一次调用从哪个 key 起手(轮转,均摊负载用) self.timeout = config.get('timeout', 600) # 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds} # 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置) @@ -82,14 +92,14 @@ class GeminiAdapter(BaseModelAdapter): return raw def _rotated_keys(self): - """按当前轮转起点排序的 (原始下标从0开始, key) 列表;每调一次就把起点挪 - 到下一个 key,多次调用下来把流量均匀摊到全部配置的 key 上。""" + """每次调用随机抽一个起点(每个 key 命中概率均等 1/n),从起点开始按 + 顺序把全部 key 过一遍,返回 (原始下标从0开始, key) 列表;起点当前 key + 全部模型都失败时,调用方会级联试列表里的下一个 key。""" n = len(self.api_keys) if n == 0: return [] - start = self._key_rotation_idx % n + start = random.randrange(n) order = list(range(start, n)) + list(range(0, start)) - self._key_rotation_idx = (start + 1) % n return [(i, self.api_keys[i]) for i in order] def health_check(self) -> bool: @@ -133,7 +143,7 @@ class GeminiAdapter(BaseModelAdapter): prompt = self._build_video_prompt(known_members_context, event_start_time) last_err = "no_key_available" for idx, key in self._rotated_keys(): - key_label = f"key{idx + 1}" + key_label = self.key_labels[idx] file_uri, file_name = self._upload_file(video_path, key) if not file_uri: self._delete_file(file_name, key) # 即使未等到 ACTIVE,也尽力清理 @@ -412,7 +422,7 @@ class GeminiAdapter(BaseModelAdapter): """纯文本 generateContent,按 key 轮换(同 analyze_video 共用一套轮转起点) × 模型 fallback 链依次尝试。""" for idx, api_key in self._rotated_keys(): - key_label = f"key{idx + 1}" + key_label = self.key_labels[idx] for model in self.model_chain: try: resp = requests.post( diff --git a/fam-edge/tests/test_gemini_adapter.py b/fam-edge/tests/test_gemini_adapter.py index 4a258cd..60171e9 100644 --- a/fam-edge/tests/test_gemini_adapter.py +++ b/fam-edge/tests/test_gemini_adapter.py @@ -40,28 +40,68 @@ def test_no_keys_at_all(): assert a.api_key == "" -def test_rotated_keys_starts_at_zero_first_call(): +def test_key_labels_default_to_generic_when_not_configured(): + a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3"])) + assert a.key_labels == ["key1", "key2", "key3"] + + +def test_key_labels_use_configured_project_names(): + a = GeminiAdapter(_cfg( + extra_api_keys=["key-2", "key-3", "key-4"], + key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"], + )) + assert a.key_labels == ["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"] + + +def test_key_labels_stay_aligned_when_a_key_is_dropped(): + """如果某个 ${ENV_VAR} 没设置被丢弃,剩下的 key_labels 要跟着剩下的 key 对齐, + 不能因为下标错位把别的项目名安到错的 key 上。""" + a = GeminiAdapter(_cfg( + extra_api_keys=["${SOME_UNSET_GEMINI_KEY_VAR}", "key-2"], + key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3"], + )) + assert a.api_keys == ["key-primary", "key-2"] + assert a.key_labels == ["智能摄像头-1", "智能摄像头-3"] + + +def test_rotated_keys_cascades_from_random_start(monkeypatch): + """起点由 random.randrange 决定;固定住随机数就能验证级联顺序是"从起点绕一圈"。""" a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"])) + monkeypatch.setattr( + "fam_edge.model_adapters.gemini_adapter.random.randrange", lambda n: 2) order = a._rotated_keys() - assert [k for _, k in order] == ["key-primary", "key-2", "key-3", "key-4"] - assert [i for i, _ in order] == [0, 1, 2, 3] + assert [k for _, k in order] == ["key-3", "key-4", "key-primary", "key-2"] + assert [i for i, _ in order] == [2, 3, 0, 1] -def test_rotated_keys_advances_each_call_evenly(): - """核心诉求: 连续调用应该轮流从不同 key 起手,而不是每次都从 0 号开始 - (旧实现的 bug:0 号 key 的 fallback 模型通常最终能成功,导致其余 key 常年闲置)。""" +def test_rotated_keys_uses_full_key_range(monkeypatch): + """random.randrange 的调用范围必须是 len(api_keys),否则会漏掉某些 key 永远选不到。""" a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"])) - starts = [a._rotated_keys()[0][1] for _ in range(4)] - assert starts == ["key-primary", "key-2", "key-3", "key-4"] - # 转满一圈后应该回到起点 - assert a._rotated_keys()[0][1] == "key-primary" + seen_n = [] + + def fake_randrange(n): + seen_n.append(n) + return 0 + + monkeypatch.setattr( + "fam_edge.model_adapters.gemini_adapter.random.randrange", fake_randrange) + a._rotated_keys() + assert seen_n == [4] -def test_rotated_keys_wraps_around_correctly(): +def test_rotated_keys_starts_roughly_uniform_over_many_calls(): + """核心诉求: 每次调用应该是真随机(每个 key 命中概率均等 1/n),而不是像旧的 + round-robin 那样顺序推进——用大样本统计每个 key 被选为起点的频率,应该接近 + 1/4,且不应该出现某个 key 明显被冷落或独占(对应此前"全部流量压在同一个 + key"的 bug)。""" a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"])) - a._key_rotation_idx = 3 # 手动模拟"上次从 4 号 key 起手" - order = a._rotated_keys() - assert [k for _, k in order] == ["key-4", "key-primary", "key-2", "key-3"] + n_trials = 4000 + counts = {"key-primary": 0, "key-2": 0, "key-3": 0, "key-4": 0} + for _ in range(n_trials): + counts[a._rotated_keys()[0][1]] += 1 + for key, c in counts.items(): + share = c / n_trials + assert 0.20 <= share <= 0.30, f"{key} 起点占比 {share} 明显偏离 1/4" def test_rotated_keys_single_key_never_errors(): diff --git a/fam-ui/src/views/ModelStats.vue b/fam-ui/src/views/ModelStats.vue index 24ee96e..015b0b3 100644 --- a/fam-ui/src/views/ModelStats.vue +++ b/fam-ui/src/views/ModelStats.vue @@ -11,10 +11,11 @@ const calls = ref([]) const loadError = ref('') const loading = ref(true) -// Gemini 适配器把实际用的 key 编号拼进 model 字段(如 "gemini-flash-latest·key2"), -// 这里拆出来单独显示成一个小徽章,而不是让用户在一长串字符串里自己找。 +// Gemini 适配器把实际用的 key 标签拼进 model 字段(如 "gemini-flash-latest·智能摄像头-2", +// 未配置项目名时退回 "gemini-flash-latest·key2"),这里拆出来单独显示成一个小徽章, +// 而不是让用户在一长串字符串里自己找。模型名本身不含 "·",按最后一个分隔符切开即可。 function splitKey(model) { - const idx = (model || '').lastIndexOf('·key') + const idx = (model || '').lastIndexOf('·') if (idx === -1) return { model, key: '' } return { model: model.slice(0, idx), key: model.slice(idx + 1) } } @@ -126,8 +127,9 @@ onMounted(async () => {
- 统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。Key 列只显示配置里第几个 - Gemini Key 被用到(key1=主 key,key2/3/4=备用 key),不会显示密钥原文。 + 统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。Key 列显示该次调用 + 用的是哪个 Gemini 项目(配置了 key_labels 就显示项目名,如"智能摄像头-2";未配置则显示 + key1/key2/...),不会显示密钥原文。 失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。