Skip to content

Commit ab5f2f8

Browse files
committed
perf(models): 判活结果缓存 30 分钟 + 失败负缓存 + 预热改后台
载入模型慢的根因是 zen 免费层探活(最慢候选首字 12s,总耗时等于最慢那个)。 三处改动: - ZenClient 缓存判活集(MODELS_CACHE_TTL_SECONDS=1800):服务层 300s TTL 到期重拉列表时直接复用,只有超过 30 分钟才真的重探。 - list_models 的 TTL 时间戳改为记「上次尝试」(含失败):上游抖动不再让 其后每次 /v1/models 都重跑一遍拉取。 - 启动预热移到后台任务,不再阻塞 /health(探活十几秒会拖挂存活探针)。 实测:冷路径 13.53s 不变;模拟服务层 TTL 到期后 0.30s(原为 13s+)。 覆盖 100%,ruff 通过。
1 parent 68e4d5d commit ab5f2f8

8 files changed

Lines changed: 176 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- **OpenAI 兼容出口**:`/v1/chat/completions`(流式 + 非流式)、`/v1/responses`(Codex CLI)、`/v1/models`、`/v1/user/balance`(DeepSeek 兼容余额)
1212
- **统一调度**:扁平模型名按健康度自动选号,`模型@渠道` 强制指定;三态健康度 + 分级冷却避开坏号。模型级限流或「该渠道无此模型」只避让那一个模型,同账号其他模型立刻可用
1313
- **模型列表按渠道凭证加载**:`/v1/models` 与 Playground 只展示**当前有可用凭证**的渠道(未暂停、未会话失效);从未接入的渠道不出现幽灵模型,暂停或凭证失效时其模型暂时消失,恢复即回来
14-
- **OpenCode Zen 免费层**(第三个渠道):`opencode.ai/zen` 的免费模型接成 `zen` 渠道,标准 OpenAI 协议、无需登录;上游清单混着付费模型且无免费标记,本服务按 `-free` 后缀收窄候选再逐个探活,**只展示匿名真正可用的免费模型**(每次动态拉取并探活,不设静态白名单);请求侧自动满足免费层门禁,响应侧过滤门禁注入的伪工具调用。无凭证概念——池里一条虚拟凭证让它和其它渠道一样可调度、可暂停、可统计
14+
- **OpenCode Zen 免费层**(第三个渠道):`opencode.ai/zen` 的免费模型接成 `zen` 渠道,标准 OpenAI 协议、无需登录;上游清单混着付费模型且无免费标记,本服务按 `-free` 后缀收窄候选再逐个探活,**只展示匿名真正可用的免费模型**(每次动态拉取并探活,判活结果按 30 分钟缓存以避开每 5 分钟重探一次的开销,不设静态白名单);请求侧自动满足免费层门禁,响应侧过滤门禁注入的伪工具调用。无凭证概念——池里一条虚拟凭证让它和其它渠道一样可调度、可暂停、可统计
1515
- **到期积分优先消化**:主窗口 36h 内将过期的积分多者先用(避免过期浪费),打平再比 7 天窗口;管理台凭证列表显示到期积分与逐个额度包明细
1616
- **会话粘性**:同一对话多轮粘住同一凭证,出错才轮换
1717
- **公共凭证池**:admin 集中维护、全员共享;按人统计用量
@@ -130,7 +130,7 @@ curl http://127.0.0.1:8000/v1/chat/completions \
130130

131131
### OpenCode Zen 免费层
132132

133-
`zen` 渠道接的是 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游模型清单里混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(每次动态拉取并探活,不维护静态白名单);用 `模型@zen` 强制指定,或由调度器自动路由。
133+
`zen` 渠道接的是 [opencode.ai/zen](https://opencode.ai) 的**免费模型**,无需账号或登录。上游模型清单里混着付费模型且不带免费标记,本服务按 `-free` 后缀收窄候选、再逐个探活,**只把匿名真正可用的免费模型**放进 `GET /v1/models`(每次动态拉取并探活,判活结果按 30 分钟缓存,不维护静态白名单);用 `模型@zen` 强制指定,或由调度器自动路由。
134134

135135
几点要知道:
136136

‎TECHNICAL.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ def health(q: Quota | None) -> HealthScore:
290290

291291
**按凭证加载(Q41)**:`list_models` 先用 `credential_providers()` 求「当前有可用凭证」的渠道集合(`candidates(selectable_only=True)`:未暂停、未硬禁用;冷却中的仍算有凭证,避免限流时列表闪没),循环里 `provider_id not in connected` 直接 `continue`——**没凭证的渠道不读缓存、不拉上游、不展示**。此前无凭证也会 `list_models({})`,CB/TRAE 回退静态表、zen 匿名拉取,于是在只接了部分渠道时列表里出现打不通的幽灵模型;启动预热也因此不再对无凭证渠道白打上游。zen 自带虚拟凭证,不受影响。
292292

293+
**失败也进 TTL(负缓存)**:`model_list_fetched_at` 记的是**上次尝试**时间(成功或失败都刷新),TTL(300s)内不再打上游——有缓存就继续用缓存,没缓存就跳过该渠道。此前只在成功时记时间戳,上游一次抖动(尤其 zen 探活的十几秒)会让其后**每次** `/v1/models` 都重跑一遍拉取,把列表请求打成一串超时。
294+
295+
**启动预热不再阻塞启动**:`lifespan` 把预热丢给后台任务 `_warm_model_list`(`force=True` 绕过 TTL),不再在 `yield` 前 `await`——zen 最慢的探活可占十几秒,内联会让应用在这段时间里不响应 `/health`,容器存活探针可能误判。预热失败仅记日志;关机时取消在途任务。
296+
293297
### 3.6 活跃上报(B1.7,默认关闭)
294298

295299
CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端对话事件;账号只被网关
@@ -575,6 +579,8 @@ UA 版本走 `ZEN_OPENCODE_VERSION` 配置(上游改阈值改 env,不硬编
575579

576580
**免费模型清单完全动态(现拉现探)**:上游 `/zen/v1/models` 免鉴权,但返回的是**全部**模型(含 70 多个付费模型),且**不带任何免费/付费标记**(`owned_by` 恒 `opencode`、无 cost 字段,换鉴权头 / query 也仍全量)。唯一权威信号是**匿名可用性**:付费模型恒 401 `Missing API key.`,免费模型永不 401。故两步过滤:① 按 `-free` 后缀收窄候选(上游命名约定,非契约);② 对候选并发探活,**只保留 2xx**——已下线(400)、区域限制(403)、上游故障(5xx)、超时都剔除。无静态白名单,上游增删免费模型自动跟随;探活结果为空时抛协议错,让 `/v1/models` 用上次成功的缓存兜底(冷启动无缓存才退化为不展示 zen)。`fetch_models` 同时是引擎登记模型归属的来源,过滤后扁平名请求不会再被路由到 zen 的付费模型上。
577581

582+
**判活结果缓存(`MODELS_CACHE_TTL_SECONDS`,30 分钟)**:探活是模型列表链路里最贵的一步(逐个真发一次推理,总耗时等于最慢那个,实测 12–15s),而免费模型增删很慢。故 `fetch_models` 缓存判活集,TTL 取 30 分钟、比服务层的 `MODEL_LIST_TTL_SECONDS`(300s)长一档:服务层每 5 分钟到期重拉列表时直接复用判活结果,只有超过 30 分钟才真的重探。代价是免费模型下线后最多多留 30 分钟(选中收到 400/401,按无效请求处理,不会误冷却凭证)。
583+
578584
**401 不算凭证失效**:Zen 无凭证,401(`Missing API key.`)只说明**该模型需要付费 key**,不是虚拟凭证失效。故 zen 的 `classify_status(401)` / `classify_error_code(401)` 归 `ErrKind.INVALID`(跳过该渠道并回 400),不归 `DEAD`——否则一次 `模型@zen` 强制付费模型就会把整条 zen 渠道硬禁用,只能重启复活。模型列表已不含付费模型,此分支只兜底强制指定的情形。
579585

580586
**统计时间序列泛化**:`stats/query.py::timeline()` 原先把 `codebuddy`/`trae` 两列写死在 SQL 的 `CASE WHEN` 里。改为按 `(hour_utc, provider)` 分组、Python 侧 pivot:渠道集合动态取自数据(新增渠道无需改 SQL),某渠道在该小时无数据时补 0(同一批点的键集合一致,前端不会断线)。渠道按名排序,恰为 `codebuddy`/`trae`/`zen` 字典序,**无 zen 数据时旧契约不变**。前端对应地按 points 的键动态生成曲线(`UsageChart.chartProviders`),颜色随渠道稳定(`--chart-1/3/4`)。

‎src/api/deps.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ class Services:
8383
# **存未过滤的原始表**:MODEL_BLOCKLIST 是可热更项,过滤在每个出口现做,
8484
# 否则改完黑名单要等 TTL(300s)才生效、被滤模型还会从兜底缓存复活。
8585
model_list_cache: dict[str, dict[str, Any]] = field(default_factory=dict)
86-
# 上次成功拉取时间(monotonic):TTL 内的请求直接用缓存,
87-
# 避免客户端频繁调 /v1/models 时上游被打成大。
86+
# 上次**尝试**拉取时间(monotonic,成功或失败都刷新):TTL 内的请求不再打
87+
# 上游——有缓存用缓存,无缓存则跳过该渠道(负缓存)。
8888
model_list_fetched_at: dict[str, float] = field(default_factory=dict)
8989

9090

‎src/api/models.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
88
带服务层缓存:某上游拉取成功后把归一结果写入 services.model_list_cache;
99
下次该上游拉取失败时用缓存兜底,保证 /v1/models 稳定返回完整列表
10-
(冷启动无缓存时才退化为跳过该上游)。
10+
(冷启动无缓存时才退化为跳过该上游)。TTL 记的是**上次尝试**时间(成功或
11+
失败都记):失败不记时间戳的话,上游一次抖动就会让其后每次 /v1/models 都
12+
重跑一遍拉取(zen 探活最慢可占十几秒),把列表请求打成一串超时。
1113
另按 MODEL_BLOCKLIST(fnmatch glob)过滤非用户模型与老模型,
1214
只影响列表展示;直连指定被滤模型不受影响。
1315
@@ -34,6 +36,8 @@
3436

3537
# 模型列表 TTL:TTL 内直接复用上次结果。客户端(IDE/Playground)打开面板
3638
# 就会调 /v1/models,无 TTL 时每次都会向上游真实发起请求。
39+
# 时间戳记「上次尝试」(含失败):失败也进 TTL,否则一次抖动之后每次请求
40+
# 都会重试,zen 探活的十几秒会叠加成一串慢请求。
3741
MODEL_LIST_TTL_SECONDS = 300
3842

3943
# 响应透传的元数据字段(Model → OpenAI 额外字段)
@@ -143,12 +147,14 @@ async def list_models(services: Services, *, force: bool = False) -> dict:
143147
if provider_id not in connected:
144148
continue
145149
fetched_at = services.model_list_fetched_at.get(provider_id)
146-
fresh = (cache.get(provider_id)
147-
and fetched_at is not None
148-
and now - fetched_at < MODEL_LIST_TTL_SECONDS)
149-
if fresh and not force:
150-
_merge_provider(grouped, aliases, provider_id,
151-
_visible(cache[provider_id], patterns))
150+
# TTL 按「上次尝试」计(失败也刷新):有缓存就继续用缓存,没缓存就跳过,
151+
# 两种情况都不再打上游,避免上游抖动时每次请求都重跑一遍拉取。
152+
if (not force and fetched_at is not None
153+
and now - fetched_at < MODEL_LIST_TTL_SECONDS):
154+
cached = cache.get(provider_id)
155+
if cached:
156+
_merge_provider(grouped, aliases, provider_id,
157+
_visible(cached, patterns))
152158
continue
153159
# 用该上游的一个可用凭证拉取(凭证有归属,模型列表是账号级的)
154160
# selectable_only:硬禁用/用户关闭的凭证取不到数据,只会白失败
@@ -160,6 +166,8 @@ async def list_models(services: Services, *, force: bool = False) -> dict:
160166
try:
161167
models = await provider.list_models(credential_data)
162168
except Exception as error: # noqa: BLE001 - 单上游失败不影响其他
169+
# 失败也记时间戳(负缓存):TTL 内不再重试,有缓存用缓存、无缓存跳过。
170+
services.model_list_fetched_at[provider_id] = time.monotonic()
163171
cached = cache.get(provider_id)
164172
if cached:
165173
logger.warning("模型列表获取失败 %s,使用上次缓存: %s", provider_id, error)

‎src/main.py‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ def _forget_task(task: asyncio.Task, pending: list) -> None:
151151
pending.remove(task)
152152

153153

154+
async def _warm_model_list(services) -> None:
155+
"""后台预热模型列表 / 别名表:失败仅记日志,绝不影响启动与聊天。"""
156+
try:
157+
await models.list_models(services, force=True)
158+
except Exception as error: # noqa: BLE001 - 预热失败不阻断服务
159+
logger.warning("启动预热模型列表失败: %s", error)
160+
161+
154162
def build_app(settings: Settings | None = None, *, providers: dict | None = None,
155163
users: object | None = None) -> FastAPI:
156164
config = settings or load_settings()
@@ -228,15 +236,21 @@ async def lifespan(app_: FastAPI):
228236
credit_events=credit_events)
229237
app_.state.task_runner = runner
230238
await runner.start()
231-
# 预热模型别名表(动态拉取失败仅记日志,不阻塞启动);force 绕过 TTL
232-
try:
233-
await models.list_models(services_, force=True)
234-
except Exception as error: # noqa: BLE001
235-
logger.warning("启动预热模型列表失败: %s", error)
239+
# 预热模型别名表:放后台跑(force 绕过 TTL)。
240+
# 不内联 await 的原因:zen 免费层探活最慢的模型可占十几秒,内联会让应用
241+
# 在这段时间里不响应 /health,容器存活探针可能误判;动态拉取失败仅记日志。
242+
app_.state.model_warmup_task = asyncio.create_task(_warm_model_list(services_))
243+
# 让预热任务先跑一步:失败时日志立即落盘(成功与否都不阻塞下面 yield)。
244+
await asyncio.sleep(0)
236245
try:
237246
yield
238247
finally:
239248
await runner.stop()
249+
warmup = getattr(app_.state, "model_warmup_task", None)
250+
if warmup is not None and not warmup.done():
251+
warmup.cancel()
252+
with contextlib.suppress(asyncio.CancelledError):
253+
await warmup
240254
for task in app_.state.pending_probes:
241255
task.cancel()
242256
app_.state.pending_probes.clear()

‎src/provider/zen/client.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@
6969
PROBE_TIMEOUT = 20.0
7070
# 探活用最小请求(真发一次流式对话;只看响应头,不读 body)。
7171
PROBE_PROMPT = "hi"
72+
# 探活结果缓存时长(秒):探活是整条模型列表链路里最贵的一步——逐个真发一次
73+
# 推理,首字最慢的模型可占十几秒,而总耗时等于最慢那个。免费模型的增删却很慢,
74+
# 所以缓存判活结果,比服务层的 MODEL_LIST_TTL_SECONDS(300s)长一档:服务层每
75+
# 5 分钟到期重拉一次,此处直接复用上次判活集,只有超过本 TTL 才真的重探。
76+
# 代价:免费模型下线后,最多多留在列表里 30 分钟(选中会 400/401,由引擎按
77+
# 无效请求处理,不会误冷却凭证)。
78+
MODELS_CACHE_TTL_SECONDS = 1800.0
7279

7380
# 流式无总超时防长流截断;短请求 30s 防悬挂(与 TRAE 同策略)
7481
STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)
@@ -194,12 +201,16 @@ def __init__(
194201
host: str = ZEN_HOST,
195202
version: str = MIN_OPENCODE_VERSION,
196203
free_suffix: str = FREE_MODEL_SUFFIX,
204+
models_cache_ttl: float = MODELS_CACHE_TTL_SECONDS,
197205
stream_client: httpx.AsyncClient | None = None,
198206
short_client: httpx.AsyncClient | None = None,
199207
) -> None:
200208
self.host = host.rstrip("/")
201209
self.version = version
202210
self.free_suffix = free_suffix
211+
self.models_cache_ttl = models_cache_ttl
212+
# 上次判活的 (monotonic 时间, 模型表);TTL 内直接复用,不再重探。
213+
self._models_cache: tuple[float, list[Model]] | None = None
203214
self._stream_client = stream_client
204215
self._short_client = short_client
205216

@@ -253,7 +264,14 @@ async def fetch_models(self) -> list[Model]:
253264
再逐个探活,只有真正匿名可用的才对下游可见:
254265
`fetch_models` 的结果同时也是引擎登记模型归属的来源,过滤后扁平名
255266
请求不会再被路由到 zen 的付费模型上(否则 401 会误伤虚拟凭证)。
267+
268+
判活集按 `models_cache_ttl` 缓存:服务层每 300s 到期重拉一次列表,此处
269+
直接复用上次判活结果,避免每 5 分钟就把十几个免费候选重探一遍。
256270
"""
271+
if self._models_cache is not None:
272+
cached_at, cached_models = self._models_cache
273+
if time.monotonic() - cached_at < self.models_cache_ttl:
274+
return list(cached_models)
257275
response = await self._short().get(
258276
f"{self.host}{EP_MODELS}", headers=gate_headers(version=self.version))
259277
if response.status_code >= 400:
@@ -283,6 +301,7 @@ async def fetch_models(self) -> list[Model]:
283301
# 全部探活失败:多半是上游整体故障而非真的没有免费模型,
284302
# 抛出让上层用上次成功的缓存兜底,避免 zen 从列表里消失。
285303
raise zen_events.UpstreamProtocolViolation("no free model passed liveness probe")
304+
self._models_cache = (time.monotonic(), list(models))
286305
return models
287306

288307
async def _probe_alive(self, model_ids: list[str]) -> set[str]:

‎tests/test_hardening.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,79 @@ def import_credential(self, raw): # pragma: no cover - 未使用
839839
assert [m["id"] for m in response.json()["data"]] == ["glm-5.2"]
840840

841841

842+
def test_models_failure_is_negatively_cached(settings):
843+
"""拉取失败也刷 TTL 时间戳:TTL 内不再重试(有缓存用缓存,无缓存跳过)。"""
844+
845+
class Flaky:
846+
id = "trae"
847+
attempts = 0
848+
849+
async def list_models(self, credential_data):
850+
type(self).attempts += 1
851+
raise RuntimeError("upstream down")
852+
853+
def import_credential(self, raw): # pragma: no cover - 未使用
854+
return raw
855+
856+
app = build_app(settings, providers={"trae": Flaky()})
857+
app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"})
858+
services = app.state.services
859+
key = app.state.api_keys.create("root")["api_key"]
860+
auth = {"Authorization": f"Bearer {key}"}
861+
with TestClient(app) as c:
862+
first = c.get("/v1/models", headers=auth)
863+
second = c.get("/v1/models", headers=auth)
864+
assert first.status_code == 200 and first.json()["data"] == []
865+
assert second.json()["data"] == []
866+
assert Flaky.attempts == 1 # 第二次命中负缓存,没重试
867+
assert services.model_list_fetched_at["trae"] is not None
868+
869+
# 清掉尝试时间戳(模拟 TTL 过期)→ 重新尝试
870+
services.model_list_fetched_at.clear()
871+
with TestClient(app) as c:
872+
c.get("/v1/models", headers=auth)
873+
assert Flaky.attempts == 2
874+
875+
876+
def test_models_failure_keeps_cache_for_ttl(settings):
877+
"""失败后 TTL 内用缓存兜底且不重试;TTL 过期才再次尝试。"""
878+
879+
class Flaky:
880+
id = "trae"
881+
fail = False
882+
attempts = 0
883+
884+
async def list_models(self, credential_data):
885+
type(self).attempts += 1
886+
if self.fail:
887+
raise RuntimeError("upstream down")
888+
return [Model(id="glm-5.2")]
889+
890+
def import_credential(self, raw): # pragma: no cover - 未使用
891+
return raw
892+
893+
provider = Flaky()
894+
app = build_app(settings, providers={"trae": provider})
895+
app.state.credentials.add(provider="trae", credential_data={"accessToken": "a"})
896+
services = app.state.services
897+
key = app.state.api_keys.create("root")["api_key"]
898+
auth = {"Authorization": f"Bearer {key}"}
899+
with TestClient(app) as c:
900+
first = c.get("/v1/models", headers=auth)
901+
assert [m["id"] for m in first.json()["data"]] == ["glm-5.2"]
902+
assert Flaky.attempts == 1
903+
904+
provider.fail = True
905+
services.model_list_fetched_at.clear() # 模拟 TTL 过期 → 触发重试并失败
906+
second = c.get("/v1/models", headers=auth)
907+
assert [m["id"] for m in second.json()["data"]] == ["glm-5.2"]
908+
assert Flaky.attempts == 2
909+
910+
third = c.get("/v1/models", headers=auth) # 失败后的 TTL 内:不再重试
911+
assert [m["id"] for m in third.json()["data"]] == ["glm-5.2"]
912+
assert Flaky.attempts == 2
913+
914+
842915
@pytest.mark.asyncio
843916
async def test_disconnect_with_bytes_but_no_usage_still_recorded():
844917
"""已吐字节但上游未给 usage 时,断开也要记账(ttfb 分支)。"""

0 commit comments

Comments
 (0)