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

+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