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逼近模型的上下文窗口时,必须做压缩。我们的策略是分层压缩:
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)独立编写。
浙公网安备 33010602011771号