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:
JianFeeeee
2026-11-02 21:54:35 +08:00
parent 9fcde9862e
commit 26dcf63fd5
2 changed files with 143 additions and 1 deletions

View File

@ -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

View File

@ -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)
}
}