返回课程首页

13

对话太长了怎么办

理解上下文溢出、切点、摘要与压缩记录。

源码基线:Pi v0.82.0 · commit 518855d

桌面 Agent 连续工作了几个小时,读取了很多文件,调用了几十次工具,每次请求都重新携带越来越长的历史。最终会遇到模型的 Context Window:

既要让模型记住目标和进度,又不能让请求超过上下文上限。

Pi 的 Compaction 不是删除 Session,而是把较老的活动路径变成摘要,同时保留近期消息。

1. 三份数据先分清

flowchart LR
    S["完整 Session Entry 树"] --> C["Compaction 选择"]
    C --> E["Compaction Entry:摘要 + 边界"]
    E --> M["缩短后的 Model Context"]
数据 作用 压缩后
Session 物理 Entry 历史、树、审计 原始 Entry 仍保留
Compaction Entry 记录摘要和保留边界 新增
Model Context 下一次模型请求 使用摘要 + 近期消息

Compaction 是 Context 视图的有损替换,不是删掉旧 JSONL 行。

2. Context Window 和预留空间

Model Metadata 中有:

model.contextWindow

System Prompt、历史 Message、Thinking、Tool Call 参数、Tool Result、图片和工具定义都会占用空间,还必须给本轮回答留出 Token。

通用 Harness 的设置:

interface CompactionSettings {
  enabled: boolean;
  reserveTokens: number;
  keepRecentTokens: number;
}

默认:

const DEFAULT_COMPACTION_SETTINGS = {
  enabled: true,
  reserveTokens: 16_384,
  keepRecentTokens: 20_000,
};
  • reserveTokens:触发压缩时为请求和输出预留的空间;
  • keepRecentTokens:压缩后希望保留的近期历史量。

3. shouldCompact 的真实条件

function shouldCompact(
  contextTokens: number,
  contextWindow: number,
  settings: CompactionSettings,
): boolean {
  if (!settings.enabled) {
    return false;
  }

  return contextTokens >
    contextWindow - settings.reserveTokens;
}

注意源码使用严格大于 >

flowchart LR
    W["Context Window"] --> U["可使用历史空间"]
    W --> R["Reserve Tokens"]
    U --> T{"Context Tokens 超过阈值?"}
    T -->|是| C["准备 Compaction"]
    T -->|否| K["继续"]

4. Token 数怎样得到

Pi 优先使用最近一次有效 Assistant Message 的 Provider Usage,再估算其后的新增消息:

最近有效 Usage 的 Context Token
+ Usage 之后 Message 的估算 Token

没有可用 Usage 时,估算全部消息。通用 Harness 的启发式包括:

  • 文字约 4 个字符一个 Token;
  • 图片按 4800 个字符估算;
  • Assistant Text、Thinking、Tool 名和参数都计入;
  • Tool Result、Custom Message、Bash 输出和 Summary 也计入。

这不是 Provider Tokenizer 的精确复刻。Usage 更可信,但只代表已经完成的某次请求,尾部仍需估算。

5. 哪些 Usage 不能直接用

源码跳过:

  • stopReason: "aborted"
  • stopReason: "error"
  • Token 总数为 0 的 Usage。

完整 coding-agent 自动压缩还会防止使用最新 Compaction 之前的旧 Usage,否则刚压缩完就可能被旧的大数再次触发。

所以不能简单写:

lastMessage.usage.totalTokens

还要判断来源、有效性和时间边界。

6. Compaction 的两个目标

压缩前:
[很老的目标与讨论][中期工作][最近工具调用和结果]

压缩后模型看到:
[Summary][最近工具调用和结果]

只保留 Summary 会丢当前操作细节;只保留最近消息又可能丢长期目标和约束。

7. 怎样找 Cut Point

findCutPoint() 从后往前累加 Message Token,直到达到 keepRecentTokens,再选择合法切点。

合法切点包括:

  • User Message;
  • Assistant Message;
  • Bash Execution Message;
  • Custom Message;
  • Branch Summary。

Tool Result 不是合法切点,因为不能让结果脱离对应 Tool Call,单独出现在保留历史开头。

flowchart RL
    N["最新 Entry"] --> A["向前累计 Token"]
    A --> B{"达到 keepRecentTokens?"}
    B -->|否| A
    B -->|是| C["选择合法 Cut Point"]

8. firstKeptEntryId 与 retainedTail

Cut Point 对应:

firstKeptEntryId
旧历史                近期历史
e1 → e2 → e3 → e4 → e5 → e6
               ↑
       firstKeptEntryId = e4

通用 Harness 新生成的 Compaction 还保存:

retainedTail?: AgentMessage[];

它让 Compaction Entry 成为自包含 Checkpoint。firstKeptEntryId 仍用于过渡和旧格式兼容。

coding-agent 专用 SessionManager 当前使用 firstKeptEntryId,没有通用 Harness 的 retainedTail 字段。这两套类型不能混用。

9. 为什么不能随便在 Turn 中间切

一个 Turn 可能是:

User
→ Assistant Tool Call
→ Tool Result
→ Assistant Tool Call
→ Tool Result
→ Assistant 最终回答

通常希望在 User Message 边界切开。但单个 Turn 自己就可能超过 keepRecentTokens。此时 Cut Point 落在 Turn 中间:

isSplitTurn: true

并记录 turnStartIndex

10. Split Turn 怎样处理

flowchart LR
    U["Turn 开始"] --> P["很长的早期 Tool 工作"]
    P --> K["Cut Point"]
    K --> R["近期后缀保留"]
    P --> PS["Turn Prefix Summary"]
    H["更早完整历史"] --> HS["History Summary"]
    PS --> C["合并 Summary"]
    HS --> C

Preparation 分成:

interface CompactionPreparation {
  messagesToSummarize: AgentMessage[];
  turnPrefixMessages: AgentMessage[];
  retainedTail: AgentMessage[];
  isSplitTurn: boolean;
  // ...
}

Split Turn 时:

  1. 更早完整历史生成或更新 History Summary;
  2. 当前大 Turn 的前缀生成 Turn Prefix Summary;
  3. 两份 Summary 合并;
  4. 近期后缀仍保留原始 Message。

11. prepareCompaction 只做准备

prepareCompaction(pathEntries, settings)

  1. 空路径或最后已经是 Compaction 时返回无可压缩内容;
  2. 找最近一次 Compaction;
  3. 取得 Previous Summary;
  4. 确定本次边界起点;
  5. 从重建后的 Session Context 估算 tokensBefore
  6. 找 Cut Point;
  7. 分出旧历史、Split Turn 前缀和 Retained Tail;
  8. 累积已读与已修改文件。

它不调用模型,也不写 Session。

flowchart LR
    P["Path Entries"] --> PREP["prepareCompaction"]
    PREP --> OLD["messagesToSummarize"]
    PREP --> PREFIX["turnPrefixMessages"]
    PREP --> TAIL["retainedTail"]
    PREP --> META["tokens / fileOps / previousSummary"]

12. Summary 怎样生成

compact() 接收 Preparation,再调用模型。结构化 Summary 包含:

  • Goal;
  • Constraints & Preferences;
  • Progress;
  • Key Decisions;
  • Next Steps;
  • Critical Context。

代码先执行:

convertToLlm(currentMessages)

再把对话序列化到 <conversation> 中。System Prompt 要求只总结,不继续回答原对话。

一次 Summary 请求:

  • 使用新的路由 Session ID;
  • cacheRetention: "none"
  • 可以使用 Retry Policy;
  • 输出上限受 reserveTokens 和模型 maxTokens 共同限制;
  • 可传 Thinking Level;
  • 返回独立 Usage。

13. Previous Summary 为什么重要

正常的迭代压缩路径会把第一次已经总结的长期信息带入下一次摘要:

Previous Summary
+ 上次压缩后的新增旧消息
→ Updated Summary

更新 Prompt 要求保留已有目标、约束和决定,加入新进度并更新 Next Steps。这是迭代摘要,不是每次从物理根发送全部历史。

但这不是当前源码的无条件保证。Split Turn 时,如果 turnPrefixMessages 非空而 messagesToSummarize 为空,两套实现都会把 History Summary 直接设为 "No prior history.";旧的 Previous Summary 不会进入最后的合并输入。宿主若把 摘要视为关键长期记忆,应为这个边界编写回归测试或通过 Hook 自行保留,而不能只依赖提示词。

14. File Operations 单独累计

默认 Details:

interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

生成新摘要时会从 Tool Call 提取文件操作,读取上一份默认 Compaction Details,合并去重,并把列表写入 Summary 与新 Details。

自定义 Hook 的 Details 结构可能不同,所以源码不会把任意 Hook Details 都当作默认文件列表。

这里的“文件操作”不是完整文件审计。默认提取器只识别名字恰好为 readwriteedit,并且参数中存在字符串 path 的 Assistant Tool Call。 bash、改名后的工具和自定义工具都不会自动进入这份列表。

15. Compaction Entry 怎样改变 Context

通用 Harness 成功后追加:

await session.appendCompaction(
  result.summary,
  result.firstKeptEntryId,
  result.tokensBefore,
  result.details,
  fromHook,
  result.usage,
  result.retainedTail,
);

随后 session.buildContext() 的默认 Transform:

  • 找最新 Compaction;
  • 将 Compaction Entry 放在最前;
  • retainedTail 时将其作为 Checkpoint;
  • 加上 Compaction 之后的新 Entry;
  • 把 Compaction Entry 投影成 CompactionSummaryMessage
模型 Context:
[Compaction Summary]
[Retained Tail]
[Compaction 之后的新消息]

物理 Session 的旧 Entry 仍存在。

16. 手动压缩通用 AgentHarness

const result =
  await harness.compact(
    "重点保留认证模块的设计决策",
  );

源码边界:

  • Harness 必须 idle;
  • 必须有 Model;
  • 无可压缩内容时抛错;
  • session_before_compact Hook 可取消或提供结果;
  • 成功后追加 Compaction Entry;
  • 发出 session_compact
  • 最后恢复 idle。

当前通用 AgentHarness 不会仅因为导出了 shouldCompact() 就自动调用 compact()。桌面宿主要自己调度,或使用完整 coding-agent 的自动流程。

17. coding-agent 的自动压缩

AgentSession._checkCompaction() 在 Agent 结束后和提交新 Prompt 前检查。

Threshold

contextTokens >
contextWindow - reserveTokens

压缩成功后不重跑已经成功的 Assistant Turn。

Overflow

isContextOverflow() 实际识别三类信号:

信号 判定 压缩后重试
Provider 明确报错 stopReason === "error" 且错误文字匹配已知模式
成功响应但输入超窗 stopReason === "stop"input + cacheRead > contextWindow 否,回答已经完成
服务端截断且没有输出 stopReason === "length"output === 0,且输入达到窗口的 99%

进入需要重试的分支时,最后一条 Assistant Message 已经写入物理 Session;代码先把 它从当前 Agent State 中移除,再执行 Compaction。压缩完成并重建 Context 后,源码还会 显式移除末尾的 Error Assistant Message;这个后置判断只针对 error

当前“一次压缩并重试”的保护对连续 error Overflow 有明确保障。需要注意一个源码边界: 每当收到非 error Assistant Message 时,_overflowRecoveryAttempted 会被重置,因此 不能把同样的保证无条件套到连续 length 截断上。桌面宿主仍应设置自己的总重试上限。

18. 为什么 Overflow 要确认同一个 Model

用户可能在小窗口模型 Overflow 后切到更大模型。旧错误不应按新模型窗口触发压缩。

源码比较 Assistant Message 的 Provider/Model 与当前 Model。只有相同时才进入 Overflow 恢复。

19. Compaction Event

完整 coding-agent 的 Reason:

type CompactionReason =
  | "manual"
  | "threshold"
  | "overflow";

生命周期包含:

  • compaction_start
  • compaction_end
  • Reason;
  • 是否 Abort;
  • 是否会 Retry;
  • Result 或 Error Message。

桌面 UI 可以显示:

正在压缩旧对话……
已将约 78k Token 压缩为摘要
正在重试刚才的请求……

不要在 compaction_start 时就显示成功。

20. Extension 怎样接管

两层都有名为 session_before_compact 的入口,但事件字段不同:

可查看的核心字段
coding-agent Extension Preparation、Branch Entries、reasonwillRetry、Custom Instructions、AbortSignal
通用 Harness Hook Preparation、Branch Entries、Custom Instructions、AbortSignal;没有 reasonwillRetry

它们都可以选择:

  • 取消;
  • 提供自定义 Compaction;
  • 让默认模型生成。

自定义结果需要保持 Summary、边界和 Token 等语义。通用 Harness 结果还可包含 retainedTail;coding-agent 专用结果结构不同,必须依据当前层的类型实现。

21. Compaction 是有损的

Summary 可能漏参数、混淆暂定与最终决定、忽略错误细节。因此:

  • 关键事实应存在代码、数据库或结构化应用状态;
  • UI 应允许查看原始 Session;
  • 高风险操作仍要重新验证;
  • Summary 适合模型继续工作,不是权威审计记录。

22. 桌面 Agent 的策略

使用通用 Harness 时,可在安全 Turn 边界调用导出的 Helper:

const shouldRun =
  shouldCompact(
    contextTokens,
    model.contextWindow,
    DEFAULT_COMPACTION_SETTINGS,
  );

if (desktopRunState === "idle" && shouldRun) {
  await harness.compact();
}

desktopRunState 是应用状态,不是 Pi API。

产品策略:

  1. Turn 完成后读取有效 Usage;
  2. 计算尾部估算;
  3. 到阈值后显示压缩状态;
  4. 允许压缩 API 支持的取消;
  5. 成功后更新 Context 预览;
  6. 失败时保留原 Session 并提示;
  7. Overflow 恢复最多有限次数;
  8. 下一请求前再次验证 Context。

取消能力也要按层区分:

  • coding-agent 的 AgentSession 暴露 abortCompaction()
  • 直接调用底层 compact() 可以传入 AbortSignal
  • 当前通用 AgentHarness.compact() 没有公开 Signal 参数,它给 Hook 的新 Signal 也没有 对外触发入口;harness.abort() 只终止正在运行的 Agent,不会取消这次显式压缩。

23. 完整流程

sequenceDiagram
    participant U as 用户
    participant A as AgentSession
    participant S as Session
    participant C as Compaction
    participant M as Summary Model

    A->>S: 读取当前 Branch
    A->>A: 计算 Context Tokens
    A->>A: shouldCompact = true
    A->>C: prepareCompaction
    C-->>A: old / prefix / firstKeptEntryId
    A-->>U: compaction_start
    A->>M: 生成或更新 Summary
    M-->>A: Summary + Usage
    A->>S: append Compaction Entry
    A->>S: buildSessionContext
    S-->>A: Summary + Recent Messages
    A-->>U: compaction_end

这张图专门描述 coding-agent 的自动流程,所以没有通用 Harness 独有的 retainedTailprepareCompaction() 成功后才发送 compaction_start;若根本没有可压缩 内容,就不会产生一个虚假的开始事件。下一次请求不再携带旧消息全文,但用户仍可在 Session 树中查看原始 Entry。

24. 常见误区

“Compaction 会删除旧 Entry”

错误。它追加 Checkpoint,并改变 Context 选择。

“reserveTokens 就是近期保留量”

错误。近期保留量是 keepRecentTokens

“Token 估算完全精确”

错误。Pi 组合 Provider Usage 与字符估算。

“Cut Point 可以放在 Tool Result 前”

错误。Tool Result 不是合法 Cut Point。

“通用 AgentHarness 默认自动压缩”

错误。它提供显式 compact() 和辅助函数。

“有 Summary 就不需要原始历史”

错误。Summary 有损,物理 Session 才是完整记录。

25. 本章小结

  • shouldCompact 使用 Context Token、窗口和 Reserve Threshold;
  • Token 计数优先采用有效 Provider Usage,再估算尾部;
  • Cut Point 约保留 keepRecentTokens,不在 Tool Result 处切;
  • 巨大 Turn 会生成 History Summary 与 Turn Prefix Summary;
  • Preparation、Summary 生成和 Session 写入彼此分离;
  • Previous Summary 支持迭代更新;
  • Compaction Entry 让模型看到 Summary + Recent Tail,原始 Entry 仍保留;
  • 通用 Harness 是显式压缩,coding-agent 支持 Threshold 和 Overflow 自动流程;
  • Overflow 会区分 error、成功但超窗、length 截断;当前一次恢复保护的可靠边界主要是 连续 error Overflow;
  • Compaction 是有损 Context 管理,不是权威历史存储。

26. 自测

  1. reserveTokenskeepRecentTokens 有什么区别?
  2. 没有 Provider Usage 时,Pi 怎样估算 Token?
  3. 为什么 Tool Result 不能作为 Cut Point?
  4. Split Turn 为什么需要 Turn Prefix Summary?
  5. 压缩后物理 Session 和 Model Context 分别包含什么?
  6. 通用 AgentHarness 会不会自动调用 compact()
  7. 三种 Overflow 信号中,哪些会重试?当前“一次恢复”保护在哪个分支最可靠?
  8. 为什么不能把 Summary 当成审计记录?