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:
2026-09-05 02:39:38 -04:00
parent 80a8013642
commit 33c8e8274b
4 changed files with 137 additions and 10 deletions
+24 -8
View File
@@ -99,14 +99,21 @@ def _guard_thinking(
def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
"""单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
`reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正
改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应。
两者取值域不相交(`"none"`/`"low"`… vs `true`/`false`),故追加进同一个列表也
不会把两种写法摘要成同一身份。
"""
parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None:
parts.append(source.enable_thinking)
if source.reasoning_effort is not None:
parts.append(source.reasoning_effort)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
@@ -114,16 +121,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
缓存冷启动。
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
源级 `reasoning_effort`(issue #20)同理: 它们一旦真正改变请求体,"关掉推理后
重启"就会读到开着推理时缓存的旧响应。全源三者皆未表态时字面量与历史实现逐字
相同,不触发存量缓存冷启动。
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
——后者由 `build_cache_key` 的 `reasoning_effort` 参数单独承担(ARCH §7.5)。
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
# 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要:
# 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动。
# 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项
# 的源根本进不了 marks,`_fingerprint_mark` 改了也白改
marks = sorted(
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
{
_fingerprint_mark(s)
for s in sources
if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None
}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()