Note
长上下文治理:Tool Result 落盘与历史摘要
How PseudoClaude spills large tool results to disk and summarizes older history near the context limit while preserving recent tool-call closure.
先给结论:PseudoClaude 的长上下文治理不是一上来就“总结所有东西”。它先做便宜、确定的事:把特别大的 Tool Result 搬到磁盘;如果这样还不够,再做昂贵、有损的事:让模型把旧历史总结成摘要。
可以把它想成清理书桌:
桌上太挤
-> 先把厚厚的工具输出放进文件柜,只在桌上留标签和前几行
-> 再看桌面是不是还挤
-> 如果还挤,把旧聊天整理成摘要
-> 最近几条原话继续留在桌上,方便接着干活
对应到代码里的完整顺序是:
Runner 每轮请求模型前
-> ManageContext
-> Layer 1: OffloadToolResults
-> Replace Conversation 为“预览快照”
-> 重新估算 Token 水位
-> Layer 2: compactConversation
-> Replace Conversation 为“摘要 + 近期原文”
-> 请求主模型
长会话的上下文增长有两种形态:一次读取大文件或运行测试可能瞬间产生巨型 Tool Result;普通对话、代码分析和多轮工具调用则会缓慢累积。两层策略正好分别处理这两类问题:Layer 1 处理“突然冒出来的大块工具结果”,Layer 2 处理“整段历史越来越长”。
两层策略使用不同预算
核心常量直接表达当前实现的取舍:
const (
// Layer 1 用字节阈值处理本地 ToolResult,大结果先落盘,不需要额外调用模型。
SingleToolResultLimitBytes = 50000
ToolRoundAggregateLimitBytes = 200000
// Layer 2 用 Token 预算保护模型窗口,自动摘要会提前留下输出空间和安全余量。
SummaryReserveTokens = 20000
AutoSafetyMarginTokens = 13000
ManualSafetyMarginTokens = 3000
// 摘要后仍保留一段近期原文,避免刚发生的细节只剩有损摘要。
RecentKeepTokens = 10000
RecentKeepMessages = 5
AutoFailureLimit = 3
EstimateCharsPerToken = 3.5
// 落盘预览只保留头部线索,全文通过预览里的路径按需重新读取。
PreviewHeadBytes = 2048
PreviewHeadLines = 20
SummaryRetryLimit = 3
)
Layer 1 的阈值按字节计算,因为它处理的是本地字符串和文件;Layer 2 按 Token 预算触发,因为它保护的是 Provider Context Window。二者不能直接用同一数值替代。
Runner 在每次模型请求前治理上下文
Compact 不是任务结束后的清理,而是 ReAct 每轮请求前的前置步骤:
if r.Compact != nil {
r.dispatchHook(ctx, hook.EventPreCompact, permissionMode, hook.Payload{"trigger": "auto"})
// ManageContext 会先做本地 ToolResult 落盘,再按水位决定是否请求摘要模型。
out, err := compact.ManageContext(ctx, compact.ManageInput{
Conversation: req.Conversation,
Runtime: r.Compact,
Provider: r.Provider,
Trigger: compact.TriggerAuto,
OnProgress: func(message string) {
sendEvent(ctx, events, Event{
Type: EventProgress, Iteration: iteration, Message: message,
})
},
})
if out.TriggeredLayer1 {
sendEvent(ctx, events, Event{
Type: EventProgress, Iteration: iteration,
Message: fmt.Sprintf(
"工具结果已落盘:%d 个;estimated tokens %d -> %d",
out.OffloadedCount, out.BeforeTokens, out.AfterTokens,
),
})
}
if out.TriggeredLayer2 {
sendEvent(ctx, events, Event{
Type: EventProgress, Iteration: iteration,
Message: fmt.Sprintf(
"上下文已压缩:estimated tokens %d -> %d",
out.BeforeTokens, out.AfterTokens,
),
})
}
r.dispatchHook(ctx, hook.EventPostCompact, permissionMode, hook.Payload{
"trigger": "auto",
"before_tokens": out.BeforeTokens,
"after_tokens": out.AfterTokens,
})
if err != nil {
sendEvent(ctx, events, Event{
Type: EventError, Iteration: iteration, Err: err,
})
sendStop(ctx, events, iteration, StopStreamError, err.Error())
return
}
}
因此,刚产生的 Tool Result 会先进入 Conversation 和 Session JSONL;下一次模型迭代前,它才可能被 Layer 1 替换成预览。落盘控制的是后续模型可见上下文,不会从追加式 JSONL 中抹除此前已经记录的原始 Entry。
Layer 1 先处理单个尖峰
OffloadToolResults 复制消息,遍历尚未做过决定的 Tool Result。单条内容超过 50,000 bytes 时立即落盘:
for i := range out {
if out[i].ToolResult == nil {
continue
}
result := out[i].ToolResult
id := toolResultID(*result, i)
decision, preview, ok := rt.existingDecision(id)
if ok {
if decision == DecisionReplace {
result.Content = preview
}
continue
}
if len(result.Content) > SingleToolResultLimitBytes {
next, err := replaceToolResult(rt, id, result.Content)
if err != nil {
firstErr = keepFirst(firstErr, err)
failed[id] = true
continue
}
result.Content = next
}
}
Runtime 的 Replacement Ledger 按 Call ID 记录 Keep 或 Replace。相同结果在后续检查中复用决定和预览,不会每轮重复写文件。Ledger 只存在于当前 Compact Runtime;恢复历史 Session 时会创建新的空 Ledger,再根据当前 Conversation 重新判断。
同一轮聚合过大时优先卸载最大结果
多个单项都低于 50,000 bytes,合计仍可能很大。实现先找到同一个 Assistant ToolCall 后连续出现的 ToolResult 组,计算尚未替换的总字节数,再按候选大小降序落盘:
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].size > candidates[j].size
})
for _, candidate := range candidates {
if total <= ToolRoundAggregateLimitBytes {
break
}
result := out[candidate.index].ToolResult
next, err := replaceToolResult(
rt, candidate.id, result.Content,
)
if err != nil {
firstErr = keepFirst(firstErr, err)
failed[candidate.id] = true
continue
}
result.Content = next
total -= candidate.size
}
选择最大项是一个局部贪心策略:目标只是用尽量少的替换次数把本轮总量降到 200,000 bytes 以内,不需要求解全局最优组合。对于一次工具轮次中的几十个结果,排序成本可以忽略。
落盘后保留可恢复线索
原文写入当前 Session 的 tool-results/<safe-call-id>.txt。Conversation 中的替代文本包含原字节数、完整路径、最多 20 行且最多 2,048 bytes 的 UTF-8 安全头部,以及要求重新读文件而不要猜测全文的提醒:
func previewHead(content string) string {
// 预览只截取头部,目的是给模型一点上下文线索,而不是替代完整 ToolResult。
lines := strings.SplitAfter(content, "\n")
if len(lines) > PreviewHeadLines {
lines = lines[:PreviewHeadLines]
}
head := strings.Join(lines, "")
if len(head) <= PreviewHeadBytes {
return head
}
cut := PreviewHeadBytes
for cut > 0 && !utf8.ValidString(head[:cut]) {
cut--
}
return head[:cut]
}
func buildPreview(originalBytes int, head string, spillPath string) string {
var b strings.Builder
// 预览必须包含落盘路径,后续需要全文时才能用文件读取工具找回原始结果。
fmt.Fprintf(&b, "[content offloaded] original size: %d bytes\n", originalBytes)
fmt.Fprintf(&b, "[saved to] %s\n", spillPath)
b.WriteString("[head preview]\n")
b.WriteString(head)
if head != "" && !strings.HasSuffix(head, "\n") {
b.WriteByte('\n')
}
b.WriteString("\n完整内容已保存到上述路径;如需完整内容,请使用文件读取工具读取该路径。不要凭头部预览猜测全文。")
return b.String()
}
func replaceToolResult(rt *Runtime, id, content string) (string, error) {
path, err := spillToolResult(rt.Snapshot().Session, id, content)
if err != nil {
return content, err
}
preview := buildPreview(len(content), previewHead(content), path)
rt.markReplace(id, preview)
return preview, nil
}
func spillToolResult(session Session, id, content string) (string, error) {
name := safeFileName(id)
path := filepath.Join(session.SpillDir, name+".txt")
if _, err := os.Stat(path); err == nil {
return path, nil
} else if !os.IsNotExist(err) {
return path, err
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return path, err
}
return path, nil
}
只有文件写入成功后才 MarkReplace。失败时保留原始 Content,并且不冻结 Ledger 决策;目录恢复后,下次检查仍可以重试。这保证压缩失败不会用一条指向不存在文件的预览替换可用原文。
Token 估算结合供应商 Usage Anchor
没有 Provider Usage 时,估算器累计 Role、Content、ToolCall 和 ToolResult 的 Go 字符串字节数,再除以 3.5:
func EstimateMessages(messages []llm.Message) int64 {
if len(messages) == 0 {
return 0
}
chars := messageChars(messages)
if chars == 0 {
return 0
}
return int64(math.Ceil(
float64(chars) / EstimateCharsPerToken,
))
}
func EstimateWithAnchor(messages []llm.Message, anchor UsageAnchor) int64 {
if anchor.Tokens <= 0 ||
anchor.MessageCount < 0 ||
anchor.MessageCount > len(messages) {
return EstimateMessages(messages)
}
return anchor.Tokens +
EstimateMessages(messages[anchor.MessageCount:])
}
Runner 每轮收到 Usage 后记录 Token 数和当时 Message Count。后续估算以真实 Usage 为锚点,只估算锚点之后新增的消息,减少纯字符估算的累计误差。Conversation 被 Replace 后,Runtime 会用新消息估算值重置 Anchor。
这里的 chars 实际来自 Go len(string),是字节数而不是 Unicode 字符数。中英文和代码的 Token 比例差异很大,因此 3.5 只是提前留余量的启发式估算,不是 tokenizer 的精确结果。
Layer 2 的触发顺序
ManageContext 总是先运行 Layer 1,再重新估算 Token。自动摘要阈值是:
estimated tokens >= context window - 20,000 - 13,000
OpenAI 默认 128,000 窗口时约在 95,000 触发;Anthropic 默认 200,000 时约在 167,000 触发。未知或无效窗口回退为 128,000。
func shouldAutoCompact(tokens, contextWindow int64) bool {
if contextWindow <= 0 {
contextWindow = defaultFallbackContextWindow
}
return tokens >= contextWindow-
SummaryReserveTokens-
AutoSafetyMarginTokens
}
20,000 Token 为摘要请求和输出预留空间,13,000 是自动触发安全余量。阈值判断在 Layer 1 之后进行,所以一个大结果成功落盘后可能已经回到安全范围,无需发起额外模型请求。
摘要请求与过长重试
摘要 Provider Request 不带 Tools,只包含一条用户消息。Prompt 要求模型先输出 <analysis>,再输出固定九部分的 <summary>;程序只保留最后一组 summary 标签中的文本,没有标签时回退为整个响应。
如果 Provider 将错误归一为 llm.ErrPromptTooLong,实现最多尝试四次,每次删除最旧一条输入消息:
func summarize(
ctx context.Context,
provider llm.Provider,
messages []llm.Message,
safetyMargin int64,
contextWindow int64,
) (string, error) {
input := cloneMessages(messages)
var lastErr error
for attempt := 0;
attempt <= SummaryRetryLimit && len(input) > 0;
attempt++ {
summary, err := summarizeOnce(ctx, provider, input)
if err == nil {
return summary, nil
}
if !errors.Is(err, llm.ErrPromptTooLong) {
return "", err
}
lastErr = err
input = dropOldestSummaryInput(input)
}
if lastErr == nil {
lastErr = errors.New("summary input is empty")
}
return "", lastErr
}
其他 Provider 错误和空摘要不重试,直接返回 Runner。每次只删除一条消息的实现简单,但可能切断摘要输入中的 ToolCall/ToolResult 配对;近期闭环保护发生在摘要成功后的保留阶段,并不覆盖这个 retry 输入。
摘要之后保留近期完整调用闭环
SelectRecent 从尾部向前累计;历史足够长时同时满足约 10,000 estimated tokens 和 5 条消息两个条件,历史较短时则全部保留。之后再检查起点:
func SelectRecent(messages []llm.Message) []llm.Message {
if len(messages) == 0 {
return nil
}
start := len(messages)
var tokens int64
for start > 0 {
start--
tokens += EstimateMessages(messages[start : start+1])
if tokens >= RecentKeepTokens &&
len(messages)-start >= RecentKeepMessages {
break
}
}
start = ExpandToToolBoundary(messages, start)
return cloneMessages(messages[start:])
}
如果起点落在 ToolResult 上,ExpandToToolBoundary 向前寻找对应 Call ID 的 Assistant ToolCall,并把起点扩展到那里。最终 Conversation 由三条压缩元消息和近期原文组成:
user: 历史会话摘要
assistant: 已记录压缩后的历史摘要
user: 摘要不是原文,需要细节时重新读取
...至少 5 条且约 10,000 Token 的近期完整消息...
近期事实可能同时出现在摘要和原文中。当前实现接受这部分重复,以换取最近工作状态、错误和工具调用参数不被有损摘要单独控制。
失败熔断与手动压缩
自动摘要失败会增加 Runtime 计数;成功则归零。连续失败三次后,后续自动调用直接跳过 Layer 2,避免每轮都重复消耗同一个失败请求。/compact 调用 ForceCompact,不检查自动熔断,可以给用户一次显式重试机会。
当前实现还有几项需要准确陈述的边界:
- Layer 1 落盘失败会保留原文,但
ManageContext最终把该错误返回 Runner,当前 ReAct 运行会以 Stream Error 停止,而不是仅显示 Warning 后继续。 - 手动
ForceCompact直接进入 Layer 2,不先运行 Layer 1。 ManualSafetyMarginTokens、传给summarize的safetyMargin和contextWindow当前未在函数体中用于裁剪或预算,所以 3,000 Token 手动余量实际上没有改变摘要请求。ErrAutoCompactTripped已声明但没有返回。熔断后自动路径安静跳过摘要,调用方无法从 error 判断是“未到阈值”还是“已经熔断”。- 摘要过长重试删除单条消息而不是完整对话轮次,最多四次也不保证足以让极长输入进入窗口。
- Spill 文件已存在时直接复用,不校验内容;Call ID 清洗后的文件名理论上可能碰撞。
- Replacement Ledger 不随 Session 恢复,重新打开会话后会重新评估已有预览和结果。
完整流程
下一次 ReAct 模型请求前
-> 读取 Conversation + Usage Anchor
-> Layer 1: 单项 > 50,000 bytes 立即落盘
-> Layer 1: 单轮 > 200,000 bytes 优先落盘最大项
-> Replace Conversation 为预览快照
-> 重新估算 Token
-> 未到窗口阈值:直接请求主模型
-> 到达阈值且未熔断:请求摘要模型
-> Prompt Too Long:最多三次额外重试
-> 提取 summary
-> 在历史足够时选择至少 10,000 Token / 5 条近期消息
-> 向前扩展 ToolCall 边界
-> 摘要元消息 + 近期原文 Replace Conversation
-> 重置 Usage Anchor
-> 请求主模型
测试验证了什么
Compact 测试覆盖单项落盘、文件内容、预览复用、失败后可重试、聚合场景优先选择最大结果、去除 analysis、近期 ToolCall/ToolResult 闭环、自动失败三次熔断、手动绕过熔断,以及摘要请求不携带工具。Agent 测试验证 Compact 在普通模型请求前运行。
当前测试没有覆盖手动余量参数的效果、真实 tokenizer 误差、超大 JSONL 行恢复、Spill 文件名碰撞和完整 Provider 上下文极限集成。这些也是现阶段不能从单元测试外推的保证。
小结
PseudoClaude 把长上下文治理拆成两个不同成本层级:大 Tool Result 用本地文件做确定性、可重新读取的卸载;缓慢增长的历史则在窗口阈值前交给 LLM 摘要,并用近期原文和 Tool 调用边界保留执行连续性。
至此,第三阶段完成了状态持久化主线:Session 负责恢复原始会话演进,Instructions 保存显式规则,Memory 沉淀跨会话知识,Compact 控制单次会话送入模型的体积。
下一阶段转向 Tool Call 的安全与权限边界。先从 run_command 的危险命令硬拒绝开始,再依次分析工作区路径沙箱、分层规则、交互审批和 Plan Mode 的双重只读校验。