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 完整闭环在压测前完成)
This commit is contained in:
tzt
2026-09-05 15:39:48 +08:00
parent 06545b150d
commit e41471c39c
4 changed files with 435 additions and 14 deletions
+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]:
"""列出已建表名(测试验收)。""" """列出已建表名(测试验收)。"""
+250 -13
View File
@@ -1,24 +1,261 @@
"""语义缓存(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
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional import asyncio
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 weighted_jaccard(ga: set, gb: set) -> float:
"""加权 Jaccard:交集中每个 3-gram 权 2、2-gram 权 1,除以并集加权。"""
if not ga or not gb:
return 0.0
inter = ga & gb
if not inter:
return 0.0
w_inter = sum(2 if g in (3,) or len(g) == 3 else 1 for g in inter)
w_union = sum(2 if len(g) == 3 else 1 for g in (ga | gb))
return w_inter / w_union if w_union else 0.0
class CacheEntry:
__slots__ = ("answer", "model", "q_norm", "g", "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.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
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
score = weighted_jaccard(g, cand.g)
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 自带)
import hashlib
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_event_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
+139
View File
@@ -0,0 +1,139 @@
"""语义缓存测试(T-P6M2):精确/n-gram 阈值/TTL/LRU/重建/晋升/singleflight/SSE 回放。"""
import asyncio
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
+1 -1
View File
@@ -141,7 +141,7 @@ 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+失败不缓存 | ✅ 完成 | T-P6 |
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | | | T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | | | T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |