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

+21 -11
View File
@@ -95,27 +95,37 @@ func TestResolver_ExtractsOnceAndThenReadsSidecar(t *testing.T) {
}
}
// TestResolver_RecordsTruncationInSidecar 钉住截断统计随 sidecar 往返:第二次
// 读 sidecar(原文件已删)仍然带着 truncated 与截断后的字符数。
func TestResolver_RecordsTruncationInSidecar(t *testing.T) {
// TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar 钉住 sidecar 的
// 存全文语义(切片 T1 §7.2 第 1 条)与安全上限的如实记账:
//
// - 正文远超旧的 64K 注入预算、但未撞上安全上限 ⇒ 整份存下,truncated=false,
// 因此 document-read 按清单游标续读能真的读到预算之外的正文;
// - 撞上安全上限(MaxSidecarBytes)⇒ truncated=true 且字符数随 sidecar 往返。
func TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar(t *testing.T) {
t.Parallel()
layout := newTestLayout(t)
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", MaxExtractChars+10)))
name := layout.writeUpload(t, txtName, []byte(strings.Repeat("宽", 100*1024)))
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
ref, _ := resolveOne(t, r, name)
if !ref.Truncated || ref.Chars != MaxExtractChars {
t.Fatalf("首次解析 = %+v, want truncated + Chars=%d", ref, MaxExtractChars)
ref, body := resolveOne(t, r, name)
if ref.Truncated || ref.Chars != 100*1024 {
t.Fatalf("首次解析 = %+v, want 全文(%d 字符、未截断)", ref, 100*1024)
}
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
t.Fatalf("删除原文件: %v", err)
}
ref2, body2 := resolveOne(t, r, name)
if !ref2.Truncated || ref2.Chars != MaxExtractChars {
t.Fatalf("sidecar 命中 = %+v, want 保留 truncated + Chars", ref2)
if ref2.Truncated || ref2.Chars != 100*1024 || body2 != body {
t.Fatalf("sidecar 命中 = %+v, want 从 sidecar 读回同一份全文", ref2)
}
if len([]rune(body2)) != MaxExtractChars {
t.Fatalf("sidecar 正文 rune 数 = %d, want %d", len([]rune(body2)), MaxExtractChars)
capped := layout.writeUpload(t, "0123456789abcdef0123456789abcdee.txt", []byte(strings.Repeat("宽", MaxSidecarBytes)))
ref3, body3 := resolveOne(t, r, capped)
if !ref3.Truncated {
t.Fatalf("撞上安全上限的文档 = %+v, want truncated=true", ref3)
}
if ref3.Chars != len([]rune(body3)) || len(body3) > MaxSidecarBytes {
t.Fatalf("上限命中:Chars=%d / 正文字节=%d,want 一致且不超过 %d", ref3.Chars, len(body3), MaxSidecarBytes)
}
}