AI Agent 的上下文窗口管理——如何让 Agent 在有限 token 内做更多事

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 理论上够。问题是:

  1. 成本线性增长 。每轮都把完整历史发给 API,第 15 轮的请求包含了前 14 轮的所有内容。按 GPT-4o 的 2.5 / 1 M i n p u t t o k e n 计算,一个任务跑下来可能花掉 2.5/1M input token 计算,一个任务跑下来可能花掉 2.5/1Minputtoken计算,一个任务跑下来可能花掉0.3-0.5。批量跑 100 个任务,成本就不可忽视了。
  2. 注意力被稀释。大模型在长上下文中的表现并不均匀。Needle-in-a-Haystack 测试已经证明,信息放在中间位置时,模型的召回率会下降 10%-30%。Agent 的早期工具调用结果恰好就在"中间"位置。
  3. 延迟增加。上下文越长,首 token 延迟(TTFT)越高。128K 上下文的 TTFT 通常是 8K 上下文的 3-5 倍。

所以问题很明确:怎么在不丢失关键信息的前提下,控制上下文的增长速度?

三种实用方案

我在生产环境中试过多种方法,最后沉淀下来三种:滑动窗口 + 摘要、工具输出截断、按需注入。

方案一:滑动窗口 + 摘要

思路很简单:只保留最近 N 轮对话,更早的内容压缩成一段摘要。

python 复制代码
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。

python 复制代码
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 需要时再注入。

python 复制代码
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%。

把三种方案组合起来

单独用一种方案效果有限。组合使用才能发挥最大价值:

python 复制代码
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 是值得的。

相关推荐
Dawson Zhu6 小时前
AI 辅助调试陷入「反复修改」循环?用证据驱动的四步法破局
人工智能·语言模型·架构·aigc·agi
Behaviour6 小时前
iPhone Duo 来了,苹果 Siri AI 接入定制 Gemini
人工智能·ios·语言模型·aigc·iphone
浩风祭月10 小时前
GPT Image 2.5 Sunburst还是Flare?模型选型、Python接入与成本避坑
python·aigc·openai·图像生成·gpt image 2.5
手写码匠10 小时前
DeepSeek 函数调用实战:从零搭建一个会“动手“的 AI 助手
人工智能·深度学习·算法·aigc
DO_Community15 小时前
RAG 的 Embedding 模型需要微调吗?什么时候值得自己训练?
人工智能·llm·aigc·agent·ai编程
墨风如雪16 小时前
从零开始,开源一本属于自己的 Pi 蓝皮书
aigc
Dawson Zhu16 小时前
Agent 记忆调用的上下文感知:从“能召回“到“敢开口“的决策框架
人工智能·语言模型·架构·aigc·agi
Dawson Zhu1 天前
工作流与 Agent 的工程选型:从“控制权归属“看 LLM 应用架构
人工智能·语言模型·架构·aigc·agi
墨风如雪1 天前
玩遍各种 Agent 后,我反而开始认真研究豆包和飞书
aigc