主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。 注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。 校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等; 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。 gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
284 lines
8.7 KiB
Go
284 lines
8.7 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 除数。
|
||
charsPerToken = 4
|
||
|
||
// 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。它无状态,
|
||
// 且并发安全。
|
||
type defaultCompactor struct{}
|
||
|
||
// Compact 实现 Compactor。
|
||
func (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)
|
||
}
|
||
|
||
// compactMessages 是共享的压缩实现。它绝不改写
|
||
// 输入切片;压缩结果始终是新分配的。
|
||
func compactMessages(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
|
||
if budget <= 0 || len(messages) < minCompactMessages {
|
||
return messages, nil
|
||
}
|
||
if estimateContextTokens(messages) <= budget {
|
||
return messages, nil
|
||
}
|
||
|
||
prefixEnd := summaryPrefixEnd(messages)
|
||
keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens)
|
||
keepStart := findKeepStart(messages, prefixEnd, keepTokens)
|
||
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 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]
|
||
}
|
||
if i == len(turns)-1 || acc+estimateContextTokens(messages[turns[i]:to]) <= keepTokens {
|
||
acc += estimateContextTokens(messages[turns[i]:to])
|
||
keptStart = turns[i]
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
return keptStart
|
||
}
|
||
|
||
// estimateContextTokens 返回 messages 的保守 token 估算。它是
|
||
// 启发式(字符 ÷ charsPerToken,加上每条消息与每个图像的开销),
|
||
// 因此无依赖且离线,但单调性足够驱动
|
||
// 压缩决策。
|
||
func estimateContextTokens(messages []model.Message) 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
|
||
}
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// 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 {
|
||
runes := []rune(s)
|
||
if len(runes) <= n {
|
||
return s
|
||
}
|
||
return string(runes[:n])
|
||
}
|