Files
Cyrene_v2/internal/runtime/loop/compactor.go
T
AskaEth 137b3e9eaf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入上限与附件注入预算统一换算(多附件不再被拦)
- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 1M 窗口下 2×349525 就顶到旧上限 699050、5 份必被拦。根因是两套换算(上限按 1char=1token,注入按 loop 的 4char=1token)。

- 修法:上限 = max(262144, 推导预算 tokens × loop.CharsPerToken) ⇒ 1M 窗口 2,796,200 字符(5 份满额注入 1,747,625 有余量);不变量升级为「N 份」(maxChatDocumentsPerTurn=5,带出处注释);WARN 文案升级为多附件口径(max_documents/document_inject_chars/document_inject_total_chars/guardrail_max_input_chars/hint)。

- 同一换算常量复用:在 loop/compactor.go 的既有无导出常量旁加导出别名 loop.CharsPerToken = charsPerToken(2 行、零行为改动、不重命名)—— 这是本切片唯一触及「不要碰」清单的改动,已由主会话复核接受(替代方案是复制一个字面量 4,会重新造出两套换算)。

- 同步陈旧注释(config/guardrail.go、wire_runtime.go 里的旧公式)与 .env.example(新公式 + 多附件口径 + 1M 算例 2796200/1747625),并有「模板 ↔ 代码现算值」的一致性用例。

- 主会话独立验收:隔离 worktree(G2b 的 7 文件,排除 B1/ST1 在途)build/vet 绿、全树 59 包 0 FAIL、护栏用例 17 PASS、双平台交叉编译绿。

- 残留(未修,需裁决):预算关闭或落在 (0,81920) token 的部署,派生态上限 = 262144 而 5×64K = 327680 ⇒ 满载附件回合仍可能被拦(启动会记 WARN,不静默);真正的下限应取 max(262144, maxDocs×每份注入)。
2026-10-04 19:41:44 +08:00

345 lines
12 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 loop
import (
"context"
"fmt"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 压缩调优常量。它们界定 compactor,使其保持确定性与
// 离线:摘要被限额、近期尾部占预算的一个比例,
// 且极短的对话即使超出预算也原样不动
// (design §1.3 上下文溢出压缩)。
const (
// DefaultMaxContextTokens 是调用方可赋给
// Options.MaxContextTokens 以启用溢出压缩的上下文预算(design §1.3)。
DefaultMaxContextTokens = 32768
// keepRecentFraction 是压缩时为近期尾部保留的
// 预算比例;余下部分留给摘要消息与提示词。
keepRecentFraction = 0.5
// minKeepTokens 是近期尾部预算的下限,使极小的
// MaxContextTokens 不会把每个 turn 都逼进摘要。
minKeepTokens = 128
// summaryMarker 标记压缩摘要系统消息,使 compactor 能
// 识别已存在的摘要前缀并保留(并入)它,
// 而不是在后续压缩中重新摘要。
summaryMarker = "[前文摘要 · history summary]"
// maxSummaryChars 限制压缩摘要,使反复压缩无法
// 无界地膨胀摘要(design §1.3 摘要容量上限)。
maxSummaryChars = 4000
// minCompactMessages 是 compactor 会触碰的最小对话;
// 更短的即使超出预算也原样不动。
minCompactMessages = 3
// messageOverheadTokens 是每条消息的 token 开销,对应 provider
// 在每条消息周围添加的角色与分隔符 token。
messageOverheadTokens = 4
// imageTokens 是单个图像部件的粗略 token 计数。
imageTokens = 1000
// charsPerToken 是离线估算使用的字符到 token 除数。附件注入预算
// (DocumentInjectCharsFor)与压缩估算共用它,是本仓库唯一的字符 ↔ token
// 换算口径。
charsPerToken = 4
// CharsPerToken 是 charsPerToken 的**导出别名**:组合根里按同一口径换算的
// 地方(cmd/cyrene 的输入护栏上限推导:上限 = 推导预算 token 数 × 本常量)
// 必须复用这一处定义,而不是另写一个字面量 4 —— 护栏上限与附件注入预算
// 曾因两边各写一套换算(1 char = 1 token vs 4 char = 1 token)而对不上,
// 表现是「2 份各自撞满注入预算的附件就被整回合拦死」(切片 G2b)。
CharsPerToken = charsPerToken
// summarySampleChars 限制被抽样进朴素摘要的每条消息文本长度。
summarySampleChars = 120
)
// Compactor 通过把更早的轮次摘要成单条前导摘要消息来压缩
// 超预算的对话,同时原样保留近期尾部 —— 包括
// 尾部的工具结果(design §1.3,与 OpenClaw 的
// context-engine 对齐)。它经 Options.Compactor 注入,使调用方能
// 换成 LLM 支持的摘要器;循环自带一个绝不调用
// 模型的结构化默认实现。
type Compactor interface {
// Compact 在估算 token 超过 budget 时返回把更早轮次压缩为
// 一条前导摘要的 messages。消息本来就在预算内,或
// 没有可安全摘要的内容时,它原样返回输入。
// ctx 是中止信号:被取消的 context 停止压缩并
// 返回包装过的 context.Canceled,使调用方能退出。
Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error)
}
// defaultCompactor 是内置的结构化 compactor。它无状态(documentInjectChars 只在
// 构造时给定),且并发安全。
type defaultCompactor struct {
// documentInjectChars 是单份附件注入上下文的字符预算,用于估算文档部件的
// token 代价(documentPartTokens)。0 表示用 defaultDocumentInjectChars。
documentInjectChars int
}
// Compact 实现 Compactor。
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, c.documentInjectChars)
}
// compactMessages 是共享的压缩实现。它绝不改写
// 输入切片;压缩结果始终是新分配的。
func compactMessages(ctx context.Context, messages []model.Message, budget, docInjectChars int) ([]model.Message, error) {
if budget <= 0 || len(messages) < minCompactMessages {
return messages, nil
}
if estimateContextTokensWith(messages, docInjectChars) <= budget {
return messages, nil
}
prefixEnd := summaryPrefixEnd(messages)
keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens)
keepStart := findKeepStart(messages, prefixEnd, keepTokens, docInjectChars)
if keepStart <= prefixEnd {
// 全部内容都被保留;没有可安全摘要的前缀。
return messages, nil
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: compact: %w", err)
}
existing := ""
if prefixEnd > 0 {
existing = strings.TrimPrefix(messageText(messages[0]), summaryMarker+"\n\n")
}
fresh := buildSummary(messages[prefixEnd:keepStart])
if fresh == "" && existing == "" {
return messages, nil
}
full := fresh
if existing != "" {
full = existing + "\n\n" + fresh
}
summary := model.TextMessage(model.RoleSystem, summaryMarker+"\n\n"+capSummary(full, maxSummaryChars))
out := make([]model.Message, 0, len(messages)-(keepStart-prefixEnd)+1)
out = append(out, summary)
out = append(out, messages[keepStart:]...)
return out, nil
}
// summaryPrefixEnd 返回前导压缩摘要消息的数量,
// 它们绝不再次被摘要。取值为 0(无摘要)或 1(单条摘要
// 前缀),因为压缩始终只产出一条前导摘要。
func summaryPrefixEnd(messages []model.Message) int {
if len(messages) == 0 || messages[0].Role != model.RoleSystem {
return 0
}
if strings.HasPrefix(messageText(messages[0]), summaryMarker) {
return 1
}
return 0
}
// findKeepStart 在近期尾部应装入 keepTokens 的前提下,
// 返回第一条要保留的消息索引。它始终保留完整的 turn:一个 turn
// 从用户消息开始,直到下一条用户消息。它从后往前
// 给 turn 计分,因此最近的 turn 得以存活,尾部的工具结果
// 绝不被切开。不存在用户 turn 时(少见),它不额外保留任何东西
// 并从 from 返回,因此什么也不被摘要。
func findKeepStart(messages []model.Message, from, keepTokens, docInjectChars int) int {
n := len(messages)
turns := make([]int, 0, 8)
for i := from; i < n; i++ {
if messages[i].Role == model.RoleUser {
turns = append(turns, i)
}
}
if len(turns) == 0 {
return from
}
acc := 0
keptStart := turns[len(turns)-1]
for i := len(turns) - 1; i >= 0; i-- {
to := n
if i+1 < len(turns) {
to = turns[i+1]
}
size := estimateContextTokensWith(messages[turns[i]:to], docInjectChars)
if i == len(turns)-1 || acc+size <= keepTokens {
acc += size
keptStart = turns[i]
} else {
break
}
}
return keptStart
}
// 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
for _, part := range m.Content {
switch part.Type {
case model.ContentTypeText:
total += estimateStringTokens(part.Text)
case model.ContentTypeImage:
total += imageTokens
case model.ContentTypeDocument:
total += documentPartTokens(part, docInjectChars)
}
}
}
return total
}
// documentPartTokens 估算一个文档部件在装配后可能占用的 token。窗口里只有
// 引用(design/24 §2.4),正文要等装配期才注入,因此这里按「原文件字节数
// (UTF-8 文本下 ≈ 字符数),上限为注入预算」估算。
//
// 关键是文档部件**不是免费的**:只认 ContentTypeImage 的分支会让纯文档回合
// 被估成 0 token,在压缩里等同于空内容(design/24 §9.5 移交的静默吞掉)。
// 大文件(尤其在预算处截断注入、或压根读不到)会被这个估计偏高地计入,方向
// 是「少放内容进上下文」——保守收紧,而不是溢出模型窗口。
func documentPartTokens(part model.Content, docInjectChars int) int {
if docInjectChars <= 0 {
docInjectChars = defaultDocumentInjectChars
}
chars := part.DocumentSize
if chars > int64(docInjectChars) {
chars = int64(docInjectChars)
}
if chars < 0 {
chars = 0
}
t := int(chars) / charsPerToken
if t < documentMinTokens {
return documentMinTokens
}
return t
}
// estimateStringTokens 是单个文本字符串的字符级 token 估算,
// 把宽字符(CJK)rune 计为双倍权重再除以 charsPerToken。空字符串
// 不花费任何 token;非空至少花费一个 token。
func estimateStringTokens(s string) int {
if s == "" {
return 0
}
runes := utf8.RuneCountInString(s)
wide := 0
for _, r := range s {
if r >= 0x2E80 {
wide++
}
}
t := (runes + wide) / charsPerToken
if t < 1 {
t = 1
}
return t
}
// buildSummary 渲染给定 messages 的紧凑结构化摘要:角色
// 分布,加上最近的用户与 assistant 文本。它是默认的
// (离线、不调模型)摘要器;想要语义化摘要的调用方
// 改为注入自己的 Compactor。
func buildSummary(messages []model.Message) string {
if len(messages) == 0 {
return ""
}
counts := map[model.Role]int{}
lastUser, lastAssistant := "", ""
for _, m := range messages {
counts[m.Role]++
text := messageText(m)
if text == "" {
continue
}
switch m.Role {
case model.RoleUser:
lastUser = text
case model.RoleAssistant:
lastAssistant = text
}
}
var b strings.Builder
fmt.Fprintf(&b, "已压缩 %d 条历史消息(", len(messages))
first := true
for _, role := range []model.Role{model.RoleSystem, model.RoleUser, model.RoleAssistant, model.RoleTool} {
if c := counts[role]; c > 0 {
if !first {
b.WriteString(",")
}
first = false
fmt.Fprintf(&b, "%s %d", role, c)
}
}
b.WriteString(")")
if lastUser != "" {
fmt.Fprintf(&b, "。最近用户: %s", truncateRunes(lastUser, summarySampleChars))
}
if lastAssistant != "" {
fmt.Fprintf(&b, "。最近助手: %s", truncateRunes(lastAssistant, summarySampleChars))
}
return b.String()
}
// capSummary 把 s 截断到至多 max 个 rune,需要裁剪时追加 "..." 标记。
// 它是 rune 安全的,因此绝不把多字节 rune 切成两半。
func capSummary(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max < 3 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
// 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, false
}
return string(runes[:n]), true
}