package main import ( "log/slog" "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。 // // 为什么推导放在这里而不是配置层:窗口与 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 } 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} }