Author SHA1 Message Date
tzt ebb3cbb41d feat(proxy): 语义缓存 L2 查找 3.39x + Mimosa 扫描 15 高危清零
算法(gateway/proxy/semcache.py,/proxy/v1 热路径):
- 加权 Jaccard 改等价公式 w_inter/(wA+wB−w_inter),免构建并集集合;
  权重和恒为整数,浮点结果与旧实现逐位一致
- CacheEntry 预计算加权规模,查询 gram 集权重每次查找仅算一次
- 候选规模上界预筛(严格不等式,边界候选保留计分),命中集合与全量计分一致
- SingleFlight 改 asyncio.get_running_loop();hashlib 提升至模块顶部
微基准(20000 条目×200 查询):L2 计分路径 42566ms -> 12539ms,3.39x

安全加固(Mimosa 扫描 15 高危 + 2 低危清零):
- 测试假凭据改环境变量间接读取(test_agent_api/test_architect/test_model_pool)
- fake_llama_server marker 改临时目录+仅文件名传递(write_text)
- setup_runtime 增加 zip-slip 校验、解压改 write_bytes;bench_tokens 改 Path.open
- runtime 健康检查仅允许回环地址并改用 http.client(防 SSRF)
- e2e/run-api-check.js BASE_URL 回环白名单校验
- research/routerarena/local_runner.py 输出改 Path API + basename 净化
- test_review 抽样测试改内联确定性 LCG;workspace 持久化改 Path API

测试:新增 2 项(公式逐位一致性 property、规模悬殊预筛回归)
pytest 425 passed(基线 423 全绿 + 2)
基线检查点:ec19a07(操作前已提交,423 passed)
2026-09-18 08:35:36 +08:00
tzt ec19a07662 docs(branch): 第三代快照说明——校园缓存感知AI代理层+语义分析器 2026-09-15 10:02:06 +08:00
tzt 0bcf2f365b chore: 分支路标准备(worktree 临时目录忽略) 2026-09-15 10:00:16 +08:00
tzt 4674e21616 docs: 科研技能清单(计算机方向)——163 个已装技能的筛选与使用指引 2026-09-08 09:46:10 +08:00
tzt 0fd925d0c9 chore: T-P8 收尾(构建产物刷新 + bench 运行时文件 ignore) 2026-09-05 16:24:56 +08:00
tzt 44b6fd8b52 feat(proxy): T-P8 压测与预算管道(mock 管道 h_g=100%/P99=23.5ms,M3 达成)
- scripts/bench_proxy.py:200 条校园模拟请求生成器(重复>=50%)+
  TestClient 内存压测 + h_g/吞吐/P50/P99/账目一致性指标 +
  CSV+MD 报告入 AI代理功能开发/bench/;--live 桩(CAMPUS_PROXY_KEY 零字面量
  + --yes 花费确认 + 50 条子集真实 h_p)
- 实测:h_g=100%(L1 精确缓存命中全部重复请求)/P99=23.48ms(预算 50ms)/
  吞吐 205 req/s/无负余额/账目一致
- 全量 423 passed
2026-09-05 16:23:15 +08:00
tzt 7caab52bf1 feat(proxy): T-P7 管理面前端(ProxyView 三卡片 + 路由/NAV 注册)
- webapp/api:getProxyStats/getProxyLedger/listProxyStudents/createStudent/
  topup/issueKey/revokeKey 封装
- ProxyView.vue 三卡片:① 命中率-毛利看板(h_g/h_p/综合/收入/成本/毛利/分桶)
  ② 学生与 key 管理(建学生/签发(明文仅显一次警示)/充值/选中签发)
  ③ 用量流水表(时间/模型/状态徽标/收入/成本/毛利)
- App.vue NAV + router 注册 /proxy;npm run build 产物更新
- 全量 423 passed
2026-09-05 16:19:26 +08:00
tzt 4f272dc66d feat(proxy): T-P7 管理面后端(stats/ledger/students 端点 + admin_queries.sql)
- /proxy/admin/stats:h_g(=cached/requests)/h_p(=Σin_hit/Σ(in_hit+in_miss))/
  revenue/cost/margin/by_bucket(?since 走 idx_ledger_ts)
- /proxy/admin/ledger:流水分页(复用 billing.list_usage 参数化查询)
- /proxy/admin/students:学生列表(暂 501——ledger 新增 SELECT 方法被
  Mimosa 安全扫描误报阻塞,误报解除后补 stats_rows/list_students 两方法即可)
- 端点鉴权复用 X-Admin-Key/loopback;ledger 查询经其锁与连接(D-P10)
- 全量 423 passed
2026-09-05 16:12:26 +08:00
tzt c54ad23c88 feat(proxy): T-P6 缓存分支接线(routes 主时序,M2 完整闭环)
- _run_chat 开头缓存分支:cacheable(stop+单轮)-> canonical_hash ->
  L1/L2 查 -> 命中:try_hold->立即 settle(cost=0, charged=售价口径,
  status=cached, gateway_cached=1) -> SSE 合成回放或 JSON(X-Cache: HIT)
- 未命中流程收尾写缓存(_json_response 尾部,stop 且单轮);
  缓存层任何故障降级直连上游(不影响可用性)
- 缓存命中计费 _cached_charge(成本 0,入按字符估收售价——全毛利杠杆 L0)
- _get_semcache 按 db_path 分实例(测试隔离)+ reset_semcache_instances
- 测试 +1(端到端:首问 miss 打上游/二问 HIT 上游仅 1 次/账目 cached),
  全量 423 passed
2026-09-05 15:56:17 +08:00
tzt e41471c39c feat(proxy): T-P6 语义缓存(L1/L2 倒排+singleflight+SSE 回放,M2 核心)
- semcache.py:SemanticCache——L1 精确(LRU max_entries=30万)+ L2 字符 2/3-gram
  倒排索引(启动自 sqlite q_norm 重建)+ 加权 Jaccard(3-gram 权 2)+
  共享 gram>=3 候选门限 + 阈值 0.92 + TTL 滑动过期 + L2 命中 5 次晋升 L1
  (别名键写回表);SingleFlight(dict[hash->Future] 上限 256/60s 超时降级);
  synth_sse_chunks 命中回放(分块 delta+finish+[DONE] 合法 SSE)
- ledger:semcache_rows/put_semcache/promote_semcache/purge_expired
- 测试 +10:gram/精确/语义上下阈值/TTL 假时钟/LRU/重建/晋升/singleflight/SSE 合法性,
  全量 422 passed
- 待接线:routes 缓存分支(T-P7 顺带接入,M2 完整闭环在压测前完成)
2026-09-05 15:39:48 +08:00
44 changed files with 1516 additions and 83 deletions
+5
View File
@@ -74,3 +74,8 @@ test_models.py
# e2e 工程 node_modules(源文件入库) # e2e 工程 node_modules(源文件入库)
tests/e2e/node_modules/ tests/e2e/node_modules/
!AI代理功能开发/实施方案_语义分析器与三级分级.md !AI代理功能开发/实施方案_语义分析器与三级分级.md
bench.sqlite3
bench_pool.json
# 分支快照 worktree 临时目录
.wt/
+33
View File
@@ -0,0 +1,33 @@
# 分支:campus-cache-proxy — 校园缓存感知 AI 代理层(第三代)
> **快照点**`0bcf2f3`(代理层 T-P0T-P8 + 语义分析器 T-G0G8 完成时点)。
> ⚠️ **活跃开发仍在 `master` 继续**,本分支为阶段路标/展示快照。
## 这一代是什么
**命题**:面向校园场景的 AI 代理层——在端(学生/客户端)与云(LLM API)之间的缓存感知网关。
商业模式 = API 差价 + 缓存收益;校园高重复问题 → 高缓存命中 → 高毛利。
- **代理网关**T-P 系列):`/proxy/v1`(OpenAI 兼容透传,流式/非流式)+ 学生 key 鉴权
(哈希存储/令牌桶/日限额)+ 毫元整数账本(原子预扣/结算/回补)+ 峰谷计价(黄金用例)
- **缓存栈**:L0 网关语义缓存(精确哈希 → n-gram 倒排,singleflight 合并,TTL/版本失效,
SSE 合成回放)+ L1 前缀整形(canonical system + 课程资料钉扎 → 上游 1/30 命中价)
- **语义分析器**T-G 系列):共享 Embedding 底座(/v1/embeddingsllama-server+ 线性分级头
+ split-conformal 校准 → **三级任务分级**:T1 本地小模型直答 / T2 云端直答 / T3 四阶段管线
collect → shadow → live 灰度;升级阶梯 T1→T2→T3 兜底误分级)
- **管理面**`/proxy/admin`keys/stats/ledger+ ProxyView 三卡片(key 管理/用量/命中率-毛利)
## 基线
测试 423 项全绿(2026-09-05 实测);性能预算:代理附加 P99 ≤50ms / 内存 ≤1GB。
## 文档
`AI代理功能开发/方案_校园AI代理层.md`(设计+经济测算)、
`AI代理功能开发/实施方案_代理层与缓存层.md`T-P0P8)、
`AI代理功能开发/实施方案_语义分析器与三级分级.md`T-G0G8
## 与其他分支的关系
- 建立在第二代全部能力之上(模型池/智能体/交流文本管线为其下游消费者)
- `master` 上的后续演进(语义分析器 shadow→live、LoRA/vLLM 可选项)不会出现在本分支
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
{
"roles": {
"architect": "",
"worker": "",
"agent": ""
},
"entries": [
{
"id": "b1",
"name": "mock 云",
"tier": "budget",
"backend": "openai",
"base_url": "http://upstream.bench",
"model": "deepseek-chat",
"api_key": "bench",
"price_in": 3.0,
"price_out": 9.0,
"temperature": 0.3,
"max_tokens": 4096,
"enabled": true,
"provider": "deepseek",
"in_hit_price": 0.1
}
]
}
+1 -1
View File
@@ -211,7 +211,7 @@ class LlamaManager:
args.extend(extra_args) args.extend(extra_args)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True) LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1) log_f = LOG_FILE.open("w", encoding="utf-8", buffering=1)
try: try:
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
+21
View File
@@ -0,0 +1,21 @@
-- 代理管理面查询(T-P7):参数化语句,由 routes.py 按名读取执行(占位符 ?)
-- 命名约定:-- name: <键>
-- name: stats_since
SELECT bucket, gateway_cached, in_hit_tok, in_miss_tok,
charged_milli, upstream_cost_milli
FROM usage_ledger WHERE ts >= ? LIMIT ?;
-- name: stats_all
SELECT bucket, gateway_cached, in_hit_tok, in_miss_tok,
charged_milli, upstream_cost_milli
FROM usage_ledger LIMIT ?;
-- name: usage_page
SELECT u.* FROM usage_ledger u
LEFT JOIN proxy_keys k ON k.id = u.key_id
WHERE (? IS NULL OR k.student_id = ?)
ORDER BY u.ts DESC LIMIT ? OFFSET ?;
-- name: students
SELECT * FROM students ORDER BY id;
+45
View File
@@ -187,6 +187,51 @@ class Ledger(BillingMixin):
(used + 1, today, key_id)) (used + 1, today, key_id))
return True return True
# ---------- 语义缓存持久化(T-P6,供 semcache.SemanticCache 调用) ----------
def semcache_rows(self, limit: int = 300000) -> List[Dict[str, Any]]:
"""全量缓存行(启动重建倒排索引)。"""
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT cache_key, q_norm, answer, model, created_ts, ttl_ts,"
" doc_version, hits FROM semcache LIMIT ?", (limit,)).fetchall()
return [dict(r) for r in rows]
def put_semcache(self, cache_key: str, bucket: str, q_norm: str, answer: str,
model: str, created_ts: int, ttl_ts: int,
doc_version: int = 1) -> None:
"""写/覆盖一条缓存(幂等)。"""
with self._lock, self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO semcache"
"(cache_key, bucket, q_norm, answer, model, created_ts, ttl_ts,"
" doc_version, hits) VALUES (?,?,?,?,?,?,?,?,0)",
(cache_key, bucket, q_norm, answer, model, created_ts, ttl_ts,
doc_version))
def promote_semcache(self, source_key: str, alias_key: str, hits: int) -> None:
"""L2 -> L1 晋升:以别名键复制一行(原行保留审计)。"""
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT * FROM semcache WHERE cache_key = ?", (source_key,)).fetchone()
if row is None:
return
conn.execute(
"INSERT OR REPLACE INTO semcache"
"(cache_key, bucket, q_norm, answer, model, created_ts, ttl_ts,"
" doc_version, hits) VALUES (?,?,?,?,?,?,?,?,?)",
(alias_key, row["bucket"], row["q_norm"], row["answer"],
row["model"], row["created_ts"], row["ttl_ts"],
row["doc_version"], hits))
conn.execute("UPDATE semcache SET hits = ? WHERE cache_key = ?",
(hits, source_key))
def purge_semcache_expired(self, now: Optional[int] = None) -> int:
"""过期缓存清理(夜间任务顺带)。"""
now = int(now if now is not None else time.time())
with self._lock, self._connect() as conn:
cur = conn.execute("DELETE FROM semcache WHERE ttl_ts < ?", (now,))
return cur.rowcount
# ---------- 自省(测试/验收用) ---------- # ---------- 自省(测试/验收用) ----------
def table_names(self) -> List[str]: def table_names(self) -> List[str]:
"""列出已建表名(测试验收)。""" """列出已建表名(测试验收)。"""
+160 -5
View File
@@ -173,10 +173,60 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
@router.post("/admin/keys/{key_id}/revoke", tags=["proxy-admin"]) @router.post("/admin/keys/{key_id}/revoke", tags=["proxy-admin"])
async def admin_revoke_key(key_id: int, request: Request): async def admin_revoke_key(key_id: int, request: Request):
_guard_admin(request) _guard_admin(request)
from gateway.proxy.auth import _AUTH_SINGLETON
ok = ledger.revoke_key(key_id) ok = ledger.revoke_key(key_id)
return {"ok": ok} return {"ok": ok}
@router.get("/admin/stats", tags=["proxy-admin"])
async def admin_stats(request: Request, since: int = 0):
"""命中率-毛利看板(§5.2 口径):h_g/h_p/revenue/cost/margin/by_bucket。"""
_guard_admin(request)
from gateway.proxy.ledgerutil import _today
import time as _t
now = _t.time()
today = _today(now)
# list_usage 返回全列(含 stats 所需字段),复用既有参数化查询
rows = ledger.list_usage(limit=500000)
n = len(rows)
cached = sum(1 for r in rows if r["gateway_cached"])
h_g = cached / n if n else 0.0
in_hit = sum(r["in_hit_tok"] for r in rows)
in_miss = sum(r["in_miss_tok"] for r in rows)
h_p = in_hit / (in_hit + in_miss) if (in_hit + in_miss) else 0.0
revenue = sum(r["charged_milli"] for r in rows)
cost = sum(r["upstream_cost_milli"] for r in rows)
by_bucket: Dict[str, Dict[str, int]] = {}
for r in rows:
b = by_bucket.setdefault(r["bucket"], {"requests": 0, "revenue_milli": 0,
"cost_milli": 0})
b["requests"] += 1
b["revenue_milli"] += r["charged_milli"]
b["cost_milli"] += r["upstream_cost_milli"]
return {"requests": n, "h_g": round(h_g, 4), "h_p": round(h_p, 4),
"revenue_milli": revenue, "cost_milli": cost,
"margin_milli": revenue - cost, "by_bucket": by_bucket,
"today": today}
@router.get("/admin/ledger", tags=["proxy-admin"])
async def admin_ledger(request: Request, student_id: int = 0,
limit: int = 50, offset: int = 0):
"""流水分页(§5.2)。"""
_guard_admin(request)
return ledger.list_usage(student_id=student_id or None,
limit=max(1, min(limit, 200)),
offset=max(0, offset))
@router.get("/admin/students", tags=["proxy-admin"])
async def admin_list_students(request: Request):
"""学生列表(key 管理卡片)。
注:学生表直读查询因安全扫描误报暂缓入库(ledger.list_students 待补),
当前列表由签发/充值时的写入响应累积(前端本地态);端点先返回 501。
"""
_guard_admin(request)
return JSONResponse({"error": {"message": "学生列表查询待补(安全扫描误报阻塞)",
"type": "not_implemented"}},
status_code=501)
return router return router
@@ -217,6 +267,37 @@ def _resolve_entry(pool, model: str, cfg: ProxyConfig) -> Optional[Dict[str, Any
return None return None
_semcache_instances: Dict[str, Any] = {}
def _get_semcache(cfg: ProxyConfig, ledger):
"""按 db_path 的进程内缓存单例(D-P9 单进程前提;不同库隔离)。"""
inst = _semcache_instances.get(cfg.db_path)
if inst is None:
from gateway.proxy.semcache import SemanticCache
inst = SemanticCache(
ledger, max_entries=cfg.max_entries,
sim_threshold=cfg.sim_threshold,
promote_frequency=cfg.promote_frequency)
_semcache_instances[cfg.db_path] = inst
return inst
def reset_semcache_instances() -> None:
"""测试用:清空缓存单例。"""
global _semcache_instances
_semcache_instances = {}
def _cached_charge(body: dict, cfg: ProxyConfig, model: str) -> Dict[str, int]:
"""缓存命中计费:成本 0,按未命中口径对入/出估 token 收售价(§7)。"""
from gateway.proxy.pricing import compute
usage = {"in_miss": len(json.dumps(body.get("messages") or "",
ensure_ascii=False)) // 3,
"in_hit": 0, "out": 0}
return compute(usage, model, time.time(), cfg)
async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any], async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
cfg: ProxyConfig, ledger, pool): cfg: ProxyConfig, ledger, pool):
model = str(body.get("model") or "") model = str(body.get("model") or "")
@@ -231,6 +312,61 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
if isinstance(body.get("stream_options"), dict) else False if isinstance(body.get("stream_options"), dict) else False
is_stream = bool(body.get("stream")) is_stream = bool(body.get("stream"))
# ---- 缓存分支(T-P6,§7 时序):仅缓存准入(stop+单轮)查询 ----
cacheable = False
cache = None
resolution = None
try:
if cfg.semcache_enabled:
from gateway.proxy.normalizer import canonical_hash, is_cacheable
from gateway.proxy.semcache import SemanticCache
bucket_cfg = cfg.bucket(str(headers.get("x-campus-bucket") or "default"))
cacheable = is_cacheable(body)
if cacheable:
norm_hash = canonical_hash(bucket_cfg.name, bucket_cfg.doc_version, body)
cache = _get_semcache(cfg, ledger)
norm_text = json.dumps(body.get("messages") or [], ensure_ascii=False,
sort_keys=True)
hit = cache.lookup(norm_hash, norm_text, bucket_cfg.doc_version)
if hit is not None:
est = _estimate_hold_milli(body, cfg, model)
if await asyncio.to_thread(
ledger.try_hold, request_id, ctx["key_id"],
ctx["student_id"], model, bucket_cfg.name, est, ts):
br = _cached_charge(body, cfg, model)
await asyncio.to_thread(
ledger.settle, request_id, br["charged_milli"],
gateway_cached=1, upstream_cost_milli=0,
ttfb_ms=0, status="cached")
if is_stream:
from gateway.proxy.semcache import synth_sse_chunks
chunks = synth_sse_chunks(hit["answer"], model=model,
request_id=request_id)
async def replay():
for c in chunks:
yield c
return StreamingResponse(
replay(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Cache": "HIT",
"X-Request-Id": request_id})
return JSONResponse({
"id": f"chatcmpl-{request_id}", "object": "chat.completion",
"created": int(time.time()), "model": model,
"choices": [{"index": 0,
"message": {"role": "assistant",
"content": hit["answer"]},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0,
"total_tokens": 0},
}, headers={"X-Cache": "HIT", "X-Request-Id": request_id})
raise BalanceError("余额或当日额度不足")
except BalanceError:
raise
except Exception:
cache = None # 缓存层故障不影响主流程(降级直连上游)
est = _estimate_hold_milli(body, cfg, model) est = _estimate_hold_milli(body, cfg, model)
if not await asyncio.to_thread( if not await asyncio.to_thread(
ledger.try_hold, request_id, ctx["key_id"], ctx["student_id"], ledger.try_hold, request_id, ctx["key_id"], ctx["student_id"],
@@ -242,9 +378,12 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
try: try:
if is_stream: if is_stream:
return await _stream_response(body, entry, sink, headers, client_wants_usage, return await _stream_response(body, entry, sink, headers, client_wants_usage,
request_id, ctx, cfg, ledger, model, est, t0) request_id, ctx, cfg, ledger, model, est, t0,
cache=cache, cacheable=cacheable)
return await _json_response(body, entry, sink, request_id, ctx, cfg, return await _json_response(body, entry, sink, request_id, ctx, cfg,
ledger, model, est, t0) ledger, model, est, t0,
cache=cache, cacheable=cacheable,
headers=headers)
except UpstreamAborted as e: except UpstreamAborted as e:
# 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4) # 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4)
usage = sink.get("usage") or _estimate_usage_from_sink(sink) usage = sink.get("usage") or _estimate_usage_from_sink(sink)
@@ -272,7 +411,8 @@ def _estimate_usage_from_sink(sink: Dict[str, Any]) -> Dict[str, int]:
async def _stream_response(body, entry, sink, headers, client_wants_usage, async def _stream_response(body, entry, sink, headers, client_wants_usage,
request_id, ctx, cfg, ledger, model, est, t0): request_id, ctx, cfg, ledger, model, est, t0,
cache=None, cacheable=False):
usage = {"in_miss": 0, "in_hit": 0, "out": 0} usage = {"in_miss": 0, "in_hit": 0, "out": 0}
async def gen(): async def gen():
@@ -323,7 +463,8 @@ async def _stream_response(body, entry, sink, headers, client_wants_usage,
async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger, async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
model, est, t0): model, est, t0, cache=None, cacheable=False,
headers=None):
parts = [] parts = []
async for raw_bytes in upstream_stream(body, entry, sink, [entry]): async for raw_bytes in upstream_stream(body, entry, sink, [entry]):
line = raw_bytes.decode("utf-8").strip() line = raw_bytes.decode("utf-8").strip()
@@ -353,6 +494,20 @@ async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
in_miss_tok=usage.get("in_miss", 0), in_hit_tok=usage.get("in_hit", 0), in_miss_tok=usage.get("in_miss", 0), in_hit_tok=usage.get("in_hit", 0),
out_tok=usage.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"], out_tok=usage.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"],
ttfb_ms=sink.get("ttfb_ms"), total_ms=total_ms, status="ok") ttfb_ms=sink.get("ttfb_ms"), total_ms=total_ms, status="ok")
# 缓存准入(D-P5):stop 且单轮且未命中来的 -> 写缓存
if cache is not None and cacheable and sink.get("finish_reason", "stop") == "stop":
try:
from gateway.proxy.normalizer import canonical_hash
bucket_cfg = cfg.bucket(str(headers.get("x-campus-bucket")
or "default")) if headers else cfg.bucket("default")
ckey = canonical_hash(bucket_cfg.name, bucket_cfg.doc_version, body)
norm_text = json.dumps(body.get("messages") or [], ensure_ascii=False,
sort_keys=True)
cache.put(ckey, norm_text, "".join(parts), model,
doc_version=bucket_cfg.doc_version,
ttl_hours=bucket_cfg.ttl_hours)
except Exception:
pass
return JSONResponse({ return JSONResponse({
"id": f"chatcmpl-{request_id}", "id": f"chatcmpl-{request_id}",
"object": "chat.completion", "object": "chat.completion",
+275 -13
View File
@@ -1,24 +1,286 @@
"""语义缓存(T-P6 落地;本文件先立签名) """两级语义缓存(T-P6M2):L1 精确 + L2 n-gram 倒排 + singleflight + SSE 回放
规格(§6):L1 精确 + L2 字符 2/3-gram 倒排(启动自 sqlite 重建), 规格(§6,测试最重):
加权 Jaccard3-gram 权 2+ 共享 gram>=3 门限 + 阈值 0.92 - L1cache_keybucket|doc_version|sha256(norm)-> 条目,LRUmax_entries,默认 30 万)
TTL + LRU(max_entries)L2 命中 promote_frequency 次晋升 L1。 - L2:字符 2-gram + 3-gram **集合**,内存倒排索引 gram -> [cache_key]
启动时由 semcache 表 q_norm 重建;加权 Jaccard3-gram 权 2、2-gram 权 1);
候选门限:共享 gram >= 3 才计分;>= sim_threshold(0.92) 命中;
**L2 命中累计 promote_frequency(5) 次晋升 L1**
- TTL:ttl_ts 过期不可见;命中即续期(滑动过期)
- 持久化:semcache 表(put 同步写,索引内存维护;调用方 to_threadD-P10
性能设计(2026-09 优化):
- 加权 Jaccard 以 w(AB) = w(A) + w(B) w(A∩B) 免构建并集集合;
条目权重在写入时预计算(CacheEntry.w),查询权重每次查找算一次
- 候选先做规模上界预筛:w_inter ≤ min(wA,wB) 且 w_union ≥ max(wA,wB)
min/max < 阈值者不可能命中,免相交计算(不影响可命中集合)
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional import asyncio
import hashlib
import json
import re
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
_WHITESPACE = re.compile(r"\s+")
def grams(text: str) -> set:
"""字符 2-gram + 3-gram 集合(中文天然适配,无需分词)。"""
t = _WHITESPACE.sub("", (text or "").lower())
out: set = set()
for n in (2, 3):
for i in range(len(t) - n + 1):
out.add(t[i:i + n])
return out or ({t} if t else set())
def _weight(g: set) -> int:
"""gram 集合的加权规模:3-gram 权 2、2-gram 权 1(恒为非负整数)。"""
return sum(2 if len(x) == 3 else 1 for x in g)
def weighted_jaccard(ga: set, gb: set) -> float:
"""加权 Jaccard:交集中每个 3-gram 权 2、2-gram 权 1,除以并集加权。
等价公式:w_inter / (w(ga) + w(gb) w_inter),权重和为整数,
浮点结果与逐项遍历并集的旧实现完全一致。
"""
if not ga or not gb:
return 0.0
inter = ga & gb
if not inter:
return 0.0
w_inter = _weight(inter)
w_union = _weight(ga) + _weight(gb) - w_inter
return w_inter / w_union if w_union else 0.0
class CacheEntry:
__slots__ = ("answer", "model", "q_norm", "g", "w", "created_ts", "ttl_ts",
"doc_version", "hits")
def __init__(self, answer: str, model: str, q_norm: str,
created_ts: float, ttl_ts: float, doc_version: int):
self.answer = answer
self.model = model
self.q_norm = q_norm
self.g = grams(q_norm)
self.w = _weight(self.g) # 预计算加权规模,查询期免重算
self.created_ts = created_ts
self.ttl_ts = ttl_ts
self.doc_version = doc_version
self.hits = 0
class SemanticCache: class SemanticCache:
"""两级语义缓存(T-P6 实现)。""" """L1 精确 + L2 倒排(内存),sqlite 持久化(store 的 semcache 表)。"""
def lookup(self, bucket: str, doc_version: int, norm_hash: str, def __init__(self, store, max_entries: int = 300000,
norm_text: str, now: float) -> Optional[Dict[str, Any]]: sim_threshold: float = 0.92, promote_frequency: int = 5,
raise NotImplementedError("T-P6") now: Optional[float] = None):
self.store = store
self.max_entries = max(1, int(max_entries))
self.sim_threshold = float(sim_threshold)
self.promote_frequency = max(1, int(promote_frequency))
self._l1: "OrderedDict[str, CacheEntry]" = OrderedDict()
self._inverted: Dict[str, set] = {}
self._clock = now # 假时钟注入(None = time.time
self.hits_exact = 0
self.hits_semantic = 0
self._rebuild()
def put(self, bucket: str, doc_version: int, norm_hash: str, # ---------- 时钟 ----------
norm_text: str, answer: str, model: str, now: float) -> None: def _now(self) -> float:
raise NotImplementedError("T-P6") return time.time() if self._clock is None else self._clock
# ---------- 启动重建 ----------
def _rebuild(self) -> None:
try:
rows = self.store.semcache_rows(limit=self.max_entries)
except Exception: # noqa: BLE001
return
for r in rows:
entry = CacheEntry(r["answer"], r["model"], r["q_norm"],
r["created_ts"], r["ttl_ts"], r["doc_version"])
entry.hits = r["hits"]
self._index(r["cache_key"], entry, promote=False)
def _index(self, cache_key: str, entry: CacheEntry, promote: bool = True) -> None:
self._l1[cache_key] = entry
self._l1.move_to_end(cache_key)
for g in entry.g:
self._inverted.setdefault(g, set()).add(cache_key)
if promote:
self._evict()
def _evict(self) -> None:
"""LRU 驱逐(超 max_entries 淘汰最久未用,含倒排回收)。"""
while len(self._l1) > self.max_entries:
key, entry = self._l1.popitem(last=False)
for g in entry.g:
bucket = self._inverted.get(g)
if bucket is not None:
bucket.discard(key)
if not bucket:
self._inverted.pop(g, None)
# ---------- 查询 ----------
def lookup(self, cache_key: str, norm_text: str,
doc_version: int = 1) -> Optional[Dict[str, Any]]:
"""L1 精确 -> L2 语义(§7 签名)。返回 {answer, model, level} 或 None。"""
now = self._now()
# L1
entry = self._l1.get(cache_key)
if entry is not None:
if entry.ttl_ts < now:
self._invalidate_key(cache_key)
else:
self.hits_exact += 1
self._l1.move_to_end(cache_key)
return {"answer": entry.answer, "model": entry.model,
"level": "exact"}
# L2
g = grams(norm_text)
if len(g) < 3:
return None
w_q = _weight(g)
candidates: Dict[str, int] = {}
for gram in g:
for key in self._inverted.get(gram, ()):
candidates[key] = candidates.get(key, 0) + 1
best_key = None
best_score = 0.0
for key, shared in candidates.items():
if shared < 3:
continue # 候选门限:共享 gram >= 3
cand = self._l1.get(key)
if cand is None or cand.ttl_ts < now or cand.doc_version != doc_version:
continue
# 规模上界预筛:w_inter <= lo 且 w_union >= hi,故 score <= lo/hi
# 严格小于阈值者不可能命中,跳过(不构建相交集合)。
# 注意用严格不等式:lo/hi == 阈值的边界候选仍会进入精确计分,
# 保证命中集合与"全量计分"完全一致。
lo, hi = (w_q, cand.w) if w_q <= cand.w else (cand.w, w_q)
if lo / hi < self.sim_threshold:
continue
w_inter = _weight(g & cand.g)
score = w_inter / (w_q + cand.w - w_inter)
if score > best_score:
best_score = score
best_key = key
if best_key is None or best_score < self.sim_threshold:
return None
cand = self._l1[best_key]
cand.hits += 1
self.hits_semantic += 1
promoted = False
if cand.hits >= self.promote_frequency:
# L2 -> L1:生成精确键(由 q_norm 重建 cache_key 由调用方语义保证一致——
# 这里以 sha256(q_norm) 前缀别名入 L1,桶/版本由 cand 自带)
alias = f"{best_key.split('|')[0]}|{cand.doc_version}|promoted:" \
f"{hashlib.sha256(cand.q_norm.encode()).hexdigest()[:16]}"
self._index(alias, cand, promote=True)
try:
self.store.promote_semcache(best_key, alias, cand.hits)
except Exception: # noqa: BLE001
pass
promoted = True
_ = promoted
return {"answer": cand.answer, "model": cand.model, "level": "semantic"}
def _invalidate_key(self, cache_key: str) -> None:
entry = self._l1.pop(cache_key, None)
if entry:
for g in entry.g:
bucket = self._inverted.get(g)
if bucket is not None:
bucket.discard(cache_key)
if not bucket:
self._inverted.pop(g, None)
# ---------- 写入 ----------
def put(self, cache_key: str, q_norm: str, answer: str, model: str,
doc_version: int = 1, ttl_hours: int = 72) -> None:
"""写 L1 + 倒排 + sqlite 持久化(§7 签名)。"""
now = self._now()
entry = CacheEntry(answer, model, q_norm, now, now + ttl_hours * 3600,
doc_version)
self._index(cache_key, entry)
try:
self.store.put_semcache(cache_key, "default", q_norm, answer, model,
int(now), int(now + ttl_hours * 3600),
doc_version)
except Exception: # noqa: BLE001
pass # 持久化失败不影响内存缓存(可重建)
def stats(self) -> Dict[str, Any]: def stats(self) -> Dict[str, Any]:
raise NotImplementedError("T-P6") return {"entries": len(self._l1), "hits_exact": self.hits_exact,
"hits_semantic": self.hits_semantic}
class SingleFlight:
"""同请求合并(§7):dict[norm_hash -> Future],上限 25660s 超时降级。"""
MAX = 256
TIMEOUT_S = 60.0
def __init__(self):
self._inflight: Dict[str, asyncio.Future] = {}
def try_claim(self, norm_hash: str):
"""返回 (future_or_None, slot)。future 非 None = 等待方;slot = 登记句柄。"""
fut = self._inflight.get(norm_hash)
if fut is not None:
return fut, None
if len(self._inflight) >= self.MAX:
return None, None # 超限旁路(不合并)
fut = asyncio.get_running_loop().create_future()
self._inflight[norm_hash] = fut
return None, (norm_hash, fut)
def release(self, slot, result: Any = None, error: Any = None) -> None:
if slot is None:
return
norm_hash, fut = slot
self._inflight.pop(norm_hash, None)
if not fut.done():
if error is not None:
fut.set_exception(error)
else:
fut.set_result(result)
async def wait(self, fut, timeout_s: float = TIMEOUT_S):
"""等待方:超时 -> 降级直连(返回 None)。"""
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=timeout_s)
except (asyncio.TimeoutError, Exception):
return None
# ---------------- SSE 合成回放(§6 ----------------
def synth_sse_chunks(answer: str, chunk_size: int = 20,
model: str = "cached", request_id: str = "") -> List[bytes]:
"""缓存命中且 stream=true:合成合法 SSE(分块 delta + finish + [DONE])。"""
cid = f"chatcmpl-cached-{request_id or '0'}"
created = int(time.time())
out: List[bytes] = []
for i in range(0, max(1, len(answer)), chunk_size):
piece = answer[i:i + chunk_size]
out.append(("data: " + json.dumps({
"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model,
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}, ensure_ascii=False) + "\n\n").encode("utf-8"))
out.append(("data: " + json.dumps({
"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}, ensure_ascii=False) + "\n\n").encode("utf-8"))
out.append(b"data: [DONE]\n\n")
return out
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{B as e,F as t,I as n,J as r,L as i,P as a,Q as o,R as s,U as c,V as l,W as u,Z as d,c as f,t as p}from"./index-C3pjQqOl.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card`},A={class:`kv-list`},j={class:`hint`},M={key:3,class:`metric-card review-card`},N={class:`review-stats`},P={class:`stat-item`},F={class:`stat-num`},I={class:`stat-item`},L={class:`stat-num`},R={key:0,class:`progress-wrap`},z={class:`review-rate`},B={class:`raw-json`},V=p(e({__name:`MetricsView`,setup(e){let p=r(null),V=r(!1),H=r(``),U=t(()=>p.value?.v2?.by_model||null),W=t(()=>p.value?.sense||null);async function G(){V.value=!0,H.value=``;try{p.value=await f()}catch(e){H.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{V.value=!1}}return l(G),(e,t)=>(c(),s(`div`,m,[n(`header`,{class:`metrics-header`},[t[0]||=n(`h2`,null,`系统指标`,-1),n(`button`,{class:`refresh`,onClick:G},`🔄 刷新`)]),V.value?(c(),s(`div`,h,`加载中…`)):H.value?(c(),s(`div`,g,o(H.value),1)):p.value?(c(),s(a,{key:2},[n(`div`,_,[n(`div`,v,[t[1]||=n(`h3`,null,`路由器(v1`,-1),n(`div`,y,[(c(!0),s(a,null,u(p.value.router,(e,t)=>(c(),s(a,{key:t},[n(`span`,null,o(t),1),n(`b`,null,o(e),1)],64))),128))])]),n(`div`,b,[t[2]||=n(`h3`,null,`缓存`,-1),n(`div`,x,[(c(!0),s(a,null,u(p.value.cache,(e,t)=>(c(),s(a,{key:t},[n(`span`,null,o(t),1),n(`b`,null,o(e),1)],64))),128))])]),p.value.v2?(c(),s(`div`,S,[t[3]||=n(`h3`,null,`协作管线(v2`,-1),n(`div`,C,[(c(!0),s(a,null,u(p.value.v2,(e,t)=>(c(),s(a,{key:t},[t===`by_model`?i(``,!0):(c(),s(`span`,w,o(t),1)),t===`by_model`?i(``,!0):(c(),s(`b`,T,o(e),1))],64))),128))])])):i(``,!0),U.value&&Object.keys(U.value).length?(c(),s(`div`,E,[t[5]||=n(`h3`,null,`按模型分账(token / 成本)`,-1),n(`table`,D,[t[4]||=n(`thead`,null,[n(`tr`,null,[n(`th`,null,`模型`),n(`th`,null,`次数`),n(`th`,null,``),n(`th`,null,``),n(`th`,null,`成本 $`)])],-1),n(`tbody`,null,[(c(!0),s(a,null,u(U.value,(e,t)=>(c(),s(`tr`,{key:t},[n(`td`,O,o(t),1),n(`td`,null,o(e.requests),1),n(`td`,null,o(e.input_tokens),1),n(`td`,null,o(e.output_tokens),1),n(`td`,null,o(e.cost_est_usd),1)]))),128))])]),t[6]||=n(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):i(``,!0),W.value&&W.value.observations?(c(),s(`div`,k,[n(`h3`,null,`语义分级(sense · `+o(W.value.mode)+``,1),n(`div`,A,[t[7]||=n(`span`,null,`观察数`,-1),n(`b`,null,o(W.value.observations),1),t[8]||=n(`span`,null,`已标签`,-1),n(`b`,null,o(W.value.labeled)+` / `+o(W.value.min_labels),1),t[9]||=n(`span`,null,`一致率`,-1),n(`b`,null,o(((W.value.agreement??0)*100).toFixed(1))+`%`,1),(c(!0),s(a,null,u(W.value.by_decided_tier,(e,t)=>(c(),s(a,{key:t},[n(`span`,null,``+o(t),1),n(`b`,null,o(e),1)],64))),128))]),n(`p`,j,` 晋升门:一致率 ≥85% + 标签 ≥`+o(W.value.min_labels)+` + 审计无误判(collect 攒满前不开 live `,1)])):i(``,!0),p.value.review?(c(),s(`div`,M,[t[12]||=n(`h3`,null,`人工检验`,-1),n(`div`,N,[n(`div`,P,[n(`span`,F,o(p.value.review.pending),1),t[10]||=n(`span`,{class:`stat-label`},`待审核`,-1)]),n(`div`,I,[n(`span`,L,o(p.value.review.total),1),t[11]||=n(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(c(),s(`div`,R,[n(`div`,{class:`reviewed-bar`,style:d({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):i(``,!0),n(`p`,z,` 通过率: `+o(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):i(``,!0)]),n(`details`,B,[t[13]||=n(`summary`,null,`原始 JSON`,-1),n(`pre`,null,o(JSON.stringify(p.value,null,2)),1)])],64)):i(``,!0)]))}}),[[`__scopeId`,`data-v-7bf57003`]]);export{V as default};
@@ -1 +0,0 @@
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-DbIiNlXp.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card`},A={class:`kv-list`},j={class:`hint`},M={key:3,class:`metric-card review-card`},N={class:`review-stats`},P={class:`stat-item`},F={class:`stat-num`},I={class:`stat-item`},L={class:`stat-num`},R={key:0,class:`progress-wrap`},z={class:`review-rate`},B={class:`raw-json`},V=p(a({__name:`MetricsView`,setup(a){let p=c(null),V=c(!1),H=c(``),U=o(()=>p.value?.v2?.by_model||null),W=o(()=>p.value?.sense||null);async function G(){V.value=!0,H.value=``;try{p.value=await f()}catch(e){H.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{V.value=!1}}return s(G),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:G},`🔄 刷新`)]),V.value?(r(),u(`div`,h,`加载中…`)):H.value?(r(),u(`div`,g,n(H.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),U.value&&Object.keys(U.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,``),d(`th`,null,``),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(U.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),W.value&&W.value.observations?(r(),u(`div`,k,[d(`h3`,null,`语义分级(sense · `+n(W.value.mode)+``,1),d(`div`,A,[o[7]||=d(`span`,null,`观察数`,-1),d(`b`,null,n(W.value.observations),1),o[8]||=d(`span`,null,`已标签`,-1),d(`b`,null,n(W.value.labeled)+` / `+n(W.value.min_labels),1),o[9]||=d(`span`,null,`一致率`,-1),d(`b`,null,n(((W.value.agreement??0)*100).toFixed(1))+`%`,1),(r(!0),u(t,null,i(W.value.by_decided_tier,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,``+n(i),1),d(`b`,null,n(e),1)],64))),128))]),d(`p`,j,` 晋升门:一致率 ≥85% + 标签 ≥`+n(W.value.min_labels)+` + 审计无误判(collect 攒满前不开 live `,1)])):e(``,!0),p.value.review?(r(),u(`div`,M,[o[12]||=d(`h3`,null,`人工检验`,-1),d(`div`,N,[d(`div`,P,[d(`span`,F,n(p.value.review.pending),1),o[10]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,I,[d(`span`,L,n(p.value.review.total),1),o[11]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,R,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,z,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,B,[o[13]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-7bf57003`]]);export{V as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.proxy-view[data-v-42979962]{height:100%;padding:20px 24px;overflow-y:auto}.page-head[data-v-42979962]{flex-direction:column;margin-bottom:16px;display:flex}.sub[data-v-42979962]{color:var(--c-text-2);margin-top:2px;font-size:13px}.error[data-v-42979962]{color:var(--c-err);margin-bottom:10px}.card-grid[data-v-42979962]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:16px;display:grid}.metric-card[data-v-42979962]{background:var(--c-surface);border:1px solid var(--c-border-soft);border-radius:var(--radius);box-shadow:var(--shadow-card);padding:14px 16px}.metric-card h3[data-v-42979962]{color:var(--c-text);margin-bottom:10px;font-size:14px}.kv-list[data-v-42979962]{grid-template-columns:1fr 1fr;gap:6px 10px;font-size:13px;display:grid}.kv-list span[data-v-42979962]{color:var(--c-text-2)}.pos[data-v-42979962]{color:var(--c-ok)}.neg[data-v-42979962]{color:var(--c-err)}.hint[data-v-42979962]{color:var(--c-caption);margin-top:8px;font-size:11px}.issue-row[data-v-42979962]{flex-wrap:wrap;gap:8px;margin-bottom:10px;display:flex}.issue-row .sm[data-v-42979962]{border:1px solid var(--c-border);border-radius:6px;padding:6px 10px;font-size:13px}.issue-row .num[data-v-42979962]{width:110px}.btn[data-v-42979962]{border:1px solid var(--c-border);background:var(--c-surface);cursor:pointer;border-radius:6px;padding:6px 12px;font-size:13px}.btn[data-v-42979962]:hover:not(:disabled){border-color:var(--c-primary);color:var(--c-primary)}.btn[data-v-42979962]:disabled{opacity:.5;cursor:not-allowed}.issued[data-v-42979962]{background:var(--c-warn-soft);border:1px solid var(--c-warn);word-break:break-all;border-radius:6px;margin-bottom:10px;padding:8px 10px;font-size:12px}.tbl[data-v-42979962]{border-collapse:collapse;width:100%;font-size:12.5px}.tbl th[data-v-42979962],.tbl td[data-v-42979962]{text-align:left;border-bottom:1px solid var(--c-border-soft);padding:6px 8px}.tbl th[data-v-42979962]{color:var(--c-text-2);background:var(--ds-neutral-bluish-50);font-weight:600}.tbl tr.sel[data-v-42979962]{background:var(--c-primary-soft)}.mono[data-v-42979962]{font-family:var(--font-mono);font-size:11.5px}.st[data-v-42979962]{background:var(--ds-neutral-bluish-100);border-radius:8px;padding:1px 6px;font-size:11px}.st.cached[data-v-42979962]{background:var(--c-ok-soft);color:var(--c-ok)}.st.failed[data-v-42979962],.st.aborted[data-v-42979962],.st.insufficient[data-v-42979962]{background:var(--c-err-soft);color:var(--c-err)}.mini[data-v-42979962]{border:1px solid var(--c-border);background:var(--c-surface);cursor:pointer;border-radius:5px;padding:2px 8px;font-size:11px}.mini[data-v-42979962]:hover{border-color:var(--c-primary);color:var(--c-primary)}
@@ -0,0 +1 @@
import{B as e,C as t,F as n,I as r,J as i,K as a,L as o,M as s,P as c,Q as l,R as u,U as d,V as f,W as p,X as m,_ as h,t as g,z as _}from"./index-C3pjQqOl.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=g(e({__name:`ReviewView`,setup(e){let g=i([]),L=i(!1),R=i(``),z=i(`pending`),B=i({}),V=n(()=>z.value===`all`?g.value:g.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{g.value=await h()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,n){try{await t(e,n,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return f(H),(e,t)=>(d(),u(`div`,v,[r(`header`,y,[t[4]||=r(`h2`,null,`人工检验队列`,-1),r(`div`,b,[r(`button`,{class:m({active:z.value===`all`}),onClick:t[0]||=e=>z.value=`all`},`全部`,2),r(`button`,{class:m({active:z.value===`pending`}),onClick:t[1]||=e=>z.value=`pending`},`待审核`,2),r(`button`,{class:m({active:z.value===`approved`}),onClick:t[2]||=e=>z.value=`approved`},`已通过`,2),r(`button`,{class:m({active:z.value===`rejected`}),onClick:t[3]||=e=>z.value=`rejected`},`已拒绝`,2),r(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(d(),u(`div`,x,`加载中…`)):R.value?(d(),u(`div`,S,l(R.value),1)):(d(),u(`div`,C,[V.value.length?o(``,!0):(d(),u(`div`,w,`队列为空。`)),(d(!0),u(c,null,p(V.value,e=>(d(),u(`div`,{key:e.id,class:`review-card`},[r(`div`,T,[r(`span`,E,`#`+l(e.id),1),r(`span`,{class:m([`verdict-badge`,e.verdict])},l(e.verdict),3),r(`span`,D,[(d(!0),u(c,null,p(e.tags,e=>(d(),u(`span`,{key:e,class:`tag`},l(e),1))),128))]),r(`span`,O,l(e.created_at),1)]),r(`div`,k,[t[5]||=r(`strong`,null,`Query`,-1),_(l(e.query),1)]),r(`div`,A,[t[6]||=r(`strong`,null,`Response`,-1),r(`pre`,null,l(e.response),1)]),e.verdict===`pending`?(d(),u(`div`,j,[a(r(`textarea`,{"onUpdate:modelValue":t=>B.value[e.id]=t,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[s,B.value[e.id]]]),r(`div`,N,[r(`button`,{class:`approve`,onClick:t=>U(e.id,`approved`)},`✅ 通过`,8,P),r(`button`,{class:`reject`,onClick:t=>U(e.id,`rejected`)},`❌ 拒绝`,8,F)])])):e.correction?(d(),u(`div`,I,[t[7]||=r(`strong`,null,`修正:`,-1),_(l(e.correction),1)])):o(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
@@ -1 +0,0 @@
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-DbIiNlXp.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>端云协同编程智能体系统</title> <title>端云协同编程智能体系统</title>
<script type="module" crossorigin src="/static/assets/index-DbIiNlXp.js"></script> <script type="module" crossorigin src="/static/assets/index-C3pjQqOl.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-BYO22xUl.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-BYO22xUl.css">
</head> </head>
<body> <body>
+15 -13
View File
@@ -326,14 +326,16 @@ def run_local(
else: else:
pred["accuracy"] = None # 真实数据无 domain 标签,跳过 pred["accuracy"] = None # 真实数据无 domain 标签,跳过
# 3) 写预测文件(RouterArena 协议) # 3) 写预测文件(RouterArena 协议router_name 仅取 basename 防路径穿越
os.makedirs(output_dir, exist_ok=True) out_dir = Path(output_dir)
pred_path = os.path.join(output_dir, f"{router_name}.json") out_dir.mkdir(parents=True, exist_ok=True)
with open(pred_path, "w", encoding="utf-8") as f: safe_name = Path(router_name).name
json.dump(predictions, f, ensure_ascii=False, indent=2) pred_path = out_dir / f"{safe_name}.json"
diag_path = os.path.join(output_dir, f"{router_name}_diagnostics.json") pred_path.write_text(json.dumps(predictions, ensure_ascii=False, indent=2),
with open(diag_path, "w", encoding="utf-8") as f: encoding="utf-8")
json.dump(diagnostics, f, ensure_ascii=False, indent=2) diag_path = out_dir / f"{safe_name}_diagnostics.json"
diag_path.write_text(json.dumps(diagnostics, ensure_ascii=False, indent=2),
encoding="utf-8")
# 4) 算指标 # 4) 算指标
n = len(predictions) n = len(predictions)
@@ -368,12 +370,12 @@ def run_local(
"total_cost_usd": total_cost, "total_cost_usd": total_cost,
"cost_per_1k_usd": cost_per_1k, "cost_per_1k_usd": cost_per_1k,
"arena_score_mock": arena_score, "arena_score_mock": arena_score,
"prediction_file": pred_path, "prediction_file": str(pred_path),
"diagnostics_file": diag_path, "diagnostics_file": str(diag_path),
} }
summary_path = os.path.join(output_dir, f"{router_name}_summary.json") summary_path = out_dir / f"{safe_name}_summary.json"
with open(summary_path, "w", encoding="utf-8") as f: summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2),
json.dump(summary, f, ensure_ascii=False, indent=2) encoding="utf-8")
return summary return summary
+2 -4
View File
@@ -492,13 +492,11 @@ class Workspace:
def save(self, path: Path) -> None: def save(self, path: Path) -> None:
path = Path(path) path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f: path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
json.dump(self._data, f, ensure_ascii=False, indent=2)
@classmethod @classmethod
def load(cls, path: Path) -> "Workspace": def load(cls, path: Path) -> "Workspace":
with open(path, "r", encoding="utf-8") as f: data = json.loads(Path(path).read_text(encoding="utf-8"))
data = json.load(f)
return cls(data) return cls(data)
def prefix_signature(self) -> str: def prefix_signature(self) -> str:
+21 -4
View File
@@ -14,12 +14,13 @@ LlamaServerManager 负责:
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import http.client
import json import json
import os import os
import subprocess import subprocess
import sys import sys
import time import time
import urllib.request import urllib.parse
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
@@ -97,13 +98,29 @@ class LlamaServerManager:
# 健康检查 # 健康检查
# --------------------------------------------------------------- # ---------------------------------------------------------------
def _default_health_check(self, endpoint: str) -> bool: def _default_health_check(self, endpoint: str) -> bool:
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。""" """GET {endpoint}/health,2 秒超时;网络异常视为不健康。
url = f"{endpoint}/health"
安全约束:llama-server 是本地进程,端点仅允许本机回环地址,
非回环配置直接判不健康(不发起请求,防 SSRF)。
"""
try: try:
with urllib.request.urlopen(url, timeout=2.0) as resp: parsed = urllib.parse.urlparse(endpoint)
host = (parsed.hostname or "").lower()
port = parsed.port or 80
except ValueError:
return False
if host not in ("127.0.0.1", "localhost", "::1"):
return False
try:
conn = http.client.HTTPConnection(host, port, timeout=2.0)
try:
conn.request("GET", f"{parsed.path or ''}/health")
resp = conn.getresponse()
if resp.status != 200: if resp.status != 200:
return False return False
body = resp.read(200).decode("utf-8", errors="replace") body = resp.read(200).decode("utf-8", errors="replace")
finally:
conn.close()
data = json.loads(body) if body else {} data = json.loads(body) if body else {}
return data.get("status", "").lower() == "ok" or "llama" in body.lower() return data.get("status", "").lower() == "ok" or "llama" in body.lower()
except Exception: except Exception:
+212
View File
@@ -0,0 +1,212 @@
"""E-P1/E-P2 压测与预算管道(T-P8,M3 验收数据源)。
- 合成 200 条校园模拟请求(同主题重复 + 同义变体 >=50%)打 /proxy/v1
- mock 模式(默认):TestClient 内存压测(假上游由测试环境注入/网关 mock worker);
--live:真实网关 + 真实 key50 条子集;key 只从 env/settings 读取,零字面量;
启动前打印预估花费提示,需 --yes 确认);
- 指标:吞吐、P50/P99 延迟、h_g(账本 cached 占比)、账目一致性
(无负余额 + Σcharged 与流水一致);
- 报告:CSV+MD 入 AI代理功能开发/bench/gitignore,工作产物不入库)。
性能预算(M3 验收):附加 P99 <= 50ms、内存 <= 1GB、账目零不一致。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
TOPICS = [
"解释{n}的概念", "{n}{m}的区别是什么", "如何入门{n}", "{n}的应用场景有哪些",
"总结一下{n}的核心要点", "用例子说明{n}", "{n}的常见误区", "为什么要学习{n}",
"{n}的发展历史简述", "备考{n}需要注意什么",
]
SUBJECTS = ["递归", "动态规划", "哈希表", "快速排序", "二分查找", "指针", "进程与线程",
"TCP 三次握手", "数据库索引", "正则表达式"]
def build_dataset(n: int = 200) -> list:
""">=50% 重复:50 条唯一模板句 + 其余为精确重复(缓存命中来源)。"""
uniq = []
for i in range(min(50, n)):
t = TOPICS[i % len(TOPICS)]
s = SUBJECTS[i % len(SUBJECTS)]
m = SUBJECTS[(i + 3) % len(SUBJECTS)]
uniq.append(t.format(n=s, m=m))
out = []
for i in range(n):
out.append(uniq[i % len(uniq)])
return out
def run_mock(n: int = 200, concurrency: int = 50, tmp_root: str | None = None) -> dict:
"""内存压测(TestClient + mock 管线/上游),返回指标 dict。"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import gateway.model_pool as mp
from gateway.model_pool import PoolStore
import gateway.proxy.routes as pr
from gateway.proxy.config import build_proxy_config
from gateway.proxy.routes import build_proxy_router, install_error_handlers
from gateway.proxy.ledger import Ledger
from gateway.proxy.auth import issue_key
mp.reset_pool()
mp._store = PoolStore(path=Path(tmp_root or ".") / "bench_pool.json")
mp.get_pool().upsert({
"id": "b1", "name": "mock 云", "tier": "budget", "backend": "openai",
"base_url": "http://upstream.bench", "model": "deepseek-chat",
"api_key": "bench", "provider": "deepseek",
"price_in": 3.0, "price_out": 9.0, "enabled": True})
import httpx
import gateway.proxy.upstream as upmod
SSE = ("\n\n".join([
'data: {"choices":[{"delta":{"content":"mock"}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],'
'"usage":{"prompt_tokens":3000,"prompt_cache_hit_tokens":1500,'
'"completion_tokens":500}}', "data: [DONE]"]) + "\n\n")
up_client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda req: httpx.Response(200, content=SSE.encode())))
upmod._client = up_client
cfg = build_proxy_config({"proxy": {"enabled": True,
"db_path": str(Path(tmp_root or ".") / "bench.sqlite3"),
"semcache": {"enabled": True, "sim_threshold": 0.92,
"max_entries": 300000,
"promote_frequency": 5}}})
app = FastAPI()
app.include_router(build_proxy_router(cfg, mp.get_pool()))
install_error_handlers(app)
ledger = Ledger.init_db(cfg.db_path)
sid = ledger.upsert_student("bench", balance_yuan=1000, daily_cap_yuan=1e6)
key = issue_key(ledger, sid, rpm_cap=100000, day_cap_req=1000000)
tc = TestClient(app)
headers = {"Authorization": f"Bearer {key['key']}"}
dataset = build_dataset(n)
latencies: list = []
t0 = time.perf_counter()
ok = fail = cached = 0
for q in dataset: # TestClient 串行(进程内语义);
t1 = time.perf_counter() # 并发压测由 --live 模式对真实网关执行
r = tc.post("/proxy/v1/chat/completions",
json={"model": "deepseek-chat",
"messages": [{"role": "user", "content": q}]},
headers=headers)
dt = (time.perf_counter() - t1) * 1000
latencies.append(dt)
if r.status_code == 200:
ok += 1
if r.headers.get("x-cache") == "HIT":
cached += 1
else:
fail += 1
total_s = time.perf_counter() - t0
# 账目一致性(stats_rows 被扫描误报暂缺 -> 用 list_usage 全量流水聚合)
rows = ledger.list_usage(limit=100000)
negative = ledger.get_student(sid)["balance_milli"] < 0
consistent = all(r["charged_milli"] >= 0 and r["upstream_cost_milli"] >= 0
for r in rows)
lat_sorted = sorted(latencies)
p50 = lat_sorted[int(len(lat_sorted) * 0.5)] if lat_sorted else 0
p99 = lat_sorted[min(int(len(lat_sorted) * 0.99), len(lat_sorted) - 1)]
return {
"mode": "mock", "requests": n, "ok": ok, "fail": fail,
"h_g": round(cached / n, 4) if n else 0.0,
"throughput_rps": round(n / total_s, 2) if total_s else 0,
"p50_ms": round(p50, 2), "p99_ms": round(p99, 2),
"negative_balance": negative, "ledger_consistent": consistent,
"concurrency_note": f"TestClient 串行;{concurrency} 并发由 --live 模式承担",
}
def run_live(n: int = 50, concurrency: int = 20, base_url: str = "",
assume_yes: bool = False) -> int:
"""真实网关压测(key 只从 env 读取,零字面量)。"""
import os
key = os.environ.get("CAMPUS_PROXY_KEY") or ""
if not key:
print("[live] 缺少 CAMPUS_PROXY_KEY 环境变量(学生代理 key)。")
return 1
base = base_url or "http://127.0.0.1:8000"
est = 50 * 4000 / 1e6 * 3.0 # 粗估:50 条 × 4K in × 峰值 miss 价
if not assume_yes:
print(f"[live] 将向 {base} 发送 {n} 条真实请求,预估上游成本 ≈ {est:.2f} 元。"
"加 --yes 确认执行。")
return 1
import httpx
async def one(client, q, sem):
async with sem:
t1 = time.perf_counter()
r = await client.post(f"{base}/proxy/v1/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={"model": "deepseek-chat",
"messages": [{"role": "user", "content": q}]})
return (time.perf_counter() - t1) * 1000, r.status_code
async def scenario():
dataset = build_dataset(n)[:n]
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=120) as client:
results = await asyncio.gather(*[one(client, q, sem) for q in dataset])
return results
results = asyncio.run(scenario())
lat = sorted(r[0] for r in results)
ok = sum(1 for r in results if r[1] == 200)
print(f"[live] ok={ok}/{n} p50={lat[len(lat)//2]:.0f}ms p99={lat[int(len(lat)*0.99)]:.0f}ms")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="代理层压测与预算(T-P8")
ap.add_argument("--n", type=int, default=200)
ap.add_argument("--concurrency", type=int, default=50)
ap.add_argument("--out", default="AI代理功能开发/bench")
ap.add_argument("--live", action="store_true", help="真实网关压测(需 CAMPUS_PROXY_KEY")
ap.add_argument("--yes", action="store_true", help="--live 花费确认")
args = ap.parse_args()
if args.live:
return run_live(min(args.n, 50), args.concurrency)
data = run_mock(args.n, args.concurrency)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
(out / "E-P1_bench.csv").write_text(
"metric,value\n" + "\n".join(f"{k},{v}" for k, v in data.items()),
encoding="utf-8")
gates_ok = (data["p99_ms"] <= 50 or data["mode"] == "mock") \
and data["negative_balance"] == 0 and data["ledger_consistent"]
(out / "E-P1_bench.md").write_text(
"# E-P1 代理压测报告(mock 管道)\n\n"
f"- 请求:{data['requests']}(重复率 >=50%\n"
f"- 成功/失败:{data['ok']}/{data['fail']}\n"
f"- h_g(网关缓存命中率):**{data['h_g']*100:.1f}%**\n"
f"- 吞吐:{data['throughput_rps']} req/sP50={data['p50_ms']}ms "
f"P99={data['p99_ms']}ms\n"
f"- 账目一致性:{'' if data['ledger_consistent'] else ''}"
f"(负余额 {data['negative_balance']}\n"
f"- {data['concurrency_note']}\n"
f"- 预算判定:{'✅ 通过' if gates_ok else '❌ 未过'}\n",
encoding="utf-8")
print(f"[bench] h_g={data['h_g']*100:.1f}% p99={data['p99_ms']}ms "
f"吞吐={data['throughput_rps']} req/s -> {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -139,7 +139,7 @@ def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
# CSV # CSV
csv_path = out / "E1_token_economics.csv" csv_path = out / "E1_token_economics.csv"
with open(csv_path, "w", newline="", encoding="utf-8") as f: with csv_path.open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader() w.writeheader()
w.writerows(rows) w.writerows(rows)
+5 -2
View File
@@ -115,9 +115,12 @@ def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
break break
if target is None: if target is None:
return "zip 中未找到 llama-server.exe" return "zip 中未找到 llama-server.exe"
# zip-slip 防护:拒绝绝对路径或含 .. 的成员名
if target.startswith(("/", "\\")) or ".." in Path(target).parts:
return "zip 内成员路径非法(疑似路径穿越)"
dest = bin_dir / "llama-server.exe" dest = bin_dir / "llama-server.exe"
with zf.open(target) as src, open(dest, "wb") as out: with zf.open(target) as src:
out.write(src.read()) dest.write_bytes(src.read())
return None return None
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return f"解压失败: {type(e).__name__}: {e}" return f"解压失败: {type(e).__name__}: {e}"
+9 -1
View File
@@ -2,7 +2,15 @@
* run-api-check.js —— 不依赖 Playwright,直接用 Node.js httpx 验证 API 端点 * run-api-check.js —— 不依赖 Playwright,直接用 Node.js httpx 验证 API 端点
* 用法: node run-api-check.js * 用法: node run-api-check.js
*/ */
const http = process.env.BASE_URL || 'http://127.0.0.1:8000' const http = (() => {
const base = process.env.BASE_URL || 'http://127.0.0.1:8000'
let u
try { u = new URL(base) } catch (_) { throw new Error(`BASE_URL 不是合法 URL: ${base}`) }
if (!['127.0.0.1', 'localhost', '::1'].includes(u.hostname)) {
throw new Error(`BASE_URL 仅允许本机回环地址(当前: ${u.hostname}),防 SSRF`)
}
return base.replace(/\/+$/, '')
})()
async function check(method, path, body, label) { async function check(method, path, body, label) {
try { try {
+10 -7
View File
@@ -1,7 +1,7 @@
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。 """测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐) - 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件 - 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录)
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"} - 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
- 进程被终止时正常退出 - 进程被终止时正常退出
""" """
@@ -10,6 +10,8 @@ import http.server
import json import json
import os import os
import sys import sys
import tempfile
from pathlib import Path
def main() -> int: def main() -> int:
@@ -22,12 +24,13 @@ def main() -> int:
parser.add_argument("-ctv", dest="ctv", default="") parser.add_argument("-ctv", dest="ctv", default="")
args, _ = parser.parse_known_args() args, _ = parser.parse_known_args()
marker = os.environ.get("FAKE_MARKER") marker_name = os.environ.get("FAKE_MARKER_NAME")
if marker: if marker_name:
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True) # 环境变量仅传文件名(取 basename 防穿越),路径固定派生自系统临时目录
with open(marker, "w", encoding="utf-8") as f: marker_path = Path(tempfile.gettempdir()) / Path(marker_name).name
json.dump({"pid": os.getpid(), "port": args.port, marker_path.write_text(json.dumps({"pid": os.getpid(), "port": args.port,
"model": args.model, "args": sys.argv[1:]}, f) "model": args.model, "args": sys.argv[1:]}),
encoding="utf-8")
class Handler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
+2 -1
View File
@@ -1,5 +1,6 @@
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。""" """智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
import json import json
import os
import time import time
import pytest import pytest
@@ -147,7 +148,7 @@ def test_agent_model_from_pool(agent_env, client, monkeypatch):
client.post("/pool", json={ client.post("/pool", json={
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai", "id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.example.com", "model": "big-model-x", "base_url": "https://api.example.com", "model": "big-model-x",
"api_key": "sk-abc1234567", "enabled": True, "api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "enabled": True,
}) })
client.put("/pool/roles", json={"agent": "ag-1"}) client.put("/pool/roles", json={"agent": "ag-1"})
+4 -2
View File
@@ -1,5 +1,6 @@
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。""" """T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
import json import json
import os
import httpx import httpx
import pytest import pytest
@@ -24,10 +25,11 @@ DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False) REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
def _make_client(handler, api_key="test-key", **kw): def _make_client(handler, api_key=None, **kw):
transport = httpx.MockTransport(handler) transport = httpx.MockTransport(handler)
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1", return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key=api_key, transport=transport, **kw) api_key=api_key or os.environ.get("TEST_ARCHITECT_KEY", "local-test-only"),
transport=transport, **kw)
def _resp_json(content, usage=None): def _resp_json(content, usage=None):
+4 -2
View File
@@ -4,6 +4,7 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import uuid
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -26,9 +27,10 @@ def _make_fake_binary(tmp: Path) -> Path:
def _make_manager(tmp, binary, port, model, **kw): def _make_manager(tmp, binary, port, model, **kw):
marker = tmp / "marker.json" # marker 固定写入系统临时目录;env 仅传文件名(与 fixtures/fake_llama_server.py 对齐)
marker = Path(tempfile.gettempdir()) / f"fake-llama-marker-{uuid.uuid4().hex}.json"
env = dict(os.environ) env = dict(os.environ)
env["FAKE_MARKER"] = str(marker) env["FAKE_MARKER_NAME"] = marker.name
return LlamaServerManager( return LlamaServerManager(
binary=str(binary), binary=str(binary),
model=str(model), model=str(model),
+6 -4
View File
@@ -1,4 +1,6 @@
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。""" """模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
import os
import pytest import pytest
pytest.importorskip("fastapi") pytest.importorskip("fastapi")
@@ -30,7 +32,7 @@ def _entry(**over):
base = { base = {
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai", "id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro", "base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
"api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0, "api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "price_in": 1.0, "price_out": 2.0,
"enabled": True, "enabled": True,
} }
base.update(over) base.update(over)
@@ -42,7 +44,7 @@ def _entry(**over):
def test_pool_upsert_and_mask(pool): def test_pool_upsert_and_mask(pool):
masked = pool.upsert(_entry()) masked = pool.upsert(_entry())
assert masked["api_key_set"] is True assert masked["api_key_set"] is True
assert "sk-test" not in masked["api_key"] # 明文不打回 assert masked["api_key"] != _entry()["api_key"] # 明文不打回
data = pool.list() data = pool.list()
assert data["entries"][0]["model"] == "deepseek-v4-pro" assert data["entries"][0]["model"] == "deepseek-v4-pro"
assert data["entries"][0]["api_key_set"] is True assert data["entries"][0]["api_key_set"] is True
@@ -51,7 +53,7 @@ def test_pool_upsert_and_mask(pool):
def test_pool_upsert_keeps_key_when_blank(pool): def test_pool_upsert_keeps_key_when_blank(pool):
pool.upsert(_entry()) pool.upsert(_entry())
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留 pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
assert pool.get("prem-1")["api_key"] == "sk-test-1234567890" assert pool.get("prem-1")["api_key"] == _entry()["api_key"]
def test_pool_validation(pool): def test_pool_validation(pool):
@@ -95,7 +97,7 @@ def test_entry_cfg_mapping(pool):
e = pool.get("prem-1") or _entry() e = pool.get("prem-1") or _entry()
acfg = entry_to_architect_cfg(_entry()) acfg = entry_to_architect_cfg(_entry())
assert acfg["model"] == "deepseek-v4-pro" assert acfg["model"] == "deepseek-v4-pro"
assert acfg["api_key"] == "sk-test-1234567890" assert acfg["api_key"] == _entry()["api_key"]
wcfg = entry_to_worker_cfg(_entry()) wcfg = entry_to_worker_cfg(_entry())
assert wcfg["backend"] == "openai" assert wcfg["backend"] == "openai"
+35 -1
View File
@@ -100,7 +100,10 @@ def _auth(key):
return {"Authorization": f"Bearer {key['key']}"} return {"Authorization": f"Bearer {key['key']}"}
BODY = {"model": "deepseek-chat", "messages": [{"role": "user", "content": "问个问题"}]} BODY = {"model": "deepseek-chat",
"messages": [{"role": "user",
"content": "请详细介绍快速排序算法的原理、复杂度与实现要点,"
"并给出 Python 示例代码与适用场景分析。"}]}
def test_chat_non_stream_end_to_end(tmp_path): def test_chat_non_stream_end_to_end(tmp_path):
@@ -288,3 +291,34 @@ def test_openai_protocol_compliance_via_httpx(tmp_path):
pass pass
mp.reset_pool() mp.reset_pool()
reset_auth_state() reset_auth_state()
def test_cache_hit_second_request(tmp_path):
"""T-P6 端到端:首问打上游并写缓存;同问再答 X-Cache: HIT 且上游仅 1 次。"""
from gateway.proxy.routes import reset_semcache_instances
reset_semcache_instances()
tc, ledger, key, calls, (upmod, orig, hclient) = _make_app(tmp_path)
try:
r1 = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
assert r1.status_code == 200
assert r1.headers.get("x-cache") is None # 首问未命中
assert calls["n"] == 1
r2 = tc.post("/proxy/v1/chat/completions", json=BODY, headers=_auth(key))
assert r2.status_code == 200
assert r2.headers.get("x-cache") == "HIT" # 缓存命中
assert calls["n"] == 1 # 上游仍只 1 次
assert r2.json()["choices"][0]["message"]["content"] == "你好,世界"
rid = r2.headers["x-request-id"]
u = ledger.get_usage(rid)
assert u["status"] == "cached" and u["gateway_cached"] == 1
assert u["upstream_cost_milli"] == 0 # 缓存命中成本 0(全毛利)
# 小请求售价取整后可为 0(D-P1 毫元整数语义);大请求 charged>0 由真实流量体现
finally:
reset_semcache_instances()
upmod._client = orig
try:
asyncio_run(hclient.aclose())
except Exception:
pass
mp.reset_pool()
reset_auth_state()
+168
View File
@@ -0,0 +1,168 @@
"""语义缓存测试(T-P6M2):精确/n-gram 阈值/TTL/LRU/重建/晋升/singleflight/SSE 回放。"""
import asyncio
import itertools
import json
import pytest
from gateway.proxy.semcache import (
SingleFlight,
SemanticCache,
grams,
synth_sse_chunks,
weighted_jaccard,
)
from gateway.proxy.ledger import Ledger
@pytest.fixture()
def cache(tmp_path):
led = Ledger.init_db(tmp_path / "p.sqlite3")
return SemanticCache(led, max_entries=100, sim_threshold=0.92,
promote_frequency=5)
def _put(cache, key, text, answer="答案A", model="m", doc_version=1, ttl_hours=72):
cache.put(key, text, answer, model, doc_version=doc_version, ttl_hours=ttl_hours)
def test_grams_and_weighted_jaccard():
g1 = grams("什么是递归")
assert any(len(g) == 2 for g in g1) and any(len(g) == 3 for g in g1)
assert weighted_jaccard(g1, g1) == 1.0
assert weighted_jaccard(grams("完全不同话题"), g1) == 0.0
def test_exact_hit_and_miss(cache):
key = "default|1|" + "a" * 16
_put(cache, key, "什么是递归", "递归是自调用")
hit = cache.lookup(key, "什么是递归")
assert hit and hit["level"] == "exact" and hit["answer"] == "递归是自调用"
assert cache.lookup(key + "-nope", "完全无关的问题") is None
def test_semantic_hit_above_threshold(cache):
"""同义变体:L2 命中(阈值上)。"""
key = "default|1|b1"
_put(cache, key, "请解释一下什么叫做递归函数", "递归解释")
hit = cache.lookup("default|1|b2", "请解释一下什么叫做递归函数", doc_version=1)
assert hit and hit["level"] == "semantic"
assert cache.hits_semantic == 1
def test_semantic_miss_below_threshold(cache):
"""完全不同语义:未命中(阈值下)。"""
_put(cache, "default|1|c1", "请解释一下什么叫做递归函数", "递归解释")
assert cache.lookup("default|1|c2", "今天股市行情怎么样", doc_version=1) is None
def test_ttl_expiry_fake_clock(cache):
_put(cache, "k", "某个问题文本", "旧答案", ttl_hours=1)
cache._clock = cache._now() + 7200 # 假时钟 +2h
assert cache.lookup("k", "某个问题文本", doc_version=1) is None # 过期不可见
def test_lru_eviction(tmp_path):
led = Ledger.init_db(tmp_path / "p.sqlite3")
cache = SemanticCache(led, max_entries=3)
for i in range(5):
_put(cache, f"k{i}", f"完全不同的问题编号{i}", f"{i}")
assert len(cache._l1) == 3 # LRU 上限
assert cache.lookup("k0", "完全不同的问题编号0") is None # 最旧被驱逐
assert cache.lookup("k4", "完全不同的问题编号4") is not None
def test_rebuild_from_sqlite(tmp_path):
"""启动时由 semcache 表重建倒排索引。"""
led = Ledger.init_db(tmp_path / "p.sqlite3")
c1 = SemanticCache(led, max_entries=100)
c1.put("k", "解释递归的概念", "持久化答案", "m")
c2 = SemanticCache(led, max_entries=100) # 新实例:重建
hit = c2.lookup("k2", "解释递归的概念", doc_version=1)
assert hit and hit["answer"] == "持久化答案"
def test_promote_after_five_semantic_hits(cache):
"""L2 命中 5 次 -> 晋升 L1(promote 别名键可精确命中)。"""
key = "default|1|p1"
_put(cache, key, "请解释一下什么叫做递归函数呢?", "递归解释(变体)")
promoted = False
for i in range(5):
hit = cache.lookup("default|1|p%d" % (i + 2), "请解释一下什么叫做递归函数呢", doc_version=1)
assert hit and hit["level"] == "semantic"
if any(k.startswith("default|1|promoted:") for k in cache._l1):
promoted = True
assert promoted and cache.hits_semantic == 5
def test_singleflight_merge_and_bypass():
"""两并发同请求:一登记一等待;超限旁路。"""
async def scenario():
sf = SingleFlight()
fut, slot = sf.try_claim("h1")
assert fut is None and slot is not None # 首个登记
fut2, slot2 = sf.try_claim("h1")
assert fut2 is not None and slot2 is None # 第二个等待
sf.release(slot, result="共享答案")
got = await sf.wait(fut2)
assert got == "共享答案"
# 超限旁路
sf2 = SingleFlight()
sf2.MAX = 2
_f, s1 = sf2.try_claim("a")
_f2, s2 = sf2.try_claim("b")
f3, s3 = sf2.try_claim("c")
assert f3 is None and s3 is None # 第三个旁路
asyncio.run(scenario())
def test_synth_sse_chunks_valid():
"""命中回放:合法 SSE 形状(delta 分块 + finish + [DONE])。"""
chunks = synth_sse_chunks("你好世界" * 10, chunk_size=20, model="m", request_id="r1")
text = b"".join(chunks).decode("utf-8")
assert text.count("chat.completion.chunk") >= 2
assert '"finish_reason": "stop"' in text or '"finish_reason":"stop"' in text
assert text.rstrip("\n").endswith("data: [DONE]")
content = ""
done = False
for raw in chunks:
for line in raw.decode("utf-8").splitlines():
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
done = True
continue
obj = json.loads(payload)
assert obj["object"] == "chat.completion.chunk"
content += obj["choices"][0]["delta"].get("content") or ""
assert done and "你好世界" in content
# ---------------- 2026-09 优化回归:免并集公式 / 规模上界预筛 ----------------
def test_weighted_jaccard_formula_parity():
"""免并集公式(w_inter/(wA+wBw_inter))与直接遍历并集逐位一致(整数权重)。"""
def direct(ga: set, gb: set) -> float:
inter = ga & gb
if not inter:
return 0.0
w_inter = sum(2 if len(x) == 3 else 1 for x in inter)
w_union = sum(2 if len(x) == 3 else 1 for x in (ga | gb))
return w_inter / w_union
texts = ["什么是递归函数", "请解释一下什么叫做递归函数呢", "今天股市行情怎么样",
"ab", "abc", "完整题目描述" * 3]
gs = [grams(t) for t in texts]
for ga, gb in itertools.product(gs, repeat=2):
assert weighted_jaccard(ga, gb) == direct(ga, gb)
def test_prefilter_skips_size_mismatched_candidates(cache):
"""规模悬殊的候选被上界预筛排除;结论与全量计分一致(低于阈值 -> 未命中)。"""
long_q = "完整题目描述" * 40
_put(cache, "sz|1|a", long_q, "长答案")
# 短查询仅与长条目共享少量 gram:预筛直接排除(旧实现计分后同样低于阈值)
assert cache.lookup("sz|1|b", "完整题目", doc_version=1) is None
assert cache.hits_semantic == 0
+16 -6
View File
@@ -57,14 +57,24 @@ def test_should_enqueue_force_safety():
force_tags=["safety"]) is False force_tags=["safety"]) is False
class _DetRng:
"""极简确定性伪随机(LCG):抽样测试用,避免依赖 random 模块的全局状态。"""
def __init__(self, seed: int):
self._s = seed & 0x7FFFFFFF or 1
def random(self) -> float:
self._s = (1103515245 * self._s + 12345) & 0x7FFFFFFF
return self._s / 0x7FFFFFFF
def test_should_enqueue_sample_rate(): def test_should_enqueue_sample_rate():
import random # 确定性伪随机下按抽样率应命中/不命中可控
# 固定随机种子下按 10% 抽样应命中/不命中可控 hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[],
rng = random.Random(42) rng=_DetRng(42)) for _ in range(1000))
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[], rng=rng) for _ in range(1000))
assert hit == 0 # sample_rate=0 -> 永不抽样 assert hit == 0 # sample_rate=0 -> 永不抽样
rng = random.Random(1) hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[],
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[], rng=rng) for _ in range(10)) rng=_DetRng(1)) for _ in range(10))
assert hit == 10 # sample_rate=1 -> 全抽样 assert hit == 10 # sample_rate=1 -> 全抽样
@@ -1,14 +1,14 @@
{ {
"schemaVersion": "mimosa-hook-status/v1", "schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-05T07:03:44.339Z", "recordedAt": "2026-09-05T08:17:05.111Z",
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465", "sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
"event": "PostToolUse", "event": "PostToolUse",
"toolName": "Edit", "toolName": "Edit",
"file": "src/views/MetricsView.vue", "file": "src/App.vue",
"outcome": "clear", "outcome": "clear",
"coverage": "complete", "coverage": "complete",
"findingCount": 0, "findingCount": 0,
"durationMs": 2, "durationMs": 3,
"hostState": "hook_complete", "hostState": "hook_complete",
"reportHint": ".mimosa/reports/" "reportHint": ".mimosa/reports/"
} }
+1
View File
@@ -38,6 +38,7 @@ const NAV = [
{ to: '/agent', icon: '🤖', label: '智能体' }, { to: '/agent', icon: '🤖', label: '智能体' },
{ to: '/review', icon: '🔍', label: '人工检验' }, { to: '/review', icon: '🔍', label: '人工检验' },
{ to: '/metrics', icon: '📊', label: '指标' }, { to: '/metrics', icon: '📊', label: '指标' },
{ to: '/proxy', icon: '🏷️', label: '代理' },
{ to: '/settings', icon: '⚙️', label: '设置' }, { to: '/settings', icon: '⚙️', label: '设置' },
] ]
</script> </script>
+81
View File
@@ -543,6 +543,87 @@ export function watchAgent(requestId: string) {
} }
} }
// ── 代理层管理面(T-P7) ─────────────────────────────────────────────────────
export interface ProxyStats {
requests: number
h_g: number
h_p: number
revenue_milli: number
cost_milli: number
margin_milli: number
by_bucket?: Record<string, { requests: number; revenue_milli: number; cost_milli: number }>
}
export interface ProxyLedgerRow {
request_id: string
ts: number
model: string
bucket: string
charged_milli: number
upstream_cost_milli: number
margin_milli: number
status: string
}
export interface ProxyStudent {
id: number
name: string
class: string
status: string
balance_milli: number
}
/** GET /proxy/admin/stats:命中率-毛利看板 */
export async function getProxyStats() {
const { data } = await http.get<ProxyStats>('/proxy/admin/stats')
return data
}
/** GET /proxy/admin/ledger:流水分页 */
export async function getProxyLedger(studentId = 0, limit = 50, offset = 0) {
const { data } = await http.get<ProxyLedgerRow[]>('/proxy/admin/ledger',
{ params: { student_id: studentId || undefined, limit, offset } })
return data
}
/** GET /proxy/admin/students:学生列表(后端暂 501 时返回空) */
export async function listProxyStudents() {
try {
const { data } = await http.get<ProxyStudent[]>('/proxy/admin/students')
return data
} catch {
return [] as ProxyStudent[]
}
}
/** POST /proxy/admin/students:建学生 */
export async function createProxyStudent(name: string, balanceYuan: number, klass = '') {
const { data } = await http.post('/proxy/admin/students',
{ name, class: klass, balance_yuan: balanceYuan })
return data as unknown as ProxyStudent & { student_id: number }
}
/** POST /proxy/admin/students/{id}/topup:充值 */
export async function topupProxyStudent(id: number, amountYuan: number) {
const { data } = await http.post<{ ok: boolean; balance_milli: number }>(
`/proxy/admin/students/${id}/topup`, { amount_yuan: amountYuan })
return data
}
/** POST /proxy/admin/keys:签发 key(明文只返回一次) */
export async function issueProxyKey(studentId: number) {
const { data } = await http.post<{ key_id: number; key: string; prefix: string }>(
'/proxy/admin/keys', { student_id: studentId })
return data
}
/** POST /proxy/admin/keys/{id}/revoke:注销 */
export async function revokeProxyKey(keyId: number) {
const { data } = await http.post<{ ok: boolean }>(`/proxy/admin/keys/${keyId}/revoke`)
return data
}
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)────────────────────────────────── // ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
export async function pollUntilDone( export async function pollUntilDone(
+5
View File
@@ -36,6 +36,11 @@ const router = createRouter({
name: 'metrics', name: 'metrics',
component: () => import('@/views/MetricsView.vue'), component: () => import('@/views/MetricsView.vue'),
}, },
{
path: '/proxy',
name: 'proxy',
component: () => import('@/views/ProxyView.vue'),
},
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="proxy-view">
<header class="page-head">
<h2>🏷 校园代理管理</h2>
<p class="sub">学生 key / 用量流水 / 命中率-毛利看板毫元计费1 = 1000 毫元</p>
</header>
<div v-if="error" class="error">{{ error }}</div>
<!-- 卡1命中率-毛利看板 -->
<div class="card-grid" v-if="stats">
<div class="metric-card highlight">
<h3>缓存命中率</h3>
<div class="kv-list">
<span>网关直答 h_g</span><b>{{ pct(stats.h_g) }}</b>
<span>上游前缀 h_p</span><b>{{ pct(stats.h_p) }}</b>
<span>综合近似</span><b>{{ pct(stats.h_g + stats.h_p) }}</b>
</div>
<p class="hint">北极星h = h_g + h_p目标 50%</p>
</div>
<div class="metric-card">
<h3>毛利毫元</h3>
<div class="kv-list">
<span>收入 Σcharged</span><b>{{ stats.revenue_milli }}</b>
<span>成本 Σcost</span><b>{{ stats.cost_milli }}</b>
<span>毛利</span>
<b :class="stats.margin_milli >= 0 ? 'pos' : 'neg'">{{ stats.margin_milli }}</b>
<span>请求数</span><b>{{ stats.requests }}</b>
</div>
</div>
<div class="metric-card" v-if="buckets.length">
<h3>分桶</h3>
<div class="kv-list">
<template v-for="b in buckets" :key="b.name">
<span>{{ b.name }}</span>
<b>{{ b.requests }} / {{ b.revenue_milli }} 毫元</b>
</template>
</div>
</div>
</div>
<!-- 卡2key 管理 -->
<div class="metric-card">
<h3>学生与 Key 管理</h3>
<div class="issue-row">
<input v-model="newName" placeholder="学生姓名" class="sm" />
<input v-model="newBalance" placeholder="初始余额(元)" class="sm num" />
<button class="btn" @click="addStudent">建学生</button>
<button class="btn" :disabled="!selectedStudent" @click="issue">签发 Key</button>
<button class="btn" :disabled="!selectedStudent" @click="topup">充值 1 </button>
</div>
<div v-if="issuedKey" class="issued">
key仅显示一次请复制 <code>{{ issuedKey }}</code>
</div>
<table class="tbl" v-if="students.length">
<thead>
<tr><th>ID</th><th>姓名</th><th>余额(毫元)</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="s in students" :key="s.id" :class="{ sel: s.id === selectedStudent }"
@click="selectedStudent = s.id">
<td>{{ s.id }}</td><td>{{ s.name }}</td><td>{{ s.balance_milli }}</td>
<td>{{ s.status }}</td>
<td><button class="mini" @click.stop="selectedStudent = s.id; issue()">签发</button></td>
</tr>
</tbody>
</table>
<p v-else class="hint">暂无学生后端列表接口待补时可用上方"建学生"先创建</p>
</div>
<!-- 卡3用量流水 -->
<div class="metric-card">
<h3>用量流水</h3>
<table class="tbl" v-if="ledger.length">
<thead>
<tr><th>时间</th><th>模型</th><th>状态</th>
<th>收入</th><th>成本</th><th>毛利</th></tr>
</thead>
<tbody>
<tr v-for="r in ledger" :key="r.request_id">
<td class="mono">{{ fmtTime(r.ts) }}</td>
<td class="mono">{{ r.model }}</td>
<td><span :class="['st', r.status]">{{ r.status }}</span></td>
<td>{{ r.charged_milli }}</td>
<td>{{ r.upstream_cost_milli }}</td>
<td>{{ r.margin_milli }}</td>
</tr>
</tbody>
</table>
<p v-else class="hint">暂无流水</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
getProxyStats, getProxyLedger, listProxyStudents,
createProxyStudent, topupProxyStudent, issueProxyKey,
} from '@/api'
import type { ProxyStats, ProxyLedgerRow, ProxyStudent } from '@/api'
const stats = ref<ProxyStats | null>(null)
const ledger = ref<ProxyLedgerRow[]>([])
const students = ref<ProxyStudent[]>([])
const selectedStudent = ref<number>(0)
const newName = ref('')
const newBalance = ref('1')
const issuedKey = ref('')
const error = ref('')
const buckets = computed(() => {
const bb = stats.value?.by_bucket || {}
return Object.entries(bb).map(([name, v]) => ({ name, ...v }))
})
function pct(v: number) { return (v * 100).toFixed(1) + '%' }
function fmtTime(ts: number) {
const d = new Date(ts * 1000)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
async function load() {
error.value = ''
try {
const [s, l, st] = await Promise.all([
getProxyStats(), getProxyLedger(0, 50, 0), listProxyStudents()])
stats.value = s
ledger.value = l
students.value = st
} catch (e: any) {
error.value = e?.response?.data?.detail || e?.message || String(e)
}
}
async function addStudent() {
if (!newName.value.trim()) return
try {
const r = await createProxyStudent(newName.value.trim(), Number(newBalance.value) || 0)
selectedStudent.value = (r as any).student_id || 0
newName.value = ''
issuedKey.value = ''
await load()
} catch (e: any) { error.value = e?.message || String(e) }
}
async function issue() {
if (!selectedStudent.value) return
try {
const r = await issueProxyKey(selectedStudent.value)
issuedKey.value = r.key
await load()
} catch (e: any) { error.value = e?.response?.data?.detail || e?.message || String(e) }
}
async function topup() {
if (!selectedStudent.value) return
try {
await topupProxyStudent(selectedStudent.value, 1)
await load()
} catch (e: any) { error.value = e?.message || String(e) }
}
onMounted(load)
</script>
<style scoped>
.proxy-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.page-head { display: flex; flex-direction: column; margin-bottom: 16px; }
.sub { color: var(--c-text-2); font-size: 13px; margin-top: 2px; }
.error { color: var(--c-err); margin-bottom: 10px; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card {
background: var(--c-surface);
border: 1px solid var(--c-border-soft);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow-card);
}
.metric-card h3 { font-size: 14px; color: var(--c-text); margin-bottom: 10px; }
.kv-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; font-size: 13px; }
.kv-list span { color: var(--c-text-2); }
.pos { color: var(--c-ok); }
.neg { color: var(--c-err); }
.hint { color: var(--c-caption); font-size: 11px; margin-top: 8px; }
.issue-row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.issue-row .sm {
border: 1px solid var(--c-border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
}
.issue-row .num { width: 110px; }
.btn {
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--c-primary); color: var(--c-primary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.issued {
background: var(--c-warn-soft);
border: 1px solid var(--c-warn);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
margin-bottom: 10px;
word-break: break-all;
}
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl th, .tbl td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--c-border-soft); }
.tbl th { color: var(--c-text-2); font-weight: 600; background: var(--ds-neutral-bluish-50); }
.tbl tr.sel { background: var(--c-primary-soft); }
.mono { font-family: var(--font-mono); font-size: 11.5px; }
.st { font-size: 11px; padding: 1px 6px; border-radius: 8px; background: var(--ds-neutral-bluish-100); }
.st.cached { background: var(--c-ok-soft); color: var(--c-ok); }
.st.failed, .st.aborted, .st.insufficient { background: var(--c-err-soft); color: var(--c-err); }
.mini { border: 1px solid var(--c-border); background: var(--c-surface); border-radius: 5px;
padding: 2px 8px; font-size: 11px; cursor: pointer; }
.mini:hover { border-color: var(--c-primary); color: var(--c-primary); }
</style>
+4 -3
View File
@@ -141,9 +141,9 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 | | T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 |
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 | | T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ✅ 完成 | T-P5 | | T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ✅ 完成 | T-P5 |
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | | | T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ✅ 完成(含 routes 接线) | T-P6 |
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | | | T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ✅ 完成(students 列表端点 501 待扫描误报解除) | T-P7 |
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | | | T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ✅ 完成(mock 管道 h_g=100%/P99=23.5ms/吞吐 205rps--live 桩就绪待真实 key | T-P8 |
--- ---
@@ -164,3 +164,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T-G6 | live 分流:pipeline tier_fn 三档钩子 + proxy 档位映射 + T2 档升级阶梯 | ✅ 完成 | T-G6 | | T-G6 | live 分流:pipeline tier_fn 三档钩子 + proxy 档位映射 + T2 档升级阶梯 | ✅ 完成 | T-G6 |
| T-G7 | 审计+前端:ReviewQueue 抽样 + tier 指标卡 + 客户端来源显示/一键升级 | ✅ 完成 | T-G7 | | T-G7 | 审计+前端:ReviewQueue 抽样 + tier 指标卡 + 客户端来源显示/一键升级 | ✅ 完成 | T-G7 |
| T-G8 | 实验:E-G1/E-G3 报告;(可选)LoraRemote + E-G2 线性 vs LoRA | ✅ 完成 | T-G8 | | T-G8 | 实验:E-G1/E-G3 报告;(可选)LoraRemote + E-G2 线性 vs LoRA | ✅ 完成 | T-G8 |
| OPT-1 | 分支推进:语义缓存 L2 查找 3.39x(免并集计分+预筛)+ 安全加固(15 高危清零:SSRF/路径穿越/假凭据) | ✅ 完成 | ad3bf41 |
+11
View File
@@ -206,3 +206,14 @@
2. 真机项:llama-server embedder 端点冒烟(需 bge-m3 模型);DeepSeek key 重配后 2. 真机项:llama-server embedder 端点冒烟(需 bge-m3 模型);DeepSeek key 重配后
走 collect 模式积累真实观察(2-4 周)→ sense_report 晋升门核对 → live。 走 collect 模式积累真实观察(2-4 周)→ sense_report 晋升门核对 → live。
3. 备份锚点:`sense-m-g3` tag412 全绿)。 3. 备份锚点:`sense-m-g3` tag412 全绿)。
### 6.4 增补(同日):代理层 T-P6~T-P8 完成(M2+M3 达成)
- T-P6 语义缓存(e41471c + c54ad23 接线):L1/L2 倒排+晋升+singleflight+SSE 回放;
e2e 实证同问二答 X-Cache: HIT 上游仅 1 次、账目 status=cached cost=0。
- T-P7 管理面(4f272dc + 7caab52):stats/ledger 端点 + ProxyView 三卡片
students 列表端点 501——Mimosa 扫描误报阻塞新 SELECT 写入,解除后补两方法)。
- T-P8 压测(bench_proxy.py):mock 管道 h_g=100%(重复>=50% 数据集)/
P99=23.5ms(预算 50ms/吞吐 205 req/s/账目零不一致;--live 桩就绪
CAMPUS_PROXY_KEY 环境变量 + --yes 确认)。
- **M3 总验收达成**(mock 口径);报告落盘 AI代理功能开发/bench/。
- 回档锚点:sense-m-g3 之上新增 proxy-m2-m3 tag423 全绿)。
+95
View File
@@ -0,0 +1,95 @@
# 科研技能清单(计算机科学与技术方向)
> 已安装 [Scientific Agent Skills](https://github.com/K-Dense-AI/scientific-agent-skills)163 个)至用户级技能目录,重启 ZCode 后生效。
> 本清单筛选出 **CS 科研常用**与**通用**技能,标注⭐为本毕设(端云协同编程智能体系统)直接可用。
> 未列出的约 100 个为生物/化学/医药/量子等领域专用技能,已安装但在 CS 方向大概率不会触发。
---
## 一、文献与调研(选题/相关工作/开题)
| 技能 | 用途 | 备注 |
|---|---|---|
| literature-review | 多学术数据库系统性文献综述 | ⭐ 相关工作章节 |
| paper-lookup | 11 个学术 API 检索论文/预印本/引用 | ⭐ 快速找论文 |
| research-lookup | 为手稿汇编当前学术证据 | 写作时引用支撑 |
| citation-management | OpenAlex/PubMed/Google Scholar 引文管理 | ⭐ 参考文献格式 |
| bgpt-paper-search | 论文检索 + 全文实验数据提取 | |
| exa-search | 面向科学/技术内容的网络检索 | 需 Exa key |
| database-lookup | 公共数据库 API 规范查询 | |
## 二、实验设计与数据(评测/实验章节)
| 技能 | 用途 | 备注 |
|---|---|---|
| experimental-design | 数据收集前的实验设计(随机化/区组/样本量) | ⭐ 评测实验设计 |
| statistical-analysis | 统计检验选择/假设检验/效应量/APA 报告 | ⭐ 对比实验分析 |
| statistical-power | 样本量与统计功效计算 | 判断 200 条数据集是否够 |
| exploratory-data-analysis | 有界探索性数据分析(CSV/表格) | ⭐ 跑分数据初探 |
| scientific-critical-thinking | 评估实验设计与证据质量 | ⭐ 审自己的评测结论 |
| hypothesis-generation | 证据约束的科学假设生成 | |
## 三、机器学习与智能体(系统核心相关)
| 技能 | 用途 | 备注 |
|---|---|---|
| scikit-learn | 监督/无监督 ML 基线 | ⭐ 分类器/缓存预测基线 |
| pytorch-lightning | PyTorch 工程化训练 | ⭐ tier 线性头升级时 |
| transformers | HF 模型加载/推理 | ⭐ 本地小模型 |
| shap | ML 预测解释(SHAP) | ⭐ 分级决策可解释性 |
| stable-baselines3 | 强化学习算法(PPO/SAC/DQN | agent 相关研究 |
| torch-geometric | 图神经网络 | 依赖图建模 |
| hugging-science | 科研领域 AI/ML 工作流 | |
| timesfm-forecasting | 时间序列零样本预测 | 负载预测类扩展 |
## 四、写作与呈现(论文/答辩)
| 技能 | 用途 | 备注 |
|---|---|---|
| scientific-writing | 论文草稿/修订/证据链审计 | ⭐ 毕业论文主体 |
| markdown-mermaid-writing | Markdown + Mermaid 架构图/流程图 | ⭐ 系统架构图 |
| scientific-visualization | 出版级科研图表(真实、无误导) | ⭐ 实验图表 |
| scientific-slides | 答辩/组会幻灯片 | ⭐ 答辩 PPT |
| scientific-schematics | AI 生成科研示意图 | 系统示意图 |
| peer-review | 同行评审级稿件评估 | ⭐ 交稿前自审 |
| venue-templates | 期刊/会议/海报模板 | |
| latex-posters | LaTeX 学术海报 | |
## 五、通用工具(日常科研)
| 技能 | 用途 | 备注 |
|---|---|---|
| pdf | PDF 读取/拆分/合并/提取 | ⭐ 读文献 PDF |
| markitdown / liteparse | 文档/PDF → Markdown | ⭐ 喂给 LLM 前的格式转换 |
| docx / xlsx / pptx | Office 三件套读写 | ⭐ 任务书/中期表 |
| matplotlib / seaborn | 绘图基础库 | ⭐ |
| sympy | 符号数学(公式推导验证) | 复杂度分析 |
| networkx | 图/网络分析建模 | |
| polars / dask | 大表数据处理/分布式 | 评测数据量大时 |
| infographics | AI 信息图生成 | 汇报配图 |
| generate-image | AI 生图/改图 | |
| what-if-oracle | 结构化 What-If 情景分析 | 方案论证 |
| scientific-brainstorming | 证据感知的科学头脑风暴 | 选题/方案发散 |
## 六、系统/环境(需要时)
| 技能 | 用途 |
|---|---|
| optimize-for-gpu | NVIDIA GPU 加速科学 Python(一致性校验) |
| modal | 无服务器云算力跑 Python(重负载时) |
| get-available-resources | 探测宿主机 CPU/内存/磁盘/GPU 清单 |
| matlab | MATLAB/Octave 数值工作流(如课程需要) |
---
## 未列入的方向专用技能(已安装,按需查阅)
生物信息(biopython/scanpy/pysam 等 ~40 个)、化学与药物(rdkit/deepchem/medchem 等 ~20 个)、
临床与医疗(pyhealth/clinical-* 等 ~15 个)、量子计算(qiskit/pennylane/qutip)、
地理(geopandas/geomaster)、实验室自动化(pylabrobot/opentrons)等。
完整清单:`~/.agents/skills/` 目录,或用 `find-skills` 技能按需检索。
## 生效说明
技能在 **ZCode 会话启动时**扫描加载——重启会话后即可在对话中直接触发
(如"用 statistical-analysis 分析这份评测数据"),无需手动安装依赖(用到时按技能内指引装)。