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
+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