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:
tzt
2026-09-19 09:59:15 +08:00
parent 9ce8718812
commit e5470a3715
5 changed files with 173 additions and 23 deletions
+37 -12
View File
@@ -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|sha2564 段);旧三段键无签名返回 ""
签名段约束不含 '|'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,