- 窗口声明: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)。
336 lines
12 KiB
Go
336 lines
12 KiB
Go
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)
|
||
}
|
||
}
|