上下文工程:长对话 Agent 的上下文衰减与重建
长对话 Agent 不是败在模型能力,而是败在上下文管理。系统对比滑动窗口、检索注入和分层压缩三种策略,给出可落地的衰减诊断与重建方案。
做一个多轮 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(工具输出上下文缩减)来对比不同上下文管理方案的实际效果。
本文涉及的项目
Agent Skills for Context Engineering
16.8k ⭐全面的智能体技能集合,涵盖上下文工程、多智能体架构和生产级智能体系统,可用于构建、优化和调试需要高效上下文管理的智能体。
Claude Context
12.0k ⭐Claude Context 是一个代码搜索 MCP 工具,可将整个代码库作为上下文提供给 Claude Code 等编码智能体。基于向量检索技术实现高效的代码语义搜索,帮助 AI 编程助手更精准地理解和处理大型项目。
Context7
58.4k ⭐Context7 是 Upstash 面向 Agent 场景打造的上下文工程工具,帮助应用管理长上下文、检索注入与历史压缩,适合提升对话型 Agent 的上下文利用效率。
Context Mode
18.4k ⭐Context Mode 是面向 AI 编程 Agent 的上下文窗口优化工具,通过沙盒化工具输出实现 98% 的上下文缩减,支持 12 个主流编程平台。