- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。 - sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。 - 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。 - 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
309 lines
9.7 KiB
Go
309 lines
9.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
|
||
case model.ContentTypeDocument:
|
||
total += documentPartTokens(part)
|
||
}
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// documentPartTokens 估算一个文档部件在装配后可能占用的 token。窗口里只有
|
||
// 引用(design/24 §2.4),正文要等装配期才注入,因此这里按「原文件字节数
|
||
// (UTF-8 文本下 ≈ 字符数),上限为注入预算」估算。
|
||
//
|
||
// 关键是文档部件**不是免费的**:只认 ContentTypeImage 的分支会让纯文档回合
|
||
// 被估成 0 token,在压缩里等同于空内容(design/24 §9.5 移交的静默吞掉)。
|
||
// 大文件(尤其在预算处截断注入、或压根读不到)会被这个估计偏高地计入,方向
|
||
// 是「少放内容进上下文」——保守收紧,而不是溢出模型窗口。
|
||
func documentPartTokens(part model.Content) int {
|
||
chars := part.DocumentSize
|
||
if chars > documentInjectedCharsCap {
|
||
chars = documentInjectedCharsCap
|
||
}
|
||
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 {
|
||
runes := []rune(s)
|
||
if len(runes) <= n {
|
||
return s
|
||
}
|
||
return string(runes[:n])
|
||
}
|