Agent 历史存 Milvus 与检索
历史对话存到 Milvus 并不是"把聊天记录往向量库一扔"那么简单。要让 Agent 在问答时能"想起"过往相关内容,需要把短期会话状态和长期可检索记忆分开设计,再用一套清晰的写入 / 检索 / 注入流程把它们串起来。
一、核心设计思路:两层记忆缺一不可
在 LangChain/LangGraph 体系里,"历史"其实有两种完全不同的生命周期,用同一个机制硬扛会两头不讨好。
| 记忆类型 | 生命周期 | 存什么 | 典型载体 |
|---|---|---|---|
| 短期 / 线程内记忆 | 单次会话的多轮对话 | 原始消息列表 | LangGraph Checkpointer(thread_id) |
| 长期 / 跨会话记忆 | 跨天数、跨会话的用户偏好、过往问答、经验 | 向量化后的历史片段 | Milvus 向量库 |
💡 LangGraph 用
thread_id管理单次会话的连续上下文,Milvus 则负责"跨会话的可语义检索历史"。两者分工:前者保证"这一轮对话没断片",后者保证"上周聊过的事这周还能想起来"。
Milvus 官方在构建长运行 Agent 时也明确采用这种分工:LangGraph for session state, Milvus for long-term memory——LangGraph 管"当前在干什么",Milvus 存"过去做过什么、遇到过什么问题、当时怎么解决的"。
为什么要这样切?
如果不加区分地把所有历史都塞进 Milvus 然后每轮无脑检索注入,会出现三个问题:
- Token 浪费与噪声:近期 3-5 轮本来就在上下文里,再检索回来就是重复信息,还会引入无关旧片段干扰 LLM 判断。
- 检索精度下降:海量历史里做 ANN 搜索,真正相关的可能被稀释。
- 延迟与成本:每轮问答都跑向量检索 + 重排序,P99 latency 压力大。
所以业界的最佳实践是:最近的对话走 Checkpointer 直接恢复;较早的、跨会话的、需要"语义唤醒"的历史才进 Milvus。
二、Milvus 数据建模:schema 怎么定
对话历史进 Milvus,不能只存一个 vector + content。参考 Milvus 官方最佳实践与生产案例,推荐的 schema 如下:
from pymilvus import MilvusClient, CollectionSchema, FieldSchema, DataType
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
# 向量字段:对话片段的 embedding
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
# 标量字段:用于精确过滤
FieldSchema(name="user_id", dtype=DataType.VARCHAR, max_length=64), # 用户隔离
FieldSchema(name="session_id", dtype=DataType.VARCHAR, max_length=64),# 会话隔离
FieldSchema(name="turn_role", dtype=DataType.VARCHAR, max_length=16), # user / assistant / tool
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=4096), # 原始文本
FieldSchema(name="created_at", dtype=DataType.INT64), # 时间戳,用于时间过滤
FieldSchema(name="importance", dtype=DataType.FLOAT), # 重要性评分
FieldSchema(name="metadata", dtype=DataType.JSON), # 扩展字段
]
schema = CollectionSchema(fields, description="Agent 对话历史长期记忆")
关键设计点:
user_id+session_id双字段:保证"用户 A 不会检索到用户 B 的记忆",session_id则可用于"只在这一次会话内回忆"。created_at时间戳:支持"过去一周关于 RAG 的讨论"这类时间 + 语义的组合查询。Milvus 2.6 原生支持向量检索与标量过滤联合执行,比"先拉 1000 条语义相似的再在应用层按时间过滤"效率高得多。importance重要性评分:可由 LLM 在写入时打分(0~1),检索时作为加权或过滤条件,让重要记忆更容易被唤醒,陈旧碎碎念自然沉底。metadataJSON 字段:Milvus 2.6 默认开启 JSON Shredding,嵌套字段会被展平为列式存储,标量过滤性能提升 3-5 倍。
索引方面,向量字段用 HNSW( metric_type 根据 embedding 模型选 COSINE 或 IP),user_id 建 TRIE 索引加速过滤:
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 200}
}
三、写入流程:历史什么时候、以什么粒度存进 Milvus
1. 写入时机:热路径 vs 后台任务
LangGraph 官方总结了两种写记忆的模式:
热路径(Hot Path):在 Agent 处理用户消息的当下同步写入。优点是实时可用、用户可感知;缺点是增加延迟,且 Agent 要"分心"判断要不要存。
后台任务(Background):对话结束后由独立任务异步抽取、写入。优点是不阻塞主链路;难点是确定触发频率。
对于"问答历史存储"场景,推荐组合使用:
- 每轮对话结束后,立即把这一轮的 (user_query, assistant_answer) 作为一条记录写入 Milvus(热路径,简单可靠)。
- 定期或触发式地,由后台任务对历史做摘要压缩,把长对话归纳为几条"语义记忆"也写入 Milvus(后台任务)。
2. 写入粒度:一条消息 vs 一轮对话 vs 摘要
- 一条消息存一条向量:粒度最细,检索精准,但存储量大。
- 一轮对话(Q+A)存一条向量:把 user 和 assistant 拼在一起向量化,推荐起步方案。
- 摘要后存储:对话超过 N 轮后,用 LLM 压缩成摘要再存,节省空间且提升检索信噪比。
3. 写入代码示例(LangGraph 节点内)
from sentence_transformers import SentenceTransformer
from pymilvus import MilvusClient
from datetime import datetime
import uuid
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
milvus_client = MilvusClient("./milvus_agent_memory.db")
if not milvus_client.has_collection("agent_history"):
milvus_client.create_collection(
collection_name="agent_history",
dimension=384,
auto_id=True
)
def save_turn_to_milvus(user_id: str, session_id: str,
user_msg: str, assistant_msg: str,
importance: float = 0.5):
"""把一轮对话存进 Milvus"""
# 拼接这一轮的语义内容
content = f"User: {user_msg}\nAssistant: {assistant_msg}"
embedding = embedding_model.encode(content).tolist()
data = [{
"embedding": embedding,
"user_id": user_id,
"session_id": session_id,
"turn_role": "exchange",
"content": content,
"created_at": int(datetime.now().timestamp()),
"importance": importance,
"metadata": {"type": "dialogue_turn"}
}]
milvus_client.insert(collection_name="agent_history", data=data)
milvus_client.flush(collection_name="agent_history")
四、检索流程:问答时怎么把历史"想起来"
这是整套设计的核心。检索不是"拿用户当前问题去 Milvus 里搜"这么粗暴,而是分步骤融合多路信号:
检索策略
Step 1:构造检索 query
建议用"当前用户问题 + 近期上下文摘要"作为检索向量,比单用当前问题召回更准。
Step 2:向量检索 + 标量过滤联合
利用 Milvus 的标量过滤能力,把检索范围先锁定在 user_id = 当前用户,再在结果里做向量相似度排序:
def retrieve_relevant_history(user_id: str, query: str, top_k: int = 3):
"""从 Milvus 检索相关历史"""
query_vec = embedding_model.encode(query).tolist()
results = milvus_client.search(
collection_name="agent_history",
data=[query_vec],
limit=top_k,
filter=f'user_id == "{user_id}"', # 标量过滤,保证用户隔离
output_fields=["content", "created_at", "importance"]
)
if results and results[0]:
return [hit["entity"] for hit in results[0]]
return []
Step 3(进阶):混合检索(Dense + Sparse)
Milvus 2.6 原生支持稠密向量(语义)+ 稀疏向量(BM25 关键词)混合检索,通过 RRF 重排序融合。对于"用户问’我们上周讨论的那个 FastAPI 性能优化方案’"这种既有语义又有明确实体的查询,混合检索比单纯向量检索召回率高 30-50%。
Step 4:时间衰减加权
对检索结果按 created_at 做时间衰减,让"最近的相似历史"排在"很久以前的相似历史"前面:
import time
def time_decay(created_at, half_life_days=30):
age_days = (time.time() - created_at) / 86400
return 0.5 ** (age_days / half_life_days)
# 对检索结果重排序
def rerank_with_decay(results):
for r in results:
score = r["distance"] * time_decay(r["entity"]["created_at"])
r["final_score"] = score
return sorted(results, key=lambda x: x["final_score"], reverse=True)
五、与 LangGraph Agent 的完整集成
下面是端到端代码,把上述设计串进一个 LangGraph Agent 里。
1. 定义状态与图
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage
class AgentState(TypedDict):
messages: Annotated[list, operator.add] # 短期记忆:当前会话消息
user_id: str
session_id: str
retrieved_history: list # 从 Milvus 捞回来的长期记忆
2. 检索节点:问答前先"回忆"
def retrieve_memory_node(state: AgentState):
"""在 LLM 调用前,从 Milvus 检索相关长期记忆"""
user_id = state["user_id"]
# 用最近一条用户消息作为检索 query
last_user_msg = [m for m in state["messages"] if isinstance(m, HumanMessage)][-1].content
# 结合近期上下文构造更丰富的检索 query
recent_ctx = "\n".join([m.content for m in state["messages"][-4:]]) # 最近 4 条
enriched_query = f"{recent_ctx}\n{last_user_msg}"
hits = retrieve_relevant_history(user_id, enriched_query, top_k=3)
return {"retrieved_history": hits}
3. LLM 节点:把检索到的历史注入 Prompt
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.0)
def call_model_node(state: AgentState):
# 把检索到的历史格式化为上下文
history_ctx = "\n---\n".join([
f"[历史 {i+1}]: {h['content']}"
for i, h in enumerate(state["retrieved_history"])
])
system_prompt = f"""你是一个有长期记忆的问答助手。以下是与你和当前用户过往相关的历史对话,
可能在回答时有参考价值:
{history_ctx if history_ctx else "(无相关历史)"}
请结合这些历史以及下面的当前对话来回答。"""
messages = [{"role": "system", "content": system_prompt}] + \
[{"role": "user", "content": m.content} for m in state["messages"]]
resp = llm.invoke(messages)
return {"messages": [AIMessage(content=resp.content)]}
4. 写入节点:回答完成后存进 Milvus
def save_memory_node(state: AgentState):
"""LLM 生成回答后,把这一轮存进 Milvus"""
msgs = state["messages"]
user_msg = [m for m in msgs if isinstance(m, HumanMessage)][-1].content
ai_msg = [m for m in msgs if isinstance(m, AIMessage)][-1].content
# 用 LLM 给这一轮打重要性分数(也可简化为固定值)
importance = 0.6
save_turn_to_milvus(
user_id=state["user_id"],
session_id=state["session_id"],
user_msg=user_msg,
assistant_msg=ai_msg,
importance=importance
)
return {}
5. 组装 LangGraph
builder = StateGraph(AgentState)
builder.add_node("retrieve", retrieve_memory_node)
builder.add_node("call_model", call_model_node)
builder.add_node("save_memory", save_memory_node)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "call_model")
builder.add_edge("call_model", "save_memory")
builder.add_edge("save_memory", END)
# 用 MemorySaver 做短期 checkpoint(生产环境换成 PostgresSaver)
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# 调用时传入 thread_id(短期会话隔离)和 user_id(长期记忆归属)
config = {"configurable": {"thread_id": "session-001"}}
result = graph.invoke({
"messages": [HumanMessage(content="我们之前讨论过的那个 RAG 优化方案,具体改了哪些参数?")],
"user_id": "user-123",
"session_id": "session-001",
"retrieved_history": []
}, config)
六、如果用经典 LangChain(非 LangGraph)
LangChain 提供了 VectorStoreRetrieverMemory,可以把 Milvus 包装成记忆组件直接挂到 ConversationChain 上:
from langchain_milvus import Milvus
from langchain_openai import OpenAIEmbeddings, OpenAI
from langchain.memory import VectorStoreRetrieverMemory
from langchain.chains import ConversationChain
# 1. Milvus 作为向量存储
vectordb = Milvus(
embedding_function=OpenAIEmbeddings(),
collection_name="agent_history",
connection_args={"uri": "./milvus_demo.db"}
)
# 2. 包装成检索式记忆
retriever = vectordb.as_retriever(search_kwargs={"k": 3})
memory = VectorStoreRetrieverMemory(retriever=retriever)
# 3. 挂到对话链上
conversation = ConversationChain(
llm=OpenAI(),
memory=memory,
verbose=True
)
# 4. 对话 —— 每轮会自动写入 Milvus,并在下一轮自动检索相关历史
conversation.predict(input="My name is Bob.")
conversation.predict(input="What's my name?") # 能从 Milvus 里检索回 "My name is Bob."
这套写法的优点是开箱即用,LangChain 自动帮你做 save_context 和 load_memory_variables;缺点是对检索时机、过滤条件、混合检索的控制力不如 LangGraph 方案精细,更适合原型或简单场景。
七、最终总结
把 LangChain/LangGraph Agent 的问答历史存到 Milvus 并检索出来,本质上是一套**“分层记忆 + 语义检索 + 上下文注入”**的工程:
🧠 记忆分层
- 短期记忆:LangGraph
Checkpointer+thread_id管理当前会话连续上下文,保证多轮对话不中断。 - 长期记忆:Milvus 存储跨会话的可语义检索历史,让 Agent "想起"用户以前聊过的内容。
📦 数据建模要点
- 向量字段存对话片段的 embedding,标量字段(
user_id、session_id、created_at、importance)支持精确隔离与时间衰减。 - HNSW 索引做向量检索,TRIE 索引加速
user_id过滤。 - 可选混合检索(Dense + Sparse)把召回率提升 30-50%。
✍️ 写入策略
- 每轮对话后热路径写入 Milvus,保证实时性。
- 后台任务做摘要压缩,避免存储膨胀。
- 用 LLM 给每条记忆打
importance分数,作为检索加权依据。
🔍 检索策略
- 用"当前问题 + 近期上下文"构造富集 query,比裸查询更准。
- 向量检索叠加
user_id标量过滤,先做隔离再做相似度排序。 - 时间衰减 + 重要性加权重排序,让"最近且重要"的历史优先注入。
⚙️ 与 Agent 的集成
- LangGraph 方案:检索节点 → LLM 节点(注入历史)→ 写入节点,全流程可控,适合生产。
- LangChain 经典方案:
VectorStoreRetrieverMemory一行接入ConversationChain,适合快速原型。
⚠️ 三个最容易踩的坑
- 不要把所有历史无差别塞进 prompt——近期走 Checkpointer,远期的才走 Milvus 检索,否则 token 爆炸且噪声严重。
- 必须带
user_id过滤——否则用户 A 能检索到用户 B 的对话,既是 bug 也是合规事故。 - 生产环境 Checkpointer 别用
MemorySaver——进程重启记忆全丢,换成PostgresSaver或RedisSaver。
💡 一个判断标准:如果你发现 Agent “记性差”,先想清楚要补的是短期(Checkpointer 没配对
thread_id?)还是长期(Milvus 检索 query 太窄?重要性没打分?),对症下药比堆基建更有效。
更多推荐



所有评论(0)