Files
projectAIpopular/gateway/proxy/normalizer.py
T
tzt e5470a3715 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 三态/两层隔离/
  重建存活/旧调用兼容)
2026-09-19 09:59:15 +08:00

134 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""请求规范化与桶解析(T-P5)。
规范化规则(§6,决定缓存命中率的代码):
1. messages 序列化:role 与 content 交替拼接为 `role\\u0001content\\u0002`
2. 剔除易变字段:temperature/frequency_penalty/seed/request_id/时间戳类内容行;
3. 系统模板与资料前缀不参与 L1 哈希(桶+doc_version 已表达),只参与上游整形;
4. 整形后消息顺序固定:[canonical_system] -> [doc_prefix] -> [原 messages]
5. 多轮判定:len(messages) > 2system 之外 >1 条)-> cacheable=FalseD-P5)。
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from gateway.proxy.ledgerutil import _today # noqa: F401 (保持导入面稳定)
_SEP_R = "\u0001"
_SEP_M = "\u0002"
# 易变字段(规范化时从 body 剔除,不参与哈希)
_VOLATILE_FIELDS = ("temperature", "frequency_penalty", "presence_penalty",
"seed", "request_id", "user", "logprobs", "top_logprobs")
# 时间戳类内容行(消息内容若整体匹配则该行剔除后再序列化)
_TIMESTAMP_LINE = re.compile(
r"^\s*(当前时间|时间|now|time|date|日期)\s*[:].*$", re.IGNORECASE)
@dataclass
class BucketResolution:
"""桶解析结果。"""
name: str
cfg: Any # BucketCfg
cacheable: bool # D-P5:多轮 False
norm_text: str # 规范化后的 messages 文本(L2 语义比对用)
norm_hash: str # bucket|doc_version|sha256(norm)L1 键)
def resolve_bucket(body: dict, headers: Dict[str, str], cfg) -> Any:
"""桶解析(D-P2):X-Campus-Bucket 头 > model->bucket 映射 > default。"""
name = (headers.get("x-campus-bucket")
or headers.get("X-Campus-Bucket") or "").strip()
if not name:
# model->bucket 映射(配置预留:bucket 名即映射;MVP 仅 default
name = "default"
return cfg.bucket(name)
def _norm_content(content: Any) -> str:
if isinstance(content, str):
lines = [ln for ln in content.splitlines()
if ln.strip() and not _TIMESTAMP_LINE.match(ln)]
return "\n".join(lines).strip()
if isinstance(content, list):
# 多模态 content parts:取 text 项拼接
parts = []
for p in content:
if isinstance(p, dict) and isinstance(p.get("text"), str):
parts.append(_norm_content(p["text"]))
return "\n".join(parts)
return ""
def normalize_messages(body: dict) -> str:
"""规则 1+2:剔易变行后按 role\\u0001content\\u0002 串接(system 亦参与——
客户端自带 system 属请求语义;桶模板/资料前缀不在这里,见 shape)。"""
out: List[str] = []
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
text = _norm_content(m.get("content"))
out.append(role + _SEP_R + text + _SEP_M)
return "".join(out)
def is_cacheable(body: dict) -> bool:
"""规则 5system 之外消息数 > 1 -> 多轮 -> 不缓存(D-P5)。"""
msgs = [m for m in (body.get("messages") or []) if isinstance(m, dict)]
non_system = [m for m in msgs if str(m.get("role")) != "system"]
return len(non_system) <= 1
def canonical_hash(bucket: str, doc_version: int, body: dict,
route_sig: str = "") -> str:
"""规则 3:缓存键 = bucket + '|' + doc_version + '|' + sha256(norm)。
route_sig 非空时插入为第三段(T-X10,采纳 cortiq 路由签名分桶:
不同路由意图——vision/tools 需求或模型——不互串答案);为空时保持
旧三段格式(既有调用与旧缓存条目兼容,旧条目随 TTL 自然淘汰)。
桶模板/资料前缀不参与哈希(由 bucket+doc_version 表达,资料更新 = 版本+1)。
"""
norm = normalize_messages(body)
digest = hashlib.sha256(norm.encode("utf-8")).hexdigest()
if route_sig:
return f"{bucket}|{doc_version}|{route_sig}|{digest}"
return f"{bucket}|{doc_version}|{digest}"
def _load_doc_prefix(bucket_cfg) -> str:
"""读桶资料前缀文件(§3:不入库不入 git;缺失返回空串)。"""
path = getattr(bucket_cfg, "doc_prefix_file", None)
if not path:
return ""
try:
from pathlib import Path
return Path(path).read_text(encoding="utf-8")
except OSError:
return ""
def shape(body: dict, bucket_cfg) -> dict:
"""规则 4:整形上游请求体 —— [canonical_system] -> [doc_prefix] -> [原 messages]。
原请求自带的 system 保留在原位(其属请求语义);桶模板仅在没有
system 时注入,资料前缀始终插在 system 之后、其余消息之前。
剔除易变顶层字段后的浅拷贝返回。
"""
shaped = {k: v for k, v in body.items() if k not in _VOLATILE_FIELDS}
msgs: List[Dict[str, Any]] = [dict(m) for m in (body.get("messages") or [])
if isinstance(m, dict)]
has_system = any(str(m.get("role")) == "system" for m in msgs)
prefix_block: List[Dict[str, Any]] = []
if not has_system and bucket_cfg.system_template:
prefix_block.append({"role": "system", "content": bucket_cfg.system_template})
doc = _load_doc_prefix(bucket_cfg)
if doc:
prefix_block.append({"role": "system",
"content": f"[课程资料 v{bucket_cfg.doc_version}]\n{doc}"})
shaped["messages"] = prefix_block + msgs
return shaped