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)。
This commit is contained in:
1 parent
5b2b385524
commit
72b95df22b
39 files changed
+2496
-377
No files matched your search
@@ -68,34 +68,38 @@ type Compactor interface {
|
||||
Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error)
|
||||
}
|
||||
|
||||
// defaultCompactor 是内置的结构化 compactor。它无状态,
|
||||
// 且并发安全。
|
||||
type defaultCompactor struct{}
|
||||
// defaultCompactor 是内置的结构化 compactor。它无状态(documentInjectChars 只在
|
||||
// 构造时给定),且并发安全。
|
||||
type defaultCompactor struct {
|
||||
// documentInjectChars 是单份附件注入上下文的字符预算,用于估算文档部件的
|
||||
// token 代价(documentPartTokens)。0 表示用 defaultDocumentInjectChars。
|
||||
documentInjectChars int
|
||||
}
|
||||
|
||||
// Compact 实现 Compactor。
|
||||
func (defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
|
||||
func (c defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("loop: compact: %w", err)
|
||||
}
|
||||
return compactMessages(ctx, messages, budget)
|
||||
return compactMessages(ctx, messages, budget, c.documentInjectChars)
|
||||
}
|
||||
|
||||
// compactMessages 是共享的压缩实现。它绝不改写
|
||||
// 输入切片;压缩结果始终是新分配的。
|
||||
func compactMessages(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
|
||||
func compactMessages(ctx context.Context, messages []model.Message, budget, docInjectChars int) ([]model.Message, error) {
|
||||
if budget <= 0 || len(messages) < minCompactMessages {
|
||||
return messages, nil
|
||||
}
|
||||
if estimateContextTokens(messages) <= budget {
|
||||
if estimateContextTokensWith(messages, docInjectChars) <= budget {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
prefixEnd := summaryPrefixEnd(messages)
|
||||
keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens)
|
||||
keepStart := findKeepStart(messages, prefixEnd, keepTokens)
|
||||
keepStart := findKeepStart(messages, prefixEnd, keepTokens, docInjectChars)
|
||||
if keepStart <= prefixEnd {
|
||||
// 全部内容都被保留;没有可安全摘要的前缀。
|
||||
return messages, nil
|
||||
@@ -144,7 +148,7 @@ func summaryPrefixEnd(messages []model.Message) int {
|
||||
// 给 turn 计分,因此最近的 turn 得以存活,尾部的工具结果
|
||||
// 绝不被切开。不存在用户 turn 时(少见),它不额外保留任何东西
|
||||
// 并从 from 返回,因此什么也不被摘要。
|
||||
func findKeepStart(messages []model.Message, from, keepTokens int) int {
|
||||
func findKeepStart(messages []model.Message, from, keepTokens, docInjectChars int) int {
|
||||
n := len(messages)
|
||||
turns := make([]int, 0, 8)
|
||||
for i := from; i < n; i++ {
|
||||
@@ -163,8 +167,9 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int {
|
||||
if i+1 < len(turns) {
|
||||
to = turns[i+1]
|
||||
}
|
||||
if i == len(turns)-1 || acc+estimateContextTokens(messages[turns[i]:to]) <= keepTokens {
|
||||
acc += estimateContextTokens(messages[turns[i]:to])
|
||||
size := estimateContextTokensWith(messages[turns[i]:to], docInjectChars)
|
||||
if i == len(turns)-1 || acc+size <= keepTokens {
|
||||
acc += size
|
||||
keptStart = turns[i]
|
||||
} else {
|
||||
break
|
||||
@@ -173,11 +178,19 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int {
|
||||
return keptStart
|
||||
}
|
||||
|
||||
// estimateContextTokens 返回 messages 的保守 token 估算。它是
|
||||
// 启发式(字符 ÷ charsPerToken,加上每条消息、每个图像与每个文档的开销),
|
||||
// 因此无依赖且离线,但单调性足够驱动
|
||||
// 压缩决策。
|
||||
// estimateContextTokens 返回 messages 的保守 token 估算,文档部件按**缺省**注入
|
||||
// 上限计入(defaultDocumentInjectChars)。需要按本回合真实注入预算估算的调用方
|
||||
// (compactor)请用 estimateContextTokensWith。
|
||||
func estimateContextTokens(messages []model.Message) int {
|
||||
return estimateContextTokensWith(messages, defaultDocumentInjectChars)
|
||||
}
|
||||
|
||||
// estimateContextTokensWith 返回 messages 的保守 token 估算。它是
|
||||
// 启发式(字符 ÷ charsPerToken,加上每条消息、每个图像与每个文档的开销),
|
||||
// 因此无依赖且离线,但单调性足够驱动压缩决策。
|
||||
// docInjectChars 是单份文档注入上下文的字符上限,决定文档部件在估算里最多
|
||||
// 算多少(0 = 缺省值)。
|
||||
func estimateContextTokensWith(messages []model.Message, docInjectChars int) int {
|
||||
total := 0
|
||||
for _, m := range messages {
|
||||
total += messageOverheadTokens
|
||||
@@ -188,7 +201,7 @@ func estimateContextTokens(messages []model.Message) int {
|
||||
case model.ContentTypeImage:
|
||||
total += imageTokens
|
||||
case model.ContentTypeDocument:
|
||||
total += documentPartTokens(part)
|
||||
total += documentPartTokens(part, docInjectChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,10 +216,13 @@ func estimateContextTokens(messages []model.Message) int {
|
||||
// 被估成 0 token,在压缩里等同于空内容(design/24 §9.5 移交的静默吞掉)。
|
||||
// 大文件(尤其在预算处截断注入、或压根读不到)会被这个估计偏高地计入,方向
|
||||
// 是「少放内容进上下文」——保守收紧,而不是溢出模型窗口。
|
||||
func documentPartTokens(part model.Content) int {
|
||||
func documentPartTokens(part model.Content, docInjectChars int) int {
|
||||
if docInjectChars <= 0 {
|
||||
docInjectChars = defaultDocumentInjectChars
|
||||
}
|
||||
chars := part.DocumentSize
|
||||
if chars > documentInjectedCharsCap {
|
||||
chars = documentInjectedCharsCap
|
||||
if chars > int64(docInjectChars) {
|
||||
chars = int64(docInjectChars)
|
||||
}
|
||||
if chars < 0 {
|
||||
chars = 0
|
||||
@@ -300,9 +316,20 @@ func capSummary(s string, max int) string {
|
||||
|
||||
// truncateRunes 把 s 缩短到至多 n 个 rune,Unicode 安全。
|
||||
func truncateRunes(s string, n int) string {
|
||||
out, _ := truncateRunesAt(s, n)
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateRunesAt 与 truncateRunes 同义,另外报告是否发生了截断。
|
||||
// 「是否截断」是清单行里续读游标的唯一判据(截断才给游标),因此它必须来自
|
||||
// 截断动作本身,而不是「长度是否等于上限」这类再推断。
|
||||
func truncateRunesAt(s string, n int) (string, bool) {
|
||||
if n <= 0 {
|
||||
return "", s != ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
return s, false
|
||||
}
|
||||
return string(runes[:n])
|
||||
return string(runes[:n]), true
|
||||
}
|
||||
Reference in new issue
Block a user