上下文工程:长对话 Agent 的上下文衰减与重建

长对话 Agent 不是败在模型能力,而是败在上下文管理。系统对比滑动窗口、检索注入和分层压缩三种策略,给出可落地的衰减诊断与重建方案。

AgentList Team · 2026年6月29日
上下文工程长上下文RAGContext7记忆系统

做一个多轮 Agent 系统,你迟早会遇到同一个瓶颈:不是模型不够强,而是上下文窗口不够用。

一个客服 Agent 平均对话 12 轮,每轮 300-500 tokens,加上工具返回、系统提示、检索结果,第 8 轮时上下文已经超过 32K。到了第 15 轮,模型开始"忘记"第 3 轮用户提过的约束,或者把工具返回的 JSON 和用户提问混在一起理解。

这种现象叫上下文衰减(Context Decay)——不是信息真的消失了,而是它在上下文中的位置和显著性被后续内容稀释了。

本文不谈"如何扩展上下文窗口",而是回答一个更实际的问题:当上下文即将耗尽时,你应该丢弃什么、保留什么、如何恢复?

衰减的三重机制

理解衰减之前,先明白它为什么发生。主要有三种机制:

位置衰减:Transformer 的注意力机制对远处 token 的敏感度呈指数下降。第 1 轮的信息在第 20 轮时,注意力分数可能只有原始的 5-10%。这不是 bug,是架构特性。

显著性稀释:每轮新消息都在争夺注意力"预算"。工具返回的大段 JSON、系统提示的冗长约束、甚至 emoji 都会挤占关键信息的空间。

语义漂移:多轮对话中,话题会自然转移。第 3 轮讨论的"订单号 12345"在第 12 轮讨论"退款政策"时,虽然还在上下文里,但模型已经不太会主动关联到它。

这三个机制叠加,导致长对话 Agent 在第 10-20 轮之间出现明显的质量拐点——回答变模糊、工具调用变随意、约束被忽略。

策略一:滑动窗口 + 摘要桥接

最直观的做法是保留最近 N 轮完整对话,更早的轮次用摘要替代。核心问题是:摘要本身也会衰减。

from dataclasses import dataclass, field
from typing import Any
from enum import Enum


class MessageRole(Enum):
    USER = "user"
    ASSISTANT = "assistant"
    SYSTEM = "system"
    TOOL = "tool"


@dataclass
class Message:
    role: MessageRole
    content: str
    turn: int
    token_count: int = 0


@dataclass
class SlidingWindowConfig:
    window_size: int = 6              # 保留最近 6 轮完整对话
    summary_trigger: int = 10         # 超过 10 轮触发摘要
    max_summary_tokens: int = 500     # 摘要长度上限


class ConversationBuffer:
    def __init__(self, config: SlidingWindowConfig):
        self.config = config
        self.messages: list[Message] = []
        self.summary: str = ""

    def add(self, message: Message):
        self.messages.append(message)
        self._maybe_compress()

    def _maybe_compress(self):
        if len(self.messages) < self.config.summary_trigger:
            return
        boundary = len(self.messages) - self.config.window_size
        if boundary <= 0:
            return
        to_compress = self.messages[:boundary]
        self.summary = self._compress(to_compress, self.summary)
        self.messages = self.messages[boundary:]

    def _compress(self, old_messages: list[Message], prev_summary: str) -> str:
        """用 LLM 压缩旧消息为摘要,保留关键实体和决策"""
        # 简化实现:实际应调用 LLM 生成结构化摘要
        parts = []
        if prev_summary:
            parts.append(f"Previous summary: {prev_summary}")
        for msg in old_messages:
            if msg.role == MessageRole.USER:
                parts.append(f"User asked: {msg.content[:100]}")
            elif msg.role == MessageRole.TOOL:
                parts.append(f"Tool returned: {msg.content[:80]}")
        return " | ".join(parts)

    def build_context(self) -> list[dict]:
        context = []
        if self.summary:
            context.append({
                "role": "system",
                "content": f"[Conversation summary] {self.summary}"
            })
        for msg in self.messages:
            context.append({
                "role": msg.role.value,
                "content": msg.content
            })
        return context

关键设计点

  • 摘要不是简单截断,而是保留"谁、做了什么、结果是什么"的结构化信息
  • summary_trigger 设为 window_size * 1.5 左右,给摘要留出缓冲空间
  • 每次压缩时把旧摘要也纳入输入,避免信息断裂

适用场景:客服对话、技术支持等话题相对线性、不频繁回溯的场景。

局限:摘要本身会丢失细节,而且摘要质量取决于 LLM 的压缩能力。如果对话涉及大量精确数值、订单号、人名,滑动窗口容易把这些关键信息挤掉。

策略二:检索注入(RAG for Context)

与其压缩所有历史,不如把历史对话当作"文档库",每次对话时只检索相关的片段。

import hashlib
from dataclasses import dataclass
from typing import Any


@dataclass
class ContextChunk:
    chunk_id: str
    turn: int
    role: str
    content: str
    embedding: list[float] | None = None
    metadata: dict[str, Any] = field(default_factory=dict)


class RetrievalAugmentedBuffer:
    def __init__(self, embedding_fn, vector_store, top_k: int = 5):
        self.embedding_fn = embedding_fn
        self.vector_store = vector_store
        self.top_k = top_k
        self.recent: list[ContextChunk] = []
        self.recent_limit = 4  # 最近 4 轮始终保留

    def add_turn(self, user_msg: str, assistant_msg: str, tool_results: list[str]):
        turn = len(self.recent) + 1
        # 将每轮拆成语义块
        chunks = self._chunk_turn(turn, user_msg, assistant_msg, tool_results)
        for chunk in chunks:
            chunk.embedding = self.embedding_fn(chunk.content)
            self.vector_store.upsert(chunk)
        self.recent.extend(chunks)

    def _chunk_turn(self, turn: int, user: str, assistant: str, tools: list[str]) -> list[ContextChunk]:
        chunks = []
        # 用户消息单独成块
        chunks.append(ContextChunk(
            chunk_id=hashlib.sha256(f"t{turn}-user".encode()).hexdigest()[:12],
            turn=turn,
            role="user",
            content=user,
        ))
        # 助手回复单独成块
        if assistant:
            chunks.append(ContextChunk(
                chunk_id=hashlib.sha256(f"t{turn}-asst".encode()).hexdigest()[:12],
                turn=turn,
                role="assistant",
                content=assistant,
            ))
        # 工具结果合并为一个块(通常较短且有结构)
        if tools:
            chunks.append(ContextChunk(
                chunk_id=hashlib.sha256(f"t{turn}-tools".encode()).hexdigest()[:12],
                turn=turn,
                role="tool",
                content="\n".join(tools),
                metadata={"type": "tool_result"},
            ))
        return chunks

    def build_context(self, current_query: str) -> list[dict]:
        # 1. 检索相关历史块
        query_emb = self.embedding_fn(current_query)
        relevant = self.vector_store.query(query_emb, top_k=self.top_k)
        relevant_ids = {c.chunk_id for c in relevant}

        # 2. 最近轮次始终保留
        recent_chunks = [c for c in self.recent[-self.recent_limit * 3:] if c.chunk_id not in relevant_ids]

        # 3. 合并:检索结果 + 最近对话 + 当前查询
        all_chunks = sorted(
            relevant + recent_chunks,
            key=lambda c: (c.turn, c.chunk_id)
        )
        return [{"role": c.role, "content": c.content} for c in all_chunks]

关键设计点

  • 每轮对话拆成 2-3 个语义块(用户/助手/工具),而不是整轮塞进一个块,这样检索精度更高
  • 最近 N 轮始终全量保留,避免检索系统"忘记"刚刚发生过什么
  • 工具返回单独成块,因为它们通常包含精确数据(ID、金额、状态),检索时应该高权重

适用场景:话题频繁跳转的对话、知识问答 Agent、需要回溯历史细节的场景。

工具参考Context7 提供了库文档检索注入的现成方案;Claude Context 用 Milvus 向量库做代码级语义检索,思路类似但针对代码库上下文。

策略三:分层压缩 + 关键信息外化

前两种策略各有所长,但都没解决一个根本问题:有些信息无论如何不能丢——用户的姓名、订单号、当前任务目标、已确认的约束。

分层压缩的思路是:把上下文分成"热层"和"冷层"。热层保留当前轮次需要的所有细节;冷层只存结构化摘要和关键实体。

from dataclasses import dataclass, field
from typing import Any
from enum import Enum


class Layer(Enum):
    HOT = "hot"       # 当前对话窗口,完整保留
    WARM = "warm"     # 最近几轮的结构化摘要
    COLD = "cold"     # 长期实体存储(用户信息、任务状态)


@dataclass
class KeyFact:
    key: str
    value: str
    source_turn: int
    confidence: float = 1.0


@dataclass
class LayeredContext:
    hot_messages: list[dict] = field(default_factory=list)
    warm_summary: str = ""
    key_facts: dict[str, KeyFact] = field(default_factory=dict)
    hot_limit: int = 6

    def add_exchange(self, user: str, assistant: str):
        self.hot_messages.append({"role": "user", "content": user})
        self.hot_messages.append({"role": "assistant", "content": assistant})
        # 提取关键实体并外化
        self._extract_facts(user, assistant)
        self._maybe_demote()

    def _extract_facts(self, user: str, assistant: str):
        """从对话中提取关键实体,存入冷层"""
        # 简化实现:实际应调用 LLM 或规则引擎提取
        import re
        # 提取数字型 ID(订单号、工单号等)
        ids = re.findall(r'\b[A-Z]{2,3}-\d{4,}\b', user + " " + assistant)
        for id_ in ids:
            self.key_facts[id_] = KeyFact(
                key="reference_id",
                value=id_,
                source_turn=len(self.hot_messages) // 2,
            )
        # 提取用户明确提到的偏好
        if "prefer" in user.lower() or "prefer" in assistant.lower():
            # 实际实现中这里应该用更精确的提取逻辑
            pass

    def _maybe_demote(self):
        if len(self.hot_messages) > self.hot_limit * 2:
            # 将最旧的几轮提升为 warm summary
            boundary = len(self.hot_messages) - self.hot_limit * 2
            old = self.hot_messages[:boundary]
            self.warm_summary = self._summarize(old, self.warm_summary)
            self.hot_messages = self.hot_messages[boundary:]

    def build_prompt(self) -> str:
        parts = []
        if self.key_facts:
            facts_str = "\n".join(
                f"- {k}: {v.value}" for k, v in self.key_facts.items()
            )
            parts.append(f"[Key facts that must not be lost]\n{facts_str}")
        if self.warm_summary:
            parts.append(f"[Earlier conversation summary]\n{self.warm_summary}")
        parts.extend([f"{m['role']}: {m['content']}" for m in self.hot_messages])
        return "\n\n".join(parts)

    def _summarize(self, old_messages: list[dict], prev: str) -> str:
        # 实际实现调用 LLM
        return prev + " | " + "; ".join(m["content"][:60] for m in old_messages)

关键设计点

  • key_facts 是冷层的核心——它不随上下文窗口滚动而丢失,永远作为 system prompt 的一部分注入
  • 关键实体提取可以基于规则(正则匹配订单号、邮箱)或 LLM 判断,前者速度快但覆盖面窄,后者更灵活但成本高
  • 分层让模型"知道"哪些信息是绝对不能丢的,从而在生成时主动引用

适用场景:任务型 Agent(订单处理、工单系统)、需要跨多轮保持精确记忆的场景。

工具参考Context Mode 的"Think in Code"范式本质上是一种分层压缩——让 LLM 写分析脚本而不是直接处理原始数据,把 315KB 的工具输出压缩到 5.4KB,实现 98% 的上下文缩减。

衰减诊断清单

在决定用哪种策略之前,先给你的 Agent 做一次诊断:

诊断项 检查方法 如果命中
平均对话轮次 > 15 统计日志中 user 消息出现频率 需要某种形式的压缩
工具返回平均 > 500 tokens 抽样检查工具输出长度 优先考虑分层压缩
用户频繁回溯早期内容 搜索"刚才说""之前""前面"等指代 检索注入比分摘要更合适
对话中有大量精确数据(ID、金额) 分析消息中的数字/编码模式 必须外化关键实体
话题线性推进,很少回溯 观察对话主题变化曲线 滑动窗口通常足够

不要一上来就上 RAG 或分层压缩。如果你的对话平均只有 8 轮,滑动窗口 + 200 token 摘要就已经够用。过早优化是上下文工程里最常见的错误。

三个常见错误

错误一:把摘要当成翻译

很多人用 LLM 做摘要时,prompt 写的是"把这段对话翻译成英文摘要"。摘要不是翻译——它应该保留实体、决策和待办事项,而不是改写句子。一个 500 字的摘要如果丢掉了唯一的订单号,它再流畅也没有价值。

正确做法:摘要 prompt 明确要求提取"关键实体 + 已确认事项 + 待办事项",而不是"总结这段对话"。

错误二:检索精度差,不如不检索

RAG 策略的效果高度依赖检索质量。如果 embedding 模型对短文本("好的""嗯")的处理和长文本一样,检索结果会充满噪音。而且 Agent 对话中的指代("那个订单")在向量空间里可能离真正相关的历史很远。

正确做法:检索前先做 query rewriting——把当前轮次的指代消解为完整表述,再去检索。另外对工具返回、用户消息用不同的 embedding 权重。

错误三:冷层只有摘要,没有实体

分层压缩最容易被做成"热层 + 更长的摘要"。这是错的。冷层的价值不在于"更短的历史",而在于结构化的关键信息。用户在第 2 轮提到的邮箱地址,不应该藏在第 50 行的摘要里——它应该作为一个 key-value 对,永远在 system prompt 中可见。

总结

  • 上下文衰减是 Transformer 架构的固有特性,不是可以通过"更好的模型"解决的问题。位置敏感度下降、显著性稀释、语义漂移三种机制同时作用,在 10-20 轮对话时形成质量拐点
  • 滑动窗口 + 摘要适合线性对话,实现简单,但对精确信息保留能力弱。适合客服、技术支持等话题不频繁回溯的场景
  • 检索注入适合话题跳跃的对话,把历史当作文档库按需检索,不丢失细节但检索精度是瓶颈。需要配合 query rewriting 和分块策略
  • 分层压缩是生产环境的最优解——热层保细节、冷层存实体。关键信息永远外化到摘要之外,确保模型不会"忘记"用户的姓名、订单号和约束
  • 先诊断再选方案。平均轮次、工具返回大小、回溯频率、精确数据密度这四个指标决定了你应该用哪种策略。不要一上来就上最复杂的方案

推荐结合 Agent Skills for Context Engineering(上下文退化模式识别与压缩策略)、Claude Context(代码库语义检索)、Context7(库文档上下文注入)和 Context Mode(工具输出上下文缩减)来对比不同上下文管理方案的实际效果。