Skip to content

Commit 4b13f64

Browse files
authored
Merge pull request #207 from itmisx/feature/tool-output-reclaim
⚡️ tool output reclaim
2 parents be00b61 + 93685b3 commit 4b13f64

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

agent/compact.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import (
2222
const keepRecentTurns = 2
2323

2424
// compactionTimeout 是摘要 LLM 调用的硬超时。没有它,卡住的请求会让压缩锁永远占住、把所有压缩堵死。
25-
// 给得宽松(容纳大摘要生成),只为兜住"永不返回",超时即失败、下轮重试。
26-
const compactionTimeout = 3 * time.Minute
25+
// 给得宽松(容纳大摘要生成 + 本地慢模型,如 4090D 上跑 qwen 摘要大历史,见 issue #201),
26+
// 只为兜住"永不返回",超时即失败、下轮重试。
27+
const compactionTimeout = 10 * time.Minute
2728

2829
// compressionPrompt 是冷路径(无前缀快照)压缩历史时发给 LLM 的 system prompt。
2930
const compressionPrompt = `你是会话「工作状态 checkpoint」生成器。把对话历史提炼成一份结构化的当前工作状态快照,用于丢弃旧历史后延续上下文。

agent/llm.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,13 @@ const (
17031703
reclaimKeepPct = 20
17041704
// reclaimMinTailToolMsgs:无论预算多小,至少保护最近这么多条工具结果不动(模型正在用)。
17051705
reclaimMinTailToolMsgs = 4
1706+
// reclaimMinMsgTokens:单条工具输出小于这个 token 数就不回收 —— 占位标记本身也占地方,
1707+
// 回收小输出既省不了多少、又可能比原文还长,还白丢信息。只对真正占地方的大输出下手。
1708+
reclaimMinMsgTokens = 200
1709+
// reclaimMinTotalPct:整趟回收总量不足窗口这个百分比,就整趟不动。
1710+
// 每次 reclaim 都会击穿"最早被回收处往后"的前缀缓存,收益太小不值当 —— 攒够一坨再一次剪。
1711+
// 用百分比而非固定阈值:固定值在小窗口过于苛刻、大窗口又偏松。
1712+
reclaimMinTotalPct = 5
17061713
// reclaimMarkerPrefix:被回收工具结果的 Content 前缀,兼作幂等判据(再次 reclaim 时跳过、不重复计数)。
17071714
reclaimMarkerPrefix = "[已回收] "
17081715
)
@@ -1711,6 +1718,7 @@ const (
17111718
// 只改 Role=="tool" 消息的 Content;user/assistant 消息、工具调用骨架、ToolCallID 配对一律不动
17121719
// (故 sanitizeToolPairs 安全、发给 API 的配对完整)。
17131720
// 保护:最近 reclaimMinTailToolMsgs 条工具结果 + 最近 reclaimKeepPct 预算内的工具输出,留全。幂等。
1721+
// 聚合下限:整趟能回收的总量不足 reclaimMinTotalPct 窗口就整趟不动 —— 避免为一点点收益击穿前缀缓存。
17141722
func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
17151723
if ctxWin <= 0 {
17161724
ctxWin = 65536
@@ -1733,7 +1741,9 @@ func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
17331741
}
17341742
}
17351743

1736-
changed := false
1744+
// 第一趟:选出"会被回收"的工具结果下标,并累加回收总量(不改动 convo)。
1745+
var victims []int
1746+
reclaimable := 0
17371747
toolSeen := 0 // 已遍历到的(未回收)工具结果条数,用于"最近 N 条"保护
17381748
keptTokens := 0 // 已保留的工具输出 token 累计,用于预算保护
17391749
for i := len(convo) - 1; i >= 0; i-- {
@@ -1744,14 +1754,28 @@ func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
17441754
continue // 已回收:不计 tail、不计预算、不重复处理(幂等)
17451755
}
17461756
toolSeen++
1757+
tokens := MsgTokens(convo[i])
17471758
if toolSeen <= reclaimMinTailToolMsgs || keptTokens < keepBudget {
1748-
keptTokens += MsgTokens(convo[i]) // 保护区:留全,并计入预算
1759+
keptTokens += tokens // 保护区:留全,并计入预算
17491760
continue
17501761
}
1762+
if tokens < reclaimMinMsgTokens {
1763+
continue // 太小:占位≈原文,回收无意义,留着
1764+
}
1765+
victims = append(victims, i)
1766+
reclaimable += tokens
1767+
}
1768+
1769+
// 聚合下限:总回收量不够就整趟不动,不为小收益击穿缓存。
1770+
if reclaimable < ctxWin*reclaimMinTotalPct/100 {
1771+
return false
1772+
}
1773+
1774+
// 第二趟:落地回收。
1775+
for _, i := range victims {
17511776
convo[i].Content = toolOutputReference(convo[i].Name, callPath[convo[i].ToolCallID])
1752-
changed = true
17531777
}
1754-
return changed
1778+
return len(victims) > 0
17551779
}
17561780

17571781
// toolOutputReference 生成被回收工具结果的引用标记:带工具名与 path(有则),

agent/reclaim_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,67 @@ func TestReclaim_UnderBudgetNoChange(t *testing.T) {
135135
}
136136
}
137137

138+
func TestReclaim_SmallOldOutputsKept(t *testing.T) {
139+
// 较新的几条大 Read 把 keptTokens 撑过预算;更旧的都是小 Bash 输出。
140+
// 大的旧 Read 应被回收,小的旧 Bash 不该(占位≈原文,回收无意义)。
141+
big := strings.Repeat("x", 8000)
142+
convo := []ChatMessage{{Role: "system", Content: "s"}, {Role: "user", Content: "t"}}
143+
for k := 0; k < 6; k++ { // 更旧:小 Bash
144+
id := fmt.Sprintf("old%d", k)
145+
convo = append(convo, asstCall(id, "Bash", `{"command":"echo"}`))
146+
convo = append(convo, toolMsg(id, "Bash", "小结果"))
147+
}
148+
for k := 0; k < 5; k++ { // 较新:大 Read
149+
id := fmt.Sprintf("new%d", k)
150+
convo = append(convo, asstCall(id, "Read", fmt.Sprintf(`{"path":"f%d.go"}`, k)))
151+
convo = append(convo, toolMsg(id, "Read", big))
152+
}
153+
reclaimToolOutputs(convo, 2048) // keepBudget≈409;5 条大 Read 远超,更旧的进入回收判定
154+
155+
reclaimedRead := false
156+
for _, m := range convo {
157+
if m.Role != "tool" || !strings.HasPrefix(m.Content, reclaimMarkerPrefix) {
158+
continue
159+
}
160+
if m.Name == "Bash" {
161+
t.Fatal("小的旧 Bash 输出不应被回收(低于 reclaimMinMsgTokens)")
162+
}
163+
if m.Name == "Read" {
164+
reclaimedRead = true
165+
}
166+
}
167+
if !reclaimedRead {
168+
t.Fatal("大的旧 Read 输出应被回收(sanity)")
169+
}
170+
}
171+
172+
func TestReclaim_BelowTotalFloorNoChange(t *testing.T) {
173+
// 总回收量低于聚合下限(reclaimMinTotalPct 窗口)时,整趟不动 —— 护缓存。
174+
ctxWin := 100000 // minTotal = 5000, keepBudget = 20000
175+
huge := strings.Repeat("x", 40000) // ~10k token,4 条就撑爆 keepBudget
176+
medium := strings.Repeat("y", 1200) // > reclaimMinMsgTokens,但两条合计仍 < minTotal
177+
convo := []ChatMessage{{Role: "system", Content: "s"}, {Role: "user", Content: "t"}}
178+
for k := 0; k < 2; k++ { // 更旧:2 条 medium(会成为候选,但总量小)
179+
id := fmt.Sprintf("old%d", k)
180+
convo = append(convo, asstCall(id, "Read", `{"path":"o.go"}`))
181+
convo = append(convo, toolMsg(id, "Read", medium))
182+
}
183+
for k := 0; k < reclaimMinTailToolMsgs; k++ { // 较新:huge 占满 tail + 撑爆预算
184+
id := fmt.Sprintf("new%d", k)
185+
convo = append(convo, asstCall(id, "Read", `{"path":"n.go"}`))
186+
convo = append(convo, toolMsg(id, "Read", huge))
187+
}
188+
189+
if reclaimToolOutputs(convo, ctxWin) {
190+
t.Fatal("总回收量低于聚合下限时不应回收(护缓存)")
191+
}
192+
for _, m := range convo {
193+
if m.Role == "tool" && strings.HasPrefix(m.Content, reclaimMarkerPrefix) {
194+
t.Fatal("聚合下限未过,任何工具输出都不应被回收")
195+
}
196+
}
197+
}
198+
138199
func TestReclaim_PairingPreserved(t *testing.T) {
139200
// 回收后每条 tool 消息的 ToolCallID / Name 不变,配对完整。
140201
convo := buildConvo(10, "Read", strings.Repeat("w", 6000))

0 commit comments

Comments
 (0)