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
+87
View File
@@ -0,0 +1,87 @@
"""语义缓存路由签名分桶(T-X10,采纳 cortiq 路由签名设计)。"""
from gateway.proxy.config import build_proxy_config
from gateway.proxy.normalizer import canonical_hash
from gateway.proxy.routes import _route_sig
_BODY = {"model": "m1", "messages": [{"role": "user", "content": "什么是递归"}]}
def test_canonical_hash_legacy_format_without_sig():
"""不带签名保持旧三段格式(旧调用/旧条目兼容)。"""
h = canonical_hash("default", 1, _BODY)
assert h.count("|") == 2
h2 = canonical_hash("default", 1, _BODY, "")
assert h == h2
def test_canonical_hash_with_sig_divides_keyspace():
"""签名不同 -> 键不同:vision 请求与纯文本请求不再共用缓存条目。"""
plain = canonical_hash("default", 1, _BODY)
vision = canonical_hash("default", 1, _BODY, "v1t0")
tools = canonical_hash("default", 1, _BODY, "v0t1")
assert len({plain, vision, tools}) == 3
def test_route_sig_scopes():
"""三种 scope 的签名形态:capabilities 默认 / model 精确 / none 旧格式。"""
cfg = build_proxy_config({"proxy": {"enabled": True}})
needs = {"vision": True, "tools": False, "min_context_tokens": 10}
assert _route_sig(needs, "m1", cfg) == "v1t0"
cfg_model = build_proxy_config({"proxy": {"semcache": {"route_sig_scope": "model"}}})
assert _route_sig(needs, "m1", cfg_model) == "m:m1"
cfg_none = build_proxy_config({"proxy": {"semcache": {"route_sig_scope": "none"}}})
assert _route_sig(needs, "m1", cfg_none) == ""
# 非法 scope 回落 capabilities
cfg_bad = build_proxy_config({"proxy": {"semcache": {"route_sig_scope": "bogus"}}})
assert _route_sig(needs, "m1", cfg_bad) == "v1t0"
def test_sig_isolation_end_to_end_via_semcache(tmp_path):
"""经 SemanticCache 验证:签名分区 L1 精确层与 L2 语义扫描两层。"""
from gateway.proxy.ledger import Ledger
from gateway.proxy.semcache import SemanticCache
led = Ledger.init_db(tmp_path / "l.sqlite3")
cache = SemanticCache(led, max_entries=100, sim_threshold=0.5,
promote_frequency=5)
k_text = canonical_hash("default", 1, _BODY, "v0t0")
k_vision = canonical_hash("default", 1, _BODY, "v1t0")
cache.put(k_text, "什么是递归", "纯文本答案", "m1", doc_version=1,
route_sig="v0t0")
# 相同问题、vision 签名:L1 键不同必不中;L2 语义扫描按签名分区也不中
assert cache.lookup(k_vision, "什么是递归", doc_version=1,
route_sig="v1t0") is None
# 同签名:L1 精确命中
hit = cache.lookup(k_text, "什么是递归", doc_version=1, route_sig="v0t0")
assert hit is not None and hit["level"] == "exact"
def test_sig_partition_survives_rebuild(tmp_path):
"""重启重建(semcache 表无签名列):签名从缓存键解析恢复,分区仍有效。"""
from gateway.proxy.ledger import Ledger
from gateway.proxy.semcache import SemanticCache
led = Ledger.init_db(tmp_path / "l.sqlite3")
c1 = SemanticCache(led, max_entries=100, sim_threshold=0.5, promote_frequency=5)
k = canonical_hash("default", 1, _BODY, "v1t0")
c1.put(k, "什么是递归", "多模态答案", "m1", doc_version=1, route_sig="v1t0")
c2 = SemanticCache(led, max_entries=100, sim_threshold=0.5, promote_frequency=5)
assert c2.lookup(k, "什么是递归", doc_version=1, route_sig="v1t0") is not None
assert c2.lookup(canonical_hash("default", 1, _BODY, "v0t0"),
"什么是递归", doc_version=1, route_sig="v0t0") is None
def test_legacy_calls_without_sig_unchanged(tmp_path):
"""不传签名(route_sig=None)的旧调用零过滤:既有测试行为不变。"""
from gateway.proxy.ledger import Ledger
from gateway.proxy.semcache import SemanticCache
led = Ledger.init_db(tmp_path / "l.sqlite3")
cache = SemanticCache(led, max_entries=100, sim_threshold=0.5, promote_frequency=5)
cache.put("k1", "请解释一下递归函数的概念", "答案", "m1", doc_version=1)
assert cache.lookup("k1", "请解释一下递归函数的概念", doc_version=1) is not None
# 语义路径同样不受影响(近似文本跨键命中)
assert cache.lookup("k2", "请解释一下递归函数的概念呢", doc_version=1) is not None