Files
Cyrene_v2/internal/config/context_budget_test.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
2026-10-04 17:57:08 +08:00

336 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// 本文件钉住上下文预算的推导(design/04 附录 A.3):budget = min(窗口 × 比例,
// 窗口 − 输出保留),以及 CONTEXT_BUDGET_RATIO 的解析与回落口径。
// 默认模型的真实窗口(facts-for-cyrene.md)与两条便于阅读的取值。
const (
oneMillionWindow = 1048576
maxTokens384K = 384 * 1024
)
// budgetCase 是推导用例的一条断言:输入 + 期望的结论要点。
type budgetCase struct {
name string
in ContextBudgetInput
wantTokens int
wantMode ContextBudgetMode
wantWindow int
wantDeclared bool
wantReserve int
}
// TestDeriveContextBudget_Derived 钉住推导侧的五种情形(任务书 §4 必测 1 的
// ①②③,外加比例回落与输出保留封顶)。
func TestDeriveContextBudget_Derived(t *testing.T) {
t.Parallel()
ratio := DefaultContextBudgetRatio
cases := []budgetCase{
{
// ① 1M × 2/3 = 699050 token(文档里写作「683K」,那是 Ki:699050/1024 ≈ 683)。
name: "1M 窗口按 2/3 推导",
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: ratio},
wantTokens: 699050,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: MinContextOutputReserve,
},
{
// ② 该组 maxTokens=384K ⇒ 输出保留顶到 384K ⇒ min(699050, 1M−384K=655360)。
name: "带 384K 输出保留时取窗口减输出的那一侧",
in: ContextBudgetInput{Window: oneMillionWindow, GroupMaxTokens: maxTokens384K, Ratio: ratio},
wantTokens: 655360,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: maxTokens384K,
},
{
// ③ 未声明窗口 ⇒ 回落 131072,131072 × 2/3 = 87381(截尾),
// 且 WindowDeclared=false(组合根据此记恰好一条 WARN)。
name: "未声明窗口回落 128Ki 并提示",
in: ContextBudgetInput{Ratio: ratio},
wantTokens: 87381,
wantMode: ContextBudgetDerived,
wantWindow: DefaultModelContextWindow,
wantDeclared: false,
wantReserve: MinContextOutputReserve,
},
{
name: "非法比例回落到 2/3",
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: 3},
wantTokens: 699050,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: MinContextOutputReserve,
},
{
// maxTokens 被声明得比窗口还大(配置矛盾)时,输出保留封顶在窗口一半,
// 预算绝不退化成 0 或负数。
name: "输出保留不超过窗口一半",
in: ContextBudgetInput{Window: 32768, GroupMaxTokens: maxTokens384K, Ratio: ratio},
wantTokens: 16384,
wantMode: ContextBudgetDerived,
wantWindow: 32768,
wantDeclared: true,
wantReserve: 16384,
},
}
for _, c := range cases {
assertBudgetCase(t, c)
}
}
// TestDeriveContextBudget_OverrideAndOff 钉住显式覆盖与关闭(任务书 §4 必测 1 的
// ④⑤):两者都**不推导**,窗口声明与否都不影响结论。
func TestDeriveContextBudget_OverrideAndOff(t *testing.T) {
t.Parallel()
ratio := DefaultContextBudgetRatio
cases := []budgetCase{
{
name: "显式覆盖原样使用",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 4096, OverrideConfigured: true, Ratio: ratio},
wantTokens: 4096,
wantMode: ContextBudgetOverride,
},
{
name: "非零覆盖不必同步标志位也生效",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 600, Ratio: ratio},
wantTokens: 600,
wantMode: ContextBudgetOverride,
},
{
name: "显式 0 = 关闭压缩(不推导)",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 0, OverrideConfigured: true, Ratio: ratio},
wantTokens: 0,
wantMode: ContextBudgetOff,
},
{
name: "负值 = 关闭压缩(不推导)",
in: ContextBudgetInput{Window: oneMillionWindow, Override: -1, OverrideConfigured: true, Ratio: ratio},
wantTokens: -1,
wantMode: ContextBudgetOff,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := DeriveContextBudget(c.in)
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
}
if got.WindowDeclared || got.Window != 0 || got.OutputReserve != 0 {
t.Fatalf("覆盖/关闭态不该带推导结论:%+v", got)
}
})
}
}
// assertBudgetCase 校验一条推导用例:结论要点 + 推导侧特有的窗口与保留量。
func assertBudgetCase(t *testing.T, c budgetCase) {
t.Helper()
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := DeriveContextBudget(c.in)
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
}
if got.WindowDeclared != c.wantDeclared {
t.Fatalf("WindowDeclared = %v, want %v", got.WindowDeclared, c.wantDeclared)
}
if got.Window != c.wantWindow || got.OutputReserve != c.wantReserve {
t.Fatalf("window/reserve = %d/%d, want %d/%d", got.Window, got.OutputReserve, c.wantWindow, c.wantReserve)
}
})
}
// TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K 是主人裁决的直接体现:
// 1M 窗口下推导出的预算必须是旧默认 32K 的二十倍以上 —— 否则「既丢上下文、又反复
// 烧缓存」(design/04 附录 A.2 第 2 条)的老毛病没被治好。
func TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K(t *testing.T) {
t.Parallel()
const oldDefault = 32768
got := DeriveContextBudget(ContextBudgetInput{Window: oneMillionWindow})
if got.Tokens <= oldDefault*20 {
t.Fatalf("1M 窗口推导出的预算 = %d,仍贴着旧默认 %d(推导没生效)", got.Tokens, oldDefault)
}
}
// TestParseContextBudgetRatio 钉住比例的两种写法与非法值判定(非法值由调用方记
// WARN 并回落默认,绝不静默生效)。
func TestParseContextBudgetRatio(t *testing.T) {
t.Parallel()
ok := []struct {
raw string
want float64
}{
{"0.5", 0.5},
{"0.6667", 0.6667},
{" 2/3 ", 2.0 / 3.0},
{"3/4", 0.75},
{"1", 1},
}
for _, c := range ok {
got, err := ParseContextBudgetRatio(c.raw)
if err != nil {
t.Fatalf("ParseContextBudgetRatio(%q) 报错: %v", c.raw, err)
}
if got != c.want {
t.Fatalf("ParseContextBudgetRatio(%q) = %v, want %v", c.raw, got, c.want)
}
}
for _, raw := range []string{"maybe", "0", "-0.5", "1.5", "2/0", "/3", "3/"} {
if _, err := ParseContextBudgetRatio(raw); err == nil {
t.Fatalf("ParseContextBudgetRatio(%q) 竟然通过了 —— 非法比例必须被报告,不得静默生效", raw)
}
}
}
// TestLoadContextBudgetRatio 钉住读取链:进程环境 > .env > 默认,且非法值返回
// 错误(调用方据此记 WARN 回落默认,并带上原始取值)。
func TestLoadContextBudgetRatio(t *testing.T) {
dir := t.TempDir()
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(ContextBudgetRatioEnv+"=0.5\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
unsetenvForTest(t, ContextBudgetRatioEnv)
got, err := LoadContextBudgetRatio(envFile)
if err != nil {
t.Fatalf("读 .env 里的比例: %v", err)
}
if got != 0.5 {
t.Fatalf(".env 里的比例 = %v, want 0.5", got)
}
// 进程环境优先于 .env。
t.Setenv(ContextBudgetRatioEnv, "2/3")
got, err = LoadContextBudgetRatio(envFile)
if err != nil {
t.Fatalf("读进程环境里的比例: %v", err)
}
if got != 2.0/3.0 {
t.Fatalf("进程环境里的比例 = %v, want 2/3", got)
}
// 非法值:返回错误,并回落到默认(错误里带原始取值,便于 WARN)。
t.Setenv(ContextBudgetRatioEnv, "四分之三")
got, err = LoadContextBudgetRatio(envFile)
if err == nil {
t.Fatal("非法比例必须返回错误")
}
if got != DefaultContextBudgetRatio {
t.Fatalf("非法比例的回落 = %v, want %v", got, DefaultContextBudgetRatio)
}
// 未配置(.env 也不看):默认比例,不报错。
t.Setenv(ContextBudgetRatioEnv, "")
got, err = LoadContextBudgetRatio(filepath.Join(dir, "missing.env"))
if err != nil || got != DefaultContextBudgetRatio {
t.Fatalf("未配置比例 = (%v, %v), want (%v, nil)", got, err, DefaultContextBudgetRatio)
}
}
// unsetenvForTest 清掉一个环境变量并在测试结束时还原(t.Setenv 只能设值,而空串
// 会被解析成 0 / 空串,与「未设置」不是一回事 —— 本文件恰好要区分这两者)。
func unsetenvForTest(t *testing.T, key string) {
t.Helper()
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("Unsetenv(%s): %v", key, err)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, prev)
return
}
_ = os.Unsetenv(key)
})
}
// TestContextMaxTokensEnvMatchesTag 钉住 ContextMaxTokensEnv 与字段的 env 标签
// 逐字一致:Load 用前者判定「未设置」,标签决定反射解码读哪个变量;两者一旦漂移,
// 「未设置 ⇒ 按窗口推导」就会在真实进程里静默失效(而单测仍可能全绿)。
func TestContextMaxTokensEnvMatchesTag(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeOf(ContextConfig{}).FieldByName("MaxTokens")
if !ok {
t.Fatal("ContextConfig 里没有 MaxTokens 字段")
}
if got := field.Tag.Get("env"); got != ContextMaxTokensEnv {
t.Fatalf("MaxTokens 的 env 标签 = %q, want %q", got, ContextMaxTokensEnv)
}
}
// TestLoad_ContextMaxTokensTriState 钉住三态判定的来源:只有真的配过这个键,
// MaxTokensSet 才为真 —— 反射解码只能给出值,分不出「没配」与「配了 0」。
func TestLoad_ContextMaxTokensTriState(t *testing.T) {
dir := t.TempDir()
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
unsetenvForTest(t, ContextMaxTokensEnv)
cfg, err := Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 0 || cfg.Context.MaxTokensSet {
t.Fatalf("未配置时 = (%d, set=%v), want (0, false)(未设置 ⇒ 按窗口推导)",
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
t.Setenv(ContextMaxTokensEnv, "0")
cfg, err = Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 0 || !cfg.Context.MaxTokensSet {
t.Fatalf("显式 0 时 = (%d, set=%v), want (0, true)(显式 0 ⇒ 关闭压缩)",
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
// .env 里写了也算「配过」(与进程环境同一优先级链)。
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(ContextMaxTokensEnv+"=2048\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
unsetenvForTest(t, ContextMaxTokensEnv)
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 2048 || !cfg.Context.MaxTokensSet {
t.Fatalf(".env 配置时 = (%d, set=%v), want (2048, true)", cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
}
// TestLoad_RejectsNegativeContextWindow 钉住窗口的负数校验:窗口没有负数语义,
// 静默当成「未声明」会让一个配错的部署悄悄用回落窗口而非报错。
func TestLoad_RejectsNegativeContextWindow(t *testing.T) {
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
t.Setenv("LLM_CONTEXT_WINDOW", "-1")
if _, err := Load(filepath.Join(t.TempDir(), "absent.env")); err == nil {
t.Fatal("负数窗口必须让 Load 报错")
}
t.Setenv("LLM_CONTEXT_WINDOW", "1048576")
cfg, err := Load(filepath.Join(t.TempDir(), "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LLM.ContextWindow != 1048576 {
t.Fatalf("LLM_CONTEXT_WINDOW = %d, want 1048576", cfg.LLM.ContextWindow)
}
}