package config import ( "fmt" "strconv" "strings" ) // 本文件是上下文预算的**推导**(design/04 附录 A.3 的主人裁决): // // budget = min(窗口 × 比例, 窗口 − 输出保留) // // 它是一条纯函数,输入全部显式给出(窗口、该组 maxTokens、显式覆盖、比例), // 因此组合根只需把配置与生效模型组摊平喂进来,推导逻辑本身可表驱动测试。 const ( // DefaultModelContextWindow 是模型窗口**未声明**时的回落窗口(128 Ki token, // design/04 附录 A.3 第 3 条)。回落而不是报错,是为了让没声明窗口的部署照常 // 工作;但组合根会为它记**恰好一条** WARN,提示「声明 contextWindow / // LLM_CONTEXT_WINDOW 才能吃满窗口」—— 默认模型的真实窗口已达 1M token, // 回落值只是保守兜底。 DefaultModelContextWindow = 131072 // DefaultContextBudgetRatio 是预算占窗口的默认比例 2/3。主人 2026-10-04 裁决 // 「1/4 太小,1/2 到 3/4 之间吧」,本切片取区间内偏大的一侧:工具 schema、 // 多轮工具结果与思考输出(缺省 64K、上限 384K)都要从窗口里分走一部分。 // 可配:CONTEXT_BUDGET_RATIO。 DefaultContextBudgetRatio = 2.0 / 3.0 // MinContextOutputReserve 是给**输出**留的最小 token 余量(design/04 附录 A.3 // 第 3 条):官方明说输入 + 输出总长受窗口限制,而思考模式的缺省输出就有 64K, // 因此输入预算必须给输出让位。 MinContextOutputReserve = 32768 // ContextBudgetRatioEnv 是推导比例的配置键名。 ContextBudgetRatioEnv = "CONTEXT_BUDGET_RATIO" ) // ContextBudgetMode 说明一个预算数是「怎么来的」。它只用于日志与断言, // 不参与任何行为分支之外的判定。 type ContextBudgetMode string const ( // ContextBudgetDerived 表示按窗口推导得到。 ContextBudgetDerived ContextBudgetMode = "derived" // ContextBudgetOverride 表示 CONTEXT_MAX_TOKENS 显式覆盖。 ContextBudgetOverride ContextBudgetMode = "override" // ContextBudgetOff 表示显式关闭压缩(0 / 负数)。 ContextBudgetOff ContextBudgetMode = "off" ) // ContextBudgetInput 是一次推导的全部输入。字段都是值,没有任何隐式来源: // 「这个数从哪来」由组合根一处决定(cmd/cyrene/wire_context.go)。 type ContextBudgetInput struct { // Window 是声明的模型窗口(token)。0 = 未声明(回落到 // DefaultModelContextWindow 并提示)。 Window int // GroupMaxTokens 是生效模型组声明的 maxTokens(models.json)。它参与 // outputReserve 的计算;0 = 该组未声明。 GroupMaxTokens int // Override 是 CONTEXT_MAX_TOKENS 的取值。 Override int // OverrideConfigured 报告 Override 是否被显式配置(见 ContextConfig.MaxTokensSet)。 OverrideConfigured bool // Ratio 是推导比例。非正数或大于 1 视为非法,回落到 // DefaultContextBudgetRatio(非法值的报告由调用方负责,见 // LoadContextBudgetRatio:那里才知道原始文本,能记出「配了什么」)。 Ratio float64 } // ContextBudget 是一次推导的结论。Tokens 是循环真正用的预算; // 其余字段是给日志与断言的可追溯信息。 type ContextBudget struct { // Tokens 是最终预算:> 0 表示启用压缩;<= 0 表示关闭压缩。 Tokens int // Mode 说明这个数怎么来的。 Mode ContextBudgetMode // Window 是本次实际采用的窗口(未声明时为回落值)。 Window int // WindowDeclared 报告窗口是否来自声明。为 false 且 Mode == derived 时, // 组合根记一条 WARN(提示声明窗口才能吃满)—— 这是「恰好一条」的那条。 WindowDeclared bool // Ratio 是本次实际采用的比例(已回落)。 Ratio float64 // OutputReserve 是本次采用的输出保留量。 OutputReserve int } // DeriveContextBudget 按 design/04 附录 A.3 推导上下文预算。 // // 取值优先级:显式覆盖 > 关闭 > 推导。具体地 —— // // - Override > 0:原样返回(override); // - Override < 0,或 Override == 0 且 OverrideConfigured:返回该值(off, // 0 与负数都表示关闭压缩,与接线前一致,**不推导**); // - 其余(即没配过):按窗口推导。 // // 推导式 budget = min(window × ratio, window − outputReserve): // 前者给「不能把窗口塞满」留比例余量,后者给输出留绝对余量。 // 输出保留取 max(MinContextOutputReserve, 该组 maxTokens),并且**不超过窗口的 // 一半** —— 组把 maxTokens 声明得比窗口还大时(配置自相矛盾),也不能让 // 「窗口 − 输出」退化到 0 或负数,否则预算会变成一个荒唐的极小值。 func DeriveContextBudget(in ContextBudgetInput) ContextBudget { if in.Override > 0 { return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOverride} } if in.Override < 0 || in.OverrideConfigured { return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOff} } window := in.Window declared := window > 0 if !declared { window = DefaultModelContextWindow } ratio := in.Ratio if ratio <= 0 || ratio > 1 { ratio = DefaultContextBudgetRatio } reserve := ContextOutputReserve(in.GroupMaxTokens) if half := window / 2; reserve > half { reserve = half } tokens := int(float64(window) * ratio) if limit := window - reserve; tokens > limit { tokens = limit } if tokens < 1 { tokens = 1 } return ContextBudget{ Tokens: tokens, Mode: ContextBudgetDerived, Window: window, WindowDeclared: declared, Ratio: ratio, OutputReserve: reserve, } } // ContextOutputReserve 返回给输出留的 token 余量:max(MinContextOutputReserve, // groupMaxTokens)。组声明了更大的输出预算时以它为准(思考模式上限 384K)。 func ContextOutputReserve(groupMaxTokens int) int { if groupMaxTokens > MinContextOutputReserve { return groupMaxTokens } return MinContextOutputReserve } // LoadContextBudgetRatio 读取推导比例(CONTEXT_BUDGET_RATIO),优先级与其余配置 // 完全一致:进程环境 > .env > 默认。未配置时返回 DefaultContextBudgetRatio。 // // 为什么需要读取函数而不是给 Config 加一个 float 字段:Config 的反射解码器只做 // 标量赋值,承载不了「非法值必须被报告并回落」的语义(记 WARN 需要原始文本); // 而直接 os.Getenv 会让只写在 .env 里的声明被静默忽略(.env 由 parseDotEnv 读进 // 独立 map,并不写回进程环境)。这里复用同一套优先级与同一份 .env 解析,因此两种 // 写法都真实生效 —— 与 LoadOwnerIdentityInjection 同源。envPath 为空表示不看 // .env(与 Load 的约定一致)。 // // 返回的 error 只承载「配了但读不懂」:调用方据此记一条带原始取值的 WARN 并回落 // 默认比例,绝不静默带过(AGENTS §10:非法配置不许静默失效)。 func LoadContextBudgetRatio(envPath string) (float64, error) { fromEnv := map[string]string{} if envPath != "" { m, err := parseDotEnv(envPath) if err != nil { return DefaultContextBudgetRatio, fmt.Errorf("config: read %s: %w", envPath, err) } fromEnv = m } raw := strings.TrimSpace(lookupRaw(ContextBudgetRatioEnv, "", fromEnv)) if raw == "" { return DefaultContextBudgetRatio, nil } ratio, err := ParseContextBudgetRatio(raw) if err != nil { return DefaultContextBudgetRatio, err } return ratio, nil } // ParseContextBudgetRatio 把比例文本解析成 (0, 1] 内的浮点数。两种写法都接受: // 小数(`0.6667`)与分数(`2/3`)—— 设计文档与主人裁决都用分数表述,若只认小数, // 写成 `2/3` 的部署会得到一条 WARN 并被静默回落到默认值,那正是「配了当没配」。 func ParseContextBudgetRatio(raw string) (float64, error) { text := strings.TrimSpace(raw) value, err := parseRatioValue(text) if err != nil { return DefaultContextBudgetRatio, err } if value <= 0 || value > 1 { return DefaultContextBudgetRatio, fmt.Errorf("config: %s=%q 超出可取范围 (0, 1]", ContextBudgetRatioEnv, raw) } return value, nil } // parseRatioValue 解析单个比例文本,失败时给出带原始取值的错误。 func parseRatioValue(text string) (float64, error) { badRatio := func() error { return fmt.Errorf("config: %s=%q 不是合法比例(用 0.6667 或 2/3 这类写法)", ContextBudgetRatioEnv, text) } if num, den, ok := strings.Cut(text, "/"); ok { n, err := strconv.ParseFloat(strings.TrimSpace(num), 64) if err != nil { return 0, badRatio() } d, err := strconv.ParseFloat(strings.TrimSpace(den), 64) if err != nil || d == 0 { return 0, badRatio() } return n / d, nil } n, err := strconv.ParseFloat(text, 64) if err != nil { return 0, badRatio() } return n, nil }