fix: keep a low-tier answer out of the cache slot a max-tier one filled
The per-call reasoning tier never reached the cache key, and the model fingerprint could not stand in for it: the fingerprint is computed once at assembly time, so two calls on the same client asking for low and max looked identical to it. Same messages, different tiers, one shared entry -- the verbatim replay of issue #4's five seeds all hitting the same response. Source-level tiers join the fingerprint under the same rule enable_thinking already follows (appended only when the source takes a position), and the filter that decides which sources enter the mark set is widened to match -- without that, a source configured with nothing but REASONING_EFFORT would never reach _fingerprint_mark at all. None (no opinion) and Effort.NONE (asked not to reason) stay distinct keys. Sources that opine on neither keep byte-identical keys and fingerprints, so nothing existing cold-starts.
This commit is contained in:
@@ -11,7 +11,13 @@ from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.errors import ResultInvalidError, TransientError
|
||||
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
Effort,
|
||||
LLMResponse,
|
||||
SourceConfig,
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
_MSGS = [{"role": "user", "content": "hi"}]
|
||||
|
||||
@@ -114,6 +120,40 @@ class TestKeyFormula:
|
||||
"m", messages2, "p", None
|
||||
)
|
||||
|
||||
def test_request_tier_changes_key(self):
|
||||
"""同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。
|
||||
|
||||
请求级档位必须**独立于** `model_fingerprint` 进 key: 后者是装配期算出的
|
||||
集合级指纹,一次调用改档位不会让它变一个字节。
|
||||
"""
|
||||
k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW)
|
||||
k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX)
|
||||
assert k_low != k_max
|
||||
|
||||
def test_explicit_none_tier_is_not_the_absent_tier(self):
|
||||
"""`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。
|
||||
|
||||
二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应,
|
||||
而后者的内容恰恰是缺推理过程的。
|
||||
"""
|
||||
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
|
||||
"m", _MSGS, "proj", None, reasoning_effort=Effort.NONE
|
||||
)
|
||||
|
||||
def test_absent_tier_keeps_legacy_key(self):
|
||||
"""不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。
|
||||
|
||||
golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加
|
||||
`reasoning_effort` 维度之前的实现,不得随实现漂移。
|
||||
"""
|
||||
assert build_cache_key(
|
||||
"qwen-max",
|
||||
[{"role": "user", "content": "hi"}],
|
||||
"proj",
|
||||
None,
|
||||
reasoning_effort=None,
|
||||
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
|
||||
|
||||
|
||||
class _Terminal:
|
||||
def __init__(self, response):
|
||||
@@ -163,6 +203,21 @@ class TestCacheFlow:
|
||||
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
||||
assert third.cache_hit is True and terminal.calls == 2
|
||||
|
||||
async def test_differing_reasoning_effort_does_not_hit(self):
|
||||
"""接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。
|
||||
|
||||
只测 `build_cache_key` 不够——参数加了却没人传是本改动最可能的落地方式,
|
||||
那种缺口在公式层的用例里完全看不见。
|
||||
"""
|
||||
backend = InMemoryCache()
|
||||
mw = _mw(backend)
|
||||
terminal = _Terminal(_resp())
|
||||
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
||||
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal)
|
||||
assert terminal.calls == 2 # 两档各自回源
|
||||
third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
|
||||
assert third.cache_hit is True and terminal.calls == 2 # 同档才命中
|
||||
|
||||
async def test_structured_injection_does_not_pollute_key(self):
|
||||
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
|
||||
backend = InMemoryCache()
|
||||
|
||||
@@ -401,6 +401,48 @@ class TestModelFingerprint:
|
||||
b = build_model_fingerprint([_source(extra_body={"temperature": 1})])
|
||||
assert a != b
|
||||
|
||||
def test_source_tier_enters_fingerprint(self):
|
||||
"""源级 `reasoning_effort` 改变请求体,就必须改变缓存身份(与 issue #5 同理)。
|
||||
|
||||
本用例同时守着一个易漏点: 只配 `REASONING_EFFORT`、既无 `extra_body` 也无
|
||||
`ENABLE_THINKING` 的源,必须能进入指纹的 marks 集合——否则 `_fingerprint_mark`
|
||||
改了也白改,四个指纹会全部相等。
|
||||
"""
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
plain = build_model_fingerprint([_source()])
|
||||
low = build_model_fingerprint([_source(reasoning_effort=Effort.LOW)])
|
||||
max_ = build_model_fingerprint([_source(reasoning_effort=Effort.MAX)])
|
||||
off = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
|
||||
assert len({plain, low, max_, off}) == 4
|
||||
|
||||
def test_source_tier_is_distinguished_from_the_thinking_sugar(self):
|
||||
"""`reasoning_effort=NONE` 与 `enable_thinking=False` 不得摘要成同一个指纹。
|
||||
|
||||
两者语义等价但取值不同(`"none"` vs `false`),让它们撞车会把"两种写法"
|
||||
变成"一种缓存身份",日后任一侧语义微调都会静默复用另一侧的响应。
|
||||
"""
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
by_tier = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
|
||||
by_sugar = build_model_fingerprint([_source(enable_thinking=False)])
|
||||
assert by_tier != by_sugar
|
||||
|
||||
def test_absent_tier_fingerprint_is_byte_identical_to_before(self):
|
||||
"""不表态档位的存量源不得因本次升级平白冷启动: 字面量逐字相同。
|
||||
|
||||
两条: 纯净源仍是裸 model 合集;只配 extra_body 的源仍是升级前那个摘要。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from polygateway.client import build_model_fingerprint
|
||||
|
||||
assert build_model_fingerprint([_source()]) == "qwen-max"
|
||||
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
|
||||
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
|
||||
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
|
||||
|
||||
|
||||
class TestFactories:
|
||||
def test_from_env_assembles(self):
|
||||
|
||||
Reference in New Issue
Block a user