mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
fix(context): 让段页式统计跟随 InactivePages
## 生产故障
2026-11-02 20:58:24:
air-interrupt prune: 12→5 events (44185→39714 tokens), ratio=1.21
context overflow: recover budget exhausted (2/2)
段页式淘汰已经把工具页标成 InactivePages,渲染层也确实跳过了这些页,
但 overflow.go 的 eventTokens 仍把事件里的全部 ToolResults 算进去。
结果是「渲染视图变小、统计视图不变」:页剔除后水位不降,L4 反复触发,
最终耗尽恢复预算并终止本轮。
## 修复
让 eventTokens 使用 process.go 同一套 toolResultPageIndex 配对规则:
- InactivePages 对应的工具页不再占预算
- llm_round(第 1 页)始终保留
- 配不上的工具结果仍按原策略保守保留
因此 accumulatedTokens、overflowRatioNow、evictPagesLocked 三条调用链
都自动使用与实际渲染一致的 token 口径。
## 判据
- TestEventTokens_ExcludesInactivePages
- TestEventTokens_LLMRoundNeverExcluded
- TestOverflowRatio_DropsAfterPageEviction
变异验证:去掉 InactivePages 判断 ⇒ 前两类核心判据变红。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
This commit is contained in:
@ -69,9 +69,36 @@ func (a *Agent) accumulatedTokens() int {
|
||||
//
|
||||
// resident.go 的 checkContextFull 有同样缺陷,但它只对驻留子生效、一直没暴露;
|
||||
// 现在两处共用本函数,口径不会再漂移。
|
||||
//
|
||||
// ## 必须排除 InactivePages(2026-11-02 实测修复)
|
||||
//
|
||||
// 段页式淘汰(P5)把某些**工具页**标成 InactivePages,渲染时整页不出现
|
||||
// (process.go:597 的 buildTimeline)。但本函数此前仍按全量 ToolResults 统计 ——
|
||||
// 于是「渲染的少了、统计的没少」:页剔了,水位却不动,L4 空中断反复触发,
|
||||
// 最终把 overflowRecoverBudget 耗尽并报「上下文超限」。
|
||||
//
|
||||
// 生产实例实证(2026-11-02 19:10:45 / 20:58:24):
|
||||
//
|
||||
// air-interrupt prune: 12→5 events (44185→39714 tokens), ratio=1.21
|
||||
//
|
||||
// 事件少了 7 条、页也剔了,token 只降 10% —— 因为被剔页的工具结果仍在算。
|
||||
// 随后下一轮 ratio 又回到 1.33,预算 2/2 用尽。
|
||||
//
|
||||
// ★ 判据口径:**统计视图必须等于渲染视图**。渲染不出的内容不该占预算,
|
||||
// 否则裁剪永远「裁不动」,这不是保守,是自相矛盾。
|
||||
func eventTokens(e ContextEvent) int {
|
||||
n := EstimateTokens(e.Input) + EstimateTokens(e.Response)
|
||||
for _, tr := range e.ToolResults {
|
||||
if len(e.ToolResults) == 0 {
|
||||
return n
|
||||
}
|
||||
// 与 process.go 的 timelineToolLines 用**同一个**页索引函数:
|
||||
// 配对规则(CallID 优先、退化到名字、配不上记 0 永不剔除)只能有一份。
|
||||
pageOf := toolResultPageIndex(&e)
|
||||
for i, tr := range e.ToolResults {
|
||||
// 已剔除的页不渲染 ⇒ 不计入预算(渲染视图 = 统计视图)。
|
||||
if i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) {
|
||||
continue
|
||||
}
|
||||
n += EstimateTokens(tr.Output)
|
||||
}
|
||||
return n
|
||||
|
||||
@ -809,3 +809,118 @@ func TestAirInterrupt_RejectsOrdinaryEmptyInput(t *testing.T) {
|
||||
t.Error("带正文的中断不应被认作空中断")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 段页式:统计视图必须等于渲染视图(2026-11-02 生产事故) ──
|
||||
|
||||
// TestEventTokens_ExcludesInactivePages 钉住「被剔除的页不占预算」。
|
||||
//
|
||||
// ## 生产现场(2026-11-02 20:58:24)
|
||||
//
|
||||
// air-interrupt prune: 12→5 events (44185→39714 tokens), ratio=1.21
|
||||
//
|
||||
// 事件从 12 条裁到 5 条、页也剔了,token 只降 10% —— 因为被剔除页的工具结果
|
||||
// 仍然被 eventTokens 计入。于是水位降不下来,L4 反复触发,最终:
|
||||
//
|
||||
// context overflow: recover budget exhausted (2/2), terminating this frame
|
||||
// 上下文超限:已裁剪 2 次仍超出窗口,本轮终止
|
||||
//
|
||||
// ## 为什么这是缺陷而不是保守
|
||||
//
|
||||
// buildTimeline(process.go:597)明确跳过 InactivePages 里的页 —— 模型根本
|
||||
// 看不到那些内容。看不到却要占预算,就是「统计视图 ≠ 渲染视图」,
|
||||
// 结果是裁剪永远裁不动:越裁越以为没裁。
|
||||
//
|
||||
// ## 变异
|
||||
//
|
||||
// 去掉 eventTokens 里的 containsInt 判断(恢复全量统计)⇒ 本判据红。
|
||||
func TestEventTokens_ExcludesInactivePages(t *testing.T) {
|
||||
big := strings.Repeat("x", 4000) // ≈ 1000+ token
|
||||
|
||||
ev := ContextEvent{
|
||||
Input: "用户提问",
|
||||
Response: "模型回答",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: "c1", Name: "tool_one"},
|
||||
{CallID: "c2", Name: "tool_two"},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: "c1", Name: "tool_one", Output: big},
|
||||
{CallID: "c2", Name: "tool_two", Output: big},
|
||||
},
|
||||
}
|
||||
|
||||
full := eventTokens(ev)
|
||||
if full <= 0 {
|
||||
t.Fatal("全量统计不应为 0")
|
||||
}
|
||||
|
||||
// 剔除第 2 页(= ToolCalls[0],即 tool_one)
|
||||
ev.InactivePages = []int{2}
|
||||
trimmed := eventTokens(ev)
|
||||
|
||||
if trimmed >= full {
|
||||
t.Fatalf("剔除一页后统计未下降:full=%d trimmed=%d —— "+
|
||||
"被剔除的页不渲染却仍占预算,裁剪会永远裁不动", full, trimmed)
|
||||
}
|
||||
// 剔除的必须**正好**是那一页的量,不能多不能少。
|
||||
onePage := EstimateTokens(big)
|
||||
if diff := full - trimmed; diff < onePage/2 || diff > onePage*2 {
|
||||
t.Fatalf("剔除量 %d 与单页约 %d token 不符 —— 剔错了页", diff, onePage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventTokens_LLMRoundNeverExcluded 钉住第 1 页受保护。
|
||||
//
|
||||
// 第 1 页是「用户说了什么 + 模型答了什么」。剔除它这一帧就只剩工具流水,
|
||||
// 模型无法理解上下文在讲什么 —— 所以 evictPagesLocked 从 p=2 起剔。
|
||||
// 本判据保证 eventTokens 不会因为误算把 Input/Response 也算成可剔除。
|
||||
func TestEventTokens_LLMRoundNeverExcluded(t *testing.T) {
|
||||
ev := ContextEvent{
|
||||
Input: "用户说的关键内容",
|
||||
Response: "模型的回答",
|
||||
// 即便页标记里有 1(不该发生),Input/Response 也必须被计入。
|
||||
InactivePages: []int{1},
|
||||
}
|
||||
if n := eventTokens(ev); n <= 0 {
|
||||
t.Fatal("llm_round(第 1 页)被整体剔除了 —— 模型将无法理解上下文在讲什么")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverflowRatio_DropsAfterPageEviction 是端到端的那一半:
|
||||
// 页剔除后 overflowRatioNow 必须真的下降。
|
||||
//
|
||||
// 只测 eventTokens 不够 —— 真正驱动报错的是 accumulatedTokens → overflowRatioNow
|
||||
// 这条链。判据覆盖到调用方才能保证「剔了页水位真的动」。
|
||||
func TestOverflowRatio_DropsAfterPageEviction(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 1, 10, 100)
|
||||
big := strings.Repeat("y", 6000)
|
||||
for i := 0; i < 6; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Input: fmt.Sprintf("问 %d", i),
|
||||
Response: "答",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: fmt.Sprintf("c%d", i), Name: "big_tool"},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: fmt.Sprintf("c%d", i), Name: "big_tool", Output: big},
|
||||
},
|
||||
})
|
||||
}
|
||||
before := a.overflowRatioNow()
|
||||
if before <= 0 {
|
||||
t.Fatal("构造后应有非零水位")
|
||||
}
|
||||
|
||||
// 对每条事件剔除其工具页(第 2 页)
|
||||
a.context.mu.Lock()
|
||||
for _, e := range a.context.events {
|
||||
e.InactivePages = []int{2}
|
||||
}
|
||||
a.context.mu.Unlock()
|
||||
|
||||
after := a.overflowRatioNow()
|
||||
if after >= before {
|
||||
t.Fatalf("剔除全部工具页后水位没有下降:before=%.2f after=%.2f —— "+
|
||||
"统计没有跟随渲染视图,超限会无限重试直到预算耗尽", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user