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

+36 -21
View File
@@ -20,15 +20,26 @@ import (
// 抽取器的常量。它们全是编译期常量或纯函数分支,使抽取路径里不出现魔法数字。
const (
// ExtractorVersion 是抽取器的版本号,写进每条 sidecar 的元数据。抽取逻辑
// 一旦变化(清洗规则、摘要口径、预算)必须递增:版本不匹配时旧 sidecar
// 一旦变化(清洗规则、摘要口径、截断口径)必须递增:版本不匹配时旧 sidecar
// 被判为失效并重抽一次,从而不会让「升级前抽出来的正文」永远留在缓存里
// (design/24 §4.3 要求 sidecar 记「抽取器版本」,这就是它的用处)。
ExtractorVersion = "documents/v1"
//
// v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限
// (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。
ExtractorVersion = "documents/v2"
// MaxExtractChars 是**单份文档**注入上下文的字符预算(design/24 §3.4、
// §4.3 的 64K 字符)。超出即按 rune 截断并置 Result.Truncated;正文绝不
// 静默消失 —— 循环据 result 渲染 truncated 与续读游标。
MaxExtractChars = 64 * 1024
// MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。
//
// 它取代了旧的 MaxExtractChars(64K 字符):那个数曾经同时兼任「抽取预算」与
// 「注入预算」,导致 sidecar 里存的就是被注入预算砍过的正文 —— 于是清单行给
// 出的续读游标(= 已注入字符数)恒等于 sidecar 正文长度,模型照它去
// `document-read` 必然读到空(切片 T1 §7.1 实测的断裂)。
//
// 现在两件事分开:sidecar 存**抽取得到的全文**,只受这个安全上限约束(防止
// 一份畸形或超大文件把内存/磁盘拖垮);每回合注入多少由**装配期**的注入预算
// (loop.Options.DocumentMaxInjectChars)决定。命中本上限时 Truncated=true,
// 如实记进 sidecar 元数据 —— 「正文到此为止」绝不等于「原文件到此为止」。
MaxSidecarBytes = 8 << 20
// csvSummaryMaxColumns 限制摘要里列出的列名个数:超宽表只列前若干列,
// 免得摘要本身挤占注入预算。
@@ -72,12 +83,14 @@ var (
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
// 截断、种类与抽取器版本,sidecar 元数据与注入渲染都只读它。
type Result struct {
// Text 是要注入模型可见上下文的文本(CSV/TSV 的摘要行 + 正文,已按 rune
// 截断到预算内)。
// Text 是要落进 sidecar(并在装配期按注入预算取用)的文本(CSV/TSV 的摘要行
// + 正文,已按安全上限 MaxSidecarBytes 截断)。
Text string
// Chars 是 Text 的字符数(rune 数)。
Chars int
// Truncated 报告正文是否因预算被截断(design/24 §4.3:绝不静默截断)。
// Truncated 报告正文是否撞上了**安全上限**(design/24 §4.3:绝不静默截断)。
// 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进
// history.DocumentRef(切片 T1 §7.2)。
Truncated bool
// Kind 是抽取出内容的种类(text / csv / tsv)。
Kind string
@@ -115,8 +128,8 @@ func Extract(name, mime string, data []byte) (Result, error) {
text = summary + "\n" + body
}
res := Result{Kind: kind, ExtractorVersion: ExtractorVersion}
if utf8.RuneCountInString(text) > MaxExtractChars {
text = prefixRunes(text, MaxExtractChars)
if len(text) > MaxSidecarBytes {
text = prefixBytes(text, MaxSidecarBytes)
res.Truncated = true
}
res.Text = text
@@ -264,17 +277,19 @@ func splitColumns(line, delim string) []string {
return out
}
// prefixRunes 返回 s 的前 n 个 rune:rune 安全,绝不把多字节字符切成两半。
func prefixRunes(s string, n int) string {
if n <= 0 {
// prefixBytes 返回 s 的前若干个**完整 rune**,使其字节数不超过 max:
// 安全上限按字节设(MaxSidecarBytes),因此截断必须落在 rune 边界上,
// 绝不把多字节字符切成两半(否则 sidecar 正文会是非法 UTF-8)。
func prefixBytes(s string, max int) string {
if max <= 0 {
return ""
}
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
if len(s) <= max {
return s
}
return s
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
+34 -8
View File
@@ -88,11 +88,15 @@ func TestExtract_TSVPrependsSummary(t *testing.T) {
}
}
// TestExtract_TruncatesHonestly 钉住「绝不静默截断」(design/24 §4.3):超预算时
// 截到预算、置 Truncated,且截断是 rune 安全的(多字节字符不被切成两半)。
func TestExtract_TruncatesHonestly(t *testing.T) {
// TestExtract_TruncatesAtSafetyCap 钉住「绝不静默截断」(design/24 §4.3):
// 撞上**安全上限**(MaxSidecarBytes,8 MiB 文本)时截到上限并置 Truncated,且截断
// 是 rune 安全的(多字节字符不被切成两半)。
//
// 注意它钉的不再是「注入预算」:注入预算由装配期按上下文预算决定(切片 T1 §7.2),
// 抽取器只保证 sidecar 里存得下全文、且撞上上限时如实记账。
func TestExtract_TruncatesAtSafetyCap(t *testing.T) {
t.Parallel()
body := strings.Repeat("你好", MaxExtractChars)
body := strings.Repeat("你好", MaxSidecarBytes/len("你好")+16)
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
@@ -100,15 +104,37 @@ func TestExtract_TruncatesHonestly(t *testing.T) {
if !res.Truncated {
t.Fatal("Truncated = false, want true")
}
if res.Chars != MaxExtractChars {
t.Fatalf("Chars = %d, want %d", res.Chars, MaxExtractChars)
if len(res.Text) > MaxSidecarBytes {
t.Fatalf("截断后正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
}
if want := strings.Repeat("你好", MaxExtractChars/2); res.Text != want {
t.Fatalf("Text 不是预算内的 rune 前缀(len=%d)", len(res.Text))
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want 正文 rune 数 %d", res.Chars, utf8.RuneCountInString(res.Text))
}
if !utf8.ValidString(res.Text) {
t.Fatal("截断后的正文不是合法 UTF-8")
}
// 上限内的正文逐字无损:截断只砍尾巴,不改写已有内容。
if !strings.HasPrefix(body, res.Text) {
t.Fatal("截断后的正文不是原文的前缀")
}
}
// TestExtract_KeepsFullTextBelowSafetyCap 钉住切片 T1 §7.2 的核心:抽取期**不再**
// 按注入预算截断,因此一份远超旧 64K 字符、但仍在安全上限内的文档必须整份存下 ——
// 否则 document-read 永远读不到注入预算之外的正文(S4 实测的断裂)。
func TestExtract_KeepsFullTextBelowSafetyCap(t *testing.T) {
t.Parallel()
body := strings.Repeat("宽", 200*1024) // 20 万字符:远超旧的 64K 注入预算
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Truncated {
t.Fatal("Truncated = true, want false(安全上限内的正文不该被截断)")
}
if res.Chars != 200*1024 || res.Text != body {
t.Fatalf("Chars = %d(want %d),正文是否逐字无损 = %v", res.Chars, 200*1024, res.Text == body)
}
}
// TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」:
+16 -6
View File
@@ -46,10 +46,14 @@ const (
sidecarDirMode = 0o700
sidecarFileMode = 0o600
// maxSidecarBytes 是读 sidecar 时的硬上限(正文预算 64K 字符,UTF-8 最多
// 4 字节/字符,再留元数据余量)。超过即判为损坏并重抽,绝不把一整份畸形
// maxSidecarBytes 是读 sidecar 时的硬上限:正文安全上限(MaxSidecarBytes)
// 再加元数据行与 JSON 编码的余量。超过即判为损坏并重抽,绝不把一整份畸形
// 文件读进内存。
maxSidecarBytes = MaxExtractChars*4 + 1<<10
maxSidecarBytes = MaxSidecarBytes + sidecarReadAllowanceBytes
// sidecarReadAllowanceBytes 是给首行元数据(含 unreadable 的原因短句)与
// JSON 编码留的余量。
sidecarReadAllowanceBytes = 64 * 1024
// sidecarTempPattern 是原子写入用的临时文件前缀。
sidecarTempPattern = ".sidecar-*"
@@ -88,11 +92,12 @@ type cachedExtract struct {
Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv)。
Kind string
// Chars 是注入正文的字符数。
// Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。
Chars int
// Truncated 报告正文是否被预算截断。
// Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」
// 不等于「原文件到此为止」)。
Truncated bool
// Text 是 Status == extracted 时的注入正文。
// Text 是 Status == extracted 时的全文正文。
Text string
}
@@ -393,6 +398,11 @@ func (r *Resolver) extract(part model.Content) cachedExtract {
// documentRefFrom 把 sidecar 记录折进文档引用:身份(存储名、MIME、字节数)
// 来自部件本身,status/chars/truncated 来自抽取结论。空状态归为 listed
// (「清单已给出、正文未注入」是事实,而不是「已抽取」)。
//
// 注意这里给出的 chars/truncated 是**抽取侧**的事实(sidecar 正文的字符数、
// 是否撞上安全上限)。装配期(loop.resolveDocuments)会按本回合的注入预算把
// 它们改写成「实际注入了多少字符 / 注入是否被截断」—— 清单行的续读游标因此
// 真的是「继续读的起点」(切片 T1 §7.2)。
func documentRefFrom(rec cachedExtract, ref history.DocumentRef) history.DocumentRef {
ref.Status = rec.Status
ref.Chars = rec.Chars
+3 -1
View File
@@ -33,7 +33,9 @@ type Sidecar struct {
Kind string
// Chars 是抽取期写盘时的正文字符数(rune)。
Chars int
// Truncated 报告抽取期的正文是否已按预算截断(design/24 §3.4 的 64K 字符)。
// Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限
// (documents.MaxSidecarBytes,8 MiB 文本)—— 它与「本回合注入被截断」是两件事:
// 后者由装配期按注入预算判定并写进 history.DocumentRef。
Truncated bool
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
Text string
+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)
}
}