- 窗口声明: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)。
221 lines
8.8 KiB
Go
221 lines
8.8 KiB
Go
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
|
||
}
|