BIZ-46: Phase3 架构设计 — SidecarContext解耦/Prometheus治理/部署支撑/测试/UX
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
# BIZ-46 Phase3: NVIDIA Sidecar Follow-up 架构设计
|
||||
|
||||
> **架构师**: 梁思筑 (architect)
|
||||
> **日期**: 2026-06-24
|
||||
> **状态**: 已批准,推进实施
|
||||
> **来源**: BIZ-42 Phase2 二轮评审 follow-up
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构解耦 / 依赖注入 — SidecarContext
|
||||
|
||||
### 1.1 现状分析
|
||||
|
||||
当前 `server.py` 使用 **模块级全局变量** 管理所有核心组件:
|
||||
|
||||
```python
|
||||
# server.py 全局状态(当前)
|
||||
_config: SidecarConfig
|
||||
_http_client: httpx.AsyncClient
|
||||
_priority_queue: PriorityRequestQueue
|
||||
_token_bucket: AdaptiveTokenBucket
|
||||
_prometheus: PrometheusMetrics
|
||||
_health_service: HealthService
|
||||
_pending_requests: dict[str, tuple[asyncio.Future, float]]
|
||||
_stats: dict[str, int]
|
||||
_stats_lock: asyncio.Lock
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- `webui.py` 通过 `from nvidia_sidecar import server` 反向导入全局变量(循环依赖风险)
|
||||
- 单元测试需要 mock 模块级变量,无法并行运行测试
|
||||
- 未来多实例/多租户扩展需重写全部模块访问逻辑
|
||||
|
||||
### 1.2 设计方案 — SidecarContext + FastAPI Dependency Injection
|
||||
|
||||
#### 1.2.1 核心数据结构
|
||||
|
||||
```python
|
||||
# context.py
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Any
|
||||
|
||||
@dataclass
|
||||
class SidecarContext:
|
||||
"""Sidecar 全局运行时上下文 — 所有核心组件的唯一容器。
|
||||
|
||||
通过 app.state.sidecar 注入 FastAPI,路由通过 Depends 获取。
|
||||
"""
|
||||
config: 'SidecarConfig'
|
||||
http_client: httpx.AsyncClient
|
||||
token_bucket: 'AdaptiveTokenBucket'
|
||||
priority_queue: 'PriorityRequestQueue'
|
||||
prometheus: 'PrometheusMetrics'
|
||||
health: 'HealthService'
|
||||
pending_requests: dict[str, tuple['asyncio.Future', float]] = field(default_factory=dict)
|
||||
stats: dict[str, int] = field(default_factory=lambda: {
|
||||
"total_requests": 0,
|
||||
"nvidia_requests": 0,
|
||||
"passthrough_requests": 0,
|
||||
"ratelimited_requests": 0,
|
||||
"queue_full_rejects": 0,
|
||||
"upstream_errors": 0,
|
||||
"start_time": 0,
|
||||
})
|
||||
stats_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def increment_stat(self, key: str, delta: int = 1) -> None:
|
||||
"""线程安全的统计计数器自增。"""
|
||||
async with self.stats_lock:
|
||||
self.stats[key] = self.stats.get(key, 0) + delta
|
||||
```
|
||||
|
||||
#### 1.2.2 注入方式
|
||||
|
||||
```python
|
||||
# server.py — lifespan 中创建 context
|
||||
from nvidia_sidecar.context import SidecarContext
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ctx = SidecarContext(
|
||||
config=load_config(),
|
||||
http_client=httpx.AsyncClient(...),
|
||||
token_bucket=AdaptiveTokenBucket(...),
|
||||
priority_queue=PriorityRequestQueue(...),
|
||||
prometheus=PrometheusMetrics(),
|
||||
health=HealthService(),
|
||||
)
|
||||
app.state.sidecar = ctx # 注入 FastAPI
|
||||
# ... worker 启动 ...
|
||||
yield
|
||||
# ... 清理 ...
|
||||
|
||||
# 依赖注入函数
|
||||
def get_context(request: Request) -> SidecarContext:
|
||||
return request.app.state.sidecar
|
||||
|
||||
# 路由使用
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request, ctx: SidecarContext = Depends(get_context)):
|
||||
return await _handle_proxy_request(request, "/v1/chat/completions", ctx)
|
||||
```
|
||||
|
||||
#### 1.2.3 webui.py 解耦
|
||||
|
||||
```python
|
||||
# webui.py — 不再反向导入 server
|
||||
from nvidia_sidecar.context import SidecarContext
|
||||
from fastapi import Depends
|
||||
|
||||
def get_webui_router():
|
||||
router = APIRouter(prefix="/api", tags=["webui"])
|
||||
|
||||
def _get_ctx(request: Request) -> SidecarContext:
|
||||
return request.app.state.sidecar
|
||||
|
||||
@router.get("/dashboard/stream")
|
||||
async def dashboard_stream(request: Request, ctx: SidecarContext = Depends(_get_ctx)):
|
||||
return await _dashboard_stream(request, ctx)
|
||||
|
||||
@router.get("/admin/config")
|
||||
async def admin_get_config(ctx: SidecarContext = Depends(_get_ctx)):
|
||||
return await get_config(ctx)
|
||||
|
||||
return router
|
||||
```
|
||||
|
||||
#### 1.2.4 Trade-off 分析
|
||||
|
||||
| 维度 | 当前(全局变量) | 方案A(SidecarContext) | 方案B(FastAPI Dependency 全函数式) |
|
||||
|------|------------------|------------------------|-------------------------------------|
|
||||
| 可测试性 | 差(需 mock 模块) | 好(注入 mock context) | 优(每个依赖独立注入) |
|
||||
| 改动量 | 无 | 中等(~8 文件) | 大(每个函数签名变更) |
|
||||
| 可读性 | 一般 | 好(ctx 一目了然) | 差(参数列表膨胀) |
|
||||
| 多实例支持 | 不支持 | 支持(多 app 多 ctx) | 支持 |
|
||||
| 循环依赖 | 有(webui→server) | 消除 | 消除 |
|
||||
|
||||
**决策**: 采用方案A(SidecarContext),平衡改动量与收益。
|
||||
|
||||
### 1.3 迁移计划
|
||||
|
||||
分 3 步渐进迁移,每步可独立合入:
|
||||
|
||||
1. **Step 1**: 创建 `context.py`,定义 `SidecarContext`,在 `lifespan` 中实例化并挂到 `app.state`
|
||||
2. **Step 2**: 路由函数改为 `Depends(get_context)`,删除模块级 `_config`、`_http_client` 等
|
||||
3. **Step 3**: `webui.py` 移除 `from nvidia_sidecar import server`,改用依赖注入
|
||||
|
||||
---
|
||||
|
||||
## 2. Prometheus 标签基数治理
|
||||
|
||||
### 2.1 现状
|
||||
|
||||
当前使用 `model_id` 作为 label 的指标:
|
||||
|
||||
| 指标 | Label | 风险 |
|
||||
|------|-------|------|
|
||||
| `sidecar_upstream_latency_seconds` | `model_id` | **高** — NVIDIA 模型名含版本号,可能无界增长 |
|
||||
| `sidecar_upstream_errors_total` | `status_code`, `model_id` | **中** — 组合基数 = 模型数 × 状态码数 |
|
||||
|
||||
### 2.2 基数评估
|
||||
|
||||
NVIDIA API 当前已知模型约 20-30 个,但:
|
||||
- 新模型持续发布(每月 2-5 个)
|
||||
- 模型名含版本后缀(`nvidia/deepseek-ai/deepseek-v4-pro`、`nvidia/llama-3.1-70b-instruct` 等)
|
||||
- 长期运行(6 个月+)可能累积 100+ 标签组合
|
||||
|
||||
**结论**: 当前基数可控(<200 组合),但长期存在膨胀风险,应提前治理。
|
||||
|
||||
### 2.3 治理方案
|
||||
|
||||
| 指标 | 当前 Label | 调整后 Label | 理由 |
|
||||
|------|-----------|-------------|------|
|
||||
| `upstream_latency_seconds` | `model_id` | `provider` | provider 固定为 `nvidia`,基数=1 |
|
||||
| `upstream_errors_total` | `status_code`, `model_id` | `status_code`, `provider` | 同上 |
|
||||
|
||||
**模型级信息迁移路径**:
|
||||
- 模型 ID → 结构化 JSON 日志(structlog 已支持)
|
||||
- 需要模型级延迟分析时 → 临时 `/status` API 查询或日志聚合
|
||||
|
||||
```python
|
||||
# metrics.py 调整
|
||||
self.upstream_latency_seconds: Histogram = Histogram(
|
||||
"sidecar_upstream_latency_seconds",
|
||||
"Upstream response latency in seconds",
|
||||
labelnames=["provider"], # 原: ["model_id"]
|
||||
buckets=(...),
|
||||
)
|
||||
|
||||
self.upstream_errors_total: Counter = Counter(
|
||||
"sidecar_upstream_errors_total",
|
||||
"Upstream error count by status code",
|
||||
labelnames=["status_code", "provider"], # 原: ["status_code", "model_id"]
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# server.py 调整 — 模型信息改记日志
|
||||
model_id = _extract_model(payload) or "unknown"
|
||||
provider = "nvidia" # 固定值,因为只有 NVIDIA 请求走 worker
|
||||
_prometheus.record_upstream_latency(provider, upstream_latency)
|
||||
if not resp.is_success:
|
||||
_prometheus.record_upstream_error(resp.status_code, provider)
|
||||
logger.info("request_completed", model_id=model_id, ...) # JSON 日志保留模型信息
|
||||
```
|
||||
|
||||
### 2.4 Trade-off
|
||||
|
||||
| 维度 | 保留 model_id | 收敛为 provider |
|
||||
|------|--------------|----------------|
|
||||
| 基数风险 | 高(无界) | 无(固定=1) |
|
||||
| 模型级分析 | Prometheus 原生查询 | 需日志聚合 |
|
||||
| 迁移成本 | 无 | 低(改 2 个指标定义 + 调用点) |
|
||||
|
||||
**决策**: 收敛为 `provider`,模型级分析通过 JSON 日志 + 日志聚合系统(ELK/Loki)完成。
|
||||
|
||||
---
|
||||
|
||||
## 3. SSE 快照共享缓存
|
||||
|
||||
### 3.1 现状
|
||||
|
||||
每个 SSE 客户端每秒独立调用 `_build_snapshot()`,该方法:
|
||||
- 获取 `_stats` 字典(需锁)
|
||||
- 调用 `_token_bucket.get_status()`(需锁)
|
||||
- 调用 `_priority_queue.get_stats()`(需 asyncio.Lock)
|
||||
|
||||
当 N 个仪表盘同时打开时,每秒 N 次锁竞争 + N 次重复计算。
|
||||
|
||||
### 3.2 设计方案 — 1s TTL 共享缓存
|
||||
|
||||
```python
|
||||
# webui.py
|
||||
_snapshot_cache: tuple[dict[str, Any], float] | None = None # (data, timestamp)
|
||||
_snapshot_lock: asyncio.Lock = asyncio.Lock()
|
||||
_SNAPSHOT_TTL: float = 1.0 # 1 秒 TTL
|
||||
|
||||
async def _build_snapshot_cached(ctx: SidecarContext) -> dict[str, Any]:
|
||||
"""带 1s TTL 的共享快照缓存。
|
||||
|
||||
多个 SSE 客户端共享同一份快照,避免重复计算和锁竞争。
|
||||
"""
|
||||
global _snapshot_cache
|
||||
|
||||
now = time.monotonic()
|
||||
if _snapshot_cache is not None:
|
||||
data, ts = _snapshot_cache
|
||||
if now - ts < _SNAPSHOT_TTL:
|
||||
return data
|
||||
|
||||
async with _snapshot_lock:
|
||||
# Double-check(避免多个协程同时 miss 后重复构建)
|
||||
if _snapshot_cache is not None:
|
||||
data, ts = _snapshot_cache
|
||||
if now - ts < _SNAPSHOT_TTL:
|
||||
return data
|
||||
|
||||
snapshot = await _build_snapshot(ctx)
|
||||
_snapshot_cache = (snapshot, now)
|
||||
return snapshot
|
||||
```
|
||||
|
||||
### 3.3 性能收益
|
||||
|
||||
| 场景 | 当前 | 优化后 |
|
||||
|------|------|--------|
|
||||
| 1 客户端 | 1 次/s 计算 | 1 次/s 计算(无变化) |
|
||||
| 5 客户端 | 5 次/s 计算,5 次锁竞争 | 1 次/s 计算,1 次锁竞争 |
|
||||
| 20 客户端 | 20 次/s 计算,20 次锁竞争 | 1 次/s 计算,1 次锁竞争 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 部署支撑
|
||||
|
||||
### 4.1 Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# services/nvidia_sidecar/Dockerfile
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖(利用 Docker 层缓存)
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# 复制源码
|
||||
COPY . .
|
||||
|
||||
# 非 root 用户运行
|
||||
RUN useradd -r -s /bin/false sidecar
|
||||
USER sidecar
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import httpx; r=httpx.get('http://127.0.0.1:9190/health'); exit(0 if r.status_code==200 else 1)"
|
||||
|
||||
EXPOSE 9190 9191
|
||||
|
||||
CMD ["uvicorn", "nvidia_sidecar.server:app", "--host", "0.0.0.0", "--port", "9190"]
|
||||
```
|
||||
|
||||
### 4.2 systemd Service
|
||||
|
||||
```ini
|
||||
# services/nvidia_sidecar/deploy/nvidia-sidecar.service
|
||||
[Unit]
|
||||
Description=NVIDIA Sidecar Rate-Limiting Proxy
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sidecar
|
||||
Group=sidecar
|
||||
WorkingDirectory=/opt/nvidia-sidecar
|
||||
ExecStart=/opt/nvidia-sidecar/.venv/bin/uvicorn nvidia_sidecar.server:app \
|
||||
--host 127.0.0.1 \
|
||||
--port 9190 \
|
||||
--log-level info
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# 环境变量
|
||||
EnvironmentFile=/opt/nvidia-sidecar/.env
|
||||
|
||||
# 安全加固
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/nvidia-sidecar/logs
|
||||
|
||||
# 资源限制
|
||||
LimitNOFILE=65536
|
||||
MemoryMax=512M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 4.3 环境变量清单
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `SIDECAR_HOST` | `127.0.0.1` | 监听地址 |
|
||||
| `SIDECAR_PORT` | `9190` | 代理端口 |
|
||||
| `SIDECAR_METRICS_PORT` | `9191` | Prometheus 指标端口 |
|
||||
| `SIDECAR_UPSTREAM` | `https://integrate.api.nvidia.com/v1` | 上游 API |
|
||||
| `SIDECAR_API_KEY` | (必填) | NVIDIA API Key |
|
||||
| `SIDECAR_RATE_RPM` | `40` | 限流速率 (RPM) |
|
||||
| `SIDECAR_BUCKET_CAPACITY` | `40` | 令牌桶容量 |
|
||||
| `SIDECAR_TIMEOUT` | `60` | 请求超时 (秒) |
|
||||
| `SIDECAR_QUEUE_MAX` | `500` | 队列最大容量 |
|
||||
| `SIDECAR_LOW_TIMEOUT` | `2` | 低优先级超时 (秒) |
|
||||
| `SIDECAR_FALLBACK_PASSTHROUGH` | `true` | 队列满时是否直通 |
|
||||
| `SIDECAR_LOG_LEVEL` | `INFO` | 日志级别 |
|
||||
| `SIDECAR_ADMIN_TOKEN` | (可选) | Admin API 认证 Token |
|
||||
|
||||
### 4.4 防火墙建议
|
||||
|
||||
```
|
||||
# 仅允许内网访问代理端口
|
||||
sudo ufw allow from 192.168.1.0/24 to any port 9190
|
||||
sudo ufw allow from 192.168.1.0/24 to any port 9191
|
||||
# 禁止外网访问
|
||||
sudo ufw deny 9190
|
||||
sudo ufw deny 9191
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Readiness HTTP Client 复用
|
||||
|
||||
### 5.1 现状
|
||||
|
||||
`HealthService.check_upstream()` 每次调用创建新的 `httpx.AsyncClient`:
|
||||
|
||||
```python
|
||||
# health.py — 当前
|
||||
async def check_upstream(self, upstream_url: str, timeout: float = 5.0, api_key: str = "") -> bool:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client: # 每次新建!
|
||||
resp = await client.get(...)
|
||||
```
|
||||
|
||||
K8s/systemd 每 10-30s 探测一次,每次创建+销毁 HTTP client 带来不必要的 TCP 连接开销。
|
||||
|
||||
### 5.2 方案 — 复用主 http_client
|
||||
|
||||
```python
|
||||
# health.py — 优化后
|
||||
async def check_upstream(
|
||||
self,
|
||||
upstream_url: str,
|
||||
http_client: httpx.AsyncClient, # 注入主 client
|
||||
api_key: str = "",
|
||||
timeout: float = 5.0,
|
||||
) -> bool:
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["authorization"] = f"Bearer {api_key}"
|
||||
resp = await http_client.get(
|
||||
f"{upstream_url.rstrip('/')}/v1/models",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
return resp.status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
# server.py — 路由调用处
|
||||
@app.get("/health/ready")
|
||||
async def health_ready(ctx: SidecarContext = Depends(get_context)):
|
||||
queue_size = await ctx.priority_queue.get_queue_size()
|
||||
bucket_status = ctx.token_bucket.get_status()
|
||||
return await ctx.health.readiness(
|
||||
upstream_url=ctx.config.upstream_url,
|
||||
http_client=ctx.http_client, # 复用主 client
|
||||
upstream_api_key=ctx.config.upstream_api_key or "",
|
||||
queue_current_size=queue_size,
|
||||
queue_max_size=ctx.config.queue_max_size,
|
||||
available_tokens=bucket_status["tokens"],
|
||||
bucket_capacity=bucket_status["capacity"],
|
||||
)
|
||||
```
|
||||
|
||||
**注意**: readiness 检查使用较短 timeout (5s),不影响主代理请求的 timeout 配置。httpx 支持per-request timeout 覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 6. Retreat 并发/死锁回归测试
|
||||
|
||||
### 6.1 风险点
|
||||
|
||||
`AdaptiveTokenBucket` 有两把锁:
|
||||
- `_lock` (Lock): 保护令牌消费/补充
|
||||
- `_retreat_lock` (RLock): 保护避退状态机
|
||||
|
||||
潜在死锁路径:
|
||||
1. `evaluate_retreat()` 持有 `_retreat_lock` → 调用 `get_429_rate()` (也获取 `_retreat_lock`,RLock 可重入 ✅)
|
||||
2. `evaluate_retreat()` → `_apply_retreat()` → `set_rate()` → 获取 `_lock` (另一把锁)
|
||||
3. Worker 线程: `consume()` 持有 `_lock` → 不调用 `_retreat_lock` (无交叉 ✅)
|
||||
|
||||
当前设计使用 RLock 已规避了重入死锁,但需要回归测试确保未来修改不引入死锁。
|
||||
|
||||
### 6.2 测试用例
|
||||
|
||||
```python
|
||||
# tests/test_retreat_concurrency.py
|
||||
import pytest
|
||||
import asyncio
|
||||
import threading
|
||||
from nvidia_sidecar.rate_limiter import AdaptiveTokenBucket, RetreatState
|
||||
|
||||
class TestRetreatConcurrency:
|
||||
"""避退模式并发安全回归测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_record_and_evaluate(self):
|
||||
"""多线程同时 record_response + evaluate_retreat 不死锁。"""
|
||||
bucket = AdaptiveTokenBucket(rate=40/60, capacity=40)
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker_record():
|
||||
for i in range(1000):
|
||||
try:
|
||||
bucket.record_response(is_429=(i % 10 == 0))
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def worker_evaluate():
|
||||
for _ in range(1000):
|
||||
try:
|
||||
bucket.evaluate_retreat()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker_record),
|
||||
threading.Thread(target=worker_record),
|
||||
threading.Thread(target=worker_evaluate),
|
||||
threading.Thread(target=worker_evaluate),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
# 所有线程必须在 10s 内完成(无死锁)
|
||||
assert all(not t.is_alive() for t in threads), "线程未完成,疑似死锁"
|
||||
assert not errors, f"并发错误: {errors}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_consume_and_retreat(self):
|
||||
"""多线程同时 consume + evaluate_retreat 不死锁。"""
|
||||
bucket = AdaptiveTokenBucket(rate=40/60, capacity=40)
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker_consume():
|
||||
for _ in range(500):
|
||||
try:
|
||||
bucket.consume(tokens=1)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def worker_retreat():
|
||||
for _ in range(500):
|
||||
try:
|
||||
bucket.record_response(is_429=False)
|
||||
bucket.evaluate_retreat()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker_consume),
|
||||
threading.Thread(target=worker_consume),
|
||||
threading.Thread(target=worker_retreat),
|
||||
threading.Thread(target=worker_retreat),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert all(not t.is_alive() for t in threads), "线程未完成,疑似死锁"
|
||||
assert not errors, f"并发错误: {errors}"
|
||||
|
||||
def test_retreat_state_transitions_under_load(self):
|
||||
"""高负载下避退状态转换正确。"""
|
||||
bucket = AdaptiveTokenBucket(
|
||||
rate=40/60, capacity=40,
|
||||
retreat_429_threshold=0.05,
|
||||
retreat_factor=0.75,
|
||||
)
|
||||
|
||||
# 模拟高 429 率
|
||||
for _ in range(100):
|
||||
bucket.record_response(is_429=True)
|
||||
|
||||
state = bucket.evaluate_retreat()
|
||||
assert state == RetreatState.RETREAT
|
||||
assert bucket.get_effective_rate_rpm() < bucket.get_base_rate_rpm()
|
||||
|
||||
# 模拟恢复
|
||||
for _ in range(200):
|
||||
bucket.record_response(is_429=False)
|
||||
|
||||
# 需要等待 RECOVER_WINDOW
|
||||
import time
|
||||
time.sleep(0.1) # 确保时间窗口过去
|
||||
bucket._last_state_change = 0 # 强制触发时间条件
|
||||
state = bucket.evaluate_retreat()
|
||||
assert state in (RetreatState.RECOVER, RetreatState.NORMAL)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dashboard UX 优化
|
||||
|
||||
### 7.1 优化项清单
|
||||
|
||||
| # | 优化项 | 实现方式 | 优先级 |
|
||||
|---|--------|---------|--------|
|
||||
| 1 | 队列柱状图 300ms 平滑动画 | CSS `transition: height 300ms ease` | P1 |
|
||||
| 2 | SSE 断连 5s 遮罩 | JS 定时器 + DOM 遮罩层 | P1 |
|
||||
| 3 | 队列图标题显示总排队数 | SSE 数据已有 `current_size`,更新标题 | P2 |
|
||||
| 4 | 页面加载同步配置 | `fetch('/api/admin/config')` 初始化表单 | P2 |
|
||||
|
||||
### 7.2 关键实现
|
||||
|
||||
```javascript
|
||||
// dashboard.html — SSE 断连检测
|
||||
let lastSSETime = Date.now();
|
||||
let reconnectMask = document.getElementById('reconnect-mask');
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
lastSSETime = Date.now();
|
||||
reconnectMask.style.display = 'none';
|
||||
// ... 更新 UI ...
|
||||
};
|
||||
|
||||
// 5s 无数据 → 显示遮罩
|
||||
setInterval(() => {
|
||||
if (Date.now() - lastSSETime > 5000) {
|
||||
reconnectMask.style.display = 'flex';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 队列柱状图动画
|
||||
// CSS: .queue-bar { transition: height 0.3s ease; }
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 页面加载时同步配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/config');
|
||||
if (resp.ok) {
|
||||
const config = await resp.json();
|
||||
document.getElementById('rate-rpm').value = config.rate_rpm;
|
||||
document.getElementById('queue-max').value = config.queue_max_size;
|
||||
// ...
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('配置加载失败(可能需要 Admin Token)', e);
|
||||
}
|
||||
}
|
||||
loadConfig();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施排期
|
||||
|
||||
| 阶段 | 内容 | 预估工时 | 依赖 |
|
||||
|------|------|---------|------|
|
||||
| **D1** | SidecarContext Step 1-3(解耦迁移) | 8h | 无 |
|
||||
| **D2** | Prometheus 标签收敛 + 日志增强 | 2h | D1 |
|
||||
| **D2** | SSE 共享缓存 | 2h | D1 |
|
||||
| **D2** | Readiness HTTP client 复用 | 1h | D1 |
|
||||
| **D3** | Dockerfile + systemd service | 2h | 无 |
|
||||
| **D3** | Dashboard UX 优化 | 3h | 无 |
|
||||
| **D3** | Retreat 并发回归测试 | 3h | 无 |
|
||||
| **D4** | 集成测试 + mypy strict | 4h | D1-D3 |
|
||||
| **合计** | | **25h** | |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准映射
|
||||
|
||||
| Issue 要求 | 本文档章节 | 状态 |
|
||||
|-----------|-----------|------|
|
||||
| SidecarContext / DI 方案落地或 ADR | §1 | ✅ 详细设计 + 迁移计划 |
|
||||
| Prometheus 高基数 label 收敛 | §2 | ✅ 收敛为 provider |
|
||||
| SSE snapshot 共享缓存 | §3 | ✅ 1s TTL 设计 |
|
||||
| Dockerfile + systemd + 部署 SOP | §4 | ✅ 完整文件 |
|
||||
| readiness 复用 HTTP client | §5 | ✅ 注入主 client |
|
||||
| retreat 并发/死锁回归测试 | §6 | ✅ 测试用例 |
|
||||
| Dashboard UX 细节 | §7 | ✅ 4 项优化 |
|
||||
Reference in New Issue
Block a user