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
+35 -10
View File
@@ -454,6 +454,23 @@ def reset_sense_runtimes() -> None:
_sense_runtimes = {}
def _route_sig(needs: Dict[str, Any], model: str, cfg: ProxyConfig) -> str:
"""路由签名(T-X10,采纳 cortiq 语义缓存路由签名分桶)。
签名进入缓存键,使不同路由意图的请求不互串答案:
- capabilities(默认):vision/tools 需求不同 -> 不同签名(多模态/工具请求
不再命中纯文本缓存答案——正确性优先);
- model:按模型名隔离(更保守,命中率换绝对隔离);
- none:空签名(旧行为,最高命中率)。
"""
scope = getattr(cfg, "route_sig_scope", "capabilities")
if scope == "model":
return f"m:{model}"
if scope == "capabilities":
return f"v{int(bool(needs['vision']))}t{int(bool(needs['tools']))}"
return ""
def _cached_charge(body: dict, cfg: ProxyConfig, model: str) -> Dict[str, int]:
"""缓存命中计费:成本 0,按未命中口径对入/出估 token 收售价(§7)。"""
from gateway.proxy.pricing import compute
@@ -510,9 +527,11 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
body = {**body, "model": model}
# ---- 缓存分支(T-P6,§7 时序):仅缓存准入(stop+单轮)查询 ----
# T-X10:缓存键带路由签名(scope 由 cfg.route_sig_scope 决定),且
# lookup/put 共用同一 norm_hash(原先 put 侧重复计算一次)
cacheable = False
cache = None
resolution = None
norm_hash = ""
try:
if cfg.semcache_enabled:
from gateway.proxy.normalizer import canonical_hash, is_cacheable
@@ -520,11 +539,14 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
bucket_cfg = cfg.bucket(str(headers.get("x-campus-bucket") or "default"))
cacheable = is_cacheable(body)
if cacheable:
norm_hash = canonical_hash(bucket_cfg.name, bucket_cfg.doc_version, body)
sig = _route_sig(needs, model, cfg)
norm_hash = canonical_hash(bucket_cfg.name, bucket_cfg.doc_version,
body, sig)
cache = _get_semcache(cfg, ledger)
norm_text = json.dumps(body.get("messages") or [], ensure_ascii=False,
sort_keys=True)
hit = cache.lookup(norm_hash, norm_text, bucket_cfg.doc_version)
hit = cache.lookup(norm_hash, norm_text, bucket_cfg.doc_version,
route_sig=sig)
if hit is not None:
est = _estimate_hold_milli(body, cfg, model)
if await asyncio.to_thread(
@@ -585,7 +607,7 @@ async def _run_chat(body: dict, headers: Dict[str, str], ctx: Dict[str, Any],
ledger, model, est, t0,
cache=cache, cacheable=cacheable,
headers=headers, budget_mode=budget_mode,
chain=chain)
chain=chain, norm_hash=norm_hash)
except UpstreamAborted as e:
# 流中失败:按已收 usage 结算(无 usage 按字符估算),不缓存(D-P4)
usage = sink.get("usage") or _estimate_usage_from_sink(sink)
@@ -670,7 +692,8 @@ async def _stream_response(body, entry, sink, headers, client_wants_usage,
async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
model, est, t0, cache=None, cacheable=False,
headers=None, budget_mode: str = "normal",
chain: Optional[List[Dict[str, Any]]] = None):
chain: Optional[List[Dict[str, Any]]] = None,
norm_hash: str = ""):
parts = []
async for raw_bytes in upstream_stream(body, entry, sink,
chain if chain is not None else [entry]):
@@ -702,17 +725,19 @@ async def _json_response(body, entry, sink, request_id, ctx, cfg, ledger,
out_tok=usage.get("out", 0), upstream_cost_milli=br["upstream_cost_milli"],
ttfb_ms=sink.get("ttfb_ms"), total_ms=total_ms, status="ok")
# 缓存准入(D-P5):stop 且单轮且未命中来的 -> 写缓存
if cache is not None and cacheable and sink.get("finish_reason", "stop") == "stop":
# T-X10:键复用 lookup 时的 norm_hash(含路由签名),不再重复计算
if cache is not None and cacheable and norm_hash \
and sink.get("finish_reason", "stop") == "stop":
try:
from gateway.proxy.normalizer import canonical_hash
from gateway.proxy.semcache import _sig_from_key
bucket_cfg = cfg.bucket(str(headers.get("x-campus-bucket")
or "default")) if headers else cfg.bucket("default")
ckey = canonical_hash(bucket_cfg.name, bucket_cfg.doc_version, body)
norm_text = json.dumps(body.get("messages") or [], ensure_ascii=False,
sort_keys=True)
cache.put(ckey, norm_text, "".join(parts), model,
cache.put(norm_hash, norm_text, "".join(parts), model,
doc_version=bucket_cfg.doc_version,
ttl_hours=bucket_cfg.ttl_hours)
ttl_hours=bucket_cfg.ttl_hours,
route_sig=_sig_from_key(norm_hash))
except Exception:
pass
fb = sink.get("upstream_fallback") or {}