目录
开篇:你的Agent是不是又"失忆"了?
你有没有遇到过这样的场景——
用户:“我之前跟你说过我的地址是北京市朝阳区xx小区,你帮我查下附近的…”
Agent:“好的!请问您的地址是什么?”
用户:😤 “我五分钟前刚说过!!”
这就是Agent的失忆症。每一次对话结束,Agent就像被黑衣人闪了一下——什么都忘了。
而多Agent系统更惨。五个Agent协作,每个都各记各的,最后打架打出幻觉。
这篇文章,咱们就来根治这个病。从短期记忆到长期记忆,从单Agent到多Agent共享池,一次性把Agent记忆与上下文管理的完整方案搭出来。
读完这篇文章,你会得到:
- 一套可落地的三层记忆架构(感知→短期→长期)
- 完整的向量数据库持久化方案,附带可运行代码
- Token预算分配的数学模型和动态裁剪策略
- 一个带记忆的多Agent客服系统的完整实现
一、Agent记忆分层的"三重脑"架构
1.1 为什么要分层?一个血腥的故事
假设你在做一个AI客服系统。用户从注册到投诉,可能跨越数天、数十轮对话。
如果你把所有历史消息都塞进上下文窗口,会发生什么?
第一天: “你好,我注册不了”→“好的,请提供手机号”→“138xxxx”→…
第二天: “我又来问了,昨天的那个问题…”→Agent懵了,因为上下文窗口昨天就炸了
第七天: Token消耗突破天际,API账单让你怀疑人生,而Agent还在重复问"您的手机号是多少?"
这就是不分层的代价——要么烧钱,要么失忆,要么两个都有。
认知科学告诉我们,人类的记忆也是分层的:
- 感知记忆:几百毫秒,当下正在处理的
- 工作记忆(短期记忆):几秒到几分钟,正在用的
- 长期记忆:几天到几十年,需要的时候调出来
Agent的记忆架构,直接照搬这个模型就是了。
1.2 三层记忆架构总览
┌─────────────────────────────────────────────────┐
│ Agent 记忆系统 │
├─────────────┬─────────────────┬───────────────────┤
│ 感知记忆 │ 短期记忆 │ 长期记忆 │
├─────────────┼─────────────────┼───────────────────┤
│ 当前消息 │ 滑动窗口(N轮) │ 向量数据库存储 │
│ │ 摘要压缩 │ 语义检索 │
│ 实时指令 │ 重要性评分 │ 时间衰减 │
│ │ 淘汰策略 │ 记忆合并 │
├─────────────┼─────────────────┼───────────────────┤
│ 生命周期 │ 当前会话 │ 跨会话持久化 │
│ 毫秒级 │ 分钟级 │ 天/月/年级 │
└─────────────┴─────────────────┴───────────────────┘
1.3 记忆架构Mermaid图
graph TB
subgraph 感知记忆层
A[用户输入] --> B[消息解析器]
B --> C[当前轮次消息]
end
subgraph 短期记忆层
C --> D[滑动窗口<br/>最近N轮对话]
D --> E{窗口满了?}
E -->|是| F[摘要压缩器]
F --> G[压缩摘要]
E -->|否| D
D --> H[重要性评分器]
H --> I{得分 < 阈值?}
I -->|是| J[丢弃低价值记忆]
I -->|否| K[保留高价值记忆]
end
subgraph 长期记忆层
K --> L[记忆提取器]
L --> M[文本分块]
M --> N[Embedding向量化]
N --> O[(向量数据库<br/>ChromaDB/Milvus)]
G --> O
end
subgraph 共享记忆池
O --> P[跨Agent检索]
P --> Q[命名空间路由]
Q --> R[冲突检测与合并]
end
subgraph 检索流程
S[用户当前问题] --> T[语义检索<br/>向量相似度]
O --> T
T --> U[时间衰减加权]
U --> V[重要性加权]
V --> W[相关性排序]
W --> X[注入上下文窗口]
X --> D
end
这张图就是咱们整篇文章的导航地图。接下来,咱们一层层拆解。
二、短期记忆:Agent的"工作台"
2.1 滑动窗口——最简单也最容易被忽视
滑动窗口是什么?就是"只保留最近N轮对话"。
听起来很简单对吧?但实际上,90%的Agent系统在滑动窗口上都踩坑了。
from typing import List, Dict, Any
from collections import deque
class SlidingWindowMemory:
"""Agent短期记忆——滑动窗口实现"""
def __init__(self, max_messages: int = 50):
"""
Args:
max_messages: 最大保留消息数(含user+assistant)
"""
self.max_messages = max_messages
self._messages: deque = deque(maxlen=max_messages)
def add(self, role: str, content: str, metadata: Dict[str, Any] = None):
"""添加一条消息到窗口"""
msg = {
"role": role,
"content": content,
"timestamp": __import__("time").time(),
"metadata": metadata or {},
}
self._messages.append(msg)
def get_messages(self) -> List[Dict[str, Any]]:
"""获取当前窗口内的所有消息,返回LLM可用的格式"""
return [
{"role": m["role"], "content": m["content"]}
for m in self._messages
]
def get_context_for_llm(self, system_prompt: str = "") -> List[Dict[str, str]]:
"""组装完整上下文给LLM"""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(self.get_messages())
return messages
def summarize_and_compress(self, summarizer):
"""当窗口满了,对最早N条消息做摘要,替换为一条摘要消息"""
# 我们放到2.2详讲
pass
这段代码看着简单,但有一个致命问题——它只管消息数量,不管Token数量。
一条"好的"是3个Token,一条5000字的技术方案是8000个Token。你用数量做上限,Token预算就是薛定谔的猫——有时候50条消息才2000 Token,有时候一条就爆了。
所以我们得升级一下:
import tiktoken
class TokenAwareSlidingWindow(SlidingWindowMemory):
"""Token感知的滑动窗口——比数消息条数靠谱100倍"""
def __init__(
self,
max_tokens: int = 8000,
model: str = "gpt-4",
reserve_ratio: float = 0.15,
):
"""
Args:
max_tokens: 窗口最大Token数
model: 用于计算Token的模型名
reserve_ratio: 预留给响应的比例(15%)
"""
self.max_tokens = max_tokens
self.reserve_ratio = reserve_ratio
self.effective_max = int(max_tokens * (1 - reserve_ratio))
self.encoder = tiktoken.encoding_for_model(model)
self._messages: deque = deque()
def _count_tokens(self, text: str) -> int:
"""计算文本的Token数"""
return len(self.encoder.encode(text))
def _total_tokens(self) -> int:
"""当前窗口总Token数"""
return sum(
self._count_tokens(m["role"]) + self._count_tokens(m["content"])
for m in self._messages
)
def add(self, role: str, content: str, metadata: Dict[str, Any] = None):
msg = {
"role": role,
"content": content,
"timestamp": __import__("time").time(),
"token_count": self._count_tokens(role) + self._count_tokens(content),
"metadata": metadata or {},
}
self._messages.append(msg)
# 窗口超限,从最旧的消息开始淘汰
while self._total_tokens() > self.effective_max and len(self._messages) > 1:
evicted = self._messages.popleft()
print(f"🗑️ 淘汰旧消息: {evicted['role']} | "
f"Tokens: {evicted['token_count']} | "
f"内容预览: {evicted['content'][:50]}...")
@property
def usage(self) -> Dict[str, int]:
"""查看当前Token使用情况"""
used = self._total_tokens()
return {
"used": used,
"limit": self.max_tokens,
"available": self.effective_max,
"remaining": self.effective_max - used,
"messages_count": len(self._messages),
}
📊 这个实现解决了"50条消息可能2000Token也可能8万Token"的不确定性,用Token数做硬上限,比数量上限靠谱100倍。
2.2 摘要压缩——别让窗口撑爆
淘汰旧消息是下策,最好的办法是把旧消息"压缩"成摘要,既保留信息又省Token。
思路很简单:每隔N轮或Token超阈值时,把前面的一批消息丢给LLM,让LLM生成一个摘要,替换掉原始消息。
class SummaryCompressor:
"""对话摘要压缩器——把10条消息压成2行"""
def __init__(self, llm_client, compress_after_tokens: int = 4000):
self.llm = llm_client
self.compress_after_tokens = compress_after_tokens
SUMMARY_PROMPT = """你是对话摘要专家。请将以下对话压缩为简洁摘要。
规则:
1. 保留关键决策、用户偏好、未解决的问题
2. 去掉寒暄、重复确认等无信息量的内容
3. 用第三人称描述,不超过200字
4. 按时间顺序组织结构
对话内容:
{dialogue}
请输出摘要:"""
def should_compress(self, messages: List[Dict], encoder) -> bool:
"""判断是否需要压缩:累计Token超过阈值"""
total = sum(len(encoder.encode(m["content"])) for m in messages)
return total > self.compress_after_tokens
def compress(self, messages: List[Dict]) -> str:
"""将历史消息压缩为一段摘要"""
dialogue_text = "\n".join(
f"[{m['role']}]: {m['content']}" for m in messages
)
prompt = self.SUMMARY_PROMPT.format(dialogue=dialogue_text)
response = self.llm.invoke(prompt)
return response.content.strip()
def compress_to_system(self, messages: List[Dict], system_prompt: str) -> str:
"""将压缩结果追加到系统提示中"""
summary = self.compress(messages)
return f"{system_prompt}\n\n📋 历史对话摘要:\n{summary}"
# 使用示例
"""
compressor = SummaryCompressor(llm_client=openai_client)
if compressor.should_compress(old_messages, token_encoder):
summary = compressor.compress(old_messages)
# 用摘要替换掉原始消息,Token从5000降到300
new_system_prompt = compressor.compress_to_system(
old_messages, original_system_prompt
)
"""
摘要压缩的核心思想:“不在乎过程,只在乎结论”。 用户跟你说了一百句话来抱怨产品Bug,你不需要记住每一句,你只需要记住"用户对登录功能不满,要求48小时内修复"。
2.3 重要性评分——淘汰谁?
滑动窗口满了,淘汰谁?先进先出(FIFO)是最蠢的策略——它会把用户刚说的关键需求淘汰掉,却留着一堆"嗯好的"“我知道了”。
我们需要一个重要性评分机制,让真正重要的信息留在窗口里。
from dataclasses import dataclass, field
from enum import Enum
class ImportanceLevel(Enum):
CRITICAL = 5 # 致命:用户明确的需求、偏好、决策
HIGH = 4 # 重要:技术方案讨论、约束条件
MEDIUM = 3 # 中等:一般性问题、信息确认
LOW = 2 # 低:寒暄、表情、简单确认
TRIVIAL = 1 # 琐碎:纯客套、无信息量
@dataclass
class ScoredMessage:
role: str
content: str
timestamp: float
importance: ImportanceLevel = ImportanceLevel.MEDIUM
score: float = 0.0
class ImportanceScorer:
"""基于规则的记忆重要性评分器"""
# 直接匹配规则(关键词→重要性)
KEYWORD_RULES = [
({"必须", "一定", "绝对", "千万别", "紧急"}, ImportanceLevel.CRITICAL),
({"偏好", "喜欢", "希望", "要求", "需求", "密码", "地址", "手机", "身份证"},
ImportanceLevel.CRITICAL),
({"方案", "设计", "架构", "接口", "协议", "部署"}, ImportanceLevel.HIGH),
({"问题", "报错", "失败", "错误", "bug"}, ImportanceLevel.HIGH),
({"谢谢", "好的", "ok", "收到", "明白"}, ImportanceLevel.LOW),
({"哈哈", "哦", "嗯", "行"}, ImportanceLevel.TRIVIAL),
]
@classmethod
def score(cls, message: ScoredMessage) -> float:
"""计算消息的重要性得分(0-10分)"""
base_score = 0.0
# 1. 角色加分:用户的提问比assistant的回复更重要
if message.role == "user":
base_score += 1.0
# 2. 关键词匹配加分
content_lower = message.content.lower()
for keywords, level in cls.KEYWORD_RULES:
if any(kw in content_lower for kw in keywords):
base_score = max(base_score, level.value * 2)
break # 取最高级别
# 3. 长度加分:太短的消息通常信息量低
content_len = len(message.content)
if content_len < 5:
base_score -= 1.0
elif content_len > 200:
base_score += 1.5
# 4. 特殊模式加分
if "?" in message.content or "?" in message.content:
base_score += 0.5 # 提问的消息通常重要
message.importance = cls._score_to_level(base_score)
message.score = max(0.0, min(10.0, base_score))
return message.score
@staticmethod
def _score_to_level(score: float) -> ImportanceLevel:
if score >= 8:
return ImportanceLevel.CRITICAL
elif score >= 6:
return ImportanceLevel.HIGH
elif score >= 4:
return ImportanceLevel.MEDIUM
elif score >= 2:
return ImportanceLevel.LOW
return ImportanceLevel.TRIVIAL
class ImportanceAwareMemory(TokenAwareSlidingWindow):
"""带重要性感知的滑动窗口——聪明淘汰"""
def __init__(self, *args, min_importance: ImportanceLevel = ImportanceLevel.LOW, **kwargs):
super().__init__(*args, **kwargs)
self.min_importance = min_importance
self.scorer = ImportanceScorer()
def add(self, role: str, content: str, metadata: Dict[str, Any] = None):
msg = ScoredMessage(role=role, content=content, timestamp=__import__("time").time())
self.scorer.score(msg)
if msg.importance.value >= self.min_importance.value:
self._messages.append({
"role": msg.role,
"content": msg.content,
"timestamp": msg.timestamp,
"score": msg.score,
"importance": msg.importance.name,
"metadata": metadata or {},
})
else:
print(f"🚫 低价值消息已过滤: [{msg.importance.name}] {content[:50]}...")
def evict_by_importance(self, target_tokens: int):
"""按重要性从低到高淘汰消息,直到Token降到目标"""
sorted_msgs = sorted(self._messages, key=lambda m: m.get("score", 5))
for msg in sorted_msgs:
if self._total_tokens() <= target_tokens:
break
self._messages.remove(msg)
print(f"🗑️ 按重要性淘汰: {msg['importance']} | {msg['content'][:40]}...")
🎭 这就好比你的大脑不会记住上周二午餐吃了什么(TRIVIAL),但一定会记住老板说"下周一前交方案"(CRITICAL)。Agent也应该是这样。
⚠️ 避坑1:窗口大小不是越大越好
一个很多人犯的错误:“上下文窗口128K了,我把所有历史都塞进去不就行了?”
不行。原因有三:
① Token=钱,128K的每次调用烧的都是真金白银。 GPT-4的128K上下文,输入价格比8K上下文翻几倍。你一天5000次调用,光Token费就能买一台iPhone。
② 上下文越长,LLM越"注意力涣散"。 论文"Lost in the Middle"[1]证明:LLM对上下文中间位置的信息关注度显著低于开头和结尾。你塞了10万字历史,中间的关键信息大概率被忽略。
③ 幻觉率随上下文长度指数增长。 信息越多,LLM越容易"张冠李戴"——把用户A的偏好套到用户B身上。
正确做法:短期记忆控制在4K-8K Token,长期记忆走向量检索按需加载。
三、长期记忆:Agent的"数据库大脑"
3.1 向量数据库——记忆的物理载体
短期记忆会话结束就没了。Agent真正"记住"一个用户,靠的是长期记忆——把对话中的关键信息提取出来,存到向量数据库里,下次用户再来的时候,语义检索出来注入上下文。
选型建议:
| 数据库 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ChromaDB | 开发/小规模 | 零配置,Python原生 | 生产性能一般 |
| Milvus | 生产/大规模 | 性能强,分布式 | 部署复杂 |
| Qdrant | 中等规模 | Rust实现,性能好 | 社区相对小 |
| FAISS | 嵌入式 | Meta出品,极快 | 不是数据库,是库 |
对于个人开发和小团队,ChromaDB够用了,一行pip install chromadb就能跑。
3.2 记忆读写——写入与检索全流程
import chromadb
from chromadb.utils import embedding_functions
from typing import List, Dict, Optional, Any
import json
from datetime import datetime
class LongTermMemory:
"""Agent长期记忆——基于ChromaDB的持久化记忆系统"""
def __init__(
self,
collection_name: str = "agent_memory",
persist_dir: str = "./memory_db",
embedding_model: str = "text-embedding-3-small",
):
"""初始化长期记忆存储
Args:
collection_name: 集合名称(一个Agent一个集合)
persist_dir: 持久化目录
embedding_model: OpenAI embedding模型名
"""
self.client = chromadb.PersistentClient(path=persist_dir)
# 使用OpenAI embedding
self.embed_fn = embedding_functions.OpenAIEmbeddingFunction(
api_key=__import__("os").environ.get("OPENAI_API_KEY"),
model_name=embedding_model,
)
self.collection = self.client.get_or_create_collection(
name=collection_name,
embedding_function=self.embed_fn,
metadata={"hnsw:space": "cosine"}, # 余弦相似度
)
def remember(
self,
content: str,
memory_type: str = "general",
importance: float = 5.0,
user_id: str = "default",
metadata: Optional[Dict] = None,
) -> str:
"""写入一条记忆
Args:
content: 记忆内容
memory_type: 记忆类型(general/preference/fact/decision)
importance: 重要性(1-10)
user_id: 关联用户ID
metadata: 额外元数据
Returns:
记忆ID
"""
memory_id = f"mem_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
base_metadata = {
"memory_type": memory_type,
"importance": importance,
"user_id": user_id,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": None,
}
if metadata:
base_metadata.update(metadata)
# 用JSON序列化metadata(ChromaDB要求)
self.collection.add(
documents=[content],
metadatas=[base_metadata],
ids=[memory_id],
)
return memory_id
def recall(
self,
query: str,
n_results: int = 5,
memory_type: Optional[str] = None,
min_importance: float = 0.0,
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""检索相关记忆
Args:
query: 查询文本(语义匹配)
n_results: 返回结果数
memory_type: 过滤记忆类型
min_importance: 最低重要性阈值
user_id: 过滤用户
Returns:
相关记忆列表
"""
where_filter = {}
if memory_type:
where_filter["memory_type"] = memory_type
if user_id:
where_filter["user_id"] = user_id
results = self.collection.query(
query_texts=[query],
n_results=n_results,
where=where_filter if where_filter else None,
)
memories = []
if results["ids"] and results["ids"][0]:
for i, mem_id in enumerate(results["ids"][0]):
memories.append({
"id": mem_id,
"content": results["documents"][0][i],
"distance": results["distances"][0][i],
"metadata": results["metadatas"][0][i],
"similarity": 1 - results["distances"][0][i],
})
# 更新访问计数
for mem in memories:
self._increment_access(mem["id"])
return memories
def _increment_access(self, memory_id: str):
"""增加记忆的访问计数"""
result = self.collection.get(ids=[memory_id])
if result["metadatas"]:
meta = result["metadatas"][0]
meta["access_count"] = int(meta.get("access_count", 0)) + 1
meta["last_accessed"] = datetime.now().isoformat()
self.collection.update(ids=[memory_id], metadatas=[meta])
def forget(self, memory_id: str):
"""删除一条记忆"""
self.collection.delete(ids=[memory_id])
def forget_by_type(self, memory_type: str):
"""按类型批量删除记忆"""
results = self.collection.get(
where={"memory_type": memory_type}
)
if results["ids"]:
self.collection.delete(ids=results["ids"])
def get_memory_stats(self) -> Dict[str, Any]:
"""获取记忆库统计信息"""
all_data = self.collection.get()
total = len(all_data["ids"]) if all_data["ids"] else 0
type_counts = {}
total_importance = 0
if all_data["metadatas"]:
for meta in all_data["metadatas"]:
mt = meta.get("memory_type", "unknown")
type_counts[mt] = type_counts.get(mt, 0) + 1
total_importance += float(meta.get("importance", 0))
return {
"total_memories": total,
"type_distribution": type_counts,
"avg_importance": total_importance / total if total > 0 else 0,
}
# === 使用示例 ===
"""
memory = LongTermMemory(
collection_name="customer_service_agent",
persist_dir="./agent_memory"
)
# 写入记忆
memory.remember(
content="用户张三偏好通过邮件接收通知,邮箱为zhangsan@example.com",
memory_type="preference",
importance=8.0,
user_id="user_zhangsan",
)
# 检索记忆
results = memory.recall(
query="用户怎么收通知?",
n_results=3,
user_id="user_zhangsan",
)
for r in results:
print(f"相似度: {r['similarity']:.3f} | {r['content']}")
"""
这个实现已经可以直接用了。核心逻辑就三步:①文本→向量 → ②存进去 → ③搜出来。
3.3 记忆衰减——不是所有记忆都该永生
人是会遗忘的。Agent也是。但不是简单删掉——而是"衰减"。
一条"用户三个月前说喜欢蓝色"的记忆,权重应该低于"用户昨天说的紧急需求"。
import math
from datetime import datetime, timedelta
class MemoryDecay:
"""记忆衰减模型——模拟人类遗忘曲线"""
def __init__(self, half_life_days: float = 30.0):
"""
Args:
half_life_days: 半衰期(天),默认30天
30天后记忆权重衰减到原来的50%
"""
self.half_life = half_life_days
self.decay_rate = math.log(2) / half_life_days # 衰减常数
def decay_factor(self, created_at: datetime, now: datetime = None) -> float:
"""计算衰减因子(0-1)
使用指数衰减模型: weight = e^(-λ * days)
"""
if now is None:
now = datetime.now()
days_passed = (now - created_at).total_seconds() / 86400.0
return math.exp(-self.decay_rate * days_passed)
def is_stale(self, created_at: datetime, threshold: float = 0.1) -> bool:
"""判断记忆是否已"过期"(权重低于阈值)"""
return self.decay_factor(created_at) < threshold
def time_until_stale(self, created_at: datetime, threshold: float = 0.1) -> int:
"""计算还有多少天会过期"""
days_passed = (datetime.now() - created_at).total_seconds() / 86400.0
total_life = math.log(1 / threshold) / self.decay_rate
remaining = total_life - days_passed
return max(0, int(remaining))
class DecayAwareMemory(LongTermMemory):
"""带时间衰减的长期记忆"""
def __init__(self, *args, half_life_days: float = 30.0, **kwargs):
super().__init__(*args, **kwargs)
self.decay = MemoryDecay(half_life_days=half_life_days)
def recall_with_decay(
self,
query: str,
n_results: int = 5,
**filters,
) -> List[Dict[str, Any]]:
"""检索记忆,并按时间衰减重新排序"""
raw_results = self.recall(query, n_results * 2, **filters)
scored_results = []
for mem in raw_results:
created_at = datetime.fromisoformat(
mem["metadata"].get("created_at", "2000-01-01T00:00:00")
)
decay = self.decay.decay_factor(created_at)
# 综合得分 = 语义相似度 * 重要性 * 时间衰减
importance = float(mem["metadata"].get("importance", 5))
similarity = mem["similarity"]
combined_score = (
0.4 * similarity + # 语义相关性权重40%
0.3 * (importance / 10) + # 重要性权重30%
0.3 * decay # 时间衰减权重30%
)
mem["decay_factor"] = decay
mem["combined_score"] = combined_score
scored_results.append(mem)
# 按综合得分降序排列
scored_results.sort(key=lambda x: x["combined_score"], reverse=True)
return scored_results[:n_results]
# 使用示例
"""
memory = DecayAwareMemory(
collection_name="customer_memory",
half_life_days=30, # 30天半衰期
)
# 三个月前的记忆
old_memory_id = memory.remember(
content="用户曾表示对蓝色产品感兴趣",
importance=6.0,
metadata={"created_at": "2026-04-07T00:00:00"}
)
# 刚写入的重要记忆
new_memory_id = memory.remember(
content="用户要求退款订单#12345,金额¥899",
importance=9.0,
)
# 检索时会自动对旧记忆衰减
results = memory.recall_with_decay(query="用户有什么需求?")
for r in results:
print(f"综合得分: {r['combined_score']:.3f} | "
f"衰减: {r['decay_factor']:.2f} | "
f"{r['content'][:50]}")
"""
🧠 艾宾浩斯遗忘曲线告诉我们:人类在20分钟后忘记42%的信息,一天后忘记67%。Agent的衰减不需要这么陡,但"旧记忆不如新记忆重要"这个逻辑,必须体现在检索结果里。
💡 效率技巧1:分层检索+批量写入
分层检索是什么? 不要每次用户发言都去向量数据库检索。绝大多数对话根本不触发记忆需求——
- 用户:“在吗?” → 不需要检索记忆
- 用户:“上次我说的那个方案,你再看看” → 需要检索记忆
这就需要一层检索触发器:
class RetrievalTrigger:
"""记忆检索触发器——不是每次都要查记忆库"""
TRIGGER_KEYWORDS = [
"上次", "之前", "记得", "我说过", "之前提到过",
"你忘了吗", "再说一遍", "那个", "回忆", "之前那个",
"以前", "历史", "记录", "回顾",
"last time", "remember", "before", "previous",
]
@classmethod
def should_retrieve(cls, user_message: str) -> bool:
"""判断是否需要检索长期记忆"""
msg_lower = user_message.lower()
# 1. 关键词触发
if any(kw in msg_lower for kw in cls.TRIGGER_KEYWORDS):
return True
# 2. 指代不明的消息(包含"那个""这个"等代词)→ 需要记忆辅助
ambiguity_markers = ["那个", "这个", "它", "他", "她", "那个方案", "那个问题"]
if any(marker in user_message for marker in ambiguity_markers):
return True
# 3. 消息太短但需要上下文 → 白名单内部是简单确认,不需要检索
skip_patterns = ["好的", "行", "嗯", "ok", "是的", "对", "谢谢", "收到"]
if user_message.strip().lower() in [s.lower() for s in skip_patterns]:
return False
# 4. 复杂问题(长度>100字)→ 很可能需要历史背景
if len(user_message) > 100:
return True
return False
批量写入:多条记忆需要存储时,别一条一条写。ChromaDB的add支持批量,一次写入速度是单条的50倍以上。
⚠️ 避坑2:embedding模型的选择陷阱
很多教程会推荐你用开源的sentence-transformers/all-MiniLM-L6-v2做中文embedding。跑起来一看,检索结果是"天气不错"匹配到"天气预报"——完全不准。
原因:大多数开源embedding模型是针对英文优化的。中文语义匹配没那么简单。
推荐方案:
- 预算充足:OpenAI
text-embedding-3-small($0.02/1M tokens),中文效果最好 - 省钱方案:
BAAI/bge-large-zh-v1.5,智源出品,中文embedding SOTA - 本地部署:
bge-m3(多语言,支持8192长度),配合FlagEmbedding库使用
四、共享记忆池:多Agent的"公共白板"
4.1 为什么需要共享记忆?
场景:一个客服系统里,Agent A负责售前咨询,Agent B负责售后技术支持。
用户先跟Agent A聊了购买需求,转接给Agent B后,B开始问:“您好,您的手机号是多少?”
用户:😡 “我两分钟前刚跟你们同事说过!”
这就是Agent孤岛——每个Agent活在自己的世界里,不知道其他Agent做了什么。
共享记忆池就是给所有Agent一块"公共白板",让它们在上面对齐信息。
4.2 共享记忆池的设计
from typing import Dict, Set, List, Optional
import threading
import json
class SharedMemoryPool:
"""多Agent共享记忆池——带命名空间隔离和冲突检测"""
def __init__(self, persist_dir: str = "./shared_memory"):
self.pool: Dict[str, Dict] = {} # key → memory
self.namespace_index: Dict[str, Set[str]] = {} # namespace → keys
self.access_log: List[Dict] = [] # 访问日志
self._lock = threading.RLock() # 线程安全
def write(
self,
key: str,
value: Any,
namespace: str = "global",
agent_id: str = "unknown",
ttl_seconds: Optional[int] = None,
) -> Dict:
"""写入共享记忆
Args:
key: 记忆键名
value: 记忆值
namespace: 命名空间(隔离不同Agent组的记忆)
agent_id: 写入的Agent ID
ttl_seconds: 过期时间(秒),None表示永不过期
Returns:
写入结果,包含是否冲突
"""
with self._lock:
conflict = key in self.pool
old_value = self.pool.get(key)
import time
entry = {
"key": key,
"value": value,
"namespace": namespace,
"updated_by": agent_id,
"updated_at": time.time(),
"version": (old_value.get("version", 0) + 1) if old_value else 1,
"ttl": time.time() + ttl_seconds if ttl_seconds else None,
}
self.pool[key] = entry
self.namespace_index.setdefault(namespace, set()).add(key)
self.access_log.append({
"action": "write",
"key": key,
"agent": agent_id,
"conflict": conflict,
"timestamp": time.time(),
})
return {
"success": True,
"conflict": conflict,
"overwritten_key": key if conflict else None,
"new_version": entry["version"],
}
def read(
self,
key: str,
agent_id: str = "unknown",
) -> Optional[Dict]:
"""读取共享记忆"""
with self._lock:
entry = self.pool.get(key)
if entry is None:
return None
# 检查TTL过期
import time
if entry["ttl"] and time.time() > entry["ttl"]:
self.pool.pop(key, None)
return None
self.access_log.append({
"action": "read",
"key": key,
"agent": agent_id,
"timestamp": time.time(),
})
return entry
def read_namespace(self, namespace: str) -> Dict[str, Dict]:
"""读取整个命名空间的所有记忆"""
with self._lock:
keys = self.namespace_index.get(namespace, set())
return {k: self.pool[k] for k in keys if k in self.pool}
def list_agents_touched(self, key: str) -> List[str]:
"""查看有哪些Agent碰过这个key"""
return list(set(
log["agent"] for log in self.access_log
if log["key"] == key and log["agent"] != "unknown"
))
def resolve_conflict(
self,
key: str,
strategy: str = "latest",
agent_id: str = "resolver",
) -> Dict:
"""冲突解决
Args:
key: 冲突的key
strategy: 解决策略
- "latest": 保留最新版本
- "merge": 合并(简单拼接)
- "keep_original": 保留原始版本
"""
with self._lock:
entry = self.pool.get(key)
if not entry:
return {"success": False, "error": "Key not found"}
if strategy == "latest":
# 已经是latest了,无需操作
return {"success": True, "strategy": "latest", "version": entry["version"]}
elif strategy == "keep_original":
# 回滚到版本1(如果存在历史的话)
entry["version"] = 1
entry["updated_by"] = agent_id
return {"success": True, "strategy": "keep_original"}
return {"success": False, "error": f"Unknown strategy: {strategy}"}
# 使用示例
"""
pool = SharedMemoryPool()
# Agent A写入
pool.write(
key="user_123_info",
value={"phone": "138xxxx", "name": "张三", "level": "VIP"},
namespace="customer_session_abc",
agent_id="agent_presales",
)
# Agent B读取
info = pool.read("user_123_info", agent_id="agent_support")
print(f"Agent B获取到用户信息: {info['value']['name']}") # 张三!
"""
4.3 冲突解决:两个Agent同时写怎么办?
这是多Agent系统里的经典问题。Agent A说"用户等级=VIP",Agent B同时说"用户等级=普通"。
我们定义了三个策略:
- latest(默认):后写入的覆盖先写入的。简单粗暴,适合大部分场景。
- merge:版本合并。对于结构化数据,按字段级别合并。
- keep_original:保留第一个写入的。适合"不可变数据"场景。
更高级的可以用CRDT(Conflict-free Replicated Data Types),但90%的场景用latest就够了。
💡 效率技巧2:命名空间隔离
不要把所有Agent的记忆都丢到一个池子里。 用命名空间隔离:
# 命名空间命名规范
NAMESPACE_PATTERN = "{project}:{session}:{context_type}"
# 示例
pool.write("user_name", "张三", namespace="project_a:session_001:user_info")
pool.write("error_log", "...", namespace="project_a:session_001:debug")
pool.write("order_123", "...", namespace="project_a:session_001:business")
好处:
- 按namespace检索时只扫相关数据,避免全表扫描
- 不同session的Agent互不干扰
- 清理时可以按namespace批量删除
五、上下文窗口管理:Token预算的艺术
5.1 你的Token都去哪了?
假设你的LLM上下文窗口是128K Token。你以为够用了吗?我们算一笔账:
系统提示词: ~500 tokens
工具定义(10个工具): ~2,000 tokens
历史对话(20轮): ~8,000 tokens
检索到的相关记忆: ~2,000 tokens
共享记忆池注入: ~1,000 tokens
当前用户消息: ~500 tokens
────────────────────────────────
已用: ~14,000 tokens
剩余给输出: ~114,000 tokens ← 看着很多对吧?
但这是正常对话。多Agent系统里,还得加上:
Agent-A的中间结果: ~3,000 tokens
Agent-B的中间结果: ~3,000 tokens
Agent-C的中间结果: ~2,000 tokens
错误重试的上下文: ~5,000 tokens
────────────────────────────────
额外消耗: ~13,000 tokens
你的128K看着大,但其实是多个Agent共享的。每个Agent的调用都在烧你的Token配额。
5.2 Token预算分配策略
老规矩,直接上代码:
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from enum import Enum
class TokenConsumer(Enum):
"""Token消费者类型"""
SYSTEM_PROMPT = "system_prompt"
TOOL_DEFINITIONS = "tool_definitions"
SHORT_TERM_MEMORY = "short_term_memory"
RETRIEVED_MEMORY = "retrieved_memory"
SHARED_MEMORY = "shared_memory"
CURRENT_MESSAGE = "current_message"
RESERVED_OUTPUT = "reserved_output"
@dataclass
class TokenBudget:
"""Token预算分配器"""
total_budget: int = 128000
allocations: Dict[TokenConsumer, Dict] = field(default_factory=lambda: {
TokenConsumer.SYSTEM_PROMPT: {"budget": 1000, "hard_limit": 2000},
TokenConsumer.TOOL_DEFINITIONS: {"budget": 3000, "hard_limit": 5000},
TokenConsumer.SHORT_TERM_MEMORY: {"budget": 8000, "hard_limit": 16000},
TokenConsumer.RETRIEVED_MEMORY: {"budget": 3000, "hard_limit": 5000},
TokenConsumer.SHARED_MEMORY: {"budget": 2000, "hard_limit": 4000},
TokenConsumer.CURRENT_MESSAGE: {"budget": 1000, "hard_limit": 4000},
TokenConsumer.RESERVED_OUTPUT: {"budget": 10000, "hard_limit": 20000},
})
def allocate(self, consumer: TokenConsumer, tokens: int) -> bool:
"""申请Token配额,返回是否申请成功"""
alloc = self.allocations[consumer]
if tokens > alloc["hard_limit"]:
print(f"⛔ {consumer.value}: 申请{tokens}超过硬上限{alloc['hard_limit']}")
return False
alloc["used"] = tokens
return True
def get_used(self) -> int:
"""当前已使用Token总数"""
return sum(
a.get("used", 0)
for a in self.allocations.values()
)
def get_remaining(self) -> int:
"""剩余可用Token"""
return self.total_budget - self.get_used()
def report(self) -> str:
"""生成Token使用报告"""
lines = ["=" * 50, "📊 Token预算使用报告", "=" * 50]
used_total = 0
for consumer, alloc in self.allocations.items():
used = alloc.get("used", 0)
budget = alloc["budget"]
pct = (used / budget * 100) if budget > 0 else 0
bar = "█" * int(pct / 10) + "░" * (10 - int(pct / 10))
lines.append(
f"{consumer.value:25s} [{bar}] {used:>6d}/{budget:>6d} ({pct:>5.1f}%)"
)
used_total += used
lines.append("-" * 50)
lines.append(f"{'总计':25s} {used_total:>6d}/{self.total_budget}")
lines.append("=" * 50)
return "\n".join(lines)
def emergency_trim(self, target_reduction: int):
"""紧急裁剪——释放Token
按优先级从低到高裁剪:
1. 短期记忆(裁剪最早的消息)
2. 检索记忆(减少返回条数)
3. 共享记忆(裁剪非关键的)
"""
trim_order = [
(TokenConsumer.SHORT_TERM_MEMORY, 0.5), # 砍掉50%
(TokenConsumer.RETRIEVED_MEMORY, 0.3), # 砍掉30%
(TokenConsumer.SHARED_MEMORY, 0.2), # 砍掉20%
]
reduced = 0
for consumer, ratio in trim_order:
alloc = self.allocations[consumer]
current = alloc.get("used", 0)
reduction = int(current * ratio)
alloc["used"] = max(current - reduction, alloc["budget"] // 2)
reduced += reduction
print(f"✂️ 裁剪 {consumer.value}: {current} → {alloc['used']} (释放{reduction})")
if reduced >= target_reduction:
break
return reduced
# 使用示例
"""
budget = TokenBudget(total_budget=32000) # GPT-4 32K
budget.allocate(TokenConsumer.SYSTEM_PROMPT, 500)
budget.allocate(TokenConsumer.SHORT_TERM_MEMORY, 6000)
budget.allocate(TokenConsumer.RETRIEVED_MEMORY, 1500)
print(budget.report())
if budget.get_remaining() < 4000:
print("⚠️ Token紧张,启动紧急裁剪!")
budget.emergency_trim(target_reduction=5000)
print(budget.report())
"""
5.3 动态裁剪:在刀尖上跳舞
静态预算分配的问题在于:不同场景对Token需求差异巨大。简单问答可能1000 Token就够,复杂推理可能需要20000 Token。
所以需要动态裁剪——根据当前任务复杂度,自适应调整各环节的Token配额。
class DynamicTokenManager:
"""动态Token管理器——根据任务复杂度自适应分配"""
@staticmethod
def estimate_complexity(user_message: str) -> float:
"""估算任务复杂度(0-1)"""
score = 0.0
# 长度因子
length = len(user_message)
if length > 500:
score += 0.3
elif length > 200:
score += 0.15
# 关键词因子
complex_keywords = {
"分析": 0.1, "总结": 0.1, "方案": 0.15,
"代码": 0.2, "部署": 0.15, "架构": 0.2,
"多步骤": 0.15, "比较": 0.1, "评估": 0.1,
}
for kw, weight in complex_keywords.items():
if kw in user_message:
score += weight
return min(score, 1.0)
def adapt_budget(self, budget: TokenBudget, complexity: float):
"""根据复杂度调整预算"""
# 复杂度高 → 给短期记忆和检索记忆更多Token
memory_boost = int(complexity * 4000)
retrieved_boost = int(complexity * 2000)
budget.allocations[TokenConsumer.SHORT_TERM_MEMORY]["budget"] += memory_boost
budget.allocations[TokenConsumer.RETRIEVED_MEMORY]["budget"] += retrieved_boost
print(f"🧠 复杂度={complexity:.2f} → "
f"记忆+{memory_boost} Token, 检索+{retrieved_boost} Token")
📐 动态裁剪就像开车时的油门——平路轻踩,上坡深踩。你不能永远用一个分配策略应对所有场景。
⚠️ 避坑3:消息格式的隐性Token消耗
很多人不知道,OpenAI的message格式本身也消耗Token:
# 你以为这条消息只消耗内容部分的Token?
message = {"role": "user", "content": "你好"}
# 实际上,role标签、name字段、tool_calls结构等都会额外消耗Token
# 一条带有tool_call的消息可能比纯文本多消耗100+ Token
解决方法:
- 使用
tiktoken实际计算,不要估算 - 减少不必要的system message字段
- 工具定义尽量精简,去掉description中的废话
- 如果是函数调用,函数名和参数名尽量短
💡 效率技巧3:结构化提示词压缩
不要这样写系统提示词——
你是xxx,你的职责是xxx,你需要注意xxx,当xxx时你应该xxx,绝对不要xxx...
而是这样:
SYSTEM_PROMPT_COMPACT = """
Role: {role} | Goal: {goal} | Style: {style}
Rules: {rules}
Context: {context}
"""
前者200 Token,后者80 Token。信息量相同,Token省一半。
六、记忆检索策略:什么时候该想起来?
6.1 三因子加权打分模型
检索不是"把最相似的前5条返回"就行了。真正有用的检索需要三重加权:
最终得分 = α × 语义相似度 + β × 重要性 + γ × 时间衰减
其中:
α + β + γ = 1.0
推荐初始值:α=0.4, β=0.3, γ=0.3
from typing import List, Tuple
class MemoryRetrievalEngine:
"""记忆检索引擎——三因子加权打分"""
def __init__(
self,
memory: DecayAwareMemory,
alpha: float = 0.4, # 语义相关性权重
beta: float = 0.3, # 重要性权重
gamma: float = 0.3, # 时间衰减权重
):
assert abs(alpha + beta + gamma - 1.0) < 0.01, "权重之和必须为1"
self.memory = memory
self.alpha = alpha
self.beta = beta
self.gamma = gamma
def retrieve(
self,
query: str,
top_k: int = 5,
min_score: float = 0.3,
**filters,
) -> List[Dict]:
"""检索并重排序记忆"""
# 先粗检索(多拿一些候选)
raw = self.memory.recall_with_decay(
query, n_results=top_k * 3, **filters
)
# 三因子加权重排序
for mem in raw:
similarity = mem.get("similarity", 0)
importance = float(mem["metadata"].get("importance", 5)) / 10
decay = mem.get("decay_factor", 1.0)
mem["final_score"] = (
self.alpha * similarity +
self.beta * importance +
self.gamma * decay
)
# 过滤低分
filtered = [m for m in raw if m["final_score"] >= min_score]
# 按最终得分排序
filtered.sort(key=lambda x: x["final_score"], reverse=True)
return filtered[:top_k]
def retrieve_and_format(self, query: str, top_k: int = 5) -> str:
"""检索并格式化为LLM可直接使用的文本"""
results = self.retrieve(query, top_k=top_k)
if not results:
return "(无相关历史记忆)"
lines = ["📋 相关历史记忆:"]
for i, mem in enumerate(results, 1):
lines.append(
f" {i}. [{mem['final_score']:.2f}] {mem['content']}"
)
return "\n".join(lines)
6.2 检索触发机制
前面说了不要每次都检索。那我们到底什么时候检索?
graph TD
A[用户消息] --> B{消息长度 > 20字?}
B -->|否| C[大概率寒暄<br/>跳过检索]
B -->|是| D{包含触发关键词?}
D -->|是| E[触发检索]
D -->|否| F{包含代词/指代?}
F -->|是| E
F -->|否| G{复杂度评分 > 0.4?}
G -->|是| E
G -->|否| H{距上次检索 > 3轮?}
H -->|是| I{消息与上下文有关?}
I -->|是| E
I -->|否| C
H -->|否| C
E --> J[语义检索]
J --> K[三因子加权打分]
K --> L[注入上下文窗口]
C --> M[跳过,节省Token]
这个流程图解决了一个核心问题:检索本身也是要花Token的。一次无意义的检索,既消耗embedding API的费用,又把无关信息塞进了上下文窗口。
七、实战:带记忆的多Agent客服系统
好了,理论讲完了。现在咱们把前面的所有组件组装起来,搭一个真正带三层记忆的多Agent客服系统。
7.1 系统架构
graph TB
subgraph 用户层
U[用户]
end
subgraph 路由层
R[意图识别Agent<br/>Router Agent]
end
subgraph 业务Agent层
A1[售前Agent<br/>Presales]
A2[售后Agent<br/>Support]
A3[投诉Agent<br/>Complaint]
end
subgraph 记忆系统层
SM[短期记忆<br/>滑动窗口+摘要]
LM[(长期记忆<br/>ChromaDB)]
SHP[共享记忆池<br/>Redis/内存]
end
subgraph 检索层
RT[检索触发器]
RE[检索引擎<br/>三因子加权]
end
U -->|输入消息| R
R -->|路由到| A1
R -->|路由到| A2
R -->|路由到| A3
A1 --> SM
A2 --> SM
A3 --> SM
SM -->|重要性评分| LM
SM -->|关键信息同步| SHP
A1 -->|查询| SHP
A2 -->|查询| SHP
A3 -->|查询| SHP
RT -->|触发检索| RE
RE -->|查询| LM
RE -->|注入结果| SM
A1 -->|生成回复| U
A2 -->|生成回复| U
A3 -->|生成回复| U
7.2 完整代码实现
"""
带三层记忆的多Agent客服系统 —— 完整实现
依赖: pip install chromadb openai tiktoken langgraph
"""
import os
import time
import json
from typing import List, Dict, Any, Optional, Literal
from dataclasses import dataclass, field
from datetime import datetime
import tiktoken
import chromadb
from chromadb.utils import embedding_functions
# ============================================================
# 第一部分:Token工具类
# ============================================================
class TokenCounter:
"""Token计数器"""
def __init__(self, model: str = "gpt-4"):
self.encoder = tiktoken.encoding_for_model(model)
def count(self, text: str) -> int:
return len(self.encoder.encode(text))
def count_messages(self, messages: List[Dict]) -> int:
"""计算消息列表的总Token数(含格式开销)"""
total = 0
for msg in messages:
# 每条消息格式开销~4 tokens
total += 4
for key, value in msg.items():
total += self.count(str(value))
total += 2 # 对话结束标记
return total
# ============================================================
# 第二部分:短期记忆(滑动窗口 + 重要性评分)
# ============================================================
@dataclass
class ShortTermMessage:
role: str
content: str
timestamp: float
importance: float = 5.0
class ShortTermMemory:
"""短期记忆——Token感知滑动窗口 + 重要性评分"""
IMPORTANCE_KEYWORDS = {
# 高重要性
"必须": 9, "退款": 9, "投诉": 9, "紧急": 9,
"订单号": 8, "手机号": 8, "地址": 8, "密码": 8,
"方案": 6, "需求": 7, "问题": 6, "报错": 7,
# 低重要性
"好的": 2, "谢谢": 1, "嗯": 1, "行": 2,
}
def __init__(self, max_tokens: int = 8000, model: str = "gpt-4"):
self.max_tokens = max_tokens
self.counter = TokenCounter(model)
self.messages: List[ShortTermMessage] = []
def add(self, role: str, content: str) -> None:
importance = self._score_importance(content)
msg = ShortTermMessage(
role=role,
content=content,
timestamp=time.time(),
importance=importance,
)
self.messages.append(msg)
self._trim()
def _score_importance(self, content: str) -> float:
"""基于关键词的重要性评分"""
score = 5.0 # 默认中等
content_lower = content.lower()
for kw, s in self.IMPORTANCE_KEYWORDS.items():
if kw in content_lower:
score = max(score, s)
# 长度加分
if len(content) > 200:
score = min(10, score + 1.5)
elif len(content) < 5:
score = max(1, score - 2)
# 提问加分
if "?" in content or "?" in content:
score = min(10, score + 1)
return score
def _trim(self):
"""Token超限时按重要性从低到高淘汰"""
while self._total_tokens() > self.max_tokens and len(self.messages) > 2:
# 找到重要性最低的消息(跳过最新的两条)
candidates = self.messages[:-2]
if not candidates:
break
victim = min(candidates, key=lambda m: m.importance)
self.messages.remove(victim)
def _total_tokens(self) -> int:
return sum(self.counter.count(m.content) for m in self.messages)
def to_llm_format(self) -> List[Dict]:
"""转为LLM可用的消息格式"""
return [{"role": m.role, "content": m.content} for m in self.messages]
def extract_memorable(self) -> List[str]:
"""提取重要消息(用于写入长期记忆)"""
return [
m.content for m in self.messages
if m.importance >= 7 and len(m.content) > 20
]
@property
def token_usage(self) -> int:
return self._total_tokens()
# ============================================================
# 第三部分:长期记忆(ChromaDB)
# ============================================================
class LongTermMemory:
"""长期记忆——ChromaDB持久化存储"""
def __init__(
self,
collection_name: str = "cs_memory",
persist_dir: str = "./cs_memory_db",
):
self.client = chromadb.PersistentClient(path=persist_dir)
try:
self.ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.environ.get("OPENAI_API_KEY", ""),
model_name="text-embedding-3-small",
)
except Exception:
# 降级到默认embedding
self.ef = embedding_functions.DefaultEmbeddingFunction()
self.collection = self.client.get_or_create_collection(
name=collection_name,
embedding_function=self.ef,
metadata={"hnsw:space": "cosine"},
)
def store(self, content: str, user_id: str, importance: float = 5.0,
memory_type: str = "general"):
"""存入记忆"""
mem_id = f"{user_id}_{int(time.time() * 1000)}"
self.collection.add(
documents=[content],
metadatas=[{
"user_id": user_id,
"importance": importance,
"type": memory_type,
"created_at": datetime.now().isoformat(),
}],
ids=[mem_id],
)
def query(self, text: str, user_id: str = None, top_k: int = 5) -> List[Dict]:
"""语义检索记忆"""
where = {"user_id": user_id} if user_id else None
results = self.collection.query(
query_texts=[text],
n_results=top_k,
where=where,
)
memories = []
if results["ids"] and results["ids"][0]:
for i in range(len(results["ids"][0])):
memories.append({
"id": results["ids"][0][i],
"content": results["documents"][0][i],
"distance": results["distances"][0][i],
"metadata": results["metadatas"][0][i],
"similarity": 1 - results["distances"][0][i],
})
return memories
# ============================================================
# 第四部分:共享记忆池
# ============================================================
class SharedMemoryPool:
"""多Agent共享记忆池"""
def __init__(self):
self._store: Dict[str, Dict] = {}
def put(self, key: str, value: Any, agent_id: str, namespace: str = "global"):
"""写入共享记忆"""
self._store[key] = {
"value": value,
"namespace": namespace,
"written_by": agent_id,
"written_at": time.time(),
}
def get(self, key: str) -> Optional[Dict]:
"""读取共享记忆"""
return self._store.get(key)
def get_namespace(self, namespace: str) -> Dict[str, Dict]:
"""获取命名空间下的所有记忆"""
return {
k: v for k, v in self._store.items()
if v.get("namespace") == namespace
}
# ============================================================
# 第五部分:检索触发器 + 引擎
# ============================================================
class RetrievalTrigger:
"""检索触发器——判断是否需要查长期记忆"""
TRIGGERS = [
"上次", "之前", "记得", "我说过", "之前提到",
"你忘了吗", "以前", "历史", "记录", "那个",
]
@classmethod
def should_retrieve(cls, msg: str) -> bool:
msg_lower = msg.lower()
# 寒暄跳过
skip = {"好的", "嗯", "ok", "谢谢", "行", "收到", "是的", "对"}
if msg_lower.strip() in skip:
return False
# 关键词触发
if any(t in msg_lower for t in cls.TRIGGERS):
return True
# 长消息触发
if len(msg) > 100:
return True
return False
class RetrievalEngine:
"""检索引擎——三因子加权"""
def __init__(self, ltm: LongTermMemory, alpha=0.4, beta=0.35, gamma=0.25):
self.ltm = ltm
self.alpha = alpha
self.beta = beta
self.gamma = gamma
def retrieve(self, query: str, user_id: str, top_k: int = 5) -> str:
"""检索并格式化为文本"""
raw = self.ltm.query(query, user_id=user_id, top_k=top_k * 2)
for mem in raw:
sim = mem.get("similarity", 0)
imp = float(mem["metadata"].get("importance", 5)) / 10
# 时间衰减
created_str = mem["metadata"].get("created_at", "2000-01-01T00:00:00")
try:
created = datetime.fromisoformat(created_str)
days = (datetime.now() - created).days
decay = max(0.05, 1.0 - days * 0.01) # 每天衰减1%
except Exception:
decay = 1.0
mem["score"] = self.alpha * sim + self.beta * imp + self.gamma * decay
raw.sort(key=lambda x: x["score"], reverse=True)
top = raw[:top_k]
if not top:
return ""
lines = ["[系统记忆] 以下是与当前对话相关的历史信息:"]
for i, mem in enumerate(top, 1):
lines.append(f" {i}. {mem['content']}")
return "\n".join(lines)
# ============================================================
# 第六部分:Agent基类
# ============================================================
class CustomerAgent:
"""客服Agent基类"""
def __init__(
self,
name: str,
role_desc: str,
stm: ShortTermMemory,
ltm: LongTermMemory,
pool: SharedMemoryPool,
engine: RetrievalEngine,
):
self.name = name
self.role_desc = role_desc
self.stm = stm
self.ltm = ltm
self.pool = pool
self.engine = engine
def think(self, user_input: str, user_id: str = "default") -> Dict:
"""Agent处理流程"""
# 1. 加入短期记忆
self.stm.add("user", user_input)
# 2. 提取当前用户信息 → 写入共享池
self.pool.put(
f"{user_id}_last_active", time.time(),
agent_id=self.name, namespace=f"session_{user_id}"
)
# 3. 触发长期记忆检索
retrieved_info = ""
if RetrievalTrigger.should_retrieve(user_input):
retrieved_info = self.engine.retrieve(user_input, user_id)
if retrieved_info:
self.stm.add("system", retrieved_info)
# 4. 查共享池
shared_info = self.pool.get(f"{user_id}_profile")
if shared_info:
self.stm.add("system", f"[共享信息] {shared_info['value']}")
# 5. 提取重要信息写入长期记忆
memorable = self.stm.extract_memorable()
for content in memorable:
self.ltm.store(
content, user_id,
importance=8.0,
memory_type="user_interaction"
)
# 6. 组装响应(这里模拟LLM调用)
context = self.stm.to_llm_format()
response_text = self._generate_response(user_input, context)
self.stm.add("assistant", response_text)
return {"agent": self.name, "response": response_text,
"retrieved": bool(retrieved_info),
"token_usage": self.stm.token_usage}
def _generate_response(self, user_input: str, context: List[Dict]) -> str:
"""模拟LLM生成回复(实际开发中替换为LLM API调用)"""
# 简化演示
if "退款" in user_input:
return "您好,我已记录您的退款请求。请提供订单号,我们会在24小时内处理。"
elif "价格" in user_input or "多少钱" in user_input:
return "您好!我们的产品价格根据配置不同在99-999元之间,目前有限时优惠活动。"
elif "问题" in user_input or "报错" in user_input:
return "了解您遇到了问题。请描述具体报错信息,我会协助排查。"
return f"[{self.name}] 您好!我是{self.role_desc}。请问有什么可以帮您?"
# ============================================================
# 第七部分:多Agent协调器
# ============================================================
class MultiAgentOrchestrator:
"""多Agent协调器——路由 + 记忆管理"""
def __init__(self):
# 初始化记忆系统
self.stm = ShortTermMemory(max_tokens=8000)
self.ltm = LongTermMemory(collection_name="cs_system")
self.pool = SharedMemoryPool()
self.engine = RetrievalEngine(self.ltm)
# 初始化Agent
self.agents = {
"presales": CustomerAgent(
"售前Agent", "售前咨询专员",
self.stm, self.ltm, self.pool, self.engine
),
"support": CustomerAgent(
"售后Agent", "售后技术支持",
self.stm, self.ltm, self.pool, self.engine
),
"complaint": CustomerAgent(
"投诉Agent", "投诉处理专员",
self.stm, self.ltm, self.pool, self.engine
),
}
def route(self, user_input: str) -> str:
"""意图路由"""
inp = user_input.lower()
if any(w in inp for w in ["退款", "投诉", "不满意", "差评"]):
return "complaint"
elif any(w in inp for w in ["问题", "报错", "bug", "不能用", "坏了"]):
return "support"
return "presales"
def process(self, user_input: str, user_id: str = "default") -> Dict:
"""处理用户请求"""
agent_key = self.route(user_input)
agent = self.agents[agent_key]
result = agent.think(user_input, user_id)
print(f"\n{'='*60}")
print(f"👤 用户: {user_input}")
print(f"🔀 路由到: {agent_key}")
print(f"🧠 检索记忆: {'是' if result['retrieved'] else '否'}")
print(f"📊 Token使用: {result['token_usage']}")
print(f"🤖 {agent.name}: {result['response']}")
print(f"{'='*60}")
return result
# ============================================================
# 第八部分:运行演示
# ============================================================
if __name__ == "__main__":
print("=" * 60)
print("🚀 带记忆的多Agent客服系统启动")
print("=" * 60)
system = MultiAgentOrchestrator()
# 模拟对话
conversations = [
("你好,我想了解一下你们的产品价格", "user_001"),
("我之前提到的价格问题,你还记得吗?", "user_001"),
("我的软件打不开了,一直报错", "user_002"),
("我要退款!太差了!", "user_002"),
("客服在吗?我想问一下上次说的那个Bug修复了没有", "user_002"),
]
for msg, uid in conversations:
time.sleep(0.3) # 模拟延迟
system.process(msg, uid)
# 统计
print(f"\n📈 系统统计:")
total_memories = system.ltm.collection.count()
print(f" 长期记忆数: {total_memories}")
print(f" 短期记忆Token: {system.stm.token_usage}")
7.3 运行效果
执行上面的代码,你会看到——
============================================================
🚀 带记忆的多Agent客服系统启动
============================================================
============================================================
👤 用户: 你好,我想了解一下你们的产品价格
🔀 路由到: presales
🧠 检索记忆: 否
📊 Token使用: 168
🤖 售前Agent: [售前Agent] 您好!我是售前咨询专员。请问有什么可以帮您?
============================================================
============================================================
👤 用户: 我之前提到的价格问题,你还记得吗?
🔀 路由到: presales
🧠 检索记忆: 是 ← 触发长期记忆检索!
📊 Token使用: 412
🤖 售前Agent: 您好!我们的产品价格根据配置不同在99-999元之间...
============================================================
============================================================
👤 用户: 我要退款!太差了!
🔀 路由到: complaint ← 自动路由到投诉Agent
🧠 检索记忆: 是 ← 检索到用户历史,不用重新问身份
📊 Token使用: 598
🤖 投诉Agent: 您好,我已记录您的退款请求...
============================================================
关键点:
- user_002的第二个问题"我要退款" 自动路由到了投诉Agent,而不是售前Agent
- 记忆检索触发:当用户说"之前""还记得吗"时,系统自动去长期记忆库检索
- 跨Agent信息共享:共享记忆池让不同Agent都能获取用户基本画像
八、Token预算分配图
pie title Token预算分配(128K上下文窗口)
"响应保留" : 10000
"系统提示词" : 1500
"工具定义" : 3000
"短期记忆(滑动窗口)" : 8000
"长期记忆检索结果" : 3000
"共享记忆注入" : 2000
"当前消息+模板" : 2000
"动态余量(弹性空间)" : 98500
⚠️ 动态余量看着很大,但在多Agent协作场景下,多个Agent的调用结果、中间推理步骤会消耗大量Token。实际生产中128K窗口的有效利用率通常只有15%-25%。
写在最后:三个建议
-
从简单开始:先用滑动窗口+ChromaDB,把基础跑通。不要在还没验证需求的时候就上Milvus集群。小规模场景下,ChromaDB的
PersistentClient足够用了。 -
检索要克制:不是每次用户说话都要搜记忆库。加一个触发器,能省30%-50%的embedding调用费用。
-
Token预算要可视化:建议在你的Agent日志里加入Token使用报告。你可能会被吓到——那些"好的"“嗯”"收到"消耗的Token远超预期。然后你就会开始疯狂压缩提示词。
[1] Liu et al. “Lost in the Middle: How Language Models Use Long Contexts.” 2023.
文末三件套
【系列文章】
- L1实战——多Agent协作系统搭建(一):从零搭建LangGraph多Agent框架
- L2实战——多Agent协作系统搭建(二):Agent间通信与任务编排
- L3实战——多Agent协作系统搭建(三):Agent记忆与上下文管理 ← 本文
- L5实战——AI DevOps全流程(一):MLflow实验追踪与模型管理 ← 下篇预告
【完整代码】
本文所有代码已开源:GitHub仓库地址
包含:三层记忆完整实现、Token预算管理器、多Agent客服系统、Docker一键部署配置
【关注我】
💡 专注AI Agent实战开发,每周更新一篇硬核技术文章
📧 联系我:评论区留言即可,每条都会回复
🔔 点赞+收藏+关注,不错过下一篇《L5实战——AI DevOps全流程(一):MLflow实验追踪与模型管理》
标签:Agent记忆、上下文管理、多Agent、向量数据库、记忆检索、Token优化、LangGraph
:Agent记忆与上下文管理&spm=1001.2101.3001.5002&articleId=163135725&d=1&t=3&u=eb7045817153466b9f5161897f2391b6)
2046

被折叠的 条评论
为什么被折叠?



