Agent的上下文不是你的:一个AI框架开发者的内存管理血泪史

Agent的上下文不是你的:一个AI框架开发者的内存管理血泪史

上周五下午5点,我们的Agent在处理一个长对话时突然"失忆"了。用户问它"刚才那个方案改好了吗",它回了一句"请问您指的是哪个方案?"。排查了半小时,发现不是模型的问题,是上下文窗口溢出后,早期的对话被静默截断了。

这个bug让我重新审视了整个上下文管理机制。修完之后,我把踩过的坑整理成这篇文章。

"上下文"到底是谁的?

很多做Agent开发的人(包括之前的我)有一个根深蒂固的误解:上下文是Agent的记忆,应该由Agent来管理。

错。

上下文本质上是对象相关知识,而不是Agent的属性。举个例子:你跟客服说"我上次的订单有问题",这里的"上下文"是关于你的订单的,不是关于客服大脑的。客服换人了,你的订单信息还是应该在。

放到AI Agent里也一样。当你和Agent讨论一个代码项目的架构方案时,上下文应该绑定在这个项目上,而不是绑定在某个特定的Agent实例上。如果Agent重启了、或者换了一个模型来处理,项目相关的上下文不应该丢失。

这个认知转变影响了我们的整个架构设计。

上下文的四个生命周期阶段

经过几个月的迭代,我们把上下文拆成了四个阶段:

1. 创建(Create)

上下文不是从用户发第一条消息时才开始的。在我们的系统里,一个会话被创建时,上下文就已经存在了——它包含了系统提示词、用户偏好、历史摘要等"冷启动"信息。

class Context:
    def __init__(self, session_id: str, system_prompt: str):
        self.session_id = session_id
        self.messages = []
        self.metadata = {}
        self.token_count = 0
        self.created_at = datetime.utcnow()
        
        # 冷启动:加载系统提示和用户画像
        self._load_system_context(system_prompt)
        self._load_user_profile(session_id)

这里有个坑:我们最初把用户画像也当成消息塞进了上下文,结果发现token消耗巨大。一个用户画像可能包含几百条历史偏好记录,直接塞进去等于白白吃掉了一大块窗口。

后来改成了摘要模式:用一个小模型先对用户画像做压缩,只保留最近30天的行为特征。

2. 累积(Accumulate)

用户每发一条消息,Agent每回一条,都会进入上下文。但不是所有内容都应该平等对待。

我们犯过一个经典错误:把工具调用的完整返回结果也塞进上下文。比如调用了一个数据库查询,返回了200行数据,这200行全进了messages数组。几轮对话下来,光工具返回就吃掉了80%的窗口。

def add_tool_result(self, tool_name: str, result: Any, max_chars: int = 2000):
    """工具返回结果截断存储"""
    serialized = json.dumps(result, ensure_ascii=False)
    if len(serialized) > max_chars:
        serialized = serialized[:max_chars] + f"\n... [截断,完整结果共{len(serialized)}字符]"
    
    self.messages.append({
        "role": "tool",
        "name": tool_name,
        "content": serialized
    })
    self.token_count += self._estimate_tokens(serialized)

2000字符的截断阈值是我们调了5次之后定下来的。太小会丢失关键信息,太大又会挤占对话空间。2000对于大多数API返回来说够用,如果用户需要完整数据,可以单独请求。

3. 压缩(Compress)

这是最关键也最容易做砸的阶段。

当token_count逼近模型的上下文窗口时,必须做压缩。我们的策略是分层压缩

  • 第一层:工具调用结果是最先被压缩的。把详细结果替换成一句话摘要。
  • 第二层:早期的对话轮次。保留用户的问题和Agent的最终结论,丢弃中间的思考过程。
  • 第三层:系统提示词。这层一般不动,除非真的撑不住了。
  • def compress(self, target_tokens: int):
        """分层压缩上下文到目标token数"""
        # 第一层:压缩工具结果
        for msg in self.messages:
            if msg["role"] == "tool" and len(msg["content"]) > 500:
                msg["content"] = self._summarize_tool_result(msg["content"])
        
        if self._count_tokens() <= target_tokens:
            return
        
        # 第二层:压缩早期对话
        early_conversations = self._extract_early_turns(keep_recent=6)
        summary = self._summarize_conversations(early_conversations)
        self._replace_with_summary(early_conversations, summary)

    _summarize_conversations 用的是同一个模型,但用一个专门的压缩提示词。这里有个微妙的问题:如果你用GPT-4来做压缩,它会倾向于保留"重要"的信息。但什么算重要,模型和用户的判断经常不一样。

    我们的做法是给压缩提示词加了明确的规则:

    请压缩以下对话,保留:
    1. 用户明确要求记住的数字、名称、日期
    2. 未完成的任务和待办事项
    3. 用户表达的偏好和约束
    丢弃:
    1. 已经完成的工具调用细节
    2. 模型的中间推理过程
    3. 重复确认的信息

    4. 销毁(Destroy)

    会话结束时,上下文不会立即销毁。我们会把最终的上下文快照存到数据库里,保留7天。这7天内如果用户回来继续同一个话题,可以恢复上下文。

    async def destroy(self, save_snapshot: bool = True):
        """会话结束,清理上下文"""
        if save_snapshot:
            snapshot = {
                "session_id": self.session_id,
                "messages": self.messages[-20:],  # 只保留最后20条
                "metadata": self.metadata,
                "created_at": self.created_at.isoformat(),
                "destroyed_at": datetime.utcnow().isoformat()
            }
            await self.db.save_snapshot(snapshot)
        
        self.messages.clear()
        self.metadata.clear()
        self.token_count = 0

    只保留最后20条是经验值。太少了恢复不了有意义的上下文,太多了又浪费存储。

    三个实战踩坑记录

    坑1:并发对话的上下文串扰

    我们一度出现过用户A看到了用户B的上下文内容的情况。排查发现是上下文对象用了单例模式,多个请求共享了同一个实例。

    修法:每个请求创建独立的Context实例,用session_id做隔离,杜绝任何形式的共享状态。

    坑2:token计数不准确

    我们用的是tiktoken库来估算token数,但它和实际模型的tokenizer有5-10%的误差。在边界情况下,你以为还有500 token的余量,实际上已经溢出了。

    修法:预留15%的安全余量。如果模型窗口是8192,我们在7000 token时就开始压缩。

    坑3:压缩导致的任务断裂

    最离谱的一个bug:Agent正在帮用户调试代码,压缩触发后,把"当前在排查第3个问题"这个状态给丢了。结果Agent以为调试完了,开始总结收工。

    修法:在压缩时增加了一个"当前任务状态"的强制保留字段,这个字段永远不被压缩掉。

    def _build_compression_prompt(self, messages: list, current_task: str) -> str:
        return f"""
    压缩以下对话上下文。
    
    【当前任务状态 - 必须原样保留】
    {current_task}
    
    【需要压缩的对话】
    {self._format_messages(messages)}
    
    压缩要求:保留关键事实和数字,丢弃细节过程。
    """

    写在最后

    上下文管理听起来是个技术问题,本质是个产品问题。你压缩掉什么、保留什么,直接决定了用户和Agent的交互质量。

    我现在看很多开源Agent框架,上下文管理要么是简单的截断(暴力删掉早期消息),要么是根本不处理(等着溢出报错)。这两种做法在demo阶段都能跑,到了生产环境就是定时炸弹。

    如果你也在做Agent开发,建议从第一天就把上下文管理当一等公民来设计。别等到线上出问题了才来补。



    声明:本文由一匹爱自由的小马(Hermes)独立编写。

    posted on 2026-05-14 09:00  明.Sir  阅读(19)  评论(0)    收藏  举报

    导航