feat(fam-edge): Gemini key 分配改真随机 + 按项目名标注统计

用户明确要求: 每次调用应该是 1/n 概率的真随机抽取,而不是上一版的顺序轮转
(round-robin 在当前单消费者串行处理场景下已经是数学最优均匀分配,但用户想要
"数组里随机取一个,每次都是四分之一概率"这种更直观的随机语义)。_rotated_keys()
去掉持久的轮转下标,改用 random.randrange 每次调用随机选起点,仍保留"起点 key
全部模型失败就级联到下一个 key"的兜底逻辑不变。

同时新增 key_labels 配置:4 个 key 分别对应用户在 Google Cloud 上开的 4 个项目
(智能摄像头-1/2/3/4),model_calls 统计现在按项目名而不是泛化的 key1/key2
展示,ModelStats 页面的 Key 列/拆分逻辑同步改为按最后一个 "·" 分隔(原来写死
按 "·key" 前缀查找,项目名场景下不适用)。

新增/重写 7 个单测覆盖:随机起点级联顺序、randrange 调用范围正确性、大样本
随机分布均匀性、key_labels 默认值/自定义值/key 被丢弃时的对齐。
This commit is contained in:
ericwyuan
2026-08-22 07:37:33 +08:00
parent 22df28f2d3
commit 708b6365f4
4 changed files with 99 additions and 40 deletions

View File

@@ -63,6 +63,13 @@ models:
- "${GEMINI_API_KEY_2}" - "${GEMINI_API_KEY_2}"
- "${GEMINI_API_KEY_3}" - "${GEMINI_API_KEY_3}"
- "${GEMINI_API_KEY_4}" - "${GEMINI_API_KEY_4}"
# 每个 key 对应的 Google Cloud 项目名(顺序对应上面 api_key + extra_api_keys
# 用于模型统计页展示是哪个项目在跑,不填则退回 "key1/key2/..."
key_labels:
- "智能摄像头-1"
- "智能摄像头-2"
- "智能摄像头-3"
- "智能摄像头-4"
timeout: 600 timeout: 600
# 模型级独立超时(最终值,不参与编排层 ×2 放大) # 模型级独立超时(最终值,不参与编排层 ×2 放大)
# gemini-flash-lite 实测 ~22-34s按用户要求放宽至 8 分钟480s避免大视频/排队时过早切断 # gemini-flash-lite 实测 ~22-34s按用户要求放宽至 8 分钟480s避免大视频/排队时过早切断

View File

@@ -9,27 +9,32 @@ provider_name = "gemini"
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON 整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
本地不切片、不抽帧Gemini 原生支持长视频) 本地不切片、不抽帧Gemini 原生支持长视频)
多 Key 轮换2026-08-21 新增2026-08-22 改为真正均摊负载: 不同 Google Cloud 多 Key 轮换2026-08-21 新增2026-08-22 改为均摊负载2026-08-22 再改为真随机):
项目的 API Key 各自独立计费/配额config 的 `api_key` 为主 Key`extra_api_keys` 不同 Google Cloud 项目的 API Key 各自独立计费/配额config 的 `api_key` 为主
可以再配多个(各自项目的 Key。outer loop 按 Key 顺序尝试inner loop 才是原有 Key`extra_api_keys` 可以再配多个(各自项目的 Key。outer loop 按 Key 顺序
的模型 fallback 链——因为 Gemini Files API 上传的文件只能被同一个 Key/项目引用, 尝试inner loop 才是原有的模型 fallback 链——因为 Gemini Files API 上传的文件
换 Key 必须重新上传,所以每个 Key 都要重走一遍"上传 -> 模型链尝试 -> 删除" 只能被同一个 Key/项目引用,换 Key 必须重新上传,所以每个 Key 都要重走一遍
"上传 -> 模型链尝试 -> 删除"
实测发现的问题: 原实现每次都从 api_keys[0] 开始试,只有 0 号 key 的所有模型全部 实测发现的问题: 原实现每次都从 api_keys[0] 开始试,只有 0 号 key 的所有模型全部
失败才会换下一个 key但 flash-lite 兜底通常最终能成功,导致 0 号 key 几乎揽下 失败才会换下一个 key但 flash-lite 兜底通常最终能成功,导致 0 号 key 几乎揽下
全部流量,其余 3 个 key 常年闲置——完全没有起到分摊配额的作用。现在改为 全部流量,其余 3 个 key 常年闲置——完全没有起到分摊配额的作用。
_rotated_keys():每次 analyze_video()/chat() 调用都从"上一次的下一个 key"开始
试起,调用结束(无论成败)就把起点往后挪一位,多次调用下来自然把请求均匀摊到
所有配置的 key 上,而不是"谁在前面谁扛所有流量"
每次实际使用的 key 会以 "key{N}"N 从 1 开始,对应 api_keys 里的原始下标)的 先改成过顺序轮转(每次从上次的下一个 key 起手),后应用户要求改为
形式拼进 model_calls.model 字段(如 "gemini-flash-latest·key2"),这样现有的 _rotated_keys():每次调用都用 random.randrange 随机抽一个起点(每个 key 命中
按 provider+model 分组统计fam-core ui_api.py 的 /api/ui/model-stats不用改 概率均等 1/n从起点开始按顺序把全部 key 过一遍(仍然保留"当前 key 全部模型
schema 就能天然按 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 os
import time import time
import json import json
import random
import requests import requests
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional 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] m for m in config.get('fallback_models', []) if m and m != self.model_name]
# 多 Key 轮换:主 key + extra_api_keys各自独立项目/配额),去重保序 # 多 Key 轮换:主 key + extra_api_keys各自独立项目/配额),去重保序
raw_keys = [config.get('api_key', '')] + list(config.get('extra_api_keys', []) or []) 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() seen = set()
self.api_keys = [] self.api_keys = []
for k in raw_keys: self.key_labels = []
for i, k in enumerate(raw_keys):
resolved = self._resolve_key(k) resolved = self._resolve_key(k)
if resolved and resolved not in seen: if resolved and resolved not in seen:
seen.add(resolved) seen.add(resolved)
self.api_keys.append(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.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
self._key_rotation_idx = 0 # 下一次调用从哪个 key 起手(轮转,均摊负载用)
self.timeout = config.get('timeout', 600) self.timeout = config.get('timeout', 600)
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds} # 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置) # 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
@@ -82,14 +92,14 @@ class GeminiAdapter(BaseModelAdapter):
return raw return raw
def _rotated_keys(self): def _rotated_keys(self):
"""按当前轮转起点排序的 (原始下标从0开始, key) 列表;每调一次就把起点挪 """每次调用随机抽一个起点(每个 key 命中概率均等 1/n从起点开始按
到下一个 key多次调用下来把流量均匀摊到全部配置的 key 上。""" 顺序把全部 key 过一遍,返回 (原始下标从0开始, key) 列表;起点当前 key
全部模型都失败时,调用方会级联试列表里的下一个 key。"""
n = len(self.api_keys) n = len(self.api_keys)
if n == 0: if n == 0:
return [] return []
start = self._key_rotation_idx % n start = random.randrange(n)
order = list(range(start, n)) + list(range(0, start)) 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] return [(i, self.api_keys[i]) for i in order]
def health_check(self) -> bool: def health_check(self) -> bool:
@@ -133,7 +143,7 @@ class GeminiAdapter(BaseModelAdapter):
prompt = self._build_video_prompt(known_members_context, event_start_time) prompt = self._build_video_prompt(known_members_context, event_start_time)
last_err = "no_key_available" last_err = "no_key_available"
for idx, key in self._rotated_keys(): 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) file_uri, file_name = self._upload_file(video_path, key)
if not file_uri: if not file_uri:
self._delete_file(file_name, key) # 即使未等到 ACTIVE也尽力清理 self._delete_file(file_name, key) # 即使未等到 ACTIVE也尽力清理
@@ -412,7 +422,7 @@ class GeminiAdapter(BaseModelAdapter):
"""纯文本 generateContent按 key 轮换(同 analyze_video 共用一套轮转起点) """纯文本 generateContent按 key 轮换(同 analyze_video 共用一套轮转起点)
× 模型 fallback 链依次尝试。""" × 模型 fallback 链依次尝试。"""
for idx, api_key in self._rotated_keys(): 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: for model in self.model_chain:
try: try:
resp = requests.post( resp = requests.post(

View File

@@ -40,28 +40,68 @@ def test_no_keys_at_all():
assert a.api_key == "" 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"])) 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() order = a._rotated_keys()
assert [k for _, k in order] == ["key-primary", "key-2", "key-3", "key-4"] assert [k for _, k in order] == ["key-3", "key-4", "key-primary", "key-2"]
assert [i for i, _ in order] == [0, 1, 2, 3] assert [i for i, _ in order] == [2, 3, 0, 1]
def test_rotated_keys_advances_each_call_evenly(): def test_rotated_keys_uses_full_key_range(monkeypatch):
"""核心诉求: 连续调用应该轮流从不同 key 起手,而不是每次都从 0 号开始 """random.randrange 的调用范围必须是 len(api_keys),否则会漏掉某些 key 永远选不到。"""
(旧实现的 bug0 号 key 的 fallback 模型通常最终能成功,导致其余 key 常年闲置)。"""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"])) a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
starts = [a._rotated_keys()[0][1] for _ in range(4)] seen_n = []
assert starts == ["key-primary", "key-2", "key-3", "key-4"]
# 转满一圈后应该回到起点 def fake_randrange(n):
assert a._rotated_keys()[0][1] == "key-primary" 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 = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
a._key_rotation_idx = 3 # 手动模拟"上次从 4 号 key 起手" n_trials = 4000
order = a._rotated_keys() counts = {"key-primary": 0, "key-2": 0, "key-3": 0, "key-4": 0}
assert [k for _, k in order] == ["key-4", "key-primary", "key-2", "key-3"] 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(): def test_rotated_keys_single_key_never_errors():

View File

@@ -11,10 +11,11 @@ const calls = ref([])
const loadError = ref('') const loadError = ref('')
const loading = ref(true) 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) { function splitKey(model) {
const idx = (model || '').lastIndexOf(key') const idx = (model || '').lastIndexOf('·')
if (idx === -1) return { model, key: '' } if (idx === -1) return { model, key: '' }
return { model: model.slice(0, idx), key: model.slice(idx + 1) } return { model: model.slice(0, idx), key: model.slice(idx + 1) }
} }
@@ -126,8 +127,9 @@ onMounted(async () => {
</div> </div>
<p class="text-[11px] text-text-mute"> <p class="text-[11px] text-text-mute">
统计来自甲骨文端每次云端模型请求的记录 30 分钟同步拉取到本地镜像Key 显示配置里第几个 统计来自甲骨文端每次云端模型请求的记录 30 分钟同步拉取到本地镜像Key 列显示该次调用
Gemini Key 被用到key1= keykey2/3/4=备用 key不会显示密钥原文 用的是哪个 Gemini 项目配置了 key_labels 就显示项目名"智能摄像头-2"未配置则显示
key1/key2/...不会显示密钥原文
失败原因取值429_quota配额耗尽/ timeout / 503_overload过载重试/ http_xxx / json_parse_failed 失败原因取值429_quota配额耗尽/ timeout / 503_overload过载重试/ http_xxx / json_parse_failed
</p> </p>
</template> </template>