mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 问题
同一件事(上下文装不下)有三套实现、三份防护差异:
task.go 发 LLM 前 maybeHandleContextOverflow(有恢复预算)
task.go 上游报错 handleUpstreamContextFull(**直调内部,绕过预算**)
resident.go 轮首 checkContextFull(只对驻留子生效)
其中 handleUpstreamContextFull 是"另起炉灶":它自己又调一遍
handleContextOverflow,绕过 f.OverflowRecover 预算——而文档 §四.1 明确
「L4 遇 L4 不能抢占,反复触发只会排队堆积」。
## 改法
裁剪成为**基础设施**,contextfull 只是「判定已被外部短路」的一种入参:
enforceContextBudget(f, query, force)
force=false 本地水位:积累量 > ratio × 窗口
force=true 外部已确认超页(provider 返回 ErrContextFull)
之后的一切(恢复预算、裁剪、raise L4)完全相同。handleUpstreamContextFull
删除,只剩 isUpstreamContextFull 这个纯判定。
## 任务恢复入口补裁剪
中断任务跑完会把它的输入/输出提交进 a.context,恢复时重建出的前缀可能
**比被挂起时更大**。不在这里裁,这份更大的前缀会被原样发出去。
恢复用的是专用入口 enforceResumeContextBudget,两处语义与通用入口不同:
1. **query 取本帧最新有效上下文**(resumeQueryFrom 从 f.Msgs 尾部往回找),
不是 f.Input。被挂起的任务可能隔了很久,f.Input 早已与当前对话脱节;
拿旧 Input 算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"的事件。
2. **保护范围是本帧自己的现场**(len(f.Msgs)-f.PrefixLen),不是全局末尾
N 条。被挂起任务的现场可能是一整批工具结果,若落在保护范围外被归档,
恢复后的任务引用的内容已不在上下文里,任务自相矛盾。
为此新增 pruneForResume + contextTopKForProtected——不能复用 contextTopK,
它内部会把 topK 抬到 effectiveProtectedCount()+1(全局保护),两套下限
打架会让恢复场景「裁不动」(实测 protected=8、20 条上下文时 topK 被抬到 20)。
## 不变的部分
声明式裁剪(业务策略)语义原样保留:
· 中断/消息注入 —— pruneOnInput,判据 payload.context_policy / ChannelDef
· 工具内容注入 —— stepToolAfter,判据 ToolDef.ContextPolicy
两者走 memoryPass → pruneByQuery,与容量保护共用底层,但职责正交。
判据:任务恢复入口必须裁、恢复查询取本帧最新、保护本帧现场、
恢复预算对所有入口生效。变异验证:恢复不裁/保护退回全局/上游绕过预算,
三种坏法均被抓。
Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
17 KiB
Go
383 lines
17 KiB
Go
package core
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
)
|
||
|
||
// 上下文超页(context overflow)处理。
|
||
//
|
||
// 背景(2026-10-01 记忆召回跑分 v4 实测):HA 的 prompt 是锯齿波,峰值达
|
||
// 窗口的 7 倍,每轮都在「远超窗口 → 紧急修剪」。根因之一是超页的三条触发
|
||
// 路径没有统一出口:
|
||
//
|
||
// ① 上游 context_full 错误 —— 完全没接,整轮 outcomeFailed(用户在报错里看到它)
|
||
// ② 本地积累超限 —— 只在轮首 checkContextFull,且根 agent no-op、一次性
|
||
// ③ 子 agent contextfull —— 已在用 L4 上报,但只推信号不携带动作
|
||
//
|
||
// 本文件把三条路径收敛到 L4 唯一入口(raiseKernelInterrupt),并把「裁剪」
|
||
// 做成同步动作 —— 详见 docs/zh/context-overflow-l4-design.md。
|
||
|
||
// overflowRatio 是触发超页的本地预判水位:积累上下文超过窗口的这个比例就裁剪。
|
||
//
|
||
// 为什么是 1.25 而不是 1.0:请求体本身被 buildMessages 按 targetUsage=0.8×窗口
|
||
// 裁过(见 buildMessages),所以「请求装不下」在本地永不会发生;能观测到的
|
||
// 超限信号是**积累上下文**(a.context 里全部事件)。1.25 留了一点余量,避免
|
||
// 在水位线附近反复触发。
|
||
const overflowRatio = 1.25
|
||
|
||
// overflowRecoverBudget 是「同一 TaskFrame 内超页恢复次数」的上限。
|
||
//
|
||
// 为什么需要上限:裁剪的目标是让积累量回到窗口以内,一次通常就够;但若
|
||
// topK 与窗口脱钩(max_context_size 是**条数**不是 token,见 pruneByQuery),
|
||
// 一次裁剪可能仍不达标。没有上限就会变成「裁剪 → 复原 → 又超页 → 再裁剪」的
|
||
// 循环,每次都付一轮任务调度开销。
|
||
//
|
||
// 超限时**不做静默重试**:保存现场并明确终止,把问题暴露给用户与状态面。
|
||
//
|
||
// 声明为 var 而非 const:测试要能变异它做判据自证(变异后超预算用例必须变红),
|
||
// 将来若要按窗口大小动态给额度(窗口越大越值得多试几次),也不用改结构。
|
||
var overflowRecoverBudget = 2
|
||
|
||
// accumulatedTokens 估算**未裁剪**的积累上下文 token 数。
|
||
//
|
||
// ⚠️ 判据必须用积累量,不能用 f.Msgs:后者已被 buildMessages 按
|
||
// targetUsage = 0.8×窗口 裁过,结构上封顶 80%,对任何 >0.8 的阈值都**永远
|
||
// 不成立**(这个坑已由 resident.go:checkContextFull 的注释记录过一次,
|
||
// 这里抽成函数是为了让两处共用同一口径,避免再次漂移)。
|
||
func (a *Agent) accumulatedTokens() int {
|
||
if a == nil || a.context == nil {
|
||
return 0
|
||
}
|
||
acc := 0
|
||
for _, e := range a.context.Recent(0) {
|
||
acc += eventTokens(e)
|
||
}
|
||
return acc
|
||
}
|
||
|
||
// eventTokens 估算单条上下文事件的 token 占用。
|
||
//
|
||
// ★ ToolResults 必须计入 —— 这是实测踩过的坑:v4 与 T10 的 prompt 峰值
|
||
// (17 万~40 万 token)几乎全部来自工具回灌,而 ContextEvent 里工具输出是
|
||
// **独立字段**(ToolResults []ToolResultItem),不算它的话 accumulatedTokens
|
||
// 会返回 0,超页判据永远不触发(诊断日志 ratio=0.00/ events=1 就是这么来的)。
|
||
//
|
||
// resident.go 的 checkContextFull 有同样缺陷,但它只对驻留子生效、一直没暴露;
|
||
// 现在两处共用本函数,口径不会再漂移。
|
||
func eventTokens(e ContextEvent) int {
|
||
n := EstimateTokens(e.Input) + EstimateTokens(e.Response)
|
||
for _, tr := range e.ToolResults {
|
||
n += EstimateTokens(tr.Output)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// contextWindowTokens 取本 agent 的窗口上限(token)。
|
||
func (a *Agent) contextWindowTokens() int {
|
||
if a != nil && a.provider != nil {
|
||
if max := a.provider.MaxContextTokens(); max > 0 {
|
||
return max
|
||
}
|
||
}
|
||
return defaultMaxContextTokens
|
||
}
|
||
|
||
// overflowRatioNow 返回「积累量 / 窗口」。窗口拿不到时返回 0(表示不判定)。
|
||
func (a *Agent) overflowRatioNow() float64 {
|
||
max := a.contextWindowTokens()
|
||
if max <= 0 {
|
||
return 0
|
||
}
|
||
return float64(a.accumulatedTokens()) / float64(max)
|
||
}
|
||
|
||
// enforceContextBudget 是**上下文容量保护**的唯一实现(基础设施)。
|
||
//
|
||
// ## 为什么它是基础设施,而不是某个组件的功能
|
||
//
|
||
// 上下文会在**每个注入点**增长:新消息、中断注入、任务恢复(中断任务的结果
|
||
// 已提交进 a.context,重建出的前缀更大)、以及轮内工具回灌。因此"装不下就裁"
|
||
// 必须在这些入口都成立,而不是只在某一条路径上碰巧被调用。
|
||
//
|
||
// <20><>史(2026-10-25 前的三个半成品入口,现已全部收敛到这里):
|
||
//
|
||
// task.go 发 LLM 前 maybeHandleContextOverflow(有恢复预算)
|
||
// task.go 上游报错 handleUpstreamContextFull(**直调内部,绕过预算**)
|
||
// resident.go 轮首 checkContextFull(只对驻留子生效,根 agent no-op)
|
||
//
|
||
// 三个入口、三套判据、三份防护差异 —— 这就是"额外造的一套机制"。
|
||
//
|
||
// ## force 的语义
|
||
//
|
||
// force 不是"另一条路径",只是**判定已被外部短路**:
|
||
//
|
||
// force=false 本地水位判定:积累量 > ratio × 窗口
|
||
// force=true 外部已确认超页(如 provider 返回 ErrContextFull)
|
||
//
|
||
// 之后的一切(恢复预算、裁剪、raise L4)完全相同 —— 这正是"复用语义"的落点。
|
||
//
|
||
// 返回 handled 表示本帧需要重跑(f.Msgs 已过期,必须重建);
|
||
// 若 f.Terminal == terminalError 则表示恢复预算耗尽,调用方应直接终止。
|
||
//
|
||
// 幂等:未超页时零开销(一次 token 估算 + 一次比较),可安全地在热路径调用。
|
||
func (a *Agent) enforceContextBudget(f *TaskFrame, query string, force bool) (handled bool, pruned int) {
|
||
light := a != nil && a.isLightKernel()
|
||
noCtx := a == nil || a.context == nil
|
||
var ratio float64
|
||
if !noCtx {
|
||
ratio = a.overflowRatioNow()
|
||
}
|
||
if a != nil {
|
||
a.overflowStat.Lock()
|
||
checked := a.overflowStat.Checked
|
||
a.overflowStat.Checked++
|
||
a.overflowStat.Unlock()
|
||
// 诊断留痕:这条判据在每个注入点都会走,而生产上很少触发。
|
||
// 没有这行日志时,「没触发」和「没执行」无法区分(2026-10-01 T10
|
||
// 排查花了 10 轮工具调用才卡在这里)。只记比值与判定,不记内容。
|
||
if checked%20 == 0 {
|
||
log.Printf("[agent] overflow check #%d: ratio=%.2f window=%d events=%d "+
|
||
"(threshold=%.2f lightKernel=%v noContext=%v force=%v)",
|
||
checked+1, ratio, a.contextWindowTokens(), a.contextLenSafe(),
|
||
overflowRatio, light, noCtx, force)
|
||
}
|
||
}
|
||
if noCtx || light {
|
||
// 轻量内核(驻留子)不做按相关度裁剪,见 pruneByQuery 的同款理由。
|
||
return false, 0
|
||
}
|
||
// 唯一判据点:本地水位 OR 外部明确报错。
|
||
if !force && ratio < overflowRatio {
|
||
return false, 0
|
||
}
|
||
if force {
|
||
// 上游报错是一次**独立的上报**(与本地水位分开计数),
|
||
// 计数放在这里而不是调用方:它是判据的一部分,不是调用方的副作用。
|
||
a.overflowStat.Lock()
|
||
a.overflowStat.Upstream++
|
||
a.overflowStat.Unlock()
|
||
}
|
||
// 恢复预算:同一帧内裁完仍超 ⇒ 说明 topK 与窗口脱钩(见 pruneByQuery),
|
||
// 再裁一次也是同样结果。超预算后**明确终止**,不静默重试。
|
||
// 这条防护对所有入口一致生效 —— 包括上游报错那条(此前它绕过了这里)。
|
||
if f != nil {
|
||
if f.OverflowRecover >= overflowRecoverBudget {
|
||
log.Printf("[agent] context overflow: recover budget exhausted (%d/%d), "+
|
||
"terminating this frame instead of looping",
|
||
f.OverflowRecover, overflowRecoverBudget)
|
||
a.markOverflowAborted(ratio, a.context.Len())
|
||
f.Err = fmt.Errorf("上下文超限:已裁剪 %d 次仍超出窗口,"+
|
||
"本轮终止(请检查 core.agent.max_context_size 与窗口是否匹配)",
|
||
f.OverflowRecover)
|
||
f.Terminal = terminalError
|
||
return true, 0 // 终止也是「已处理」:调用方不要再重跑
|
||
}
|
||
f.OverflowRecover++
|
||
}
|
||
return true, a.handleContextOverflow(query, ratio)
|
||
}
|
||
|
||
// maybeHandleContextOverflow 保留为发 LLM 请求前的薄封装(历史调用点)。
|
||
//
|
||
// 它现在只是 enforceContextBudget 的一个调用点,不再持有独立判据。
|
||
func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled bool, pruned int) {
|
||
return a.enforceContextBudget(f, query, false)
|
||
}
|
||
|
||
// enforceResumeContextBudget 是**任务恢复**入口的容量保护。
|
||
//
|
||
// 与通用 enforceContextBudget 的区别只有两点,但都很关键:
|
||
//
|
||
// 1. **query 取任务帧的最新有效上下文**,不是 f.Input。
|
||
// 被恢复的任务可能已挂起很久,它的 f.Input 与当前对话早已不同步;
|
||
// 拿旧 Input 去算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"
|
||
// 的事件——恢复出来的任务反而失去了它自己的现场。
|
||
// 这里的做法是取该帧**尾部最后一条有内容的消息**(f.Msgs 的末条),
|
||
// 那才是它此刻真正要继续做的东西。
|
||
//
|
||
// 2. **保护范围是该任务帧自己的尾部**,不是全局上下文的末尾 N 条。
|
||
// PruneWithProtected 默认保护的 c.events 末 N 条是"最近的对话",
|
||
// 而被挂起的任务在现场里可能攒了整批工具结果——那些必须一起保住,
|
||
// 否则恢复后 f.Msgs[PrefixLen:] 引用的内容已被归档,任务自相矛盾。
|
||
//
|
||
// 判据仍是容量(不看声明):声明式裁剪归 pruneOnInput / stepToolAfter,
|
||
// 那是业务策略;这里是"装不下就得裁"的基础设施。
|
||
func (a *Agent) enforceResumeContextBudget(f *TaskFrame) (handled bool, pruned int) {
|
||
if f == nil {
|
||
return false, 0
|
||
}
|
||
// 水位判定(与通用入口同一套判据,只是 query/保护范围不同)。
|
||
if !a.overBudgetLocked() {
|
||
return false, 0
|
||
}
|
||
// 恢复预算:同一帧不能无限裁。
|
||
if f.OverflowRecover >= overflowRecoverBudget {
|
||
log.Printf("[agent] resume overflow: recover budget exhausted (%d/%d), terminating frame",
|
||
f.OverflowRecover, overflowRecoverBudget)
|
||
a.markOverflowAborted(a.overflowRatioNow(), a.context.Len())
|
||
f.Err = fmt.Errorf("上下文超限:恢复时已裁剪 %d 次仍超出窗口,本轮终止", f.OverflowRecover)
|
||
f.Terminal = terminalError
|
||
return true, 0
|
||
}
|
||
f.OverflowRecover++
|
||
|
||
query := resumeQueryFrom(f)
|
||
// 保护该任务帧自己的现场条数(尾部消息 = 本任务攒下的工具轮产物)。
|
||
protected := len(f.Msgs) - f.PrefixLen
|
||
if protected < 0 {
|
||
protected = 0
|
||
}
|
||
before := a.context.Len()
|
||
a.pruneForResume(query, protected)
|
||
after := a.context.Len()
|
||
if after >= before {
|
||
log.Printf("[agent] resume overflow: no reduction (%d→%d events), not raising L4", before, after)
|
||
return false, 0
|
||
}
|
||
pruned = before - after
|
||
log.Printf("[agent] resume overflow: pruned=%d (%d→%d events), protected=%d frame tail msgs",
|
||
pruned, before, after, protected)
|
||
a.raiseContextOverflow(pruned)
|
||
return true, pruned
|
||
}
|
||
|
||
// overBudgetLocked 是纯判据(无副作用),供各入口复用。
|
||
//
|
||
// 命名带 Locked 是历史遗留,实际不加锁;调用方在调度器单线程上下文里使用。
|
||
func (a *Agent) overBudgetLocked() bool {
|
||
if a == nil || a.context == nil || a.isLightKernel() {
|
||
return false
|
||
}
|
||
return a.overflowRatioNow() >= overflowRatio
|
||
}
|
||
|
||
// resumeQueryFrom 取任务帧的**最新有效上下文**作为相关性查询。
|
||
//
|
||
// 从尾部往回找第一条有实质内容的消息:恢复时 f.Msgs 的最后一条
|
||
// 可能是 tool 结果、continuation 占位或空的 assistant 消息,
|
||
// 直接拿它算相关性会退化成无意义查询。
|
||
func resumeQueryFrom(f *TaskFrame) string {
|
||
for i := len(f.Msgs) - 1; i >= 0; i-- {
|
||
if c := strings.TrimSpace(f.Msgs[i].Content); c != "" {
|
||
return c
|
||
}
|
||
}
|
||
return f.Input
|
||
}
|
||
|
||
// handleContextOverflow 执行「裁剪 + L4 打断」。
|
||
//
|
||
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
|
||
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore),
|
||
// 把它放进 L4 任务里做只会多付一次完整任务调度开销;
|
||
// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。
|
||
//
|
||
// 出错处理(用户口径:保存退出,不重试):裁剪 0 条说明「本来装得下」
|
||
// 却报超页 —— 这是判定逻辑的 bug,不是可恢复状态。此时保存现场并明确终止,
|
||
// 绝不再次触发中断。
|
||
func (a *Agent) handleContextOverflow(query string, ratio float64) int {
|
||
// 单条事件本身就超预算 ⇒ 裁剪在任何保护数下都无解(实测 T10c:
|
||
// 事件 40026 token vs 预算 10667)。此时直接终止并报真实原因,
|
||
// 而不是裁剪两轮后 Budget 耗尽 —— 后者会丢失已经裁掉的记忆。
|
||
if !a.singleEventFitsBudget() {
|
||
avg := a.accumulatedTokens() / a.context.Len()
|
||
log.Printf("[agent] context overflow: single event (%d tok) exceeds budget (%d tok) — "+
|
||
"pruning cannot help, terminating", avg, a.computeTokenBudget().ContextTokens)
|
||
a.markOverflowAborted(ratio, a.context.Len())
|
||
return 0
|
||
}
|
||
|
||
before := a.context.Len()
|
||
beforeTokens := a.accumulatedTokens()
|
||
// pruned 是**写进 docStore 的条数**(Prune 内部计数),不是「少了几条」。
|
||
// 判据必须看上下文是否真的变小了 —— 因为 docStore == nil 时(轻量内核、
|
||
// 文档记忆未初始化)pruned 恒为 0,但裁剪其实照常发生了。用 pruned 判会
|
||
// 把正常裁剪误判成 bug 而终止。
|
||
pruned := a.pruneByQuery(query)
|
||
after := a.context.Len()
|
||
afterTokens := a.accumulatedTokens()
|
||
|
||
if after >= before || afterTokens >= beforeTokens {
|
||
// 超页了却裁不动 ⇒ 判定逻辑有问题(或 topK ≥ 实际条数)。
|
||
// 按用户口径:保存现场 + 明确终止,**不重试不触发中断**。
|
||
// 为什么不再试一次:L4 遇 L4 不能抢占(canPreempt 用严格大于),
|
||
// 第二个 L4 只会排队等,永远等不到能执行的时机。
|
||
log.Printf("[agent] context overflow aborted: ratio=%.2f no reduction "+
|
||
"(%d→%d events, %d→%d tokens) — 判定逻辑可能有 bug,保存现场并终止本轮,不重试",
|
||
ratio, before, after, beforeTokens, afterTokens)
|
||
a.markOverflowAborted(ratio, before)
|
||
return 0
|
||
}
|
||
pruned = before - after
|
||
log.Printf("[agent] context overflow: ratio=%.2f pruned=%d (%d→%d events), raising L4",
|
||
ratio, pruned, before, after)
|
||
a.raiseContextOverflow(pruned)
|
||
return pruned
|
||
}
|
||
|
||
// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
|
||
func (a *Agent) markOverflowAborted(ratio float64, events int) {
|
||
a.overflowStat.Lock()
|
||
a.overflowStat.Aborted++
|
||
a.overflowStat.LastRatio = ratio
|
||
a.overflowStat.LastEvents = events
|
||
a.overflowStat.Unlock()
|
||
}
|
||
|
||
// raiseContextOverflow 通过 **L4 唯一入口**上报上下文超页。
|
||
//
|
||
// 与 panic / selfip 并列,是 L4 的第三个来源。
|
||
//
|
||
// 消息内容不是通知而是**必要的认知输入**:L4 任务按调度器设计拿不到被打断
|
||
// 者的上下文(suspend 的 D1=B 注释明确「不把被打断任务的任何内容交给它」),
|
||
// 所以「已裁剪了什么、查不到不等于不存在」必须显式写进去。
|
||
//
|
||
// 这条提示直接对应 v4 实测的一类失败:HA 对查不到的事实给出
|
||
// 「库里根本没有 X,任何数字都是编的」——它不知道有内容被移走,
|
||
// 于是把「没检索到」推断成「不存在」。
|
||
func (a *Agent) raiseContextOverflow(pruned int) {
|
||
a.overflowStat.Lock()
|
||
a.overflowStat.Triggered++
|
||
a.overflowStat.LastPruned = pruned
|
||
a.overflowStat.Unlock()
|
||
|
||
a.raiseKernelInterrupt("kernel/overflow", "kernel", overflowNotice(pruned))
|
||
}
|
||
|
||
// overflowNotice 构造超页中断消息。
|
||
//
|
||
// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入
|
||
// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||
// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答
|
||
// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。
|
||
func overflowNotice(pruned int) string {
|
||
return fmt.Sprintf("[内核] 上下文超页:已裁剪 %d 条低相关事件到文档记忆。"+
|
||
"被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned)
|
||
}
|
||
|
||
// isUpstreamContextFull 判定上游错误是否表示「上下文装不下」。
|
||
//
|
||
// 它只做**判定**,不做处理:处理一律交给 enforceContextBudget(force=true)。
|
||
// 此前这里叫 handleUpstreamContextFull,自己又调了一遍 handleContextOverflow ——
|
||
// 那等于在中断语义之外另造一套裁剪,绕过恢复预算,且与 raise L4 抢占的
|
||
// 任务互相抢跑(2026-10-25 实测)。
|
||
func isUpstreamContextFull(llmErr error) bool {
|
||
var pe *agentAPI.ProviderError
|
||
return errors.As(llmErr, &pe) && pe.Kind == agentAPI.ErrContextFull
|
||
}
|
||
|
||
// contextLenSafe 是 nil 安全的上下文条数(诊断日志用)。
|
||
func (a *Agent) contextLenSafe() int {
|
||
if a == nil || a.context == nil {
|
||
return 0
|
||
}
|
||
return a.context.Len()
|
||
}
|
||
|
||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|