系列目录:[Phase 1-2 基础框架] → [Phase 3 状态机+文件读取] → [Phase 4-6 原生FC+Judge校验] → [Phase 5 多工具并行+BashTool] → [Phase 6 流式输出] → [Phase 7 Token预算与滑动窗口] → Phase 8 摘要压缩(本文)
上一篇链接:Java/Go后端手撸原生Agent(第七篇):Token预算管理 + 滑动窗口上下文裁剪
前言
上一篇我们实现了Token精确计数和滑动窗口裁剪,解决了"上下文爆了怎么办"的问题——超预算就从最早的消息开始删,严格保护tool_calls↔tool_result的配对完整性。但滑动窗口有一个本质缺陷:硬删除=永久遗忘。
想象这个场景:你和Agent对话了10轮,Agent用read_file读了你的项目结构、用bash跑了测试、用calculator算了几个数值,最后你问"总结一下我们分析了什么"。如果前7轮因为token预算被裁掉了,Agent只记得最近3轮的对话,它会回答得像失忆了一样。
人类不会这样。我们听完一段长对话后,不会逐字记住每句话,但会提炼要点留在脑子里:用户想干什么、已经确定了什么结论、哪些工具已经用过、关键路径是什么。这就是摘要压缩的核心思想——用~200个token的摘要保留~2000+ token的语义信息。
本文完成摘要压缩能力。改造文件:agent/memory.py(summary字段+消息转文本+摘要源文本拼接)、main.py(SUMMARY_PROMPT+_summarize函数+THINKING集成)。零改动文件:llm_client.py、tools/下所有工具、Judge逻辑、token_counter.py。

一、问题本质:硬删除的代价
1.1 纯滑动窗口的失忆问题
纯滑动窗口裁剪后的消息序列长这样:
[系统Prompt]
[对话摘要] ← (本文新增)
user: "第8轮问题:再帮我看看xxx"
assistant(tool_calls=[...]): ...
tool: ...
assistant: "根据工具结果..."
user: "[Judge反馈] 你还没回答完整..."
assistant(tool_calls=[...]): ...
最早的1-7轮对话全部消失。用户在第1轮说"我的项目路径是/Users/xxx/project,用Python 3.9,目标是重构遗留Java系统",这些关键信息也被一起删掉了。Agent在第10轮可能会问"你的项目在哪个目录?",就像失忆了一样。
1.2 摘要的本质:有损压缩保语义
摘要本质上是有损压缩。就像JPEG对图片做有损压缩——你能看清图片内容,但像素级数据丢失了。摘要保的是语义要点(用户目标、已确定结论、关键约束、未完成任务),丢的是细节流水账(中间推理、错误尝试、重复工具调用)。
关键设计决策:什么时候做摘要? 不是每轮都做(太贵,且没必要),而是在滑动窗口实际删掉消息块时才做——被删掉的块就是"摘要源"。这个触发时机和GC很像:不是每次内存分配都GC,而是分配后发现超了才回收。
1.3 三种上下文管理策略的分层
回顾上一篇的策略分层:
| 策略 | 原理 | 可靠性 | 阶段 |
|---|---|---|---|
| 滑动窗口 | 从最早开始删完整块 | 确定性,不调LLM | Phase 7已做 |
| 摘要压缩 | 被删块→LLM压缩成要点→插回历史 | 保留语义,依赖LLM | 本文做 |
| 重要性排序/RAG | 按相关性选择保留,向量检索 | 最优但复杂 | 未来 |
摘要压缩的定位:确定性裁剪之上的语义保留层。裁剪是"保证不爆窗口"(必须做),摘要是"尽量不忘记重要信息"(尽量做,但失败不阻塞)。
二、设计决策
在写代码前,先回答几个关键设计问题。
Q1:摘要存在哪里?
不放在SYSTEM_PROMPT里(行为规则和历史上下文语义不同,混在一起不好维护)。也不引入新的message role(OpenAI API只有system/user/assistant/tool四种)。方案:在ShortMemory新增summary: Optional[str]字段,get_messages()时在最前面插入一条带[对话摘要]前缀的user消息。这和已有的[Judge反馈]、[系统提示]前缀标记法一致。
Q2:摘要怎么更新?追加还是重新生成?
追加会导致摘要无限增长(第一轮摘要200字,第二轮追加到400字,第三轮600字……最后摘要自己就爆了token)。正确做法是每次更新时把旧摘要+新被裁块一起丢给LLM重新生成。摘要始终控制在~300字以内。
Q3:哪些内容进摘要?哪些不进?
进摘要:用户原始问题和需求、已确定的关键事实和答案、关键工具结论、用户提到的约束条件(路径、环境变量、偏好)、未完成的子任务。
不进摘要:框架内部消息([Judge反馈]、[系统提示]重复调用拦截)、中间推理过程、重复/错误尝试、过长的工具原始输出(截断到300字以内)。
Q4:摘要调用失败怎么办?
fail-open:降级为纯滑动窗口。摘要就是调chat_completion,如果网络错误/LLM返回异常,直接保留旧摘要(或没有摘要就没有),不阻塞主流程。Agent可能忘了早期信息但不会崩溃。
Q5:摘要是同步还是异步?
同步。摘要调用是轻量调用(不带tools、输入约1000 token、输出约200 token),比主轮THINKING调用快得多,阻塞时间可忽略。学习阶段同步最直接,不需要引入asyncio复杂度。
三、改造1:ShortMemory支持summary
3.1 消息转文本辅助函数
给LLM做摘要前,需要把结构化消息转成简洁文本。直接把原始JSON丢给LLM会浪费token在id/type/tool_call_id等结构字段上。
SUMMARY_PREFIX = "[对话摘要]"
def _message_to_text(msg: dict[str, Any]) -> str:
"""把一条消息转为简洁文本,用于送给LLM做摘要(去掉冗余结构,保留语义)"""
role = msg.get("role", "")
content = msg.get("content") or ""
if role == "assistant" and msg.get("tool_calls"):
tools_desc = []
for tc in msg["tool_calls"]:
fn = tc.get("function", {})
name = fn.get("name", "")
args = fn.get("arguments", "")
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
pass
args_str = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else str(args)
if len(args_str) > 200:
args_str = args_str[:200] + "..."
tools_desc.append(f"{name}({args_str})")
thinking = f",思考:{content}" if content else ""
return f"AI调用工具: {', '.join(tools_desc)}{thinking}"
if role == "tool":
content_str = str(content)
if len(content_str) > 300:
content_str = content_str[:300] + "..."
return f"工具返回: {content_str}"
if role == "user":
if content.startswith("["):
return None # 框架内部消息([Judge反馈]、[系统提示]等)不进入摘要
return f"用户: {content}"
if role == "assistant":
return f"AI回答: {content}"
return None
注意过滤user消息中以[开头的内容——这是框架注入的反馈消息([Judge反馈] xxx、[系统提示] 你已经调用过...),这些是运行时纠偏信号,不是用户意图,不应该进入长期摘要。
3.2 blocks_to_summary_text:拼接摘要源文本
def blocks_to_summary_text(blocks: list[list[dict]], existing_summary: Optional[str]) -> str:
"""把被裁掉的消息块和旧摘要转为待摘要的文本,喂给LLM做压缩"""
parts = []
if existing_summary:
parts.append(f"已有摘要:\n{existing_summary}")
parts.append("新的对话内容(需要合并到摘要中):")
for block in blocks:
for msg in block:
text = _message_to_text(msg)
if text:
parts.append(text)
return "\n".join(parts)
如果有旧摘要,先把旧摘要放进去——LLM会基于旧摘要+新内容做合并,而不是从零生成。这样摘要能累积所有历史要点。
3.3 ShortMemory新增summary相关方法
核心改动:
class ShortMemory:
def __init__(self, max_tokens: int = 6000):
self.history: list[dict[str, Any]] = []
self.max_tokens = max_tokens
self.truncated_blocks = 0
self.summary: Optional[str] = None # 新增:对话摘要
def set_summary(self, text: Optional[str]):
"""设置或更新对话摘要"""
self.summary = text
def get_messages(self) -> list[dict]:
"""返回消息列表(浅拷贝)。如果有摘要,在最前面插入[对话摘要]消息"""
messages = []
if self.summary:
messages.append({"role": "user", "content": f"{SUMMARY_PREFIX}\n{self.summary}"})
messages.extend(m.copy() for m in self.history)
return messages
def _summary_token_count(self) -> int:
"""摘要消息的token开销(get_messages时会插入这条消息)"""
if not self.summary:
return 0
return count_message({"role": "user", "content": f"{SUMMARY_PREFIX}\n{self.summary}"})
def token_count(self) -> int:
"""当前memory的有效token总数(含摘要消息)"""
return count_messages(self.history) + self._summary_token_count()
_pop_oldest_block改为返回被删除的消息列表(之前只返回删除的token数):
def _pop_oldest_block(self) -> list[dict]:
"""删除最早的一个完整消息块,返回被删除的消息列表"""
if not self.history:
return []
first = self.history[0]
removed: list[dict] = []
# 独立消息块
if first["role"] == "user" or (first["role"] == "assistant" and not first.get("tool_calls")):
removed.append(self.history.pop(0))
return removed
# 工具调用块:assistant(tool_calls) + 匹配的tool消息
if first["role"] == "assistant" and first.get("tool_calls"):
tc_ids = {tc["id"] for tc in first["tool_calls"]}
removed.append(self.history.pop(0))
i = 0
while i < len(self.history) and self.history[i]["role"] == "tool":
msg_tc_id = self.history[i].get("tool_call_id")
if msg_tc_id in tc_ids:
removed.append(self.history.pop(i))
tc_ids.discard(msg_tc_id)
if not tc_ids:
break
else:
i += 1
return removed
# 异常:tool消息在最前面
removed.append(self.history.pop(0))
return removed
truncate方法改为返回被裁掉的块列表:
def truncate(self, reserve_tokens: int = 0) -> list[list[dict]]:
"""
裁剪最早的消息块,直到token数在max_tokens - reserve_tokens以内。
:return: 被裁掉的消息块列表(每个块是一个消息list),供调用方做摘要
"""
budget = self.max_tokens - reserve_tokens
if budget < 0:
budget = 0
removed_blocks: list[list[dict]] = []
while self.history and (count_messages(self.history) + self._summary_token_count()) > budget:
block = self._pop_oldest_block()
if not block:
break
removed_blocks.append(block)
self.truncated_blocks += 1
return removed_blocks
注意truncate里的token预算计算包含了self._summary_token_count()——如果摘要已经存在,它占的token要算在总预算里,否则可能出现"摘要+history加起来又超了"的情况。
四、改造2:摘要生成函数
4.1 SUMMARY_PROMPT
SUMMARY_SYSTEM_PROMPT = """你是对话摘要生成器。你的任务是将一段对话历史(以及可能存在的旧摘要)压缩为一段简洁的要点摘要,供AI助手在后续对话中参考,避免遗忘早期关键信息。
摘要必须包含以下内容(如果相关):
1. 用户的核心问题、需求和任务目标;
2. 已经确定的重要事实、结论和答案;
3. 已经调用过的关键工具及其核心结论(不需要记录所有细节,只记录有价值的结论);
4. 用户提到的约束条件(路径、环境、偏好等);
5. 尚未完成的子任务或待解决的问题。
要求:
- 摘要控制在300字以内,用第三人称客观描述;
- 如果已有旧摘要,将新信息合并进去形成更新版摘要,不要简单拼接;
- 删除寒暄、重复、错误尝试、中间推理等冗余信息;
- 框架内部消息([Judge反馈]、[系统提示]等)不需要记录;
- 如果没有实质内容,返回空字符串。
直接输出摘要文本,不要加前缀或解释。"""
这个prompt明确要求5类必含信息和5个约束,LLM输出质量可控。
4.2 _summarize函数
def _summarize(removed_blocks: list[list[dict]], existing_summary: Optional[str]) -> str:
"""
把被裁掉的消息块和旧摘要一起喂给LLM,生成新摘要。
如果LLM调用失败,返回旧摘要(降级为纯滑动窗口)。
"""
summary_text = blocks_to_summary_text(removed_blocks, existing_summary)
if not summary_text.strip():
return existing_summary or ""
messages = [
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
{"role": "user", "content": f"请生成/更新对话摘要:\n\n{summary_text}"},
]
try:
resp = chat_completion(messages)
if resp.content and len(resp.content.strip()) > 10:
return resp.content.strip()
except Exception:
pass # 摘要失败降级:保留旧摘要
return existing_summary or ""
fail-open设计:任何异常(网络错误、空响应、内容过短)都直接返回旧摘要。摘要不是主流程,不能因为摘要失败导致整个Agent崩溃。
五、改造3:THINKING集成
在THINKING状态的token预算检查中,裁剪后拿到被删块就调用摘要:
if total_estimate > MODEL_CONTEXT_WINDOW:
print(f"【上下文管理】当前约{total_estimate} tokens,超出窗口{MODEL_CONTEXT_WINDOW},裁剪最早对话...")
removed_blocks = memory.truncate(reserve_tokens=external_reserve)
if removed_blocks:
# 摘要压缩:把被裁掉的块+旧摘要一起丢给LLM生成新摘要
new_summary = _summarize(removed_blocks, memory.summary)
memory.set_summary(new_summary if new_summary else memory.summary)
print(f"【上下文管理】已裁剪{len(removed_blocks)}个消息块(累计{memory.truncated_blocks}块),剩余约{memory.token_count()} tokens")
if new_summary:
print(f"【对话摘要】{new_summary[:100]}{'...' if len(new_summary) > 100 else ''}")
else:
print(f"【上下文管理】裁剪后剩余约{memory.token_count()} tokens")
关键顺序:先裁剪(确定性),再摘要(尽力而为)。裁剪保证不超token窗口,摘要是锦上添花。如果_summarize调用很慢或失败,裁剪已经完成了,Agent不会爆窗口。
六、运行效果
配置8K窗口跑一个多轮复杂任务,触发裁剪后:
=== 第6轮 THINKING ===
用户问了文件结构问题,我先用bash看一下目录...
【工具调用意图】bash
...
=== 第7轮 THINKING ===
【上下文管理】当前约8342 tokens,超出窗口8192,裁剪最早对话...
【上下文管理】已裁剪2个消息块(累计2块),剩余约5890 tokens
【对话摘要】用户要求分析native-agent-demo项目结构。已确认项目是Python 3.9实现的原生Agent,核心模块包括:llm_client.py(支持同步和流式SSE调用)、agent/memory.py(ShortMemory带token管理)、agent/token_counter.py(tiktoken精确计数)、tools/下三个工具(calculator/read_file/bash)...
=== 第7轮 THINKING(续)===
现在我已经有了项目结构的摘要,继续分析...
注意看:
- 第7轮THINKING时触发裁剪,删了2个最早的消息块(第1轮的user提问和对应的assistant+tool结果);
- 被删的内容经LLM压缩成一段约100字的摘要,涵盖了项目语言版本、核心模块、工具列表等关键信息;
- 摘要通过get_messages()作为
[对话摘要]消息插入最前面,LLM在第7轮能看到之前几轮的结论; - 如果后续继续多轮对话再次触发裁剪,旧摘要+新被裁块一起重新摘要,摘要始终是最新的全局要点。
七、小结
本阶段做的事情:
- memory.py:新增summary字段、set_summary/get_messages带摘要插入/_summary_token_count;_pop_oldest_block返回被删消息列表;truncate返回被裁块列表;新增_message_to_text和blocks_to_summary_text辅助函数;
- main.py:新增SUMMARY_SYSTEM_PROMPT(5必含+5约束)、_summarize函数(fail-open降级);THINKING状态裁剪后自动触发摘要生成和更新;
- 核心设计原则:先裁剪后摘要(确定性优先)、fail-open(摘要失败不阻塞)、重新生成而非追加(摘要长度可控)、内部消息不进摘要(避免污染)。
至此,Agent的上下文管理从"无限增长→爆了就硬删"升级为"超了先裁剪+裁掉内容做摘要"——Agent不会再因为长对话而"失忆",它会像人一样记住要点、忘掉细节。
后续可选做:当对话继续延长,单条摘要300字可能不够用(多轮任务的摘要会越来越泛化),可以引入多层记忆架构:短期记忆(当前history)→工作记忆(summary摘要)→长期记忆(向量数据库RAG)。但那是需要引入向量数据库的大改动,等当前这套摘要机制跑起来遇到真实瓶颈再做。
下一篇链接:【Java/Go后端手撸原生Agent(第九篇):Plan-and-Execute规划模式——从“走一步看一步“到“先谋后动“】
60

被折叠的 条评论
为什么被折叠?



