跑过长会话 Agent 的人都见过状态栏上那行 "Context left until auto-compact: 32%"。一个排查了 40 分钟的 bug,历史消息里堆满了文件内容、命令输出和试错的工具调用,上下文窗口以肉眼可见的速度被吃掉——更糟的是,塞得越满,模型注意力越涣散,答案质量越差。Anthropic 在官方工程博客里给出的第一顺位解法就是 compaction(压缩):对话接近窗口上限时,把已有内容总结成摘要,带着摘要重启新窗口继续干活。
这是《上下文工程》系列第 5 篇。本文先拆解 compaction 的机制与三种触发策略,然后用 LangGraph 从零手写一个生产可用的压缩节点,接着讨论总结提示词的工程设计、三个最容易翻车的质量陷阱,最后对照 Claude Code 的 /compact 实现与 LangChain 官方中间件,帮你决定该手写还是用现成的。
1、Compaction 机制解析
Anthropic 在《Effective context engineering for AI agents》一文中给出的定义只有一句话:
Compaction is the practice of taking a conversation nearing the context window limit, summarizing its contents, and reinitiating a new context with that summary.
翻译成工程语言就是三步:检测接近上限,总结内容,用摘要重启新上下文。它和本系列第 2 篇讲过的"上下文腐烂"是同一枚硬币的两面:窗口里无关细节越多,关键信息的信噪比越低。所以压缩解决的不只是"装不下",更是"装太多反而变笨"。
一次完整的 compaction 要回答两个问题。保留什么:
- 摘要:用户目标、关键决策、未完成任务、重要数据
- 最近 N 轮对话原文(原因见第 5 节的工具 id 断链)
- 系统提示词:永远不参与压缩
丢什么:寒暄与客套、已被推翻的中间结论、大段文件内容与命令输出、成功执行的重复调用。判断标准只有一条——后续轮次还会用到吗?现在不用的,就是摘要里的一行字;现在在用的,就是原文。
2、触发策略对比
什么时候压?业界有三种流派,各有适用场景:
| 策略 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| token 阈值 | 累计 token 超过阈值触发 | 精确控制窗口余量 | 需要计数,略费计算 |
| 按轮次 | 每 N 轮触发一次 | 实现最简单 | 轮次与 token 不等价 |
| 手动 | 用户主动触发 | 时机可控、可带指令 | 依赖用户感知,容易忘 |
生产建议:自动 token 阈值兜底,允许手动提前压缩。阈值不要贴着窗口上限设——压缩动作本身要消耗一次完整的模型调用,还得给新任务留空间。200k 窗口的系统,阈值设在 60%~70% 比较稳妥。
轮次触发适合消息长度均匀的客服对话;Agent 场景必须用 token 触发——一次工具调用动辄几千 token,只有 token 才是硬通货。手动档则是给用户的逃生舱口,下一节手写实现时,路由函数会同时支持自动阈值与手动标记。
3、手写压缩节点
理论说完,动手。目标:在 LangGraph 的 Agent 循环里加一个 compact 节点,token 超阈值就调小模型总结历史、替换旧消息、保留最近 2 轮原文。分三步走。
3.1、状态与阈值检测
先定义状态与常量,代码如下:
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, SystemMessage, RemoveMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
TOKEN_LIMIT = 100_000 # 触发压缩的阈值(200k 窗口设一半)
KEEP_MESSAGES = 4 # 保留最近 2 轮对话的 4 条消息
add_messages 是 LangGraph 的消息归约器:新消息追加,RemoveMessage 按 id 删除旧消息——后者就是压缩的删除手段。再写一个粗略的 token 计数器:
def count_tokens(messages: list[AnyMessage]) -> int:
"""粗估消息列表 token 数;生产环境换 count_tokens API"""
total = 0
for m in messages:
total += len(str(m.content)) // 3 # 中英混排折中估计
return total
三成以内的估算误差对触发判断完全够用——阈值本身就是个缓冲带。介意精度的,各模型厂商都有 count_tokens 接口,按模型精确计数即可。
3.2、压缩节点实现
核心节点:把待压缩历史加上总结提示词交给小模型,拿摘要换掉旧消息。实现如下:
from langchain.chat_models import init_chat_model
small_model = init_chat_model("openai:gpt-5-nano") # 换成你的便宜小模型
SUMMARY_PROMPT = (
"把以下对话历史压缩成工作摘要,供新窗口继续任务。"
"必须保留:用户目标、关键决策、未完成任务、"
"重要数据(路径/ID/数值)、工具调用的关键发现。"
"丢弃:寒暄、试错过程、已被推翻的结论、冗长工具输出。"
)
def compact_node(state: AgentState) -> dict:
messages = state["messages"]
summary = small_model.invoke(
[SystemMessage(content=SUMMARY_PROMPT)] + messages
).content
keep = messages[-KEEP_MESSAGES:] # 最近原文
removed = messages[:-KEEP_MESSAGES] # 待删旧历史
return {"messages": [
*[RemoveMessage(id=m.id) for m in removed], # 删旧消息
SystemMessage(content=f"此前对话摘要:\n{summary}"),
*keep,
]}
四步走:调小模型总结、圈出保留窗口、用 RemoveMessage 按 id 删除旧消息、摘要以 SystemMessage 注入并接上保留原文。总结用小模型是关键的成本决策——压缩是高频动作,用主力模型做总结等于每次都把全量历史再烧一遍钱。
一个隐患先按下不表:按固定条数切窗口,可能恰好把工具调用对切开,第 5 节专门讲。另外若历史已超过小模型自己的窗口,先分段"滚雪球"总结再压缩。
3.3、节点接线与路由
最后把节点接进 Agent 循环,让路由函数在工具调用与压缩之间分流:
def route_after_agent(state: AgentState) -> str:
if count_tokens(state["messages"]) > TOKEN_LIMIT:
return "compact" # 超阈值先压缩
if getattr(state["messages"][-1], "tool_calls", None):
return "tools"
return END
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node) # 你的模型节点
builder.add_node("tools", tool_node) # 你的工具节点
builder.add_node("compact", compact_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges(
"agent", route_after_agent, ["compact", "tools", END]
)
builder.add_edge("compact", "agent") # 压缩完回主循环
builder.add_edge("tools", "agent")
graph = builder.compile()
路由发生在 agent 节点之后:先看 token 是否超标,超标进 compact;否则有工具调用进 tools,都没有就结束。压缩完回到 agent,此时模型看到的是"摘要 + 最近 2 轮原文",像刚接手的同事读完了交接文档。这就是 Anthropic 说的 reinitiating a new context:不是把摘要写进日志,而是让它真的成为新窗口的起点。
4、总结提示词设计
压缩质量的上限由总结提示词决定。3.2 节的自然语言版适合起步;要求更高时换结构化版本,把保留清单变成硬字段:
STRUCTURED_PROMPT = """将对话历史压缩为 JSON 摘要,供后续轮次使用:
{
"user_goal": "用户核心目标",
"decisions": ["已确定的关键决策"],
"pending": ["未完成任务与待答问题"],
"facts": ["关键数据:路径、ID、数值、报错原文"],
"tool_findings": ["工具调用中的关键发现"]
}
要求:关键值保留原文,不转述改写;缺失字段留空数组。"""
五个字段对应五类必须存活的信息:目标让 Agent 不忘初心,决策防止反复摇摆,pending 驱动任务推进,facts 是后续追问的高频对象,tool_findings 保存只出现一次的检索发现。关键值必须保留原文——"报错在 auth.py 第 42 行"一旦被总结成"有个登录模块的报错",下一轮追问就答不上来了。
JSON 与自然语言怎么选:
- JSON:字段可程序校验、可增量合并、可做摘要质检
- 自然语言:更省 token,长事实不被字段框死,小模型不易格式出错
实践中折中:主体用 JSON 字段,facts 里允许自由文本条目。另外摘要要显式标注代际——开头加一句"这是第 N 次压缩的摘要",多次压缩叠加时模型才知道自己在读历史层积,而不是当前事实。
5、常见质量陷阱
压缩是把有损操作插进了对话管线,三个坑最高发。
陷阱一:总结丢细节,追问翻车。症状是压缩后用户问"刚才那个路径是什么",Agent 一脸茫然。根源是总结模型按自己理解的"重要性"筛信息,而它的重要性和你不一致。对策:保留清单写进提示词、关键值保留原文、压缩后做质检(第 8 节练习)。
陷阱二:身份漂移。人设与语气约束若只存在于早期对话里,多次压缩后被逐层稀释,Agent 慢慢"忘了自己是谁"。对策:人设永远放系统提示词(不参与压缩),摘要模板里再加一个角色字段兜底。
陷阱三:工具调用 id 断链。这是硬性 API 约束:assistant 消息里的 tool_calls 与紧随的 ToolMessage 必须成对出现且 id 对应,对不上的历史直接被 API 拒绝。3.2 节按固定条数切窗口,切在调用与结果之间就会断链。这就是"保留最近 N 轮原文"的真正原因:最近轮还挂着未闭合的工具调用上下文,而摘要无法伪造 id。工程修法:切完窗口检查边界,第一条若是孤立的 ToolMessage,就再多保留一条。
注意:压缩不可逆。旧消息删除后无法从窗口恢复,生产系统应把完整历史另行落盘(数据库或 trace)——摘要只服务上下文窗口,不服务审计与回溯。
6、Claude Code 压缩拆解
Claude Code 的 /compact 是 compaction 最著名的落地,值得对照自查。它同时提供手动与自动两档,手动档支持附加指示:
/compact 保留数据库 schema 决策与未完成的迁移任务
手动 /compact 带指示参数,正是第 2 节"手动触发可附带指令"的体现:用户最清楚接下来的任务方向,一句指示就能让摘要偏向保留相关信息。自动档在接近窗口上限时触发,界面实时显示 "Context left until auto-compact" 的剩余百分比。Claude Code 101 的上下文管理一课对这套机制的描述,与我们的手写实现几乎逐条对应:总结重要细节、清除不再需要的工具调用结果、带着摘要继续工作。
两点值得抄作业。其一,压缩是单向门:摘要无法还原成完整历史,这倒逼你认真对待保留清单,而不是指望"回头再翻"。其二,压缩后模型对新指令的响应普遍更好——上下文腐烂被治好了,付出的代价只是早期细节降级为摘要。
7、中间件与手写取舍
《AI Agent 开发》系列第 23 篇介绍 LangChain 1.0 中间件体系时提过 SummarizationMiddleware,当时一笔带过的 trigger 参数这里正好展开。官方中间件把第 3 节的全部逻辑封装成三个参数:
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
agent = create_agent(
model, tools,
middleware=[SummarizationMiddleware(
model=small_model, # 执行总结的小模型
trigger=100_000, # 阈值,token 数或窗口占比
keep=10, # 保留最近 10 条消息原文
)],
)
trigger 决定何时压,keep 决定留多少原文,model 决定谁来压。它只作用于 state 的 messages 键,不动其他状态,职责边界清晰。取舍对比如下:
| 维度 | 手写节点 | SummarizationMiddleware |
|---|---|---|
| 上手速度 | 半天起步 | 十分钟 |
| 摘要格式 | 完全自定义 | 中间件内置,定制有限 |
| 保留策略 | 按轮、按配对边界 | 按消息条数 |
| 质检与落盘 | 自由实现 | 需求超出演示围栏 |
务实路线:先用中间件跑通业务,等压缩质量成为可观测的瓶颈(追问翻车率、摘要膨胀)再换成手写节点。第 3 节的代码随时等着被复制;中间件的实现源码与预置中间件文档都是公开的,读一遍不亏。
8、总结
Compaction 的本质是承认窗口有限且越满越糟,主动用有损摘要换回无损的工作空间。记住四件事:触发用 token 阈值兜底、允许手动提前;保留"摘要 + 最近 N 轮原文 + 系统提示词"三层;总结提示词写死五类必留信息、关键值保留原文;警惕细节丢失、身份漂移、工具 id 断链三坑,完整历史另行落盘。
留一个练习:给 3.2 节的 compact_node 加"总结质检"——压缩完成后,自动从被压缩的早期对话里抽 3 个事实性问题(用户的原始目标、某个关键路径、某个已定决策),让另一个小模型只凭新摘要作答;答错任意一题就判定摘要不合格,加大保留窗口重新压缩一次。跑十轮长任务,对比质检开关前后的追问正确率,你会直观看到摘要质量的方差。
对于正在构建长会话 Agent 的团队,建议本周做一件事:把一段真实的长会话 dump 出来,按本文的保留清单人工压缩一次,看看压缩结果能不能撑住三个后续追问——人都做不好的事,先别急着交给模型自动化。

