@@ -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(失败)复位。
136139type 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 常是上下文超长),重试无意义 → 直接返回错误。
12101231func 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,让重试循环立刻中止。
0 commit comments