【Java/Go后端手撸原生Agent(第八篇):摘要压缩——让Agent“记要点“而不是“记流水账“】

系列目录:[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.pytools/下所有工具、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 三种上下文管理策略的分层

回顾上一篇的策略分层:

策略原理可靠性阶段
滑动窗口从最早开始删完整块确定性,不调LLMPhase 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(续)===
现在我已经有了项目结构的摘要,继续分析...

注意看:

  1. 第7轮THINKING时触发裁剪,删了2个最早的消息块(第1轮的user提问和对应的assistant+tool结果);
  2. 被删的内容经LLM压缩成一段约100字的摘要,涵盖了项目语言版本、核心模块、工具列表等关键信息;
  3. 摘要通过get_messages()作为[对话摘要]消息插入最前面,LLM在第7轮能看到之前几轮的结论;
  4. 如果后续继续多轮对话再次触发裁剪,旧摘要+新被裁块一起重新摘要,摘要始终是最新的全局要点。

七、小结

本阶段做的事情:

  1. memory.py:新增summary字段、set_summary/get_messages带摘要插入/_summary_token_count;_pop_oldest_block返回被删消息列表;truncate返回被裁块列表;新增_message_to_text和blocks_to_summary_text辅助函数;
  2. main.py:新增SUMMARY_SYSTEM_PROMPT(5必含+5约束)、_summarize函数(fail-open降级);THINKING状态裁剪后自动触发摘要生成和更新;
  3. 核心设计原则:先裁剪后摘要(确定性优先)、fail-open(摘要失败不阻塞)、重新生成而非追加(摘要长度可控)、内部消息不进摘要(避免污染)。

至此,Agent的上下文管理从"无限增长→爆了就硬删"升级为"超了先裁剪+裁掉内容做摘要"——Agent不会再因为长对话而"失忆",它会像人一样记住要点、忘掉细节。

后续可选做:当对话继续延长,单条摘要300字可能不够用(多轮任务的摘要会越来越泛化),可以引入多层记忆架构:短期记忆(当前history)→工作记忆(summary摘要)→长期记忆(向量数据库RAG)。但那是需要引入向量数据库的大改动,等当前这套摘要机制跑起来遇到真实瓶颈再做。

下一篇链接:【Java/Go后端手撸原生Agent(第九篇):Plan-and-Execute规划模式——从“走一步看一步“到“先谋后动“】

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值