S06 Compact 上下文压缩
随着我们向 AI 上下文中添加的内容越来越多:工具列表、文件内容、工具返回、skill 等等。无论是用 todo list 还是 sub Agent 技术,最终上下文还是会被占满的。如果我们不对上下文进行压缩, 智能体根本没法在大项目里干活。
所以我们要对上下文进行压缩,去掉一些用过一次就没用的东西,留下必须记住的东西。
这一章我们学习三种压缩的策略来达到我们想要的“无限”上下文的目的。
- micro-compact:在上下文中,工具返回可能是文件的内容、命令执行的结果、搜索结果等等,一般情况下,都是工具返回的结果占据最多的 token。所以第一个压缩策略 micro-compact 就是先压缩工具返回的结果:让 AI 将工具返回的结果总结为一句话,然后替换掉原本的工具返回的结果。
- auto-compact: 在 micro-compact 策略下,如果上下文的长度还是不可避免的超过了 256k 的阈值,那就启动第二种压缩策略,直接让 AI 将前面所有的对话总结成一句话,然后替换掉所有的上下文。
- archival: 当用户认为应该进行压缩的时候,直接由用户发起压缩命令,然后对整个上下文进行压缩,然后替换掉原本的上下文。
当然,后面两种压缩方式,并不是将历史全部直接丢掉不管了。完整历史通过备份到 transcript 目录保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
"上下文总要满的, 要有办法腾地方" -- 三种压缩策略, 换来无限会话。
代码流程图
代码
全量代码在 gitee.com/sanqiushu/D…
下面是一些全局变量,系统提示词我这里也用英文了,意思就是:你是一个工作在 {WORKDIR} 目录下的编程助手。请使用工具来解决问题。
然后是触发第二种压缩 auto-compact 的阈值,这里设置的小一点就写 50000 了,你可以根据自己的模型上下文大小来设置。
还有就是被压缩的原文副本存放的目录 TRANSCRIPT_DIR 和设置最近的 KEEP_RECENT 3 次工具输出结果不压缩。
# 系统提示词
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
# 阈值:按照你自己的模型来设置即可,正常来说 256k = 262144 你设置 200000 左右吧,我这里按照作者的代码里直接写 50000 了
THRESHOLD = 50000
# 对话历史的副本目录
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
# 设置保留最近的三条工具返回的结果。
KEEP_RECENT = 3
然后下面是结算消息历史的大小的函数,这里中文和英文的计算方式不太一样,英文其 Token 与单词、字符的换算关系相对稳定,差不多 1 个 Token ≈ 4 个英文字母(包括空格、标点等)。但是中文比较复杂:
综合来看,中文 Token 与汉字数量的对应关系如下:
- 常用汉字:在大多数情况下,一个常用汉字(如“我”、“人”、“山”)会被识别为一个独立的Token。
- 高频复合词:像“人工智能”、“云计算”这类在训练语料中出现频次极高的词组,通常会被整体视为一个Token,从而显著降低Token总量。
- 生僻字:对于不常见的字或词,分词器倾向于逐字切分,每个字单独成为一个Token。
- 标点符号:中文的标点符号(如句号、逗号)各占1个Token。
因此,中文的 Token 估算是一个范围值:
- 平均 1 个 Token ≈ 1.5 到 2 个汉字。这是最常被引用的经验公式。
我这里就不写中文的 token 计算了,等会就直接使用英文来说我们的需求,而且读文件这种任务,大多数代码文件都是英文的。
# 计算 token 数量
def estimate_tokens(messages: list) -> int:
"""Rough token count: ~4 chars per token."""
return len(str(messages)) // 4
micro_compact 微量压缩
下面是 micro_compact 微量压缩的代码。
def micro_compact(messages: list) -> list:
# 如果工具返回的内容没有超过 3 个,那就不压缩
tool_results = []
for message in messages:
if message["role"] == "tool":
tool_results.append(message)
if len(tool_results) <= KEEP_RECENT:
return messages
# 保留最近的三个工具返回的结果,其他的就直接替换掉
to_clear = tool_results[:-KEEP_RECENT]
for message in to_clear:
if len(message["content"]) > 100:
message["content"] = f"[Previous: used {message['tool_call_id']}]"
return messages
auto_compact 自动压缩
下面是第二种压缩 auto_compact 的具体代码:
def auto_compact(messages: list) -> list:
# 保存全量历史数据到磁盘
TRANSCRIPT_DIR.mkdir(exist_ok=True)
timestamp = int(time.time())
formatted_time = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d_%H-%M-%S')
transcript_path = TRANSCRIPT_DIR / f"transcript_{formatted_time}.jsonl"
with open(transcript_path, "a") as transcript_file:
for message in messages:
# 将每条消息都写入文件,使用 indent 进行格式化,ensure_ascii 转中文,default 处理特殊对象
transcript_file.write(json.dumps(message, indent=4, ensure_ascii=False, default=str) + "\n")
print(f"[transcript 备份已经保存到: {transcript_path}]")
# 与 AI 对话,要求其压缩对话。
conversation_text = json.dumps(messages, default=str)[:80000]
prompt = {"role": "user", "content":
"Summarize this conversation for continuity. Include: " +
"1) What was accomplished, 2) Current state, 3) Key decisions made. " +
"Be concise but preserve critical details.\n\n" + conversation_text}
response = client.chat.completions.create(model=MODEL, message=[prompt], max_tokens=2000)
summary = response.choices[0].message
return [
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
]
让我们仔细看看上面的代码:首先是保存对话历史的代码:
# 保存全量历史数据到磁盘
TRANSCRIPT_DIR.mkdir(exist_ok=True)
timestamp = int(time.time())
formatted_time = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d_%H-%M-%S')
transcript_path = TRANSCRIPT_DIR / f"transcript_{formatted_time}.jsonl"
with open(transcript_path, "w") as transcript_file:
for message in messages:
# 将每条消息都写入文件,使用 indent 进行格式化,ensure_ascii 转中文,default 处理特殊对象
transcript_file.write(json.dumps(message, indent=4, ensure_ascii=False, default=str) + "\n")
print(f"[transcript 备份已经保存到: {transcript_path}]")
文件名的格式是:transcript_2026-04-24_14-32-10.jsonl jsonl 的格式是每行一个 json,不需要把所有 json 强行组成一个数组。
{"xx":"xxx"}
{"xx":"xxx"}
{"xx":"xxx"}
然后后面是要求 AI 讲对话总结的代码
# 与 AI 对话,要求其压缩对话。
conversation_text = json.dumps(messages, default=str)[:80000]
prompt = {"role": "user", "content":
"Summarize this conversation for continuity. Include: " +
"1) What was accomplished, 2) Current state, 3) Key decisions made. " +
"Be concise but preserve critical details.\n\n" + conversation_text}
response = client.chat.completions.create(model=MODEL, message=[prompt], max_tokens=2000)
summary = response.choices[0].message
return [
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
]
其中提示词大意为:
prompt = {"role": "user", "content":
"请总结本次对话以确保连续性。总结需包含:\n" +
"1) 已完成的事项,\n" +
"2) 当前状态,\n" +
"3) 已做出的关键决策。\n" +
"请保持简洁,但保留关键细节。\n\n" + conversation_text}
总结后的结果大意为:
return [
{"role": "user", "content": f"[对话已压缩。完整记录文件:{transcript_path}]\n\n{summary}"},
{"role": "assistant", "content": "明白。我已从摘要中获取上下文。继续。"},
]
压缩不是免费的:摘要本身会消耗 token,还有生成摘要的 API 调用成本。如果对话只有 25,000 token,压缩可能节省 5,000 token,但需要一次 API 调用,且产出的摘要可能不如原文连贯。合适的阈值设置可以确保只在节省量明显超过开销时才进行压缩。
auto_compact 触发时,会生成摘要并替换全部消息历史,不会在摘要中保留最近的 N 条消息。这避免了一个微妙的连贯性问题:如果同时保留近期消息和旧消息的摘要,模型会看到重叠内容的两种表示。摘要可能说'我们决定使用方案 X',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。
尽管上下文被压缩了,但完整的未压缩对话仍会追加到磁盘上的 JSONL 文件中。每条消息、每次工具调用、每个结果都不会丢失。压缩对对话上下文是有损操作,但 JSONL 记录是无损的。事后分析(调试 agent 行为、计算 token 用量、提取训练数据)始终可以基于完整记录进行。JSONL 格式仅追加写入,对并发写入安全,易于流式处
手动压缩
然后是新建一个第三种压缩方式,手动压缩
# 工具索引
TOOL_HANDLERS = {
......
"compact": lambda **kw: "Manual compression requested.",
# "compact": lambda **kw: "已请求手动压缩。"
}
然后是发给 AI 的工具的描述
TOOLS = BASE_TOOLS + [
{
"type": "function",
"function": {
"name": "compact",
"description": "Trigger manual conversation compression.", # 触发手动对话压缩。
"parameters": {
"type": "object",
"properties": {
"focus": {
"type": "string",
"description": "What to preserve in the summary" # 在摘要中需要保留的内容
}
},
}
}
}
]
下面是 Agent 主循环:总体没啥变化,就是当 AI 想要调用 compact 函数的时候,手动调用 auto_compact 来压缩上下文就行,?不对呀,前面不是说第三种 compact 压缩是用户主动调用的吗,咋变成 AI 想要调用了?
其实是用户输入提示词,类似于 Use the compact tool to manually compress the conversation 这个时候 AI 就会帮我们 "主动" 调用了。这样就可以不用在程序中用代码做特殊的处理,而是将压缩逻辑借助 AI 来实现,如果不这样做可能会增加用户的操作成本,比如加一个主动压缩的按钮什么的,这还不如让用户直接对着 AI 说“我要压缩”来的方便。
def agent_loop(messages: list):
while True:
# 每一次对话前都执行微量压缩
micro_compact(messages)
# 如果 token 超过阈值 50000 了,就执行自动压缩
if estimate_tokens(messages) > THRESHOLD:
print("[仔细自动压缩中.....]")
messages[:] = auto_compact(messages)
response = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS, max_tokens=8000)
# 将大模型返回的消息添加到消息列表中
message = response.choices[0].message
messages.append(message.model_dump())
# 如果大模型没有调用工具,那么结束执行
if response.choices[0].finish_reason != "tool_calls":
# 打印 AI 的响应
print(f"\n\033[36mAI: {message.content}\033[0m\n")
return
# 打印 AI 的响应
print(f"\n\033[36mAI: \033[0m\033[36;9m思考: {message.content}\033[0m\n\033[36m回答:{message.reasoning_content} \033[0m\n")
# 如果 AI 需要调用工具
manual_compact = False
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
call_id = tool_call.id
# 3. 路由并执行本地函数
if func_name == "compact":
manual_compact = True
output = "压缩中....."
else:
handler = TOOL_HANDLERS.get(func_name)
try:
output = handler(**func_args) if handler else f"不存在 {func_name} 工具"
except Exception as e:
output = f"Error: {e}"
print(f"\n\033[33mtools : \n{output}\033[0m\n") # 打印工具的输出
messages.append({"role": "tool", "content": output, "tool_call_id": call_id})
if manual_compact:
print("[手动压缩]")
messages[:] = auto_compact(messages)
然后是 main 函数,也写了个循环,这样就可以在当前上下文里多次跟 AI 沟通了。
if __name__ == "__main__":
# 注入模型需遵循的指令,包括扮演的角色、背景信息等。
history = [{"role": "system", "content": SYSTEM}]
while True:
try:
hi = f"{ Path(sys.argv[0]).stem} :) 你好人类👋 (输入 exit 退出)"
query = input(f"\n\033[36m{hi}\033[0m\n>> ")
except (EOFError, KeyboardInterrupt):
exit()
if query.strip().lower() in ("q", "exit", ""):
exit()
history.append({"role": "user", "content": query})
agent_loop(history)
# print(json.dumps(history, indent=4, ensure_ascii=False))
open("result.json", "w").write(json.dumps(history, indent=4, ensure_ascii=False))
然后我让 AI 帮我执行了两个任务:
任务 1:Read every Python file in the agents/ directory one by one, and Explain the content of the document to Chinese.
任务2:Use the compact tool to manually compress the conversation
总体来说, AI 还行,能完成一定的任务
但是我让他压缩的时候,它又把第一个任务执行了一遍,..... 服了。