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

+77 -10
View File
@@ -1,16 +1,83 @@
package main
import "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
import (
"log/slog"
// contextBudget 返回循环的上下文溢出压缩预算(CONTEXT_MAX_TOKENS,
// design/04 §1.3)。**非正 = 关闭压缩**,行为与本条接线前逐字节一致;
// 未配置时由 config 层给出 DefaultContextMaxTokens(即默认启用)。
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件是**上下文预算的唯一决策点**(组合根):把「配置 + 生效模型组」摊平成
// config.ContextBudgetInput,喂给纯函数 config.DeriveContextBudget,再把结论交给
// loop.Options.MaxContextTokens 与 loop.Options.DocumentMaxInjectChars。
//
// 取值只从配置来,组合根不做二次猜测:阈值语义(含「0 关掉」)由配置层
// 一处定义,组合根只负责把它交给 loop.Options.MaxContextTokens。
func (a *app) contextBudget() int {
if a.cfg == nil {
return config.DefaultContextMaxTokens
// 为什么推导放在这里而不是配置层:窗口与 maxTokens 都在 models.json 的**生效组**
// 上(面板可编辑、热重载),而 CONTEXT_MAX_TOKENS 在 .env。两个来源的会合点只有
// 组合根,推导函数本身因此保持纯粹、可表驱动测试。
//
// 语义(design/04 §1.3 + 附录 A.3):
// - CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导 budget = min(窗口 × 比例, 窗口 − 输出保留);
// - > 0 ⇒ 显式覆盖;0 / 负数 ⇒ 关闭压缩(与接线前一致);
// - 窗口未声明 ⇒ 回落 config.DefaultModelContextWindow 并记**恰好一条** WARN;
// - 比例非法(CONTEXT_BUDGET_RATIO)⇒ 记一条带原始取值的 WARN 并回落 2/3。
// ctxBudget 是组合根推导出的两项预算:循环的溢出压缩预算与单份附件注入预算。
type ctxBudget struct {
// Budget 是压缩预算的完整结论(含推导来源,供日志与断言)。
Budget config.ContextBudget
// DocumentInjectChars 是单份附件注入上下文的字符上限(T1 §7.2)。
DocumentInjectChars int
}
// contextBudget 计算本次运行生效的上下文预算。envPath 是启动时合并的 .env 路径
// (比例键的读取与其余配置同一套优先级;空串表示不看 .env)。
//
// 模型组取**生效组**(models.json 下标 0,或 .env 合成的组):压缩预算与注入预算
// 都是循环级的单一值,而生效组才是新请求默认用的那个。
func (a *app) contextBudget(envPath string) ctxBudget {
in := config.ContextBudgetInput{}
docOverride := 0
if a.cfg != nil {
in.Override = a.cfg.Context.MaxTokens
in.OverrideConfigured = a.cfg.Context.MaxTokensSet
docOverride = a.cfg.Context.DocumentMaxInjectChars
}
return a.cfg.Context.MaxTokens
if a.llm != nil {
g := a.llm.Group()
in.Window = g.ContextWindow
in.GroupMaxTokens = g.MaxTokens
}
return deriveContextBudget(sourcedWiringLog(a.log), envPath, in, docOverride)
}
// deriveContextBudget 是 contextBudget 的可测内核:取值来源收成显式参数,
// 日志与「恰好一条 WARN」也在这里(测试用捕获型 handler 断言条数)。
func deriveContextBudget(log *slog.Logger, envPath string, in config.ContextBudgetInput, docOverride int) ctxBudget {
ratio, err := config.LoadContextBudgetRatio(envPath)
if err != nil {
// 非法比例:记一条带原始取值的 WARN 并回落默认(AGENTS §10:不许静默失效)。
log.Warn("context budget ratio invalid, falling back",
"env", config.ContextBudgetRatioEnv, "error", err,
"fallback_ratio", config.DefaultContextBudgetRatio)
ratio = config.DefaultContextBudgetRatio
}
in.Ratio = ratio
budget := config.DeriveContextBudget(in)
// 单份附件注入预算:显式覆盖优先,否则随推导出的预算走。
docChars := docOverride
if docChars <= 0 {
docChars = loop.DocumentInjectCharsFor(budget.Tokens)
}
if budget.Mode == config.ContextBudgetDerived && !budget.WindowDeclared {
// 窗口未声明:**恰好一条** WARN(每次启动只推导一次)。
log.Warn("model context window not declared, falling back",
"env", "LLM_CONTEXT_WINDOW", "json_key", "contextWindow",
"window", budget.Window, "hint", "声明窗口才能吃满模型的真实窗口")
}
log.Info("context budget",
"mode", string(budget.Mode), "tokens", budget.Tokens,
"window", budget.Window, "window_declared", budget.WindowDeclared,
"ratio", budget.Ratio, "output_reserve", budget.OutputReserve,
"document_inject_chars", docChars)
return ctxBudget{Budget: budget, DocumentInjectChars: docChars}
}