From 3250e4e09992c16eadc8f322d09f107b39d03a2c Mon Sep 17 00:00:00 2001 From: tzt <14718231+flying-travel@user.noreply.gitee.com> Date: Sat, 5 Sep 2026 09:40:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20T-P5=20=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96+=E6=A1=B6=EF=BC=88canonical=5Fhash=20=E4=BA=94?= =?UTF-8?q?=E8=A7=84=E5=88=99/=E6=95=B4=E5=BD=A2=E9=A1=BA=E5=BA=8F/doc=5Fv?= =?UTF-8?q?ersion=20=E5=A4=B1=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gateway/proxy/normalizer.py | 126 ++++++++++++++++++++++++++++++--- tests/test_proxy_normalizer.py | 106 +++++++++++++++++++++++++++ 任务拆解与执行计划.md | 2 +- 3 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 tests/test_proxy_normalizer.py diff --git a/gateway/proxy/normalizer.py b/gateway/proxy/normalizer.py index 6b963ff..f4917c8 100644 --- a/gateway/proxy/normalizer.py +++ b/gateway/proxy/normalizer.py @@ -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 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: - """解析课程桶:X-Campus-Bucket 头 > model->bucket 映射 > default(T-P5 实现)。""" - raise NotImplementedError("T-P5") +@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) -> str: - """规范化哈希:稳定序列化 -> sha256(五规则见 §6;T-P5 实现)。""" - raise NotImplementedError("T-P5") + """规则 3:缓存键 = bucket + '|' + doc_version + '|' + sha256(norm)。 + + 桶模板/资料前缀不参与哈希(由 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: - """整形上游请求体:[canonical_system] -> [doc_prefix] -> [原 messages](T-P5 实现)。""" - raise NotImplementedError("T-P5") + """规则 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 diff --git a/tests/test_proxy_normalizer.py b/tests/test_proxy_normalizer.py new file mode 100644 index 0000000..d2f8a84 --- /dev/null +++ b/tests/test_proxy_normalizer.py @@ -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" diff --git a/任务拆解与执行计划.md b/任务拆解与执行计划.md index e596303..0970e77 100644 --- a/任务拆解与执行计划.md +++ b/任务拆解与执行计划.md @@ -140,7 +140,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯 | T-P2 | 上游客户端:流式派发+三家 usage 归一化+首 token 前 failover | ✅ 完成 | T-P2 | | T-P3 | 计价+结算:峰谷窗口/毫元整数/黄金用例 ≥10 组 | ✅ 完成 | T-P3 | | 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-P7 | 管理面+前端:stats/ledger 端点+ProxyView 三卡片 | ⬜ 待办 | | | T-P8 | 压测+预算:200 并发流式;P99≤50ms/内存≤1GB/账目零不一致 | ⬜ 待办 | |