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:
+35
-10
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user