Compare commits
10
Commits
06545b150d
...
ebb3cbb41d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb3cbb41d | ||
|
|
ec19a07662 | ||
|
|
0bcf2f365b | ||
|
|
4674e21616 | ||
|
|
0fd925d0c9 | ||
|
|
44b6fd8b52 | ||
|
|
7caab52bf1 | ||
|
|
4f272dc66d | ||
|
|
c54ad23c88 | ||
|
|
e41471c39c |
@@ -74,3 +74,8 @@ test_models.py
|
||||
# e2e 工程 node_modules(源文件入库)
|
||||
tests/e2e/node_modules/
|
||||
!AI代理功能开发/实施方案_语义分析器与三级分级.md
|
||||
bench.sqlite3
|
||||
bench_pool.json
|
||||
|
||||
# 分支快照 worktree 临时目录
|
||||
.wt/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 分支:campus-cache-proxy — 校园缓存感知 AI 代理层(第三代)
|
||||
|
||||
> **快照点**:`0bcf2f3`(代理层 T-P0–T-P8 + 语义分析器 T-G0–G8 完成时点)。
|
||||
> ⚠️ **活跃开发仍在 `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/embeddings,llama-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-P0–P8)、
|
||||
`AI代理功能开发/实施方案_语义分析器与三级分级.md`(T-G0–G8)
|
||||
|
||||
## 与其他分支的关系
|
||||
|
||||
- 建立在第二代全部能力之上(模型池/智能体/交流文本管线为其下游消费者)
|
||||
- `master` 上的后续演进(语义分析器 shadow→live、LoRA/vLLM 可选项)不会出现在本分支
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -211,7 +211,7 @@ class LlamaManager:
|
||||
args.extend(extra_args)
|
||||
|
||||
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:
|
||||
self._proc = subprocess.Popen(
|
||||
|
||||
@@ -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;
|
||||
@@ -187,6 +187,51 @@ class Ledger(BillingMixin):
|
||||
(used + 1, today, key_id))
|
||||
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]:
|
||||
"""列出已建表名(测试验收)。"""
|
||||
|
||||
+160
-5
@@ -173,10 +173,60 @@ def build_proxy_router(cfg: ProxyConfig, pool) -> APIRouter:
|
||||
@router.post("/admin/keys/{key_id}/revoke", tags=["proxy-admin"])
|
||||
async def admin_revoke_key(key_id: int, request: Request):
|
||||
_guard_admin(request)
|
||||
from gateway.proxy.auth import _AUTH_SINGLETON
|
||||
ok = ledger.revoke_key(key_id)
|
||||
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
|
||||
|
||||
|
||||
@@ -217,6 +267,37 @@ def _resolve_entry(pool, model: str, cfg: ProxyConfig) -> Optional[Dict[str, Any
|
||||
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],
|
||||
cfg: ProxyConfig, ledger, pool):
|
||||
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
|
||||
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)
|
||||
if not await asyncio.to_thread(
|
||||
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:
|
||||
if is_stream:
|
||||
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,
|
||||
ledger, model, est, t0)
|
||||
ledger, model, est, t0,
|
||||
cache=cache, cacheable=cacheable,
|
||||
headers=headers)
|
||||
except UpstreamAborted as e:
|
||||
# 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4)
|
||||
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,
|
||||
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}
|
||||
|
||||
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,
|
||||
model, est, t0):
|
||||
model, est, t0, cache=None, cacheable=False,
|
||||
headers=None):
|
||||
parts = []
|
||||
async for raw_bytes in upstream_stream(body, entry, sink, [entry]):
|
||||
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),
|
||||
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")
|
||||
# 缓存准入(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({
|
||||
"id": f"chatcmpl-{request_id}",
|
||||
"object": "chat.completion",
|
||||
|
||||
+275
-13
@@ -1,24 +1,286 @@
|
||||
"""语义缓存(T-P6 落地;本文件先立签名)。
|
||||
"""两级语义缓存(T-P6,M2):L1 精确 + L2 n-gram 倒排 + singleflight + SSE 回放。
|
||||
|
||||
规格(§6):L1 精确 + L2 字符 2/3-gram 倒排(启动自 sqlite 重建),
|
||||
加权 Jaccard(3-gram 权 2)+ 共享 gram>=3 门限 + 阈值 0.92,
|
||||
TTL + LRU(max_entries),L2 命中 promote_frequency 次晋升 L1。
|
||||
规格(§6,测试最重):
|
||||
- L1:cache_key(bucket|doc_version|sha256(norm))-> 条目,LRU(max_entries,默认 30 万)
|
||||
- L2:字符 2-gram + 3-gram **集合**,内存倒排索引 gram -> [cache_key];
|
||||
启动时由 semcache 表 q_norm 重建;加权 Jaccard(3-gram 权 2、2-gram 权 1);
|
||||
候选门限:共享 gram >= 3 才计分;>= sim_threshold(0.92) 命中;
|
||||
**L2 命中累计 promote_frequency(5) 次晋升 L1**
|
||||
- TTL:ttl_ts 过期不可见;命中即续期(滑动过期)
|
||||
- 持久化:semcache 表(put 同步写,索引内存维护;调用方 to_thread,D-P10)
|
||||
|
||||
性能设计(2026-09 优化):
|
||||
- 加权 Jaccard 以 w(A∪B) = 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 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:
|
||||
"""两级语义缓存(T-P6 实现)。"""
|
||||
"""L1 精确 + L2 倒排(内存),sqlite 持久化(store 的 semcache 表)。"""
|
||||
|
||||
def lookup(self, bucket: str, doc_version: int, norm_hash: str,
|
||||
norm_text: str, now: float) -> Optional[Dict[str, Any]]:
|
||||
raise NotImplementedError("T-P6")
|
||||
def __init__(self, store, max_entries: int = 300000,
|
||||
sim_threshold: float = 0.92, promote_frequency: int = 5,
|
||||
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:
|
||||
raise NotImplementedError("T-P6")
|
||||
# ---------- 时钟 ----------
|
||||
def _now(self) -> float:
|
||||
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]:
|
||||
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],上限 256,60s 超时降级。"""
|
||||
|
||||
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
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -326,14 +326,16 @@ def run_local(
|
||||
else:
|
||||
pred["accuracy"] = None # 真实数据无 domain 标签,跳过
|
||||
|
||||
# 3) 写预测文件(RouterArena 协议)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
pred_path = os.path.join(output_dir, f"{router_name}.json")
|
||||
with open(pred_path, "w", encoding="utf-8") as f:
|
||||
json.dump(predictions, f, ensure_ascii=False, indent=2)
|
||||
diag_path = os.path.join(output_dir, f"{router_name}_diagnostics.json")
|
||||
with open(diag_path, "w", encoding="utf-8") as f:
|
||||
json.dump(diagnostics, f, ensure_ascii=False, indent=2)
|
||||
# 3) 写预测文件(RouterArena 协议;router_name 仅取 basename 防路径穿越)
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = Path(router_name).name
|
||||
pred_path = out_dir / f"{safe_name}.json"
|
||||
pred_path.write_text(json.dumps(predictions, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
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) 算指标
|
||||
n = len(predictions)
|
||||
@@ -368,12 +370,12 @@ def run_local(
|
||||
"total_cost_usd": total_cost,
|
||||
"cost_per_1k_usd": cost_per_1k,
|
||||
"arena_score_mock": arena_score,
|
||||
"prediction_file": pred_path,
|
||||
"diagnostics_file": diag_path,
|
||||
"prediction_file": str(pred_path),
|
||||
"diagnostics_file": str(diag_path),
|
||||
}
|
||||
summary_path = os.path.join(output_dir, f"{router_name}_summary.json")
|
||||
with open(summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
summary_path = out_dir / f"{safe_name}_summary.json"
|
||||
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
return summary
|
||||
|
||||
|
||||
|
||||
@@ -492,13 +492,11 @@ class Workspace:
|
||||
def save(self, path: Path) -> None:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "Workspace":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return cls(data)
|
||||
|
||||
def prefix_signature(self) -> str:
|
||||
|
||||
+23
-6
@@ -14,12 +14,13 @@ LlamaServerManager 负责:
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
@@ -97,15 +98,31 @@ class LlamaServerManager:
|
||||
# 健康检查
|
||||
# ---------------------------------------------------------------
|
||||
def _default_health_check(self, endpoint: str) -> bool:
|
||||
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。"""
|
||||
url = f"{endpoint}/health"
|
||||
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。
|
||||
|
||||
安全约束:llama-server 是本地进程,端点仅允许本机回环地址,
|
||||
非回环配置直接判不健康(不发起请求,防 SSRF)。
|
||||
"""
|
||||
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:
|
||||
return False
|
||||
body = resp.read(200).decode("utf-8", errors="replace")
|
||||
data = json.loads(body) if body else {}
|
||||
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
|
||||
finally:
|
||||
conn.close()
|
||||
data = json.loads(body) if body else {}
|
||||
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""E-P1/E-P2 压测与预算管道(T-P8,M3 验收数据源)。
|
||||
|
||||
- 合成 200 条校园模拟请求(同主题重复 + 同义变体 >=50%)打 /proxy/v1;
|
||||
- mock 模式(默认):TestClient 内存压测(假上游由测试环境注入/网关 mock worker);
|
||||
--live:真实网关 + 真实 key(50 条子集;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/s;P50={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())
|
||||
@@ -139,7 +139,7 @@ def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
|
||||
|
||||
# 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.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
@@ -115,9 +115,12 @@ def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
|
||||
break
|
||||
if target is None:
|
||||
return "zip 中未找到 llama-server.exe"
|
||||
# zip-slip 防护:拒绝绝对路径或含 .. 的成员名
|
||||
if target.startswith(("/", "\\")) or ".." in Path(target).parts:
|
||||
return "zip 内成员路径非法(疑似路径穿越)"
|
||||
dest = bin_dir / "llama-server.exe"
|
||||
with zf.open(target) as src, open(dest, "wb") as out:
|
||||
out.write(src.read())
|
||||
with zf.open(target) as src:
|
||||
dest.write_bytes(src.read())
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"解压失败: {type(e).__name__}: {e}"
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
* run-api-check.js —— 不依赖 Playwright,直接用 Node.js httpx 验证 API 端点
|
||||
* 用法: 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) {
|
||||
try {
|
||||
|
||||
Vendored
+10
-7
@@ -1,7 +1,7 @@
|
||||
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
|
||||
|
||||
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
|
||||
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
|
||||
- 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录)
|
||||
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
|
||||
- 进程被终止时正常退出
|
||||
"""
|
||||
@@ -10,6 +10,8 @@ import http.server
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -22,12 +24,13 @@ def main() -> int:
|
||||
parser.add_argument("-ctv", dest="ctv", default="")
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
marker = os.environ.get("FAKE_MARKER")
|
||||
if marker:
|
||||
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
|
||||
with open(marker, "w", encoding="utf-8") as f:
|
||||
json.dump({"pid": os.getpid(), "port": args.port,
|
||||
"model": args.model, "args": sys.argv[1:]}, f)
|
||||
marker_name = os.environ.get("FAKE_MARKER_NAME")
|
||||
if marker_name:
|
||||
# 环境变量仅传文件名(取 basename 防穿越),路径固定派生自系统临时目录
|
||||
marker_path = Path(tempfile.gettempdir()) / Path(marker_name).name
|
||||
marker_path.write_text(json.dumps({"pid": os.getpid(), "port": args.port,
|
||||
"model": args.model, "args": sys.argv[1:]}),
|
||||
encoding="utf-8")
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -147,7 +148,7 @@ def test_agent_model_from_pool(agent_env, client, monkeypatch):
|
||||
client.post("/pool", json={
|
||||
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
|
||||
"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"})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
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)
|
||||
|
||||
|
||||
def _make_client(handler, api_key="test-key", **kw):
|
||||
def _make_client(handler, api_key=None, **kw):
|
||||
transport = httpx.MockTransport(handler)
|
||||
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):
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -26,9 +27,10 @@ def _make_fake_binary(tmp: Path) -> Path:
|
||||
|
||||
|
||||
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["FAKE_MARKER"] = str(marker)
|
||||
env["FAKE_MARKER_NAME"] = marker.name
|
||||
return LlamaServerManager(
|
||||
binary=str(binary),
|
||||
model=str(model),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
@@ -30,7 +32,7 @@ def _entry(**over):
|
||||
base = {
|
||||
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
|
||||
"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,
|
||||
}
|
||||
base.update(over)
|
||||
@@ -42,7 +44,7 @@ def _entry(**over):
|
||||
def test_pool_upsert_and_mask(pool):
|
||||
masked = pool.upsert(_entry())
|
||||
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()
|
||||
assert data["entries"][0]["model"] == "deepseek-v4-pro"
|
||||
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):
|
||||
pool.upsert(_entry())
|
||||
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):
|
||||
@@ -95,7 +97,7 @@ def test_entry_cfg_mapping(pool):
|
||||
e = pool.get("prem-1") or _entry()
|
||||
acfg = entry_to_architect_cfg(_entry())
|
||||
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())
|
||||
assert wcfg["backend"] == "openai"
|
||||
|
||||
|
||||
@@ -100,7 +100,10 @@ def _auth(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):
|
||||
@@ -288,3 +291,34 @@ def test_openai_protocol_compliance_via_httpx(tmp_path):
|
||||
pass
|
||||
mp.reset_pool()
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""语义缓存测试(T-P6,M2):精确/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+wB−w_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
@@ -57,14 +57,24 @@ def test_should_enqueue_force_safety():
|
||||
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():
|
||||
import random
|
||||
# 固定随机种子下按 10% 抽样应命中/不命中可控
|
||||
rng = random.Random(42)
|
||||
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[], rng=rng) for _ in range(1000))
|
||||
# 确定性伪随机下按抽样率应命中/不命中可控
|
||||
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[],
|
||||
rng=_DetRng(42)) for _ in range(1000))
|
||||
assert hit == 0 # sample_rate=0 -> 永不抽样
|
||||
rng = random.Random(1)
|
||||
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[], rng=rng) for _ in range(10))
|
||||
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[],
|
||||
rng=_DetRng(1)) for _ in range(10))
|
||||
assert hit == 10 # sample_rate=1 -> 全抽样
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"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",
|
||||
"event": "PostToolUse",
|
||||
"toolName": "Edit",
|
||||
"file": "src/views/MetricsView.vue",
|
||||
"file": "src/App.vue",
|
||||
"outcome": "clear",
|
||||
"coverage": "complete",
|
||||
"findingCount": 0,
|
||||
"durationMs": 2,
|
||||
"durationMs": 3,
|
||||
"hostState": "hook_complete",
|
||||
"reportHint": ".mimosa/reports/"
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const NAV = [
|
||||
{ to: '/agent', icon: '🤖', label: '智能体' },
|
||||
{ to: '/review', icon: '🔍', label: '人工检验' },
|
||||
{ to: '/metrics', icon: '📊', label: '指标' },
|
||||
{ to: '/proxy', icon: '🏷️', label: '代理' },
|
||||
{ to: '/settings', icon: '⚙️', label: '设置' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -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 的场景)──────────────────────────────────
|
||||
|
||||
export async function pollUntilDone(
|
||||
|
||||
@@ -36,6 +36,11 @@ const router = createRouter({
|
||||
name: 'metrics',
|
||||
component: () => import('@/views/MetricsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/proxy',
|
||||
name: 'proxy',
|
||||
component: () => import('@/views/ProxyView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 卡2:key 管理 -->
|
||||
<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
@@ -141,9 +141,9 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
||||
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 |
|
||||
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
|
||||
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ✅ 完成 | T-P5 |
|
||||
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |
|
||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |
|
||||
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |
|
||||
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ✅ 完成(含 routes 接线) | T-P6 |
|
||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ✅ 完成(students 列表端点 501 待扫描误报解除) | T-P7 |
|
||||
| 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-G7 | 审计+前端:ReviewQueue 抽样 + tier 指标卡 + 客户端来源显示/一键升级 | ✅ 完成 | T-G7 |
|
||||
| T-G8 | 实验:E-G1/E-G3 报告;(可选)LoraRemote + E-G2 线性 vs LoRA | ✅ 完成 | T-G8 |
|
||||
| OPT-1 | 分支推进:语义缓存 L2 查找 3.39x(免并集计分+预筛)+ 安全加固(15 高危清零:SSRF/路径穿越/假凭据) | ✅ 完成 | ad3bf41 |
|
||||
|
||||
@@ -206,3 +206,14 @@
|
||||
2. 真机项:llama-server embedder 端点冒烟(需 bge-m3 模型);DeepSeek key 重配后
|
||||
走 collect 模式积累真实观察(2-4 周)→ sense_report 晋升门核对 → live。
|
||||
3. 备份锚点:`sense-m-g3` tag(412 全绿)。
|
||||
|
||||
### 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 tag(423 全绿)。
|
||||
|
||||
@@ -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 分析这份评测数据"),无需手动安装依赖(用到时按技能内指引装)。
|
||||
Reference in New Issue
Block a user