历史对话存到 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 然后每轮无脑检索注入,会出现三个问题:

  1. Token 浪费与噪声:近期 3-5 轮本来就在上下文里,再检索回来就是重复信息,还会引入无关旧片段干扰 LLM 判断。
  2. 检索精度下降:海量历史里做 ANN 搜索,真正相关的可能被稀释。
  3. 延迟与成本:每轮问答都跑向量检索 + 重排序,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),检索时作为加权或过滤条件,让重要记忆更容易被唤醒,陈旧碎碎念自然沉底
  • metadata JSON 字段:Milvus 2.6 默认开启 JSON Shredding,嵌套字段会被展平为列式存储,标量过滤性能提升 3-5 倍。

索引方面,向量字段用 HNSW( metric_type 根据 embedding 模型选 COSINEIP),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_contextload_memory_variables;缺点是对检索时机、过滤条件、混合检索的控制力不如 LangGraph 方案精细,更适合原型或简单场景。


七、最终总结

把 LangChain/LangGraph Agent 的问答历史存到 Milvus 并检索出来,本质上是一套**“分层记忆 + 语义检索 + 上下文注入”**的工程:

🧠 记忆分层

  • 短期记忆:LangGraph Checkpointer + thread_id 管理当前会话连续上下文,保证多轮对话不中断。
  • 长期记忆:Milvus 存储跨会话的可语义检索历史,让 Agent "想起"用户以前聊过的内容。

📦 数据建模要点

  • 向量字段存对话片段的 embedding,标量字段(user_idsession_idcreated_atimportance)支持精确隔离与时间衰减。
  • HNSW 索引做向量检索,TRIE 索引加速 user_id 过滤。
  • 可选混合检索(Dense + Sparse)把召回率提升 30-50%。

✍️ 写入策略

  • 每轮对话后热路径写入 Milvus,保证实时性。
  • 后台任务做摘要压缩,避免存储膨胀。
  • 用 LLM 给每条记忆打 importance 分数,作为检索加权依据。

🔍 检索策略

  • 用"当前问题 + 近期上下文"构造富集 query,比裸查询更准。
  • 向量检索叠加 user_id 标量过滤,先做隔离再做相似度排序。
  • 时间衰减 + 重要性加权重排序,让"最近且重要"的历史优先注入。

⚙️ 与 Agent 的集成

  • LangGraph 方案:检索节点 → LLM 节点(注入历史)→ 写入节点,全流程可控,适合生产。
  • LangChain 经典方案VectorStoreRetrieverMemory 一行接入 ConversationChain,适合快速原型。

⚠️ 三个最容易踩的坑

  1. 不要把所有历史无差别塞进 prompt——近期走 Checkpointer,远期的才走 Milvus 检索,否则 token 爆炸且噪声严重。
  2. 必须带 user_id 过滤——否则用户 A 能检索到用户 B 的对话,既是 bug 也是合规事故。
  3. 生产环境 Checkpointer 别用 MemorySaver——进程重启记忆全丢,换成 PostgresSaverRedisSaver

💡 一个判断标准:如果你发现 Agent “记性差”,先想清楚要补的是短期(Checkpointer 没配对 thread_id?)还是长期(Milvus 检索 query 太窄?重要性没打分?),对症下药比堆基建更有效。

更多推荐