feat(sense): T-X3 路由决策缓存——live 模式 embed+线性头去重(采纳 cortiq 决策哈希缓存)

- gateway/sense/decision_cache.py:DecisionCache(sha256(scope+文本) 键、
  LRU + TTL 60s、4096 条上限、时钟可注入),对齐 auth._AuthCache 进程内模式
- Grader:仅 live 模式缓存纯决策负载(probs/head_version/vec);
  命中跳过 embed + 线性头预测;tier 仍按当前 conformal 阈值即时重算;
  观察落盘(observer.log)不因缓存命中跳过——校准数据完整性不受影响
- collect/shadow 模式不走缓存(校准必须全量);invalidate() 同步清空缓存
  (阈值/工件切换即时生效,TTL 仅兜底陈旧)

pytest 455 passed(T-X2 后 447 + 8)
This commit is contained in:
tzt
2026-09-18 22:41:15 +08:00
parent a043493548
commit 27a092a3f8
4 changed files with 267 additions and 17 deletions
+74
View File
@@ -0,0 +1,74 @@
"""路由决策缓存(T-X3,采纳 cortiq router_client 的决策哈希缓存思路)。
- key = sha256(scope + 0x00 + 归一文本)LRU + TTL(默认 60s / 4096 条,
对齐 auth._AuthCache 的进程内模式,D-P9 单进程前提)。
- 只缓存**纯决策负载**(如 probs/head_version/vec);调用方的观察与审计
落盘职责不因缓存命中而跳过。
- 时钟可注入(now(monotonic)),供测试确定性。
"""
from __future__ import annotations
import hashlib
import threading
import time
from collections import OrderedDict
from typing import Any, Callable, Optional
_DEFAULT_TTL_S = 60.0
_DEFAULT_MAX = 4096
class DecisionCache:
"""进程内决策缓存:LRU + TTL,键为 (scope, 归一文本) 的 sha256。"""
def __init__(self, maxsize: int = _DEFAULT_MAX, ttl_s: float = _DEFAULT_TTL_S,
now: Optional[Callable[[], float]] = None):
self._max = max(1, int(maxsize))
self._ttl = float(ttl_s)
self._now = now or time.monotonic
self._data: "OrderedDict[str, tuple[float, Any]]" = OrderedDict()
self._lock = threading.Lock()
self.hits = 0
self.misses = 0
@staticmethod
def key(scope: str, text: str) -> str:
"""缓存键:scope 隔离消费方/模式,文本归一由调用方完成。"""
return hashlib.sha256(f"{scope}\x00{text}".encode("utf-8")).hexdigest()
def get(self, scope: str, text: str) -> Optional[Any]:
now = self._now()
k = self.key(scope, text)
with self._lock:
item = self._data.get(k)
if item is None:
self.misses += 1
return None
ts, payload = item
if now - ts > self._ttl:
self._data.pop(k, None)
self.misses += 1
return None
self._data.move_to_end(k)
self.hits += 1
return payload
def put(self, scope: str, text: str, payload: Any) -> None:
k = self.key(scope, text)
with self._lock:
self._data[k] = (self._now(), payload)
self._data.move_to_end(k)
while len(self._data) > self._max:
self._data.popitem(last=False)
def clear(self) -> None:
"""清空条目(阈值/工件版本切换时由 Grader.invalidate 调用)。"""
with self._lock:
self._data.clear()
def stats(self) -> dict:
with self._lock:
return {"entries": len(self._data), "hits": self.hits,
"misses": self.misses,
"hit_rate": round(self.hits / (self.hits + self.misses), 4)
if (self.hits + self.misses) else 0.0}
+35 -17
View File
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional
from gateway.sense.calibrate import load_active
from gateway.sense.classifier import LinearHead
from gateway.sense.config import SenseConfig
from gateway.sense.decision_cache import DecisionCache
from gateway.sense.embedder import embed
from gateway.sense.errors import EmbedderDown
from gateway.sense.features import gate
@@ -53,6 +54,7 @@ class Grader:
self._head: Optional[LinearHead] = None
self._head_loaded = False
self._thresholds: Optional[Dict[str, Any]] = None
self._dcache = DecisionCache() # T-X3live 模式决策缓存
def _load_head(self):
if not self._head_loaded:
@@ -66,10 +68,11 @@ class Grader:
return self._head
def invalidate(self) -> None:
"""工件 promote 后调用(重载 active 工件与阈值)。"""
"""工件 promote 后调用(重载 active 工件与阈值;同步清空决策缓存)。"""
self._head = None
self._head_loaded = False
self._thresholds = None
self._dcache.clear()
def _thresholds_cached(self) -> Dict[str, Any]:
if self._thresholds is None:
@@ -79,32 +82,47 @@ class Grader:
async def decide(self, query_or_messages, consumer: str,
domain: str = "", request_id: str = "",
executed_tier: str = "") -> TierDecision:
"""分级决策(§8 时序;全模式写观察)。"""
"""分级决策(§8 时序;全模式写观察)。
T-X3live 模式下对 (consumer, 文本) 的纯决策负载(probs/head_version/vec
做 60s LRU 缓存,命中时跳过 embed + 线性头;观察/审计照常落盘。
collect/shadow 模式不走缓存(校准数据必须全量产出)。
"""
ts = time.time()
rid = request_id or ("rt" + uuid.uuid4().hex[:10])
text = (query_or_messages if isinstance(query_or_messages, str)
else "\n".join(str(m.get("content") or "")
for m in query_or_messages))
feats = gate(query_or_messages, consumer, self.cfg)
probs: Dict[str, float] = {}
head_version = ""
fallback = False
vec: Optional[List[int]] = None
try:
vec = await embed(feats and (query_or_messages
if isinstance(query_or_messages, str)
else "\n".join(
str(m.get("content") or "")
for m in query_or_messages)),
self.cfg.embedder)
except EmbedderDown:
fallback = True
cached = self._dcache.get(consumer, text) \
if self.cfg.mode == "live" else None
if cached is not None:
probs = dict(cached["probs"])
head_version = str(cached["head_version"])
vec = cached["vec"]
else:
try:
vec = await embed(text, self.cfg.embedder)
except EmbedderDown:
fallback = True
head = None if fallback else self._load_head()
if head is None:
fallback = True
head = None if fallback else self._load_head()
if head is None:
fallback = True
if not fallback and vec is not None:
probs = head.predict([float(v) for v in vec])
head_version = head.version
if not fallback and vec is not None:
probs = head.predict([float(v) for v in vec])
head_version = head.version
if self.cfg.mode == "live":
self._dcache.put(consumer, text,
{"probs": dict(probs),
"head_version": head_version,
"vec": vec})
th = self._thresholds_cached()
th_version = str(th.get("version") or "")