Files
Cyrene_v2/internal/config/context_budget.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

221 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}