Token、上下文管理、记忆链:我是如何让AI Agent在多轮对话里不“失忆”的?
一、前言:我的Agent,好像得了“阿尔茨海默症”
兄弟们,你们有没有过这种崩溃时刻?
花了三天三夜,写了一个号称“智能对话”的AI Agent。第一次对话,它还能清晰记住我是谁、我要做什么、甚至我上一句随口提的“我想给客户A做一个季度销售分析”。结果聊到第三轮,我问它“那客户A的分析报告里,我们重点要突出什么数据?”
它愣了三秒,然后回了一句:“抱歉,我没有收到过关于‘客户A’的相关信息,请您重新说明。”
我:???
我看着后台的日志,陷入了沉思。我花那么多钱接的大模型,怎么转头就把我忘了?这哪里是智能助手,这分明是得了阿尔茨海默症的金鱼,记忆只有7个Token。
作为一个后端开发,我不能忍。于是我决定,亲手给我的Agent装个“大脑外挂”——搞定上下文管理,让它再也不会在多轮对话里“失忆”。这篇文章,就是我踩过的坑、趟过的雷,以及最终跑通的完整方案,全是干货,还有可直接跑的代码。
二、罪魁祸首:为什么你的Agent会“失忆”?
在动手之前,我们得先搞明白,Agent为什么会忘事?
很多新手(包括曾经的我)以为,大模型是个无限内存的U盘,我说过的每一句话它都记得。但事实是,大模型的“记忆”,完全依赖于你每次发请求时,塞进去的messages列表。
举个例子:
- 第一轮请求:
messages = [{"role": "user", "content": "我是你老板,你要记住我,我叫老王"}] - 模型回复:
{"role": "assistant", "content": "好的老王老板,我记住了!"} - 第二轮请求:如果你只发
messages = [{"role": "user", "content": "我是谁?"}],模型根本不知道你是老王,自然会答错。
所以,最基础的“记忆”方案,就是把所有历史对话,都拼到messages里,一起发给模型。
但这又带来了新问题:Token限制。
大模型的上下文窗口是有限的,比如GPT-3.5是4k/16k,GPT-4是8k/32k,哪怕是国产的大模型,也有token上限。每一个汉字、每一个标点,甚至空格,都在消耗Token。对话多了,历史消息越长,Token就越容易超标,然后就会触发:
- 报错:
context window is full - 模型自动截断,直接忘掉前面的对话
- 成本飙升:Token越多,API调用越贵
所以,我们的目标不是简单地“把对话全塞进去”,而是在Token限制内,高效地管理对话上下文,让Agent既能记住关键信息,又不爆内存、不超预算。
而实现这个目标,我们需要三件套:
- Token计数:精准监控你的上下文,别超了还不知道
- 上下文窗口管理:把没用的信息丢掉,留下有用的
- 记忆链(Memory Chain):把对话里的关键信息抽出来,存成结构化数据,让Agent永远不忘
接下来,我们一个一个来搞定。
三、第一步:先搞懂Token,别再当“盲盒玩家”
很多人对Token的理解就是“大概是个字数单位”,但差远了。不同语言的Token消耗不一样,中文1个Token大概对应1-2个汉字,英文1个Token大概对应0.75个单词。而且,大模型的输入和输出,都要算Token。
所以,第一步,我们必须给Agent加上Token计数器,实时监控上下文的Token消耗,这是所有上下文管理的基础。
3.1 用OpenAI官方工具精准计数
如果你用的是OpenAI系列模型,官方提供了tiktoken库,可以精准计算Token数量。
import tiktoken
# 选择对应模型的编码器
def count_tokens(text: str, model: str = "gpt-3.5-turbo-16k") -> int:
"""计算文本的token数量"""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
# 如果模型不在内置列表里,用cl100k_base(大部分OpenAI模型都用这个)
encoding = tiktoken.get_encoding("cl100k_base")
return len(encoding.encode(text))
# 测试一下
if __name__ == "__main__":
test_text = "Token、上下文管理、记忆链:我是如何让AI Agent在多轮对话里不“失忆”的?"
print(f"文本:{test_text}")
print(f"Token数:{count_tokens(test_text)}")
运行一下,你会发现,一句话的Token数和字数完全不一样。
3.2 非OpenAI模型怎么办?
如果你用的是国产大模型,比如文心一言、通义千问,没有官方的Token计数器怎么办?
没关系,我们可以用一个通用的估算方法,或者直接调用模型的API自带的Token统计接口。这里给一个通用的估算函数,误差不大,足够我们做上下文管理了:
def estimate_tokens(text: str) -> int:
"""通用Token估算,中文按1.5字/Token,英文按0.75词/Token"""
# 中文部分按字数估算
chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
# 英文部分按单词数估算
english_words = len([word for word in text.split() if word.isascii()])
return int(chinese_chars / 1.5 + english_words / 0.75)
# 测试
print(estimate_tokens("我是老王,我想让你帮我分析客户A的季度销售数据"))
有了Token计数,我们就可以给上下文设置一个“安全水位线”,比如模型上限是16k,我们就设14k为安全线,超过了就开始裁剪。
四、第二步:上下文窗口管理,把没用的对话“丢进垃圾桶”
最粗暴的上下文管理,就是“直接截断”,把最早的对话删掉,只留最新的N轮对话。但这种方法有个问题:如果关键信息在很早的对话里,直接截断就丢了。
所以,我们需要一个更智能的窗口管理策略,这里给大家实现两种常用方案:
4.1 方案一:滑动窗口(Sliding Window)——简单粗暴但有效
滑动窗口的逻辑很简单:设置一个最大对话轮数,比如10轮,当对话超过10轮时,就把最早的1轮删掉,保持窗口内永远只有最新的10轮对话。
from typing import List, Dict
class SlidingWindowManager:
def __init__(self, max_rounds: int = 10, max_tokens: int = 14000, model: str = "gpt-3.5-turbo-16k"):
self.max_rounds = max_rounds
self.max_tokens = max_tokens
self.model = model
self.messages: List[Dict] = []
def add_message(self, role: str, content: str):
"""添加新消息,同时管理窗口"""
self.messages.append({"role": role, "content": content})
# 先按轮数裁剪
if len(self.messages) > self.max_rounds * 2: # 每轮对话包含user和assistant两条
# 删掉最早的一轮(两条消息)
self.messages = self.messages[2:]
# 再按Token裁剪,确保不超过上限
self._trim_by_tokens()
def _trim_by_tokens(self):
"""按Token数裁剪消息,从最早的开始删"""
while True:
# 计算当前所有消息的总Token数
total_tokens = sum(
count_tokens(msg["content"], self.model)
for msg in self.messages
)
if total_tokens <= self.max_tokens or len(self.messages) <= 2:
# 至少保留系统提示和最后一轮对话
break
# 删掉最早的非系统消息
if self.messages[0]["role"] == "system":
# 系统提示不能删,删下一条
del self.messages[1]
else:
del self.messages[0]
def get_messages(self) -> List[Dict]:
"""获取最终要发给模型的消息列表"""
return self.messages
# 使用示例
if __name__ == "__main__":
manager = SlidingWindowManager(max_rounds=5, max_tokens=1000)
# 添加系统提示
manager.add_message("system", "你是一个专业的销售分析助手,要记住用户的所有关键信息")
# 模拟多轮对话
manager.add_message("user", "我是老王,客户A的季度销售额是100万")
manager.add_message("assistant", "好的,我记住了,客户A的季度销售额是100万")
manager.add_message("user", "客户B的销售额是200万")
manager.add_message("assistant", "收到,客户B销售额200万")
manager.add_message("user", "帮我对比一下两个客户的销售数据")
print("当前消息列表:", manager.get_messages())
滑动窗口的优点是实现简单、性能高,缺点是会丢失早期的对话信息。适合对话轮数不多、不需要长期记忆的场景。
4.2 方案二:基于Token的动态裁剪——精准控制内存
滑动窗口是按轮数裁剪,而动态裁剪是直接按Token数裁剪,更精准。我们刚才的代码里已经加了_trim_by_tokens方法,就是干这个的。
这里有个小技巧:系统提示词(system prompt)尽量放在最前面,并且不要轻易裁剪,因为它决定了Agent的角色和行为逻辑,丢了就彻底跑偏了。
五、第三步:记忆链(Memory Chain)——给Agent装个“长期记忆”
滑动窗口只能解决短期对话不爆Token的问题,但还是会丢早期的关键信息。比如用户第一轮说“我是老王,我要分析客户A的数据”,对话了20轮之后,滑动窗口把第一轮删掉了,模型还是会忘。
这时候,我们就需要记忆链(Memory Chain),把对话里的关键信息,抽出来存成结构化数据,比如用户是谁、用户的需求是什么、客户信息是什么,不管对话多少轮,这些关键信息永远存在。
5.1 记忆链的核心:结构化记忆存储
我们先定义一个结构化的记忆模型,用来存对话里的关键信息:
from dataclasses import dataclass, asdict
from typing import Optional, List, Dict
import json
@dataclass
class UserInfo:
"""用户基础信息"""
name: Optional[str] = None # 用户名
role: Optional[str] = None # 用户角色(比如老板、销售)
preferences: List[str] = None # 用户偏好
@dataclass
class ClientInfo:
"""客户信息"""
client_id: str
name: Optional[str] = None
sales_data: Optional[float] = None
key_requirements: List[str] = None
@dataclass
class ConversationMemory:
"""对话记忆"""
user_info: UserInfo = None
clients: Dict[str, ClientInfo] = None # key是client_id
current_task: Optional[str] = None # 当前用户的核心任务
key_entities: List[str] = None # 对话中提到的关键实体
def __post_init__(self):
if self.user_info is None:
self.user_info = UserInfo()
if self.clients is None:
self.clients = {}
if self.key_entities is None:
self.key_entities = []
def to_dict(self):
"""转成字典,方便发给模型"""
return asdict(self)
def to_json(self):
"""转成JSON字符串"""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
5.2 关键:用大模型做记忆抽取
我们需要在每一轮对话之后,调用一次模型,把对话里的关键信息,更新到我们的ConversationMemory里。
这里的核心是写一个结构化输出的Prompt,让模型按我们定义的格式,输出更新后的记忆数据。
def extract_memory_from_conversation(
messages: List[Dict],
current_memory: ConversationMemory,
llm_call_func # 这里传入你的大模型调用函数
) -> ConversationMemory:
"""从对话中抽取关键信息,更新记忆"""
# 构建Prompt,要求模型按JSON格式输出
prompt = f"""
你是一个对话记忆抽取助手,需要根据用户和助手的对话历史,更新用户的记忆信息。
请严格按照给定的JSON格式输出,不要添加任何额外解释,只返回JSON。
当前已有的记忆信息:
{current_memory.to_json()}
最新的对话历史:
{json.dumps(messages[-4:], ensure_ascii=False, indent=2)} # 取最新的几轮对话,减少Token消耗
请根据对话,更新以下信息:
1. user_info: 用户的姓名、角色、偏好
2. clients: 对话中提到的客户信息(名称、销售额、需求)
3. current_task: 用户当前的核心任务
4. key_entities: 对话中提到的关键实体(比如客户名、产品名)
输出格式(只返回JSON,不要其他内容):
{{
"user_info": {{
"name": "用户姓名,没有则保持原有值",
"role": "用户角色,没有则保持原有值",
"preferences": ["用户偏好,列表形式"]
}},
"clients": {{
"client_id_1": {{
"name": "客户名称",
"sales_data": 1000000,
"key_requirements": ["客户需求"]
}}
}},
"current_task": "当前核心任务,没有则保持原有值",
"key_entities": ["关键实体列表"]
}}
"""
# 调用大模型,获取结构化输出
response = llm_call_func(prompt)
# 解析JSON
try:
updated_memory_dict = json.loads(response)
# 用新的信息更新原有记忆
updated_memory = ConversationMemory(
user_info=UserInfo(**updated_memory_dict["user_info"]),
clients={k: ClientInfo(client_id=k, **v) for k, v in updated_memory_dict["clients"].items()},
current_task=updated_memory_dict["current_task"],
key_entities=updated_memory_dict["key_entities"]
)
return updated_memory
except Exception as e:
print(f"记忆抽取失败,使用原有记忆:{e}")
return current_memory
这里有个关键点:每次只取最新的几轮对话(比如最后4轮)来做记忆抽取,这样可以大幅减少Token消耗,而且早期的关键信息已经被存在记忆里了,不需要重复抽取。
5.3 把记忆链注入到上下文里
有了结构化的记忆之后,我们就可以把它拼到每次发给模型的消息列表里,这样不管对话多少轮,模型都能看到用户的关键信息了。
我们改造一下之前的SlidingWindowManager,加上记忆链的支持:
class MemoryEnhancedManager(SlidingWindowManager):
def __init__(self, max_rounds: int = 10, max_tokens: int = 14000, model: str = "gpt-3.5-turbo-16k"):
super().__init__(max_rounds, max_tokens, model)
self.memory = ConversationMemory()
# 记忆在消息列表中的位置,默认在系统提示之后
self.memory_index = 1
def add_message(self, role: str, content: str, llm_call_func=None):
"""添加消息,同时更新记忆(如果提供了llm_call_func)"""
super().add_message(role, content)
# 如果提供了LLM调用函数,就更新记忆
if llm_call_func and role == "assistant":
# 对话完成后,更新记忆
self.memory = extract_memory_from_conversation(
self.messages, self.memory, llm_call_func
)
# 把记忆拼接到消息列表里
self._inject_memory_to_messages()
def _inject_memory_to_messages(self):
"""把结构化记忆注入到消息列表中"""
# 先把原来的记忆消息删掉(如果有的话)
if len(self.messages) > self.memory_index and self.messages[self.memory_index]["role"] == "system" and "对话记忆信息" in self.messages[self.memory_index]["content"]:
del self.messages[self.memory_index]
# 生成记忆消息
memory_content = f"""
【对话记忆信息】
以下是本次对话的关键信息,请你务必记住并在回答中使用:
{self.memory.to_json()}
"""
# 插入到系统提示之后
self.messages.insert(self.memory_index, {"role": "system", "content": memory_content})
# 再做一次Token裁剪,防止超了
self._trim_by_tokens()
# 使用示例
if __name__ == "__main__":
# 模拟一个简单的LLM调用函数(实际项目中换成你的模型调用)
def mock_llm_call(prompt):
# 这里只是模拟,实际需要调用OpenAI或其他大模型的API
return """
{
"user_info": {"name": "老王", "role": "销售经理", "preferences": ["数据对比用柱状图", "结果要简洁明了"]},
"clients": {
"clientA": {"name": "客户A", "sales_data": 1000000, "key_requirements": ["季度销售分析", "同比增长率"]},
"clientB": {"name": "客户B", "sales_data": 2000000, "key_requirements": ["季度销售分析", "客户画像分析"]}
},
"current_task": "对比客户A和客户B的季度销售数据,输出分析报告",
"key_entities": ["客户A", "客户B", "季度销售额", "销售分析"]
}
"""
manager = MemoryEnhancedManager(max_rounds=5, max_tokens=10000)
# 添加系统提示
manager.add_message("system", "你是一个专业的销售分析助手,要基于对话记忆信息回答用户问题")
# 模拟对话
manager.add_message("user", "我是老王,客户A的季度销售额是100万,客户B是200万")
manager.add_message("assistant", "好的,我记住了客户A和B的销售额")
# 对话完成后,更新记忆
manager.add_message("user", "帮我对比一下两个客户的销售数据")
manager.add_message("assistant", "好的,我来帮你分析", llm_call_func=mock_llm_call)
print("最终消息列表:")
for msg in manager.get_messages():
print(f"{msg['role']}: {msg['content'][:100]}...")
这样,不管滑动窗口怎么裁剪早期的对话,【对话记忆信息】里的关键数据永远都在,模型再也不会忘了你是谁、客户是谁了。
六、进阶优化:让记忆链更稳定、更高效
到这里,我们的基础方案已经跑通了,但在实际业务中,还有几个坑要填,不然你的Agent还是会“间歇性失忆”。
6.1 优化1:结构化输出失败?用JSON Schema兜底
有时候,大模型会“抽风”,输出的JSON格式不对,导致记忆抽取失败。这时候,我们可以用JSON Schema来约束模型的输出,比如用pydantic来定义模型,再用LangChain的with_structured_output功能。
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
# 用pydantic定义记忆的Schema
class UserInfoSchema(BaseModel):
name: Optional[str] = Field(None, description="用户姓名,没有则不修改")
role: Optional[str] = Field(None, description="用户角色,比如销售经理")
preferences: List[str] = Field(default_factory=list, description="用户的偏好设置")
class ClientInfoSchema(BaseModel):
name: Optional[str] = Field(None, description="客户名称")
sales_data: Optional[float] = Field(None, description="客户销售额")
key_requirements: List[str] = Field(default_factory=list, description="客户的关键需求")
class ConversationMemorySchema(BaseModel):
user_info: UserInfoSchema
clients: Dict[str, ClientInfoSchema] = Field(default_factory=dict)
current_task: Optional[str] = Field(None, description="用户当前的核心任务")
key_entities: List[str] = Field(default_factory=list, description="对话中提到的关键实体")
# 在LangChain中使用结构化输出
from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage
llm = ChatOpenAI(model="gpt-3.5-turbo-16k", temperature=0)
structured_llm = llm.with_structured_output(ConversationMemorySchema)
def extract_memory_with_schema(messages: List[Dict], current_memory: ConversationMemory) -> ConversationMemorySchema:
prompt = f"""
你是对话记忆抽取助手,请根据对话更新记忆信息,严格按照要求的格式输出。
当前记忆:{current_memory.to_json()}
最新对话:{json.dumps(messages[-4:], ensure_ascii=False)}
"""
response = structured_llm.invoke([
SystemMessage(content=prompt)
])
return response
用了pydantic的JSON Schema约束,模型输出的JSON格式基本不会出错,记忆抽取的稳定性大大提升。
6.2 优化2:记忆合并,避免信息冲突
当对话多了,不同轮次抽取的记忆可能会冲突,比如用户第一次说“我是老王”,第二次又说“我是小王”,这时候模型不知道该留哪个。
我们可以给记忆加一个更新策略:
- 优先保留最新的信息,覆盖旧信息
- 列表类型的信息(比如用户偏好、关键实体),不覆盖,而是合并去重
- 数值类型的信息(比如销售额),如果有新值,直接覆盖旧值
def merge_memory(old_memory: ConversationMemory, new_memory: ConversationMemorySchema) -> ConversationMemory:
"""合并新旧记忆,解决信息冲突"""
# 用户信息:用新值覆盖旧值,None则保留旧值
if new_memory.user_info.name is not None:
old_memory.user_info.name = new_memory.user_info.name
if new_memory.user_info.role is not None:
old_memory.user_info.role = new_memory.user_info.role
# 偏好列表:合并去重
old_memory.user_info.preferences = list(set(old_memory.user_info.preferences + new_memory.user_info.preferences))
# 客户信息:更新已有客户,新增新客户
for client_id, new_client in new_memory.clients.items():
if client_id in old_memory.clients:
old_client = old_memory.clients[client_id]
if new_client.name is not None:
old_client.name = new_client.name
if new_client.sales_data is not None:
old_client.sales_data = new_client.sales_data
old_client.key_requirements = list(set(old_client.key_requirements + new_client.key_requirements))
else:
old_memory.clients[client_id] = ClientInfo(
client_id=client_id,
name=new_client.name,
sales_data=new_client.sales_data,
key_requirements=new_client.key_requirements
)
# 当前任务:优先用新值
if new_memory.current_task is not None:
old_memory.current_task = new_memory.current_task
# 关键实体:合并去重
old_memory.key_entities = list(set(old_memory.key_entities + new_memory.key_entities))
return old_memory
6.3 优化3:给记忆加个“索引”,用RAG快速召回
当对话轮数非常多(比如几百轮),哪怕是结构化记忆,塞到上下文里也会占不少Token。这时候,我们可以把历史对话向量存储起来,用RAG(检索增强生成)技术,根据当前用户的问题,召回相关的历史对话片段,动态注入到上下文里。
# 伪代码,展示核心逻辑
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
# 初始化向量数据库
embeddings = OpenAIEmbeddings()
vector_store = FAISS.from_texts([], embedding=embeddings)
def add_to_vector_store(role: str, content: str, turn_id: int):
"""把每轮对话存到向量数据库"""
doc = f"轮次{turn_id} {role}: {content}"
vector_store.add_texts([doc], metadatas=[{"turn_id": turn_id}])
def retrieve_relevant_memory(query: str, top_k: int = 3) -> List[str]:
"""根据用户当前的问题,召回相关的历史对话"""
docs = vector_store.similarity_search(query, k=top_k)
return [doc.page_content for doc in docs]
# 在生成上下文时,把召回的对话拼进去
relevant_dialogs = retrieve_relevant_memory("帮我对比客户A和B的销售数据")
context = f"""
【相关历史对话】
{chr(10).join(relevant_dialogs)}
【对话记忆信息】
{memory.to_json()}
"""
RAG的好处是,只把和当前问题相关的历史对话召回,不用把所有历史对话都塞进去,Token消耗大大降低,而且模型也能拿到需要的上下文。
七、踩坑实录:这些问题我替你踩过了
最后,给大家分享几个我在实现过程中踩过的坑,帮你少走弯路:
-
坑1:Token计数不准,导致超了上下文窗口
- 解决:不要用通用的字数估算,一定要用对应模型的官方Token计数器,比如OpenAI的
tiktoken,不然很容易算少了,导致API报错。
- 解决:不要用通用的字数估算,一定要用对应模型的官方Token计数器,比如OpenAI的
-
坑2:记忆抽取Prompt写得不好,模型乱输出
- 解决:结构化输出的Prompt一定要写清楚格式,加上例子,并且用
JSON Schema约束,不要让模型自由发挥。
- 解决:结构化输出的Prompt一定要写清楚格式,加上例子,并且用
-
坑3:记忆更新太频繁,导致Token消耗过高
- 解决:不要每一轮对话都做记忆抽取,可以设置一个阈值,比如每3轮对话抽取一次,或者对话轮数超过10轮再抽取,减少API调用次数。
-
坑4:把所有记忆都塞到上下文里,Token还是不够用
- 解决:用“分层记忆”的思路,关键信息存在结构化记忆里,次要的历史对话用RAG召回,不要全塞进去。
八、总结:终于,我的Agent不再失忆了
经过这一套组合拳:Token计数 + 滑动窗口管理 + 结构化记忆链 + RAG召回,我的Agent终于治好了“阿尔茨海默症”。不管对话多少轮,它都能记住我是老王、记住客户A和B的销售额,甚至还记得我之前提过“对比数据要用柱状图”。
其实,上下文管理的核心,从来都不是“把所有对话都存下来”,而是在有限的Token里,把模型最需要的信息,以最高效的方式喂给它。滑动窗口解决短期对话不爆内存,结构化记忆解决关键信息不丢失,RAG解决海量历史对话的召回问题,三者结合,才能让Agent真正“有脑子”。
希望这篇文章,能帮你少踩我踩过的坑,写出不“失忆”的AI Agent。如果你有更好的方案,也欢迎在评论区交流~
更多推荐


所有评论(0)