你和 AI 编程助手连续讨论了 30 轮,突然收到的不是代码诊断,而是上下文窗口超限错误。问题不一定出在代码本身,而是模型需要同时读取的历史消息太多了。
这不是某个工具独有的边缘问题。只要编程助手支持长对话、代码补丁、终端输出和多轮追问,就必须在有限的 token 预算里管理越来越长的上下文。Pi 采用的核心机制叫 compaction,可以理解为:把较早的对话压缩成一份结构化摘要,同时保留足够的新近上下文,让助手继续工作。
两个选择都不完美
当对话接近上下文窗口上限时,系统通常有两种直观做法。
一种是直接删除最早的消息。实现简单,也不会额外消耗 token,但可能丢失关键事实:项目使用的框架版本、已经确认的约束、某个失败方案的原因,或者用户明确要求不能修改的文件。
另一种是把完整历史交给模型,让模型生成摘要。这样可以保留更多语义信息,但摘要本身也需要消耗上下文和推理资源,而且压缩结果可能遗漏细节。尤其在编程任务中,“看起来重复”的内容有时恰好包含重要的接口契约或错误日志。
compaction 的价值不在于消除这个取舍,而在于把它变成一个明确的上下文管理流程:
- 识别已经接近窗口限制的时机。
- 选择较早的一段对话作为压缩范围。
- 将这段对话整理成后续工作需要的摘要。
- 保留最近的消息和当前任务状态。
- 把摘要重新注入后续请求,让模型知道之前发生过什么。
这相当于把“无限增长的聊天记录”转换成“摘要加近期原文”的工作集。
压缩时应该保留什么
高质量摘要不是把每句话改写得更短,而是保留能够影响下一步决策的信息。对于编码助手,摘要至少应覆盖以下几类内容:
- 目标:用户最终想完成什么,而不是只记录讨论过哪些话题。
- 当前状态:已经修改了哪些文件,哪些命令执行成功,哪些测试仍然失败。
- 关键约束:不能改变的 API、兼容性要求、性能目标和用户偏好。
- 已验证事实:编译器、测试、运行时或终端输出明确证明了什么。
- 失败尝试:哪些方案已经试过,以及为什么没有采用。
- 待办事项:下一步需要检查或实现的具体任务。
例如,“我们讨论了数据库连接池”信息密度很低;“连接池上限保持为 20,因为测试环境的数据库只允许 25 个连接,当前泄漏发生在 close() 未执行的异常路径”才是适合进入摘要的内容。
摘要也不能替代所有原文。最近的代码片段、刚刚出现的错误日志和用户最新指令通常应该继续以原始消息形式保留,因为它们需要精确匹配,而不是依靠概括后的描述。
一个最小可运行的压缩示例
下面的代码不依赖模型 API,演示一个可以改造成实际助手的最小流程。它使用字符数近似 token 数,并把旧消息压缩为结构化文本。真实系统中,可以把 summarize() 替换为模型调用,并使用目标模型提供的 tokenizer 计算预算。
运行前只需要保存为 compaction_demo.py,然后执行 python compaction_demo.py。
from dataclasses import dataclass
from typing import List
@dataclass
class Message:
role: str
content: str
def estimate_tokens(text: str) -> int:
"""演示用估算:真实系统应使用目标模型的 tokenizer。"""
return max(1, len(text) // 4)
def summarize(messages: List[Message]) -> str:
"""最小示例;生产环境可替换为模型摘要调用。"""
facts = []
for message in messages:
content = " ".join(message.content.split())
if message.role == "user":
facts.append(f"用户目标:{content}")
elif message.role == "assistant":
facts.append(f"助手处理:{content}")
else:
facts.append(f"工具结果:{content}")
return "压缩上下文:\n- " + "\n- ".join(facts)
def compact(history: List[Message], limit: int, keep_recent: int) -> List[Message]:
total = sum(estimate_tokens(item.content) for item in history)
if total <= limit:
return history
old = history[:-keep_recent]
recent = history[-keep_recent:]
summary = Message(role="system", content=summarize(old))
compacted = [summary, *recent]
print(f"压缩前约 {total} tokens,压缩后约 "
f"{sum(estimate_tokens(item.content) for item in compacted)} tokens")
return compacted
history = [
Message("user", "修复支付服务的超时问题,不能修改公开 API。"),
Message("assistant", "已定位到异常路径没有释放连接。"),
Message("tool", "pytest tests/test_payment.py: 3 passed"),
Message("user", "请继续检查重试逻辑,并保留现有日志字段。"),
]
history = compact(history, limit=35, keep_recent=2)
for item in history:
print(f"[{item.role}] {item.content}")
这个例子体现了几个实用边界:压缩不是每轮都执行,而是在预算不足时触发;最近消息需要保留;摘要应该带有明确的角色和事实结构;token 估算不能长期依赖字符数。
Compaction 的代价与工程边界
压缩会带来信息损失,因此不能把它当作无条件的“记忆增强”。如果摘要遗漏了一个文件名、参数默认值或测试失败原因,后续模型可能产生看似合理但错误的修改。
工程上可以采用几项防护措施:
- 在达到硬限制之前预留压缩空间,不要等请求已经无法发送才处理。
- 把用户约束、代码变更、测试结果和未完成任务分成固定字段,降低摘要遗漏概率。
- 压缩后保留最近若干轮原文,尤其是最新指令、错误日志和补丁。
- 将摘要视为工作状态,而不是永久事实;代码和测试仍然是最终依据。
- 对压缩前后的上下文做可观测记录,方便分析模型为什么丢失了关键信息。
- 对长日志、重复工具输出和大段代码采用截断或按需重新读取,避免让摘要承担所有细节。
采用 Pi 这类机制时,值得检查的不是“上下文能不能无限变长”,而是“上下文变长后,助手能否稳定保留下一步真正需要的信息”。一个可靠的编程助手通常会让摘要负责方向和状态,让文件系统、版本控制和测试命令负责精确事实。
采用前的检查清单
- 是否有明确的上下文预算和触发阈值?
- 摘要是否保留目标、约束、变更、验证结果和待办事项?
- 最新用户指令和关键错误日志是否仍以原文保留?
- 压缩后的请求是否经过 token 预算检查?
- 丢失信息时,助手是否能通过重新读取文件或运行测试恢复事实?
compaction 解决的不是模型记忆能力的根本限制,而是把有限上下文变成可管理的工作集。对长时间编码任务来说,这种显式的状态压缩往往比单纯扩大聊天记录更可控,也更容易调试。