feat(proxy): T-P5 规范化+桶(canonical_hash 五规则/整形顺序/doc_version 失效)
- normalizer.py:resolve_bucket(X-Campus-Bucket 头 > 映射 > default,D-P2); normalize_messages(role\u0001content\u0002 串接,剔易变字段与时间戳行, 多模态 content 取 text);is_cacheable(system 外 >1 条 = 多轮不缓存,D-P5); canonical_hash(bucket|doc_version|sha256(norm)——模板/资料不参与哈希, 资料更新 = 版本+1 旧键失效);shape([canonical_system(无才注入)] -> [课程资料前缀(文件读取,不入git)] -> [原 messages],易变顶层字段剔除) - 测试 +8:同义同哈希/易变剔除/模板不入哈希/doc_version 失效/多轮判定/ 整形顺序+资料注入/桶解析回落/序列化形态,全量 370 passed
This commit is contained in:
+117
-9
@@ -1,19 +1,127 @@
|
|||||||
"""请求规范化与桶解析(T-P5 落地;本文件先立签名)。"""
|
"""请求规范化与桶解析(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
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict
|
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)
|
||||||
|
|
||||||
|
|
||||||
def resolve_bucket(body: dict, headers: dict, cfg) -> Any:
|
@dataclass
|
||||||
"""解析课程桶:X-Campus-Bucket 头 > model->bucket 映射 > default(T-P5 实现)。"""
|
class BucketResolution:
|
||||||
raise NotImplementedError("T-P5")
|
"""桶解析结果。"""
|
||||||
|
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) -> str:
|
def canonical_hash(bucket: str, doc_version: int, body: dict) -> str:
|
||||||
"""规范化哈希:稳定序列化 -> sha256(五规则见 §6;T-P5 实现)。"""
|
"""规则 3:缓存键 = bucket + '|' + doc_version + '|' + sha256(norm)。
|
||||||
raise NotImplementedError("T-P5")
|
|
||||||
|
桶模板/资料前缀不参与哈希(由 bucket+doc_version 表达,资料更新 = 版本+1)。
|
||||||
|
"""
|
||||||
|
norm = normalize_messages(body)
|
||||||
|
digest = hashlib.sha256(norm.encode("utf-8")).hexdigest()
|
||||||
|
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:
|
def shape(body: dict, bucket_cfg) -> dict:
|
||||||
"""整形上游请求体:[canonical_system] -> [doc_prefix] -> [原 messages](T-P5 实现)。"""
|
"""规则 4:整形上游请求体 —— [canonical_system] -> [doc_prefix] -> [原 messages]。
|
||||||
raise NotImplementedError("T-P5")
|
|
||||||
|
原请求自带的 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
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""规范化测试(T-P5):同义同哈希 / 易变剔除 / 顺序固定 / doc_version 失效 / 多轮不缓存。"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from gateway.proxy.config import BucketCfg
|
||||||
|
from gateway.proxy.normalizer import (
|
||||||
|
canonical_hash,
|
||||||
|
is_cacheable,
|
||||||
|
normalize_messages,
|
||||||
|
resolve_bucket,
|
||||||
|
shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body(content="什么是递归?", role="user", n_extra=0, **over):
|
||||||
|
msgs = [{"role": "system", "content": "你是助教"},
|
||||||
|
{"role": role, "content": content}]
|
||||||
|
for i in range(n_extra):
|
||||||
|
msgs.append({"role": "assistant", "content": f"答 {i}"})
|
||||||
|
b = {"model": "deepseek-chat", "messages": msgs}
|
||||||
|
b.update(over)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_semantics_same_hash():
|
||||||
|
"""同输入同哈希(确定性)+ content 顺序敏感。"""
|
||||||
|
b1 = _body()
|
||||||
|
b2 = _body()
|
||||||
|
assert canonical_hash("default", 1, b1) == canonical_hash("default", 1, b2)
|
||||||
|
b3 = _body("什么是递归") # 差一个问号 -> 不同哈希
|
||||||
|
assert canonical_hash("default", 1, b1) != canonical_hash("default", 1, b3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volatile_fields_stripped():
|
||||||
|
"""规则 2:temperature/seed 等易变字段不参与哈希;时间戳内容行剔除。"""
|
||||||
|
b1 = _body(temperature=0.2, seed=1)
|
||||||
|
b2 = _body(temperature=0.9, seed=42)
|
||||||
|
assert canonical_hash("default", 1, b1) == canonical_hash("default", 1, b2)
|
||||||
|
b3 = _body("当前时间:2026-09-05 10:00:00\n什么是递归?")
|
||||||
|
b4 = _body("当前时间:2026-09-06 23:59:59\n什么是递归?")
|
||||||
|
assert canonical_hash("default", 1, b3) == canonical_hash("default", 1, b4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_template_not_in_hash():
|
||||||
|
"""规则 3:桶模板/资料前缀不参与哈希——不同模板同哈希。"""
|
||||||
|
b = _body()
|
||||||
|
assert canonical_hash("python24", 1, b) == canonical_hash("python24", 1, b)
|
||||||
|
# 桶不同 -> 键不同(隔离)
|
||||||
|
assert canonical_hash("default", 1, b) != canonical_hash("python24", 1, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_version_bumps_key():
|
||||||
|
"""doc_version+1 后旧缓存键不可见(资料更新失效)。"""
|
||||||
|
b = _body()
|
||||||
|
assert canonical_hash("course", 1, b) != canonical_hash("course", 2, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiturn_not_cacheable():
|
||||||
|
"""规则 5:system 外 >1 条 -> 多轮 cacheable=False(D-P5)。"""
|
||||||
|
assert is_cacheable(_body()) is True # system+1 user
|
||||||
|
assert is_cacheable(_body(n_extra=1)) is False # +assistant 历史
|
||||||
|
assert is_cacheable({"messages": [{"role": "user", "content": "x"}]}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_fixed_order_and_template_injection(tmp_path):
|
||||||
|
"""规则 4:整形顺序 [canonical_system] -> [doc_prefix] -> [原 messages];
|
||||||
|
自带 system 时不重复注入模板;资料前缀文件内容插入。"""
|
||||||
|
prefix_file = tmp_path / "python24.txt"
|
||||||
|
prefix_file.write_text("第一章:变量与类型", encoding="utf-8")
|
||||||
|
bucket = BucketCfg(name="python24", system_template="你是 Python 助教。",
|
||||||
|
doc_prefix_file=str(prefix_file), doc_version=3)
|
||||||
|
# 无 system:注入模板 + 资料
|
||||||
|
b = {"model": "m", "messages": [{"role": "user", "content": "q"}]}
|
||||||
|
shaped = shape(b, bucket)
|
||||||
|
roles = [m["role"] for m in shaped["messages"]]
|
||||||
|
assert roles == ["system", "system", "user"]
|
||||||
|
assert "Python 助教" in shaped["messages"][0]["content"]
|
||||||
|
assert "课程资料 v3" in shaped["messages"][1]["content"]
|
||||||
|
assert "第一章" in shaped["messages"][1]["content"]
|
||||||
|
# 自带 system:模板不重复注入;资料前缀在原 messages 之前(规则 4 顺序)
|
||||||
|
b2 = {"model": "m", "messages": [{"role": "system", "content": "自定义"},
|
||||||
|
{"role": "user", "content": "q"}]}
|
||||||
|
shaped2 = shape(b2, bucket)
|
||||||
|
assert "课程资料" in shaped2["messages"][0]["content"]
|
||||||
|
assert shaped2["messages"][1]["content"] == "自定义"
|
||||||
|
# 易变字段剔除
|
||||||
|
assert "temperature" not in shape(_body(temperature=0.7), bucket)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_bucket_header_and_fallback():
|
||||||
|
"""D-P2:X-Campus-Bucket 头优先;未知名回落 default。"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
class Cfg:
|
||||||
|
def bucket(self, name):
|
||||||
|
return SimpleNamespace(name=name or "default")
|
||||||
|
|
||||||
|
r = resolve_bucket(_body(), {"x-campus-bucket": "python24"}, Cfg())
|
||||||
|
assert r.name == "python24"
|
||||||
|
r2 = resolve_bucket(_body(), {}, Cfg())
|
||||||
|
assert r2.name == "default"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_serialization_format():
|
||||||
|
"""规则 1:role\\u0001content\\u0002 串接形态。"""
|
||||||
|
b = {"messages": [{"role": "user", "content": "hi"}]}
|
||||||
|
assert normalize_messages(b) == "user\u0001hi\u0002"
|
||||||
+1
-1
@@ -140,7 +140,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 |
|
| T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 |
|
||||||
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 |
|
| T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 |
|
||||||
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
|
| T-P4 | 路由端到端:/proxy/v1 非流式+流式+402/429/413 语义 | ✅ 完成 | T-P4 |
|
||||||
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ⬜ 待办 | |
|
| T-P5 | 规范化+桶:稳定哈希/整形顺序/doc_version 失效/多轮不缓存 | ✅ 完成 | T-P5 |
|
||||||
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |
|
| T-P6 | 语义缓存:L1 精确+L2 n-gram 倒排+singleflight+TTL+失败不缓存 | ⬜ 待办 | |
|
||||||
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |
|
| T-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | |
|
||||||
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |
|
| T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |
|
||||||
|
|||||||
Reference in New Issue
Block a user