- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 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×每份注入)。
345 lines
12 KiB
Go
345 lines
12 KiB
Go
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
|
||
}
|