feat(proxy): T-X10 采纳 cortiq 语义缓存路由签名分桶——不同路由意图不互串答案
- normalizer.canonical_hash 增加可选 route_sig 段:键 = bucket|doc_version|sig|sha256 (旧三段格式向后兼容,旧条目随 TTL 自然淘汰) - ProxyConfig 新增 semcache.route_sig_scope:capabilities(默认,vision/tools 需求 签名)/ model(按模型隔离)/ none(旧行为);非法值回落 capabilities - semcache:签名升级为条目属性并分区 L2 语义扫描(仅键分桶不够——语义层仍会 跨签名命中);签名从缓存键第四段解析,重启重建零 schema 变更; 晋升别名键携带签名段;route_sig=None 的旧调用零过滤完全兼容 - routes:lookup/put 共用同一 norm_hash(消除 put 侧重复哈希),签名贯穿两层 - 新增 tests/test_route_sig.py 6 项(键格式/键空间分割/scope 三态/两层隔离/ 重建存活/旧调用兼容)
This commit is contained in:
+37
-12
@@ -62,10 +62,11 @@ def weighted_jaccard(ga: set, gb: set) -> float:
|
||||
|
||||
class CacheEntry:
|
||||
__slots__ = ("answer", "model", "q_norm", "g", "w", "created_ts", "ttl_ts",
|
||||
"doc_version", "hits")
|
||||
"doc_version", "hits", "sig")
|
||||
|
||||
def __init__(self, answer: str, model: str, q_norm: str,
|
||||
created_ts: float, ttl_ts: float, doc_version: int):
|
||||
created_ts: float, ttl_ts: float, doc_version: int,
|
||||
sig: str = ""):
|
||||
self.answer = answer
|
||||
self.model = model
|
||||
self.q_norm = q_norm
|
||||
@@ -75,6 +76,17 @@ class CacheEntry:
|
||||
self.ttl_ts = ttl_ts
|
||||
self.doc_version = doc_version
|
||||
self.hits = 0
|
||||
self.sig = sig # 路由签名(T-X10:L2 扫描按签名分区)
|
||||
|
||||
|
||||
def _sig_from_key(cache_key: str) -> str:
|
||||
"""从缓存键解析路由签名(T-X10)。
|
||||
|
||||
带签名键 = bucket|doc_version|sig|sha256(4 段);旧三段键无签名返回 ""。
|
||||
签名段约束不含 '|'(capabilities/model 两种 scope 均满足),故解析无歧义。
|
||||
"""
|
||||
parts = cache_key.split("|")
|
||||
return parts[2] if len(parts) >= 4 else ""
|
||||
|
||||
|
||||
class SemanticCache:
|
||||
@@ -106,7 +118,8 @@ class SemanticCache:
|
||||
return
|
||||
for r in rows:
|
||||
entry = CacheEntry(r["answer"], r["model"], r["q_norm"],
|
||||
r["created_ts"], r["ttl_ts"], r["doc_version"])
|
||||
r["created_ts"], r["ttl_ts"], r["doc_version"],
|
||||
sig=_sig_from_key(r["cache_key"]))
|
||||
entry.hits = r["hits"]
|
||||
self._index(r["cache_key"], entry, promote=False)
|
||||
|
||||
@@ -131,12 +144,18 @@ class SemanticCache:
|
||||
|
||||
# ---------- 查询 ----------
|
||||
def lookup(self, cache_key: str, norm_text: str,
|
||||
doc_version: int = 1) -> Optional[Dict[str, Any]]:
|
||||
"""L1 精确 -> L2 语义(§7 签名)。返回 {answer, model, level} 或 None。"""
|
||||
doc_version: int = 1,
|
||||
route_sig: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""L1 精确 -> L2 语义(§7 签名)。返回 {answer, model, level} 或 None。
|
||||
|
||||
route_sig 非 None 时按签名分区(T-X10):L1 命中要求条目签名一致,
|
||||
L2 扫描跳过签名不一致的候选(不同路由意图不互串答案);
|
||||
None = 不校验(旧调用/旧测试完全兼容)。
|
||||
"""
|
||||
now = self._now()
|
||||
# L1
|
||||
entry = self._l1.get(cache_key)
|
||||
if entry is not None:
|
||||
if entry is not None and (route_sig is None or entry.sig == route_sig):
|
||||
if entry.ttl_ts < now:
|
||||
self._invalidate_key(cache_key)
|
||||
else:
|
||||
@@ -161,6 +180,8 @@ class SemanticCache:
|
||||
cand = self._l1.get(key)
|
||||
if cand is None or cand.ttl_ts < now or cand.doc_version != doc_version:
|
||||
continue
|
||||
if route_sig is not None and cand.sig != route_sig:
|
||||
continue # 签名分区:跨路由意图不参与计分
|
||||
# 规模上界预筛:w_inter <= lo 且 w_union >= hi,故 score <= lo/hi;
|
||||
# 严格小于阈值者不可能命中,跳过(不构建相交集合)。
|
||||
# 注意用严格不等式:lo/hi == 阈值的边界候选仍会进入精确计分,
|
||||
@@ -181,9 +202,12 @@ class SemanticCache:
|
||||
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]}"
|
||||
# 这里以 sha256(q_norm) 前缀别名入 L1,桶/版本/签名由 cand 自带)
|
||||
h = hashlib.sha256(cand.q_norm.encode()).hexdigest()[:16]
|
||||
if cand.sig:
|
||||
alias = f"{best_key.split('|')[0]}|{cand.doc_version}|{cand.sig}|promoted:{h}"
|
||||
else:
|
||||
alias = f"{best_key.split('|')[0]}|{cand.doc_version}|promoted:{h}"
|
||||
self._index(alias, cand, promote=True)
|
||||
try:
|
||||
self.store.promote_semcache(best_key, alias, cand.hits)
|
||||
@@ -205,11 +229,12 @@ class SemanticCache:
|
||||
|
||||
# ---------- 写入 ----------
|
||||
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 签名)。"""
|
||||
doc_version: int = 1, ttl_hours: int = 72,
|
||||
route_sig: str = "") -> None:
|
||||
"""写 L1 + 倒排 + sqlite 持久化(§7 签名)。route_sig 随条目留存供 L2 分区。"""
|
||||
now = self._now()
|
||||
entry = CacheEntry(answer, model, q_norm, now, now + ttl_hours * 3600,
|
||||
doc_version)
|
||||
doc_version, sig=route_sig)
|
||||
self._index(cache_key, entry)
|
||||
try:
|
||||
self.store.put_semcache(cache_key, "default", q_norm, answer, model,
|
||||
|
||||
Reference in New Issue
Block a user