- 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 三态/两层隔离/ 重建存活/旧调用兼容)
134 lines
5.5 KiB
Python
134 lines
5.5 KiB
Python
"""请求规范化与桶解析(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) > 2(system 之外 >1 条)-> cacheable=False(D-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:
|
||
"""规则 5:system 之外消息数 > 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
|