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_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避免大视频/排队时过早切断

View File

@@ -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(

View File

@@ -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 号开始
(旧实现的 bug0 号 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():

View File

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