Coding Agent Harness 拆解 · 上下文管理:缓存分块、reminder、记忆与四段压缩(七)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot)以及它底下的执行内核 agentcore。本篇处理其中最核心的子系统之一:上下文管理。它要同时回答两个彼此冲突的问题:每一轮发给模型的那批字节该怎么组织,才能既省钱又让模型看到最新状态;以及当这批字节不断变长、迟早撑爆窗口时,该由谁在什么时机把它压下去。
上下文管理到底在管什么
在《架构总览》里,Agent 循环的第一步只被写成“准备上下文”。真实系统里这是一件精细活,因为它夹在两条硬约束之间:
- 省钱:provider 的 prompt cache 只对逐字节相同的前缀生效。系统提示词里有些内容几十天都不变(身份、工具清单、代码规范),有些每一轮都在变(今天日期、技能列表、项目上下文、记忆)。两者混在一起,每轮请求都是一份全新字节,缓存一次都命不中。
- 不爆窗口:对话会无限变长。每追加一轮问答、每塞一次工具结果,历史就长一截。窗口是硬上限,撞上去请求直接报错;就算没撞上,越长的历史每轮重算也越贵。
这两条约束把上下文管理劈成两半,本篇也照这个顺序讲。前半是“怎么组织一份上下文”:三块缓存分块、每轮注入的 <system-reminder>、两层记忆。后半是“上下文变长了怎么办”:内核 ContextEngine 的四段压缩流水线与三个入口。
贯穿全篇的原则只有一句:按“变化频率”给一切排队。不变的进缓存前缀、越长越靠前;易变的赶到缓存断点之后;实在压不住了,再动用最贵的那把 LLM 总结。
prompt cache 省在哪里
Anthropic 这类 provider 支持给请求的前缀打缓存断点(cache_control: ephemeral):只要这一段字节和上一次请求逐字节相同,服务端就直接复用,命中部分的 token 只按很低的比例计费(Anthropic 的缓存读取约为原价的十分之一)。
两个词要同时成立:逐字节相同,且是前缀。一旦前缀里混进一个每天都变的日期,明天的第一个请求就和今天的缓存对不上,整段前缀全部落空。所以上下文工程的第一原则是把系统提示词按“变化频率”切块:让不变的部分尽可能长、尽可能靠前,把易变的部分全部赶到缓存断点之后。
还有个容易忽略的下限:Anthropic 要求可缓存前缀至少约 1024 个 token(Haiku 4.5 约 4096),不足这个长度即便标了断点也不会真正缓存。稳定前缀因此不仅要“纯”,还要够长才划算,这也是把两大块相对稳定的系统提示合并进同一段前缀、不切得过碎的原因之一。
三块系统提示词
internal/bootstrap/assemble_session.go 里的系统块组装就是这条原则的落地。它产出一个 agentcore.SystemBlock 数组;内核并不关心里面装了什么,只认每个块带不带 CacheControl。分层如下:
flowchart TB
subgraph cached["缓存前缀 · CacheControl: ephemeral"]
B1["块1 通用底座 BuildUniversalBase<br/>中性身份 / Environment / 5 段通用规范"]
B2["块2 角色块 BuildLeaderRoleBlock<br/>leader 身份 / 工具清单 / 任务·团队 / 记忆用法"]
end
subgraph nocache["动态尾部 · 无 CacheControl"]
B3["块3 动态块 BuildDynamicSystemPart<br/>MCP 工具 / plan_mode 覆盖层"]
B4["块4 git 快照(会话启动时采集一次)"]
end
subgraph turn["每轮用户消息 · 会话尾部"]
R["system-reminder ×N<br/>日期 / 技能 / 项目上下文 / MEMORY.md"]
U["用户真正说的话"]
end
B1 --> B2 --> B3 --> B4 --> R --> U
对照 internal/config/prompt.go,三块各自的职责与缓存策略是:
| 块 | 构造函数 | 内容 | 为什么放这 | 缓存 |
| --- | --- | --- | --- | --- |
| 块1 通用底座 | BuildUniversalBase(cwd) | 中性身份、## Environment(cwd + OS)、并行执行/做任务/用工具/系统约定/输出效率五段通用规范 | 进程内绝不变;且不含工具清单,好让 leader 和 teammate 共享这段字节 | ephemeral |
| 块2 角色块 | BuildLeaderRoleBlock(ctx, localTools) | leader 身份、本地工具清单、(条件性的)任务管理/团队协作段、auto-memory 用法说明 | 会话内固定(工具集、记忆目录都不会变) | ephemeral |
| 块3 动态块 | BuildDynamicSystemPart(mcpTools, overlays) | 晚到的 MCP 工具描述、plan_mode 等覆盖层 | MCP 刷新或计划模式切换时会变,不能进缓存 | 无 |
assemble_session.go 把前两块打上 CacheControl: "ephemeral",动态块和 git 快照块不带任何标记:它们随时可能变,标了反而害人。
// Block 1 (identity) + block 2 (instructions) carry CacheControl: ephemeral;
// the dynamic block and the git snapshot stay uncached — they can change
// mid-session, so caching them would only poison the prefix.
blocks := []agentcore.SystemBlock{
{Text: identity, CacheControl: "ephemeral"},
{Text: frozenInstructions, CacheControl: "ephemeral"},
}
if dynamic != "" {
blocks = append(blocks, agentcore.SystemBlock{Text: dynamic})
}
if ctxFiles.GitSnapshot != "" {
blocks = append(blocks, agentcore.SystemBlock{Text: ctxFiles.GitSnapshot})
}
上层的 BuildFrozenSystemParts 把块1、块2 一次算好,进程内永不重算;Session 后续只需不断重建那条动态尾部。
::: tip 为什么工具清单不在块1
BuildUniversalBase 的注释点出一个约束:leader 和 teammate 的工具集不同,如果把工具清单塞进共享前缀,两个 agent 的字节就不再相等,跨 agent 的缓存复用作废。所以块1 保持中性,工具清单挪到各自的角色块(块2)。leader 跑热几轮后,新 spawn 的 teammate 第一次请求就能命中同一段块1 缓存,两层分工在这里落成了字节层面的收益。
:::
还有两个边界值得知道:一是当 cwd 下有 SYSTEM.md 时,ContextFiles.SystemOverride 会整段替换默认系统提示词,块1 为空、块2 就是这份 override;二是块3 的覆盖层顺序要求确定性排列,同样的内容换个顺序会改变字节、白白打破这一段可能存在的缓存。这些约束指向同一件事:任何进前缀的字节,都要对“逐字节稳定”负责。
日期为什么不在系统提示词里
现在看那些“每轮都在变”的内容。它们被 BuildReminders 统一包成 <system-reminder> 文本片段,注入到用户消息里,不进系统提示词。看 internal/config/prompt.go:
// Today's date is surfaced here (not in the system prompt) so that the
// system prefix stays identical across days and prompt cache can hit.
func BuildReminders(ctx ContextFiles, skills []skill.Spec) []string {
reminders := []string{
"<system-reminder>\nToday's date is " + time.Now().Format("2006-01-02") + ".\n</system-reminder>",
}
// ...skills / project context / MEMORY.md appended in a fixed order
}
BuildReminders 会按固定顺序产出若干片段:
- 今天日期:每天都变,绝不能进缓存前缀;
- 技能列表(
## Skills):技能可增删,排序还会按使用频次动态调整;
- 项目上下文(
## Project Context,来自 AGENTS.md);
- APPEND_SYSTEM.md 追加内容(若有);
- MEMORY.md 内容(auto-memory,下一节详述)。
这些片段在 internal/agent/session_runtime.go 的 buildUserMessage 里,作为文本块前置到每一轮用户消息的最前面:
// buildUserMessage creates a user message with reminders prepended as text blocks.
blocks := make([]agentcore.ContentBlock, 0, ...)
for _, r := range runtimeReminders { blocks = append(blocks, agentcore.TextBlock(r)) }
for _, r := range staticReminders { blocks = append(blocks, agentcore.TextBlock(r)) }
blocks = append(blocks, userBlocks...) // the user's actual words come last
关键在于位置:reminder 落在整个系统提示词之后的会话尾部。系统前缀(块1+块2)保持不动,缓存照常命中;所有易变信息又每轮都刷新给了模型。“缓存前缀稳定”和“模型看到最新状态”这两个本来冲突的目标,靠“把易变项赶到缓存断点之后”这一手同时满足。
设想同一个项目跨两天各发一次请求,就能看清这个设计保住了什么:
| 段落 | 第一天 | 第二天 | 命中缓存? |
| --- | --- | --- | --- |
| 块1 通用底座 | 身份/环境/规范 | 逐字节相同 | 命中 |
| 块2 leader 角色块 | 工具清单/记忆用法 | 逐字节相同 | 命中 |
| reminder · 日期 | 2026-07-14 | 2026-07-15 | 变了,但它在尾部 |
| reminder · MEMORY.md | 昨天的记忆 | 多了两条 | 变了,同样在尾部 |
易变项确实变了,但它们全在缓存断点之后,动的是会话尾部,不是前缀,前缀该命中还是命中。要是把日期写进块1,第二天这张表会从第一行就失效,整段前缀连锁作废。
系统提示词里的 <system-reminder> 是带外指令:块1 的“系统约定”明确告诉模型,这些标签由 harness 自动注入,不等于用户本人说的话。这样模型既能读到日期和记忆,又不会把它们误当成用户的新命令。
记忆的两层:别把两个 memory 搞混
codebot 里有两套都叫 memory 的东西,作用完全不同,先划清界限:
| | auto-memory | session memory |
| --- | --- | --- |
| 文件 | MEMORY.md + 主题文件 | 项目内一份 running summary |
| 位置 | internal/config/memory.go | internal/agent/session_memory.go |
| 生命周期 | 跨会话长期沉淀 | 服务于压缩/恢复的运行期摘要 |
| 谁写 | 模型主动用 Write/Edit 写 | 后台每隔若干 token 自动抽取 |
| 谁读 | 每轮 reminder 注入 | 压缩流水线第三段惰性读取 |
auto-memory 是本节主角;session memory 属于后半篇压缩流水线的一环,等讲到第三段策略时再展开。
auto-memory:索引 + 主题文件,200 行截断
这就是每轮 reminder 第 5 项注入的那份记忆。config.LoadMemory(cwd) 从 ~/.codebot/projects/<projectID>/memory/MEMORY.md 读取,只读前 200 行(memoryMaxLines):
lines := strings.Split(raw, "\n")
if len(lines) > memoryMaxLines {
content = strings.Join(lines[:memoryMaxLines], "\n")
// Not a silent cut: append a hint steering the model to treat MEMORY.md
// as an index and push details into standalone topic files.
content += "... <!-- MEMORY.md is long; only the first 200 lines were loaded. " +
"Move details into topic files and keep MEMORY.md as a lean index. -->"
}
截断时不只砍掉末尾,还追加一条注释,引导模型把 MEMORY.md 当索引用、细节写进 debugging.md、patterns.md 这类主题文件。这套用法规约由 BuildAutoMemoryInstructions(memoryDir) 生成,放进块2(缓存前缀),因为“怎么用记忆”是会话内不变的规矩;记忆的具体内容则走每轮 reminder,因为它随时会被写新。
用法说明进缓存、内容进 reminder:同一件“记忆”被拆成一稳一动两半,又一次套用了三块缓存那条原则。
BuildAutoMemoryInstructions 还给模型划了清晰的存/不存边界,避免记忆退化成流水账:
- 该存:多次交互确认的稳定约定、关键架构决策与重要文件路径、用户的工作流偏好、反复出现问题的解法。
- 不该存:当前任务的临时状态、未经核实的单文件臆测、与
AGENTS.md 重复或冲突的内容。
- 纠正即改源:当用户纠正了一条你凭记忆说出的结论,必须回到记忆文件把那条错的就地改掉或删掉,否则同一个错会在未来每次会话里复读。
还有个兜底细节:MEMORY.md 为空时,reminder 里塞的是一句占位提示而非空字符串。否则模型看到系统承诺“MEMORY.md 已加载”却发现是空的,会去 Read 这个尚不存在的文件,在首次会话触发 ENOENT。
上下文变长了怎么办
三块缓存和 reminder 解决的是“怎么组织一份上下文”,没解决“上下文会无限变长”。窗口是硬上限,撞上去请求直接报错;就算没撞上,越长的历史每轮重算也越贵。所以必须有人在合适的时机把旧消息压缩成摘要、腾出窗口,同时尽量不丢关键信息。
难点在“压什么、留什么”:正在进行的任务、刚落地的编辑、未闭合的 tool_use 都不能被摘要一刀切掉,否则模型醒来就断片。codebot 把这套逻辑做成一条四段流水线,下面逐层拆开。
一个引擎,三个入口
agentcore 的循环并不知道“压缩”长什么样,它只认一个接口 ContextManager(agentcore/context.go)。ContextEngine(agentcore/context/engine.go)是这个接口的策略驱动实现。接口里真正承载压缩语义的是三个入口,它们的触发时机和“提交语义”各不相同:
flowchart TB
subgraph loop["AgentLoop 每次 LLM 调用"]
P["Project(ctx, msgs)<br/>非提交式投影 · 每轮都跑"]
RO["RecoverOverflow(ctx, msgs, err)<br/>provider 报溢出后恢复"]
end
subgraph user["用户显式动作"]
C["Compact(ctx, msgs, reason)<br/>/compact 强制压缩"]
end
P -->|"projection.Messages<br/>仅本次调用生效"| Send["发给模型"]
C -->|"commit.Messages<br/>替换 runtime 基线"| Base["agent.SetMessages"]
RO -->|"recovery.CommitMessages<br/>替换基线后重试"| Base
| 入口 | 谁调它 | 提交语义 | 返回类型 |
| --- | --- | --- | --- |
| Project | agentcore/loop.go 的 callLLM,每次 LLM 调用前 | 默认不提交:只产出本次调用要发的字节,agent 里那份完整消息历史保持不动 | ContextProjection |
| Compact | codebot 的 /compact 命令(Session.Compact) | 提交:调用方用返回的 Messages 替换 runtime 基线 | ContextCommitResult |
| RecoverOverflow | loop.go 的 recoverOverflow,provider 抛 context overflow 后 | 提交:用 CommitMessages 替换基线再重试那次调用 | ContextRecoveryResult |
“非提交式投影”是最容易想错的一点,看 callLLM 就清楚了:
// Stage 1: ContextManager projects the transcript into what this call sends.
projection, err := config.ContextManager.Project(ctx, messages)
// ... when ShouldCommit is false, agentCtx.Messages is left untouched ...
if projection.Messages != nil {
messages = projection.Messages // only THIS call uses the projected view
}
codebot 装配引擎时没有打开 CommitOnProject,所以 Project 是纯投影:每一轮都从完整的 transcript 重新算一份“压缩后的视图”发给模型,算完即弃,下一轮再算一遍。好处是投影永远最新,不会把某次压缩的损失永久写进历史;代价是每轮都要重跑一遍流水线,这也是为什么排在前面的策略必须便宜且确定。/compact 和溢出恢复则是提交式的:它们是有意设的检查点,结果会真的替换掉历史。
断路器:别在压缩上烧钱
Project 里包了一层电路断路器,专治“压缩本身一直失败”,比如 FullSummary 依赖的那次总结 LLM 调用连续报错。逻辑在 engine.go:
- 每次
Project 压缩失败,consecutiveFailures++;
- 累计到
maxFailures(默认 3)就跳过这一轮压缩,直接把原始消息原样返回,避免继续浪费 API 调用;
- 跳过不是静默的:它仍触发
OnProject 回调、把 Reason 标成 "circuit_breaker",让 host 能观测并显示“已跳过”;
- 跳过后把计数回落到半开态(
maxFailures-1),下一轮还能再试一次,一次成功压缩(Changed)就清零。
::: tip 半开而非彻底熔断
断路器不永久关死压缩,只是“跳一轮、再放一次进来试探”。RecoverOverflow 成功后也会清零 consecutiveFailures,因为一次成功的溢出恢复本身就证明 LLM 可达。这套“熔断-半开-恢复”让一个临时抽风的 provider 不至于把长会话推向必然溢出。
:::
四段流水线:一条按序逃逸的阶梯
三个入口共用同一个 apply 内核,它按顺序跑一组 Strategy(agentcore/context/strategy.go)。codebot 在 internal/bootstrap/assemble_runtime.go 的 buildContextEngine 里装配了四段,顺序固定:
Strategies: []agentctx.Strategy{
toolCompact, // 1. tool_result_microcompact
trimCompact, // 2. light_trim
memoryCompact, // 3. session_memory
summaryCompact, // 4. full_summary
},
flowchart LR
In["超阈值的消息视图"] --> S1
S1["① ToolResultMicrocompact<br/>清空旧工具结果<br/>无 LLM · 确定性"] -->|"仍超阈值"| S2
S2["② LightTrim<br/>截断超长文本块<br/>无 LLM · 确定性"] -->|"仍超阈值"| S3
S3["③ SessionMemory<br/>用活文档种子生成检查点<br/>无 LLM"] -->|"无种子则跳过"| S4
S4["④ FullSummary<br/>LLM 生成 ContextSummary<br/>最后手段 · 唯一可 Force"]
S1 -.->|"降到阈值下就停"| Done["够了,停"]
S2 -.-> Done
S3 -.-> Done
关键在 apply(engine.go)里那两句判断:每跑完一个策略就重算预算,budget.Tokens <= budget.Threshold(Threshold = ContextWindow - ReserveTokens)就立即 break:
for _, strategy := range e.cfg.Strategies {
if budget.Window <= 0 || budget.Tokens <= budget.Threshold {
break // already fits — don't invoke heavier strategies
}
nextView, result, err := strategy.Apply(ctx, e.snapshotTranscript(), view, budget)
// ... apply, recompute budget ...
if result.Applied && budget.Tokens <= budget.Threshold {
break
}
}
所以这是一条逃逸阶梯:越靠前越便宜,能用便宜手段解决就绝不惊动昂贵的那一段。ReserveTokens 由 codebot 的 CompactRatio 反推(assemble_runtime.go):设了压缩比就在窗口的对应位置留出余量,没设就退回引擎内建的固定缓冲。
| 段 | 策略(构造函数) | 做什么 | 成本 | 结构 |
| --- | --- | --- | --- | --- |
| ① | NewToolResultMicrocompact | 把旧的、可压缩工具结果的正文替换成 [Tool result cleared to save context.],保护最近 5 个(按 工具名+args 去重) | 无 LLM,纯本地改写 | 不动消息条数 |
| ② | NewLightTrim | 把 KeepRecent(4) 之前消息里超过 4000 runes 的文本块截断,保留头 1200 + 尾 800,中间填 [N characters trimmed] | 无 LLM | 不动消息条数 |
| ③ | NewSessionMemory | 若有种子(活文档),直接用它生成 ContextSummary 检查点,不调 LLM;无种子则原样返回,交给下一段 | 无 LLM | 折叠旧历史为一条 summary |
| ④ | NewFullSummary | 调 LLM 把旧历史压成结构化 ContextSummary(Goal/Progress/Next Steps…),保留最近一段 verbatim | 一次总结 LLM 调用 | 折叠旧历史为一条 summary |
①② 只改内容不改结构,是每轮 Project 都能安心跑的“微压缩”;③④ 会把一大段旧历史折叠成一条摘要检查点,是“真压缩”。①②③ 都不需要 LLM,正好契合 Project 每轮重跑的前提;真正会烧钱的 FullSummary 放在阶梯最底,只有前三段都压不下去时才触发。
::: tip 强制路径只有 FullSummary 会跑
apply(force=true)(即 Compact 和 RecoverOverflow)并不跑全部四段:它只挑实现了 ForceCompactionStrategy 接口(多一个 ForceApply 方法)的策略,命中第一个 Applied 就停。四段里只有 FullSummary 实现了 ForceApply。所以 /compact 和溢出恢复会直接跳到 LLM 总结,跳过 micro/trim/session_memory。也就是说,SessionMemory 的“复用活文档、省掉一次总结”只在自动的 Project 路径上生效,手动 /compact 拿到的永远是一份新鲜的 LLM 摘要。
:::
另外一处边界:内核自带的 NewDefaultEngine 是三段(micro/trim/summary),不含 session_memory。codebot 用 NewEngine 显式装配,把 SessionMemory 插进第三段,这是 harness 对内核默认管线的一次定制。
检查点消息 ContextSummary
③④ 产出的“摘要”有自己的类型:一个实现了 AgentMessage 接口的 ContextSummary(agentcore/context/message.go),不是随手拼的一段文本:
type ContextSummary struct {
Summary string
TokensBefore int
ReadFiles []string // files read within the collapsed history
ModifiedFiles []string // files modified within it
Timestamp time.Time
}
它在发给模型前由 ContextConvertToLLM 包成一条 user 消息,正文夹在 <context-summary>...</context-summary> 里。FullSummary 生成它时有个讲究:findCutPoint 从尾部往前累积 KeepRecentTokens(默认 20000)找切点,绝不切在 assistant 的 tool_use 和它对应的 tool_result 之间;切点落在半途时,还会额外为“这一轮的前缀”生成一段 split-turn 摘要。ReadFiles / ModifiedFiles 则被 codebot 用来做压缩后的文件回读(下一节)。
codebot 侧:把内核接口焊到事件流
内核只管算,codebot 负责把三个入口接到产品的持久化、遥测和 UI 上。wireSessionRuntime(assemble_runtime.go)三行装配就是缝合点:
summaryCompact.SetPostSummaryHooks(session.PostSummaryRecoveryHook())
contextEngine.SetProjectHook(session.HandleProjectedRewrite)
contextEngine.SetRecoverHook(session.HandleOverflowRewrite)
自动路径(Project / RecoverOverflow):compaction_policy.go 里的 HandleProjectedRewrite / HandleOverflowRewrite 都汇流到 handleAutomaticRewrite。只在 info.Changed 时,它把内核的 RewriteEvent 翻成一对 SEAutoCompactionStart / SEAutoCompactionEnd 事件发给 UI,并按策略名(compactionKindForStrategy)把这次压缩计入 micro/trim/full 三类遥测指标之一。这里有个边界:SEAutoCompaction* 是纯产品层事件,内核根本不认识“压缩”,它是 harness 在那条唯一事件流上长出来的。
手动路径(/compact):/compact 命令(internal/ui/commands/basics.go)调 Session.Compact(),落到 session_compaction.go 的 compactWithReason("manual")。这是压缩的产品级事务:
sequenceDiagram
participant Cmd as /compact
participant Sess as Session.compactWithReason
participant Eng as ContextEngine.Compact
participant Store as JSONL Store
participant Agent as agentcore.Agent
Cmd->>Sess: Compact()
Sess->>Sess: emit SEAutoCompactionStart(Full)
Sess->>Eng: Compact(ctx, msgs, "manual") 90s 超时
Eng-->>Sess: CompactionResult{Changed, Strategy, ...}
Note over Sess: 仅当 Changed 且 tokensAfter < tokensBefore
Sess->>Sess: injectCommittedFileRestores(回读文件)
Sess->>Store: persistCompaction(summary + kept)
Sess->>Agent: SetMessages(压缩后的历史)
Sess->>Sess: emit SEAutoCompactionEnd(带前后 token)
CompactionResult 把内核返回的 Strategy / CompactedCount / KeptCount / SplitTurn 一路带出来供 UI 显示。两个防呆判断值得记住:压缩没变化、或压完反而更大(tokensAfter >= tokensBefore)就直接放弃,不写盘也不改历史,避免一次糟糕的压缩把会话搞得更糟。
提交式压缩独有的一步是 injectCommittedFileRestores:它读出 ContextSummary 里记的 ReadFiles / ModifiedFiles,用 readRecentFiles 把这些文件的当前内容重新读进来(最多 5 个、总预算 50k token,跳过已在保留区里的以及二进制/记忆文件),插在摘要之后。这样模型压缩醒来后能直接接着改文件,不必再补一次 Read。这一步只在提交式重写里做:每轮就丢的投影视图不值得为它读盘。
还有 PostSummaryRecoveryHook:FullSummary 生成检查点后,postCompactRecoveryMessages 会把“已调用技能提醒、deferred 工具前言、静态 reminder”重新注入到检查点之后。这些带外指令本来散在被压掉的历史里,压缩会把它们一并吞掉,钩子负责把它们捞回来。
session memory:一份活文档,两端解耦
回到前面挂起的那半个话题:第三段策略 SessionMemory 的种子从哪来?答案是 codebot 的活文档摘要(internal/agent/session_memory.go),它和压缩策略之间通过磁盘文件解耦:一端后台写,一端压缩时读。
写端:maybeExtractSessionMemory 挂在每条 assistant message_end 之后,先做几个廉价判断:没有正在跑的抽取(带 stale 回收)、越过 token 阈值(首次 10_000,之后每 5_000 增量)、当前尾部是安全摘要边界(没有悬空的 tool_use)。都满足才 go 一个后台 goroutine,用一次带 generation 守卫的 ephemeralQuery 让模型按 # Session Memory 模板(Current State / Task Specification / Files and Functions / Workflow / Errors & Corrections / Learnings / Worklog)原地更新这份 markdown,再原子落盘。整个过程不阻塞主循环。
读端:SessionMemorySeedFn(cwd) 是喂给内核 SessionMemoryStrategy.SeedFn 的闭包,压缩时才被惰性调用:
func SessionMemorySeedFn(cwd string) func() (string, error) {
return func() (string, error) {
data, err := os.ReadFile(config.SessionMemoryPath(cwd))
// Missing / empty / still the untouched template → return "" (no usable memory)
if body == strings.TrimSpace(sessionMemoryTemplate) {
return "", nil
}
return body, nil
}
}
返回 "" 是关键信号:文件不存在、为空、或还只是没被填过的模板,都意味着“没有有用的记忆”,SessionMemory 策略便原样返回、跳过自己,让流水线自然落到第四段 FullSummary 去调 LLM。只有当活文档真的攒下内容,第三段才会用它当 ContextSummary 正文,把原本要花在总结 LLM 调用上的钱省下来。
::: tip 为什么要绕一层文件
后台抽取和同步压缩是两条时间线:抽取在“高价值轮次之后”慢慢积累,压缩在“撞到阈值那一刻”才发生。用一份磁盘文件解耦,两端谁都不用等谁:压缩要用时读到的永远是最新一次落盘的版本,读不到就干净地退化成 LLM 总结。这也把它和前半篇的 auto-memory 彻底分开:auto-memory 跨会话沉淀、模型主动写、每轮注入;session memory 服务于压缩/恢复、后台自动抽、压缩时才读。
:::
三问回顾
- 上下文管理解决了什么痛点? 前半让“稳定内容进缓存、易变内容每轮刷新”这两个本来冲突的目标同时成立,既省 token 又让模型总能看到最新状态;后半把“压缩”变成一条便宜在前、昂贵在后、够了就停的逃逸阶梯:每轮非提交式投影用确定性微压缩扛住日常增长,
/compact 和溢出恢复才动用 LLM 总结做提交式检查点,活文档种子还能在多数情况下把那次总结调用也省掉。
- 如果没有它,会在哪里崩? 日期/技能/记忆混进系统前缀,prompt cache 每天甚至每轮全部落空;没有分层策略,要么每轮都花一次总结 LLM(贵),要么一撞窗口就硬报错(脆);没有断路器,压缩一抽风就反复烧 API;没有非提交式投影,一次糟糕压缩的损失会被永久写进历史;没有文件回读和 post-summary 钩子,模型压缩醒来会丢文件、丢技能上下文而断片。
- 它和别处怎么接上? 系统块和 reminder 由
bootstrap 在会话启动时装配、由 Session 每轮重建动态尾部;三个压缩入口的触发时机由 AgentLoop 在跨 LLM 边界时驱动,结果由 Session 翻译成 SEAutoCompaction* 事件汇入那条唯一的事件流,并把压缩检查点落进 JSONL 树。内核只暴露一个 ContextManager 接口和一组可插拔策略,策略顺序、回调、种子闭包的装配全在 bootstrap 里焊起来。
延伸阅读