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:
@@ -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),避免大视频/排队时过早切断
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 永远选不到。"""
|
||||||
(旧实现的 bug:0 号 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():
|
||||||
|
|||||||
@@ -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=主 key,key2/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>
|
||||||
|
|||||||
Reference in New Issue
Block a user