跳到主要内容

Continue — 上下文自动压缩

长对话会把模型的上下文窗口撑爆。本章讲 Continue 怎么在快撑爆前,把一大段历史总结成一条摘要继续跑,而且尽量无缝。

1. 这一章解决的小问题

模型一次能看的 token 有上限(context window)。agent 跑久了,历史(尤其是工具输出,如一大坨终端日志)会越堆越多,迟早超限。两个朴素但糟糕的应对:

  • 直接报错停掉 —— 任务半截崩了。
  • 粗暴砍掉最老的消息 —— 可能砍掉关键上下文。

Continue 的做法是:让模型自己把目前为止的对话总结成一条消息,之后用这条摘要代替前面一长串,既省 token 又保留要点。

2. 思路/直觉:把「磁盘」压成「便签」

压缩前历史: 压缩后历史:
system system
user: 改这个... ┌──────────────────────────┐
assistant: 好,读文件 │ assistant(摘要): │
tool_result: <长输出> │ 目前在做X,已改A/B, │
assistant: 改了 │ 当前正卡在Y,接下来要Z... │
tool_result: <长输出> └──────────────────────────┘
...(很长)... (后续新消息接在这之后)

摘要里特意要求「讲清楚压缩前那一刻正在做什么」,这样模型读完摘要能接着原来的活继续,而不是断片。

3. 核心机制

3.1 什么时候触发:阈值计算

它要解决的小问题: 不能等真超限才压(那次请求已经发不出去了),要提前在逼近时压。

真实实现: shouldAutoCompact()(extensions/cli/src/compaction.ts:266)。它算当前总输入 token(历史 + 系统消息 + 工具定义,countTotalInputTokens),和一个压缩阈值比:

compactionThreshold = contextLimit − maxTokens − compactionBuffer
  • contextLimit:模型上下文上限。
  • maxTokens:给输出预留的空间(getModelMaxTokens)。
  • compactionBuffer:额外缓冲,取「按比例算的缓冲」和 maxTokens 中较大者,再封顶在 AUTO_COMPACT_BUFFER_CAP = 15_000(compaction.ts:19279-286)。比例由 AUTO_COMPACT_BUFFER_RATIO = 0.8(compaction.ts:20)控制,即大约在 80% 处触发。

输入 token ≥ 阈值就该压(compaction.ts:299)。

3.2 在循环里的三个检查点

主循环(见 01 章)在三处可能触发压缩,都在 streamChatResponse.ts 里通过 helper 调用:

检查点时机helper
调模型前这圈请求会不会超长handlePreApiCompaction (streamChatResponse.ts:461)
工具执行后工具输出可能很大,执行完再查一次handlePostToolValidation (streamChatResponse.ts:525)
常规阈值80% 阈值检查handleNormalAutoCompaction (streamChatResponse.ts:544)

任何一处真压了,就把 compactionOccurredThisTurn 置真,供后面「自动续跑」用。

3.3 怎么压:让模型出一条摘要

真实实现: compactChatHistory()(compaction.ts:53):

  1. 在历史末尾追加一条固定的压缩提示(COMPACTION_PROMPT,compaction.ts:40),要求模型「给出迄今对话的简洁摘要,保留关键上下文/决定/当前状态,并讲清楚压缩前那一刻的工作流」。
  2. 如果「历史 + 压缩提示」本身就超预算,先 pruneLastMessage 反复砍最后的消息直到放得下(compaction.ts:93-114)。
  3. 流式调 streamChatResponse(...) 拿到摘要文本。
  4. 组装新历史:[系统消息, 摘要消息]——摘要消息带特殊标记 conversationSummary(compaction.ts:144-155)。

之后发给模型时,getHistoryForLLM()(compaction.ts:222)据 compactionIndex 只取「系统消息 + 摘要 + 摘要之后的新消息」,前面那一长串就不再发了。

pruneLastMessage 的小心思: 砍消息时要保持对话结构合法——它会成对地砍(如「assistant+其 toolCalls」一起砍、「user 回合」一起砍),避免留下一个没有对应 assistant 的孤儿 tool 消息(compaction.ts:195-220)。

3.4 压缩后无缝续跑

它要解决的小问题: 压缩是个内部维护动作,不该让用户感觉 agent「干到一半停了」。

真实实现: 见 [01 章 4.x] 提到的 handleAutoContinuation()(streamChatResponse.ts:95):若这一圈发生过压缩、而模型这圈又不打算继续,就自动追加一条 user 消息 "continue",让循环再转一圈,从摘要无缝接着干(streamChatResponse.ts:564-575)。转完把标志位清零防止无限续跑。

4. 巧妙之处

  • 三个检查点覆盖「请求前 / 工具后 / 阈值」,尤其「工具执行后」这点很关键——一条 Bash 输出几千行就可能瞬间逼近上限,等下一圈请求前才查就晚了。
  • 摘要提示明确要求复述「当前正在做什么」,把「压缩」从「丢信息」变成「换种紧凑表示」,让 agent 能接续。
  • 压缩自动注入 continue,把内部动作对用户透明化——这是产品体验上的细节,但很影响「agent 跑长任务」的连贯感。
  • 缓冲既有比例又有封顶(min(max(...), 15_000)),避免在超大上下文模型上预留过多浪费,也避免在小模型上预留过少。

5. 边界与局限

  • 摘要是有损的:模型总结时可能丢掉后面才发现重要的细节。Continue 没有「按需回看被压掉的原始历史」机制——压掉就只剩摘要。
  • 阈值和缓冲基于token 估算(gpt-tokenizerencode 等),和真实模型分词有偏差,所以另有 SAFETY_BUFFER(streamChatResponse.ts:219)等保守余量兜底。
  • 极端情况下(单条消息本身就超预算),pruneLastMessage 砍不动会 break 并告警,可能仍超限(compaction.ts:104-110)。

6. 横向对比

「总结式压缩(summary compaction)」是长会话 agent 的主流方案之一(对比:滑动窗口截断、向量召回历史)。Continue 的取舍偏简单可靠:一条摘要 + 三检查点 + 自动续跑,不引入向量库/分层记忆。想看「把历史当磁盘、按需召回」的更重方案,可对照本 shelf 里带长期记忆的 agent 子库。

7. 代码地图

主题文件符号名
是否该压缩extensions/cli/src/compaction.tsshouldAutoCompact
执行压缩extensions/cli/src/compaction.tscompactChatHistory
压缩提示词extensions/cli/src/compaction.tsCOMPACTION_PROMPT
取发给 LLM 的历史extensions/cli/src/compaction.tsgetHistoryForLLM
安全地砍消息extensions/cli/src/compaction.tspruneLastMessage
找摘要位置extensions/cli/src/compaction.tsfindCompactionIndex
循环里的三检查点extensions/cli/src/stream/streamChatResponse.compactionHelpers.tshandlePreApiCompaction / handlePostToolValidation / handleNormalAutoCompaction
压缩后续跑extensions/cli/src/stream/streamChatResponse.tshandleAutoContinuation