Skip to content

Commit 00e93d7

Browse files
authored
Merge pull request #209 from itmisx/feat/dynamic-compact-threshold
✨ dynamic-compact-threshold
2 parents b38b99c + b2f1a33 commit 00e93d7

3 files changed

Lines changed: 84 additions & 17 deletions

File tree

agent/compact_trigger_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package agent
2+
3+
import "testing"
4+
5+
// 动态阈值:主流窗口(128K~256K)保持 ~70%,大窗口触发线升高(少丢上下文),
6+
// 极小窗口触发线降低(单轮暴涨占比大,该早压)。且必须单调:窗口越大,触发的绝对 token 越大。
7+
func TestCompactTriggerTokens(t *testing.T) {
8+
cases := []struct {
9+
ctxWin int
10+
wantPct int // 期望触发线约占窗口的百分比(±1)
11+
}{
12+
{40_000, 40}, // 极小:headroom 30K 撞 60% 上限(→24K) → 40% 触发
13+
{64_000, 53}, // headroom 撞 30K 下限 → 53%
14+
{128_000, 70}, // 主流下限:headroom=38.4K → 70%
15+
{256_000, 70}, // step-flash:headroom=76.8K → 70%
16+
{600_000, 70}, // headroom=180K 正好=30% → 70%
17+
{1_048_576, 82}, // 1M:headroom 封顶 180K → ~82%
18+
}
19+
for _, c := range cases {
20+
got := CompactTriggerTokens(c.ctxWin)
21+
pct := got * 100 / c.ctxWin
22+
if pct < c.wantPct-1 || pct > c.wantPct+1 {
23+
t.Errorf("CompactTriggerTokens(%d) = %d (%d%%), 期望 ~%d%%", c.ctxWin, got, pct, c.wantPct)
24+
}
25+
}
26+
27+
// 单调:窗口越大,触发绝对阈值越大(不会出现大窗口反而更早压)。
28+
prev := 0
29+
for _, w := range []int{64_000, 128_000, 256_000, 512_000, 1_048_576} {
30+
got := CompactTriggerTokens(w)
31+
if got <= prev {
32+
t.Errorf("非单调:窗口 %d 触发 %d 不大于更小窗口的 %d", w, got, prev)
33+
}
34+
prev = got
35+
}
36+
37+
// headroom 恒为正(触发线永远 < 窗口),否则等于不压。
38+
for _, w := range []int{1000, 20_000, 256_000, 2_000_000} {
39+
if got := CompactTriggerTokens(w); got >= w || got <= 0 {
40+
t.Errorf("CompactTriggerTokens(%d) = %d,应在 (0, %d) 内", w, got, w)
41+
}
42+
}
43+
44+
if CompactTriggerTokens(0) != 0 || CompactTriggerTokens(-1) != 0 {
45+
t.Error("ctxWin<=0 应返回 0")
46+
}
47+
}

agent/llm.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@ const (
3636
// (见 issue #84:旧的固定 100 轮上限会把合法长任务在中途打断、需手动继续。)
3737
maxNoProgressRounds = 15
3838

39-
// compactTriggerPct:单个 turn 内,上一轮真实 prompt tokens 占模型上下文窗口达到这个百分比,
40-
// 就在下次请求前自动压缩历史(对标 Claude Code 的 auto-compact:压缩+继续,而非熔断停)。
41-
// 取 70 留 ~30% 给本轮输出;压缩后历史缩到 ~20% 窗口,不会立刻反复触发。
42-
compactTriggerPct = 70
39+
// 压缩/回收的触发阈值不再是固定百分比,而是按窗口大小动态算(见 CompactTriggerTokens)。
40+
// 曾用固定 70%:对小窗口合适(留 ~30% 给输出+单轮暴涨缓冲),但大窗口(1M)按 70% 就压会白丢
41+
// 几十万本可保留的上下文、还多压几次。改为「保留 headroom 随窗口缩放、封顶」——主流窗口
42+
// (128K~256K)行为不变(≈70%),大窗口触发线自然升到 ~80%+,极小窗口更早压(暴涨风险大)。
43+
compactHeadroomPct = 30 // headroom 基准:窗口的这个比例
44+
compactHeadroomFloor = 30_000 // headroom 绝对下限:极小窗口也留够单轮暴涨缓冲
45+
compactHeadroomCap = 180_000 // headroom 绝对上限:大窗口不必按比例留那么多,省得过早压
4346

4447
// maxAPIRetries:streamOnce 在「进入流式之前」失败(网络错 / 429 限流 / 5xx 服务端错误)时的最大重试次数。
4548
// 只重试这类瞬时错误,且只在还没吐任何 token 给 UI 时重试(进流式后的中途断不在此列,重试会重复吐字)。
@@ -85,7 +88,7 @@ type RetryNoticeMsg struct {
8588
Delay time.Duration // 本次退避等待时长
8689
Reason string // 触发原因,如 "HTTP 429" / 网络错误
8790
}
88-
type ToolCallStartMsg struct { // 即将调用工具
91+
type ToolCallStartMsg struct { // 即将调用工具
8992
Name string
9093
Args string
9194
ReviewCh chan bool // review 模式下的审核通道,nil = 无需审核
@@ -130,7 +133,7 @@ type CompactedMsg struct {
130133
}
131134

132135
// CompactingMsg 轮内自动压缩「开始」时发给 UI:RunCompression 是一次同步的 LLM 调用,
133-
// 最长会卡 compactionTimeout(2 分钟),期间 agent 停在那儿、一个 token 也不吐。不发这条的话
136+
// 最长会卡 compactionTimeout(10 分钟),期间 agent 停在那儿、一个 token 也不吐。不发这条的话
134137
// 界面上只有普通的流式 spinner,用户看不出在压缩,只觉得卡死了(issue #201)。
135138
// 收到后 UI 把活动状态行切成「压缩中…」,由 CompactedMsg(成功)或 CompactFailedMsg(失败)复位。
136139
type CompactingMsg struct{}
@@ -666,7 +669,7 @@ func StartStream(
666669
for i := range convo {
667670
curEst += MsgTokens(convo[i]) // 实时估算(与 clampMaxTokens 同口径);可后续复用其结果省一遍
668671
}
669-
if max(lastPromptTokens, curEst) >= ctxWin*compactTriggerPct/100 {
672+
if max(lastPromptTokens, curEst) >= CompactTriggerTokens(ctxWin) {
670673
if n, freed := reclaimToolOutputs(convo, ctxWin); n > 0 {
671674
// 回收后 prompt 估算 = system+messages(curEst 已含,减去净回收量)+ tools schema。
672675
estAfter := curEst - freed + toolSpecsTokens
@@ -680,10 +683,10 @@ func StartStream(
680683
// 对标 Claude Code:压缩 convo[1:] 成摘要、重建 [system(新摘要)]+尾部,新摘要经 CompactedMsg
681684
// 回传 TUI 存 session(否则被剥的 system 摘要会丢失),history 截断经 HistoryUpdateMsg 同步。
682685
if ctxWin := currentEntry.ContextWindow; !inLoopCompactOff && ctxWin > 0 &&
683-
lastPromptTokens >= ctxWin*compactTriggerPct/100 &&
686+
lastPromptTokens >= CompactTriggerTokens(ctxWin) &&
684687
len(convo) > 0 && convo[0].Role == "system" {
685688
hist := convo[1:]
686-
ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 2 分钟,期间不吐任何 token
689+
ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 10 分钟,期间不吐任何 token
687690
sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin)
688691
if cerr != nil {
689692
inLoopCompactOff = true // 压不动(历史太短/摘要失败)→ 本轮不再尝试,退回撞窗口报错
@@ -698,7 +701,7 @@ func StartStream(
698701
estAfter := EstimatePromptTokens(workspace, skillCatalog, summary, kept)
699702
// 防死循环:若压缩后历史仍超阈值(最近 5 轮本身就超窗口,RunCompression 切点缩不动),
700703
// 再压也无效 → 本轮关掉循环内压缩,退回撞窗口优雅报错,避免反复压缩刷请求。
701-
if estAfter >= ctxWin*compactTriggerPct/100 {
704+
if estAfter >= CompactTriggerTokens(ctxWin) {
702705
inLoopCompactOff = true
703706
}
704707
// 摘要 + 截断历史 + 新量级一条送达,原子生效(见 CompactedMsg 注释)。
@@ -1205,6 +1208,24 @@ func clampMaxTokens(maxTokens, ctxWin int, convo []ChatMessage) int {
12051208
return maxTokens
12061209
}
12071210

1211+
// CompactTriggerTokens 返回「上下文用量达到多少 token 就触发压缩 / 回收」的动态阈值。
1212+
//
1213+
// = 窗口 - headroom,headroom = clamp(窗口×30%, 30K, 180K),再夹「不超过窗口 60%」防极小窗口留过头。
1214+
// headroom 是留给「本轮输出 + 触发到实际压缩之间继续追加的工具结果 + clamp 的 5% 保险」的空间。
1215+
// - 128K~256K 主流窗口:headroom≈30% → 触发≈70%,与旧固定值一致,不改变现有行为。
1216+
// - ≥600K 大窗口:headroom 封顶 180K → 触发线升到 ~80%+,少丢上下文、少压几次。
1217+
// - 极小窗口:headroom 撞下限/60% 上限 → 触发线降低,更早压(单轮暴涨占比大,本就该早压)。
1218+
func CompactTriggerTokens(ctxWin int) int {
1219+
if ctxWin <= 0 {
1220+
return 0
1221+
}
1222+
headroom := ctxWin * compactHeadroomPct / 100
1223+
headroom = max(headroom, compactHeadroomFloor) // 下限:极小窗口也留够暴涨缓冲
1224+
headroom = min(headroom, compactHeadroomCap) // 上限:大窗口不必按比例留那么多
1225+
headroom = min(headroom, ctxWin*60/100) // 别把 headroom 撑到吃掉大半窗口
1226+
return ctxWin - headroom
1227+
}
1228+
12081229
// isRetryableStatus 判断 HTTP 状态码是否值得重试:429 限流、5xx 服务端错误、408/409/425 等瞬时类。
12091230
// 400/401/403/404/422 是请求本身的问题(尤其 400 常是上下文超长),重试无意义 → 直接返回错误。
12101231
func isRetryableStatus(code int) bool {
@@ -1223,8 +1244,8 @@ func retryBackoff(attempt int, retryAfter string) time.Duration {
12231244
return time.Duration(min(secs, 60)) * time.Second // 夹一下:防个别服务器给超大值
12241245
}
12251246
}
1226-
d := min(time.Second<<uint(attempt), 30*time.Second) // 1,2,4,8,16,32… 封顶 30s
1227-
return d + time.Duration(rand.Int63n(int64(d)/5+1)) // +0~20% 抖动
1247+
d := min(time.Second<<uint(attempt), 30*time.Second) // 1,2,4,8,16,32… 封顶 30s
1248+
return d + time.Duration(rand.Int63n(int64(d)/5+1)) // +0~20% 抖动
12281249
}
12291250

12301251
// sleepCtx 等待 d;期间 ctx 取消(ESC/退出)则提前返回 false,让重试循环立刻中止。

tui/model.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,8 @@ type shadowResultMsg struct {
554554
err error
555555
}
556556

557-
// compactTriggerPct 实时会话压缩触发阈值(上下文用量占窗口的百分比)。与 agent 包轮内 auto-compact
558-
// 对齐到 70%:留 30% 给本轮输出;1M 窗口下也有充足 headroom。
559-
const compactTriggerPct = 70
557+
// 轮末会话压缩的触发阈值走 agent.CompactTriggerTokens(ctxWin) —— 与 agent 包轮内 auto-compact
558+
// 用同一个动态阈值(按窗口缩放 headroom),单一真相源,避免两处不一致。
560559

561560
// shadowPoints 影子热压的触发档位(上下文用量百分比)。30 起步而非 20:keepTarget 也是 20%,
562561
// 在 20% 处 history < keepTarget 会被 RunCompression 拒(白跑);30% 起 history 才够压。
@@ -2966,12 +2965,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29662965
// 显示区按字节预算自动裁剪 (chatLog.Append/Open 内部已调 trim),
29672966
// 这里无需额外动作 — 旧的 trimDisplayTurns 按"10 轮"裁的逻辑已被 chatLog 取代。
29682967

2969-
// 检查是否需要触发会话压缩:估算 token 数接近窗口的 compactTriggerPct(70%)时触发
2968+
// 检查是否需要触发会话压缩:上下文用量达到 agent.CompactTriggerTokens(动态阈值)就压
29702969
ctxWin := m.models.Pro.ContextWindow
29712970
if ctxWin <= 0 {
29722971
ctxWin = 65536
29732972
}
2974-
if m.session != nil && m.models.Pro.Model != "" && !m.compacting && m.lastPromptTokens() >= ctxWin*compactTriggerPct/100 {
2973+
if m.session != nil && m.models.Pro.Model != "" && !m.compacting && m.lastPromptTokens() >= agent.CompactTriggerTokens(ctxWin) {
29752974
m.compacting = true
29762975
m.compactingFG = true // 前台阻塞:同手动 /compact,footer 转 spinner + 期间挡输入。子 agent 走 runSubAgent 不经此处,天然例外。
29772976
// 前台阻塞 + spinner;排队输入推迟到 compressionResultMsg(压缩完成后)再发。

0 commit comments

Comments
 (0)