Skip to content

Commit b53ec91

Browse files
authored
Merge pull request #213 from itmisx/fix/compact-turn-boundary
🐛 fix: compact turn boundary
2 parents 6c9ef14 + 4cf6f92 commit b53ec91

7 files changed

Lines changed: 294 additions & 68 deletions

File tree

agent/compact.go

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,67 @@ import (
1818
// 本文件封装与 TUI 无关的压缩纯逻辑:token 估算 + RunCompression。
1919
// bubbletea 胶水(触发时机、tea.Cmd 包装、消息处理、读 model/session)仍在 tui 包,调用这里的导出函数。
2020

21-
// ErrCompactTooFewTurns:压缩因「user 轮数不足」被拒。这是**结构性不可恢复**失败 —— 轮内 for 循环
22-
// 是单个 user turn,期间 user 轮数恒定,冷却后重试也永远失败,且这个检查在发 LLM 请求前就本地秒拒。
23-
// agent 据此区别对待:轮数不足永久关本轮压缩(而非冷却重试),避免每圈闪「压缩中…/失败」刷屏
24-
// (issue #201)。只有瞬时失败(超时/网络)才值得冷却重试。
25-
var ErrCompactTooFewTurns = errors.New("user 轮数不足,无需压缩")
26-
27-
// keepRecentTurns 是压缩时至少保留的最近 user 轮数下限(与 20% 预算取较大者)。
28-
// 2 轮能覆盖"最近指代",同时大幅减少过度保留。可按需调整。
21+
// ErrCompactTooFewTurns:压缩因「对话轮数不足」被拒(轮数按 isTurnBoundary 计,见下)。
22+
// 这个检查在发 LLM 请求前就本地秒拒,agent 据此区别对待:轮数不足永久关本轮压缩(而非冷却重试),
23+
// 避免每圈闪「压缩中…/失败」刷屏(issue #201)。只有瞬时失败(超时/网络)才值得冷却重试。
24+
var ErrCompactTooFewTurns = errors.New("对话轮数不足,无需压缩")
25+
26+
// keepRecentTurns 是压缩时至少保留的最近对话轮数下限(与 compactKeepPct 预算取较大者)。
27+
// 2 轮能覆盖"最近指代",同时不过度保留。
2928
const keepRecentTurns = 2
3029

30+
// compactKeepPct 是压缩后保留的尾部上下文占上下文窗口的百分比(切点由它反推)。
31+
// 15%(原 20%):留得更少 = 压完离触发线更远、少压几次,长任务会话尤其受益。
32+
const compactKeepPct = 15
33+
34+
// CompactKeepTokens 返回压缩后应保留的尾部上下文 token 数(= 窗口 × compactKeepPct%)。
35+
// 导出供 tui 使用:"值不值得压"的判断必须与这里的保留口径一致,别再各处各写一份百分比。
36+
func CompactKeepTokens(ctxWin int) int { return ctxWin * compactKeepPct / 100 }
37+
38+
// CutHistory 按切点截断历史,返回要保留的尾部(新切片,不共享底层数组、不改入参)。
39+
//
40+
// 切点按 user|assistant 取(见 isTurnBoundary),长任务轮里往往落在 assistant 上,于是保留段以
41+
// assistant 打头、整段可能一条 user 消息都没有。这时把**被压掉那部分里最近一条 user 消息**复制
42+
// 一份放到最前面,一举两得:
43+
// - 模型能看到用户任务的原文,而不是只剩摘要里的转述;
44+
// - 开头恒为 system → user → assistant。vLLM 按模型自带 chat template 渲染,Mistral / Llama-2 系
45+
// 模板写着 raise_exception("Conversation roles must alternate ..."),system 后直接跟 assistant 会被拒。
46+
//
47+
// 从**被压掉的部分**取,所以它在时间线上必早于保留段的全部内容,插到最前面顺序正确,
48+
// 也不会与保留段里已有的 user 消息重复。复制时丢掉图片:图会被重新渲染成 base64、很占 token,
49+
// 而摘要里已有相关描述。保留段本就以 user 开头时原样返回,不做任何事。
50+
//
51+
// 所有应用切点的地方都该走这里,别自己 history[cutIdx:] —— 否则各处行为不一致。
52+
func CutHistory(history []ChatMessage, cutIdx int) []ChatMessage {
53+
cutIdx = min(max(cutIdx, 0), len(history))
54+
kept := history[cutIdx:]
55+
out := make([]ChatMessage, 0, len(kept)+1)
56+
if len(kept) > 0 && kept[0].Role != "user" {
57+
for i := cutIdx - 1; i >= 0; i-- {
58+
// 取最近一条**有正文**的 user 消息:纯图片消息剥掉图后正文为空,
59+
// 发出去会是一条没有 content 字段的 user 消息,部分后端直接拒。
60+
if history[i].Role != "user" || strings.TrimSpace(history[i].Content) == "" {
61+
continue
62+
}
63+
task := history[i]
64+
task.ImagePaths = nil // 图不复制(见上)
65+
task.ContentParts = nil
66+
out = append(out, task)
67+
break
68+
}
69+
}
70+
return append(out, kept...)
71+
}
72+
73+
// isTurnBoundary 判断这条消息能否当作"一轮对话"的边界,也即能否作为压缩切点。
74+
//
75+
// user 和 assistant 都算,tool 不算。两个原因:
76+
// - "一个 user 消息 + 几十轮工具调用"的长任务轮里一条 user 都没有(issue #201 的典型形态),
77+
// 只认 user 就找不到切点,压缩要么退到最前面(等于不压)、要么把整个长轮全保住;
78+
// - tool 消息必须紧跟发起调用的 assistant,切在它上面会留下孤儿 tool → API 400
79+
// (见 sanitizeToolPairs);而切在 assistant 上,它的 tool 结果自然跟着一起保留,配对不坏。
80+
func isTurnBoundary(m ChatMessage) bool { return m.Role == "user" || m.Role == "assistant" }
81+
3182
// compactionTimeout 是摘要 LLM 调用的硬超时。没有它,卡住的请求会让压缩锁永远占住、把所有压缩堵死。
3283
// 给得宽松(容纳大摘要生成 + 本地慢模型,如 4090D 上跑 qwen 摘要大历史,见 issue #201),
3384
// 只为兜住"永不返回",超时即失败、下轮重试。
@@ -191,7 +242,8 @@ func EstimatePromptTokens(workspace, skillCatalog, summary string, history []Cha
191242

192243
// === 压缩执行 ===
193244

194-
// RunCompression 执行一次会话压缩:按 context_window × 20% 保留尾部上下文。
245+
// RunCompression 执行一次会话压缩:保留 max(context_window × compactKeepPct%, 最近 keepRecentTurns 轮)
246+
// 的尾部上下文,切点落在 user / assistant 边界(见 isTurnBoundary),因此也能切进长任务轮内部。
195247
// 通常在 goroutine 中调用。history 仅含会话消息(不含 system / 旧摘要消息)。
196248
//
197249
// 缓存友好:传入 lastSystemPrompt(上次实际发送的 system 文本)时,摘要请求构造成
@@ -201,35 +253,39 @@ func EstimatePromptTokens(workspace, skillCatalog, summary string, history []Cha
201253
func RunCompression(lastSystemPrompt, lastToolSpecsJSON string, history []ChatMessage, entry ModelEntry, ctxWin int) (
202254
summary string, cutIdx int, compressedTurns int, err error) {
203255

204-
totalUsers := 0
256+
// 轮数按 isTurnBoundary(user 或 assistant)计:一个 user 消息 + 几十轮工具调用同样是几十轮对话,
257+
// 只数 user 会把这种长任务会话判成"轮数不足"而永远压不动(issue #201)。
258+
// 总轮数不多于要保留的轮数 → 全都要留,没有可压前缀。
259+
turns := 0
205260
for _, msg := range history {
206-
if msg.Role == "user" {
207-
totalUsers++
261+
if isTurnBoundary(msg) {
262+
turns++
208263
}
209264
}
210-
if totalUsers <= 2 {
265+
if turns <= keepRecentTurns {
211266
return "", 0, 0, ErrCompactTooFewTurns
212267
}
213268

214-
// 保留量 = max(20% 预算, 最近 keepRecentTurns 轮),取保留更多者(切点更靠前 = 下标更小)。
215-
// 关键:budgetStart 初值 = 0 = "默认保留全部"。从尾部累加 token 攒够 20% 窗口,才把切点后移
216-
// (留得更少)。整段历史都不到 20% → budgetStart 停在 0 → keepStart=0 → 守卫拒绝,不压。
217-
keepTarget := ctxWin * 20 / 100
269+
// 保留量 = max(compactKeepPct 预算, 最近 keepRecentTurns 轮),取保留更多者(切点更靠前 = 下标更小)。
270+
// 两个循环都只在 isTurnBoundary 处取切点 —— 切在 tool 上会留下孤儿 tool、被 API 拒。
271+
// 关键:budgetStart 初值 = 0 = "默认保留全部"。从尾部累加 token 攒够预算,才把切点后移
272+
// (留得更少)。整段历史都不够预算 → budgetStart 停在 0 → keepStart=0 → 守卫拒绝,不压。
273+
keepTarget := CompactKeepTokens(ctxWin)
218274

219-
budgetStart := 0 // 默认保留全部;攒够 20% 才后移切点
275+
budgetStart := 0 // 默认保留全部;攒够预算才后移切点
220276
cc := 0
221277
for i := len(history) - 1; i >= 0; i-- {
222278
// 用 MsgTokens(tiktoken 精确分词)按 token 累加全字段,含 ReasoningContent + ToolCalls 参数。
223279
cc += MsgTokens(history[i])
224-
if history[i].Role == "user" && cc >= keepTarget {
280+
if isTurnBoundary(history[i]) && cc >= keepTarget {
225281
budgetStart = i
226282
break
227283
}
228284
}
229-
turnStart := len(history) // 最近 keepRecentTurns 个 user 轮;不足则保持 len(不参与取 min)
285+
turnStart := len(history) // 最近 keepRecentTurns 轮;不足则保持 len(不参与取 min)
230286
uc := 0
231287
for i := len(history) - 1; i >= 0; i-- {
232-
if history[i].Role == "user" {
288+
if isTurnBoundary(history[i]) {
233289
uc++
234290
if uc >= keepRecentTurns {
235291
turnStart = i
@@ -242,8 +298,8 @@ func RunCompression(lastSystemPrompt, lastToolSpecsJSON string, history []ChatMe
242298
keepStart = turnStart
243299
}
244300
if keepStart <= 0 {
245-
// 整段都要留:历史不足 20% 窗口,或 20% 边界就在最前一条 —— 没有可压缩前缀。
246-
return "", 0, 0, fmt.Errorf("历史不足 20%% 窗口,无需压缩")
301+
// 整段都要留:历史不足保留预算,或预算边界就在最前一条 —— 没有可压缩前缀。
302+
return "", 0, 0, fmt.Errorf("历史不足 %d%% 窗口,无需压缩", compactKeepPct)
247303
}
248304
cutIdx = keepStart
249305

agent/compact_cooldown_test.go

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,51 @@ import (
1212
)
1313

1414
// RunCompression 因轮数不足被拒时,返回可识别的哨兵错误(agent 据此决定永久关而非冷却重试)。
15+
// 轮数按 user|assistant 计(见 isTurnBoundary),所以"不足"= 总共不多于 keepRecentTurns 条非 tool 消息。
1516
func TestRunCompression_TooFewTurnsSentinel(t *testing.T) {
1617
hist := []ChatMessage{
1718
{Role: "user", Content: "一"},
1819
{Role: "assistant", Content: "回一"},
19-
{Role: "user", Content: "二"},
20-
} // 只有 2 个 user 轮
20+
} // 2 轮(user + assistant),不多于要保留的 keepRecentTurns
2121
_, _, _, err := RunCompression("", "", hist, ModelEntry{ContextWindow: 100000}, 100000)
2222
if !errors.Is(err, ErrCompactTooFewTurns) {
2323
t.Fatalf("2 轮应返回 ErrCompactTooFewTurns 哨兵, got %v", err)
2424
}
2525
}
2626

27-
// C-分流①:轮数不足是结构性不可恢复(本轮 user 轮数恒定)→ 永久关,压缩只尝试一次,不每圈刷屏。
28-
func TestInLoopCompact_TooFewTurnsPermanentOff(t *testing.T) {
27+
// 一个 user 消息 + 几十轮工具调用:轮数按 user|assistant 计后不再被"轮数不足"拒
28+
// —— 这正是 issue #201 里长任务会话压不动的形态(旧规则只数 user,这里 totalUsers=1 直接秒拒)。
29+
func TestRunCompression_SingleUserLongTurnNotRejected(t *testing.T) {
30+
hist := []ChatMessage{{Role: "user", Content: "跑测试并修好所有失败"}}
31+
body := strings.Repeat("覆盖率数据 line 42 hit; ", 200)
32+
for i := range 6 {
33+
id := fmt.Sprintf("s%d", i)
34+
hist = append(hist, asstCall(id, "Bash", `{"command":"go test"}`), toolMsg(id, "Bash", body))
35+
}
36+
// BaseURL 为空 → 摘要请求在本地就失败;这里只关心它已越过轮数 / 切点守卫。
37+
_, _, _, err := RunCompression("sys", "[]", hist, ModelEntry{ContextWindow: 20000}, 20000)
38+
if errors.Is(err, ErrCompactTooFewTurns) {
39+
t.Fatal("单个 user 长任务轮不应再被判成轮数不足")
40+
}
41+
if err != nil && strings.Contains(err.Error(), "无需压缩") {
42+
t.Fatalf("不该被切点守卫拒绝:%v", err)
43+
}
44+
}
45+
46+
// C-分流①:压缩成功但历史仍超阈值(再压也切不动)→ 永久关,只尝试一次,不每圈刷屏。
47+
func TestInLoopCompact_StillOverThresholdPermanentOff(t *testing.T) {
2948
const ctxWin = 20000
30-
url := compactTestServer(t, false) // 不注入失败;压缩会被 totalUsers<=2 本地拒(发不出请求)
49+
url := compactSummaryServer(t) // 压缩请求返正常摘要,其余返工具调用 + 高 prompt_tokens
3150

3251
cfg := ModelConfig{Flash: ModelEntry{BaseURL: url, Model: "m", APIKey: "k", ContextWindow: ctxWin, MaxTokens: 256}}
33-
attempts := runLoopCountingCompactAttempts(t, cfg, bloat2Turns())
52+
attempts, est := runLoopCountingCompactAttempts2(t, cfg, bloatedHistory())
3453

54+
if est > 0 && est < CompactTriggerTokens(ctxWin) {
55+
t.Skipf("前提不成立:压缩后估算 %d tok 已低于触发线 %d,本例测不到永久关",
56+
est, CompactTriggerTokens(ctxWin))
57+
}
3558
if attempts != 1 {
36-
t.Fatalf("轮数不足应永久关、只尝试 1 次压缩,实际 %d 次(冷却重试会 >1,刷屏)", attempts)
59+
t.Fatalf("压完仍超阈值应永久关、只尝试 1 次压缩,实际 %d 次(冷却重试会 >1,刷屏)", attempts)
3760
}
3861
}
3962

@@ -79,8 +102,59 @@ func runLoopCountingCompactAttempts(t *testing.T, cfg ModelConfig, hist []ChatMe
79102
return attempts
80103
}
81104

82-
// bloat2Turns 造「2 个 user 轮 + 大工具输出」,过触发线但轮数不足。
83-
func bloat2Turns() []ChatMessage {
105+
// runLoopCountingCompactAttempts2 同上,额外返回压缩成功时上报的估算量(用于判断"压完是否仍超线")。
106+
func runLoopCountingCompactAttempts2(t *testing.T, cfg ModelConfig, hist []ChatMessage) (attempts, est int) {
107+
ctx, cancel := context.WithCancel(context.Background())
108+
defer cancel()
109+
_, ch := StartStream(ctx, cfg, hist, AgentMode_Auto, t.TempDir(), "", "", "flash", WorkingModeDefault)
110+
111+
rounds := 0
112+
for msg := range ch {
113+
switch m := msg.(type) {
114+
case CompactingMsg:
115+
attempts++
116+
case CompactedMsg:
117+
est = m.EstPromptTokens
118+
case ToolCallStartMsg:
119+
if rounds++; rounds >= compactRetryCooldown+4 {
120+
cancel()
121+
}
122+
}
123+
}
124+
return attempts, est
125+
}
126+
127+
// compactSummaryServer:对「压缩请求」(body 含 checkpoint 指令)返回一份正常的非流式摘要;
128+
// 其余请求返回流式 Bash 工具调用 + 高 prompt_tokens(驱动循环、过触发线)。
129+
func compactSummaryServer(t *testing.T) string {
130+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
body, _ := io.ReadAll(r.Body)
132+
if strings.Contains(string(body), "checkpoint") {
133+
w.Header().Set("Content-Type", "application/json")
134+
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"## 任务目标\n跑测试\n\n## 下一步\n继续\n最后模式: auto"}}]}`)
135+
return
136+
}
137+
w.Header().Set("Content-Type", "text/event-stream")
138+
w.WriteHeader(http.StatusOK)
139+
f, ok := w.(http.Flusher)
140+
if !ok {
141+
t.Error("ResponseWriter 不支持 Flush")
142+
return
143+
}
144+
f.Flush()
145+
fmt.Fprint(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"Bash","arguments":"{\"command\":\"echo hi\"}"}}]}}]}`+"\n\n")
146+
f.Flush()
147+
fmt.Fprint(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":18000,"completion_tokens":10}}`+"\n\n")
148+
f.Flush()
149+
fmt.Fprint(w, "data: [DONE]\n\n")
150+
f.Flush()
151+
}))
152+
t.Cleanup(srv.Close)
153+
return srv.URL
154+
}
155+
156+
// bloatedHistory 造「过触发线的大历史」:一个 user 任务 + 若干轮大工具输出,可压。
157+
func bloatedHistory() []ChatMessage {
84158
hist := []ChatMessage{{Role: "user", Content: "轮一"}}
85159
body := strings.Repeat("覆盖率数据 line 42 hit; ", 200)
86160
for i := 0; i < 6; i++ {

agent/llm.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,15 @@ type CompactedMsg struct {
144144
type CompactingMsg struct{}
145145

146146
// CompactFailedMsg 轮内自动压缩失败(摘要请求超时/被拒、历史压不动)。
147-
// 失败后本轮不再尝试压缩,退回「撞窗口优雅报错」保护 —— 这件事此前完全静默,
148-
// 用户只看到上下文一路涨上去却不知道中间试过一次(issue #201)。
147+
// 这件事此前完全静默,用户只看到上下文一路涨上去却不知道中间试过一次(issue #201)。
148+
//
149+
// Retrying 区分两种处置,UI 据此给不同的提示 —— 二者对用户的意味完全不同:
150+
// - true:瞬时失败(超时/网络/请求被拒),冷却 compactRetryCooldown 圈后还会再试;
151+
// - false:结构性失败,本轮永久关闭压缩,此后只剩 reclaim 与工具输出截断兜底,
152+
// 上下文会一路涨到撞满窗口、由 API 400 收场(即代码里说的「撞窗口优雅报错」)。
149153
type CompactFailedMsg struct {
150-
Reason string
154+
Reason string
155+
Retrying bool
151156
}
152157

153158
// ContextReclaimedMsg 工具输出回收(reclaim)落地后发给 UI。reclaim 是长任务(user 轮≤2、压缩
@@ -708,17 +713,18 @@ func StartStream(
708713
ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 10 分钟,期间不吐任何 token
709714
sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin)
710715
if cerr != nil {
711-
// 按失败类型分流:轮数不足是本轮结构性不可恢复(user 轮数恒定,重试注定再失败)→ 永久关,
716+
// 按失败类型分流:轮数不足是本轮结构性不可恢复(轮数恒定,重试注定再失败)→ 永久关,
712717
// 不刷屏;瞬时失败(超时/网络)→ 冷却 compactRetryCooldown 圈后重试(见状态变量注释)。
713-
if errors.Is(cerr, ErrCompactTooFewTurns) {
714-
inLoopCompactOff = true
715-
} else {
718+
retrying := !errors.Is(cerr, ErrCompactTooFewTurns)
719+
if retrying {
716720
compactCooldown = compactRetryCooldown
721+
} else {
722+
inLoopCompactOff = true
717723
}
718-
ch <- CompactFailedMsg{Reason: cerr.Error()}
724+
ch <- CompactFailedMsg{Reason: cerr.Error(), Retrying: retrying}
719725
} else {
720726
summary = sum
721-
kept := append([]ChatMessage(nil), hist[cutIdx:]...)
727+
kept := CutHistory(hist, cutIdx) // 保留段不以 user 开头时,补回用户任务原文
722728
convo = append([]ChatMessage{{Role: "system", Content: BuildSystemPrompt(workspace, skillCatalog, summary)}}, kept...)
723729
lastPromptTokens = 0 // 压完归零,等下一轮真实 usage 再判
724730
// 压缩后 prompt 的本地估算:一份两用 —— ① 防死循环判断(下面);② 随 CompactedMsg

0 commit comments

Comments
 (0)