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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user