AI Agent 的上下文窗口管理——如何让 Agent 在有限 token 内做更多事
问题:Agent 跑着跑着就"失忆"了
GPT-4o 有 128K 上下文窗口,Claude 有 200K。看起来很大,但跑一个复杂的 Agent 任务,token 消耗速度远超预期。
我做过一个测试:让一个 ReAct Agent 完成"调研某个开源项目并写一份技术评估报告"的任务。Agent 调用了 12 次工具,每次工具返回的内容平均 2000 token。加上系统提示词 1500 token、每轮的 reasoning 输出约 800 token,跑到第 8 轮时,上下文已经超过 40K token。到第 15 轮,逼近 80K。
问题不是"装不下"——128K 理论上够。问题是:
- 成本线性增长。每轮都把完整历史发给 API,第 15 轮的请求包含了前 14 轮的所有内容。按 GPT-4o 的 0.3-0.5。批量跑 100 个任务,成本就不可忽视了。
- 注意力被稀释。大模型在长上下文中的表现并不均匀。Needle-in-a-Haystack 测试已经证明,信息放在中间位置时,模型的召回率会下降 10%-30%。Agent 的早期工具调用结果恰好就在"中间"位置。
- 延迟增加。上下文越长,首 token 延迟(TTFT)越高。128K 上下文的 TTFT 通常是 8K 上下文的 3-5 倍。
所以问题很明确:怎么在不丢失关键信息的前提下,控制上下文的增长速度?
三种实用方案
我在生产环境中试过多种方法,最后沉淀下来三种:滑动窗口 + 摘要、工具输出截断、按需注入。
方案一:滑动窗口 + 摘要
思路很简单:只保留最近 N 轮对话,更早的内容压缩成一段摘要。
from openai import OpenAI
client = OpenAI()
def summarize_messages(messages: list[dict], max_tokens: int = 300) -> str:
"""把一组消息压缩成摘要"""
content = "\n".join(
f"[{m['role']}]: {m['content'][:500]}" for m in messages
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "用中文总结以下对话的关键信息,保留所有数据和结论,去掉过程细节。"},
{"role": "user", "content": content}
],
max_tokens=max_tokens
)
return resp.choices[0].message.content
def sliding_window(messages: list[dict], window_size: int = 6) -> list[dict]:
"""保留最近 window_size 轮,其余压缩为摘要"""
system_msg = messages[0] # 系统提示词始终保留
conversation = messages[1:]
if len(conversation) <= window_size * 2:
return messages # 还没超,原样返回
old_messages = conversation[:-window_size * 2]
recent_messages = conversation[-window_size * 2:]
summary = summarize_messages(old_messages)
return [
system_msg,
{"role": "user", "content": f"[之前的对话摘要]\n{summary}"},
*recent_messages
]
window_size=6 是我反复调整后的值。太小(比如 3)会丢失刚发生的上下文,Agent 容易重复做已经做过的事。太大(比如 15)起不到压缩效果。6 轮在大多数任务中够用。
摘要用 gpt-4o-mini 生成,成本低,速度快。一次摘要大约 0.001 美元,但能节省后续每轮 5000-10000 token 的输入。
方案二:工具输出截断
Agent 调用搜索 API、读取文件、抓取网页,返回的内容经常有大段无用信息。一个网页抓取结果可能有 8000 token,但 Agent 真正需要的信息只有 500 token。
import tiktoken
encoder = tiktoken.encoding_for_model("gpt-4o")
def truncate_tool_output(content: str, max_tokens: int = 1500) -> str:
"""截断工具输出,保留前后各一部分"""
tokens = encoder.encode(content)
if len(tokens) <= max_tokens:
return content
# 保留前 60% 和后 20%,中间截断
head_count = int(max_tokens * 0.6)
tail_count = int(max_tokens * 0.2)
head = encoder.decode(tokens[:head_count])
tail = encoder.decode(tokens[-tail_count:])
removed = len(tokens) - head_count - tail_count
return f"{head}\n\n[...已省略 {removed} tokens...]\n\n{tail}"
def smart_truncate(content: str, query: str, max_tokens: int = 1500) -> str:
"""基于相关性的截断:保留与查询相关的段落"""
paragraphs = content.split("\n\n")
query_words = set(query.lower().split())
scored = []
for p in paragraphs:
p_words = set(p.lower().split())
overlap = len(query_words & p_words)
scored.append((overlap, p))
scored.sort(key=lambda x: x[0], reverse=True)
result = []
current_tokens = 0
for score, paragraph in scored:
p_tokens = len(encoder.encode(paragraph))
if current_tokens + p_tokens > max_tokens:
break
result.append(paragraph)
current_tokens += p_tokens
return "\n\n".join(result)
smart_truncate 比简单截断更好。它根据段落和当前查询的词汇重叠度排序,优先保留相关段落。在实际使用中,这个方法把工具输出的平均 token 数从 3200 降到了 1100,Agent 的任务完成率没有明显下降。
更精确的做法是用 embedding 计算相似度,但对于大多数场景,词汇重叠已经够用,还省掉了 embedding API 的调用。
方案三:按需注入
不要一开始就把所有背景信息塞进系统提示词。把信息拆成模块,Agent 需要时再注入。
CONTEXT_MODULES = {
"code_style": "代码规范:使用 black 格式化,类型注解必须完整,docstring 用 Google 风格...",
"project_arch": "项目结构:src/ 下按领域划分模块,每个模块有 models/、services/、api/ 三层...",
"api_docs": "API 文档:POST /users 创建用户,需要 name 和 email 字段...",
"db_schema": "数据库:users 表有 id, name, email, created_at 四个字段...",
}
def build_system_prompt(base_prompt: str, active_modules: list[str]) -> str:
"""根据当前任务阶段,拼装系统提示词"""
parts = [base_prompt]
for mod in active_modules:
if mod in CONTEXT_MODULES:
parts.append(f"\n---\n{CONTEXT_MODULES[mod]}")
return "\n".join(parts)
# Agent 在写代码阶段,只注入 code_style 和 project_arch
prompt = build_system_prompt(
"你是一个 Python 开发助手。",
["code_style", "project_arch"]
)
# Agent 在调试 API 阶段,换成 api_docs 和 db_schema
prompt = build_system_prompt(
"你是一个 Python 开发助手。",
["api_docs", "db_schema"]
)
这个方法在编码类 Agent 中效果最好。一个典型的项目可能有 5000 token 的背景信息,但 Agent 在某个具体步骤中只需要其中 1000-1500 token。动态注入可以把系统提示词的平均大小降低 60%。
把三种方案组合起来
单独用一种方案效果有限。组合使用才能发挥最大价值:
class ContextManager:
def __init__(self, window_size=6, max_tool_tokens=1500):
self.window_size = window_size
self.max_tool_tokens = max_tool_tokens
self.messages = []
self.active_modules = []
def add_message(self, role: str, content: str):
if role == "tool":
content = truncate_tool_output(content, self.max_tool_tokens)
self.messages.append({"role": role, "content": content})
def set_modules(self, modules: list[str]):
self.active_modules = modules
def get_messages(self, base_prompt: str) -> list[dict]:
system = build_system_prompt(base_prompt, self.active_modules)
all_msgs = [{"role": "system", "content": system}] + self.messages
return sliding_window(all_msgs, self.window_size)
def token_count(self) -> int:
text = "".join(m["content"] for m in self.messages)
return len(encoder.encode(text))
踩坑经验
1. 摘要丢信息是不可避免的,关键是选择丢什么。
最初我直接让 LLM "总结对话",结果它总是丢掉具体的数字和 URL。后来改成在摘要提示词里加一句"保留所有数据、数字、URL、文件路径",情况好了很多。但仍然会偶尔丢东西。我的解决办法是维护一个独立的 key_facts 列表,把每轮提取的关键数据单独存储,不参与摘要压缩。
2. 截断位置比截断长度更重要。
最开始我用简单的前 N 个 token 截断。问题是很多网页的前 1000 token 是导航栏和版权声明。改成"前 60% + 后 20%"之后好了一些,但最佳方案还是基于相关性的 smart_truncate。
3. token 计算别用 len(text) / 4 估算。
中文文本的 token 比例和英文差异很大。一个中文字符通常是 1-2 个 token,不是 0.25 个。用 tiktoken 精确计算,别估算。我因为用估算值设了截断阈值,导致实际发送的 token 比预期多了 40%,白白多花了钱。
4. 滑动窗口的 window_size 要根据任务类型调。
代码生成任务,window_size 可以小一点(4-5 轮),因为每轮相对独立。调研类任务需要更大的窗口(8-10 轮),因为前后轮之间的依赖关系更强。我现在的做法是根据任务类型动态设置,而不是用一个固定值。
5. 别在每轮都做摘要。
摘要本身也有成本——gpt-4o-mini 的一次调用大约 50-100ms 延迟加上 token 费用。我设了一个阈值:只有当历史消息超过 window_size * 3 时才触发摘要。频繁摘要反而拖慢了 Agent 的响应速度。
上下文管理没有银弹。不同任务需要不同策略,代码里的参数也需要根据实际效果反复调整。但把这三种方法组合起来用,在我的场景中把平均 token 消耗降低了 55%,任务完成率只下降了 3%。对于大多数 Agent 应用来说,这个 trade-off 是值得的。