feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 窗口声明: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)。
This commit is contained in:
AskaEth committed 2026-10-04 17:57:08 +08:00
1 parent 5b2b385524
commit 72b95df22b
39 files changed
+2496 -377

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
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)
}
}