首页 / 文章

基于Qdrant的LangGraph语义智能体记忆与跨线程检索机制

利用 Qdrant 为 LangGraph 智能体提供长期记忆功能:嵌入用户信息,跨线程 ID 进行检索,并通过负载过滤器实现多租户隔离。

发布者:REACTAPP.TOP阅读约 10 分钟2269 词

来源: **Lab:Langgraph: Integrating Qdrant for Semantic Agentic Memory.**

在构建任何东西之前,先明确为什么智能体需要语义记忆,以及它与情景记忆有何不同,这是很有帮助的。

主要有三个原因:

  1. 跨会话持续性。标准的 LangGraph 检查点工具会将状态与某个 thread_id 相关联。当会话结束或开始新对话时,上下文会重置为零。
  2. 亚线性检索能力。半年的原始对话记录无法全部放入上下文窗口中,这会导致延迟急剧上升,准确性因“中间丢失”效应而下降,同时还会超出令牌限制。
  3. 意图匹配优于精确关键词匹配。当用户重新表述某个概念时,基于关键词的查询会失效,而向量相似度则能弥补这种概念上的差异。

Qdrant 可以通过 Docker Desktop 在本地部署,也可以作为托管服务在 Qdrant Cloud 上运行,或是部署在 Kubernetes 环境中用于生产环境。下文将介绍本地部署方式。

环境配置

首先创建一个 Python 虚拟环境。依赖项会放在 requirements.txt 文件中:

fastapi>=0.110.0
uvicorn>=0.28.0
pydantic>=2.6.0
python-dotenv>=1.0.1
langchain-core>=0.1.30
langchain-openai>=0.1.0
langgraph>=0.0.30
qdrant-client>=1.10.0

在 Ubuntu 或 Debian 上创建环境的标准方法是使用 venv 模块:

#Install venv module (Ubuntu/Debian)
$sudo apt update && sudo apt install python3-venv

#Create a virtual environment
$python3 -m venv myenv

#Activate the environment
$source myenv/bin/activate

#Install saved dependencies
$pip install -r requirements.txt

#Save dependencies
#pip freeze > requirements.txt

#Deactivate when finished:
$deactivate

#Delete the environment:
$rm -rf myenv

使用 Astral 的快速包安装工具及项目管理工具 uv 完成相同任务的效果如下:

#Install uv
$curl -LsSf [https://astral.sh/uv/install.sh](https://astral.sh/uv/install.sh) | sh

#Create a virtual environment
$uv venv myenv

#Activate the environment
$source myenv/bin/activate

#Install saved dependencies:
$uv pip install -r requirements.txt

#Save dependencies:
$uv pip freeze > requirements.txt

#Sync dependencies from lockfile
$uv sync

#Add a new package and auto-update file
$uv add <package_name>

使用 Docker Compose 运行 Qdrant

为了进行本地测试,docker-compose.yaml 文件用于启动数据库:

version: '3.8'

services:
  qdrant:
    image: qdrant/qdrant:latest    #image name download from docker.io
    container_name: sbi_semantic_memory  #container name
    ports:
      - "6333:6333"   # REST HTTP API & Web Dashboard
      - "6334:6334"   # High-speed gRPC API
    volumes:
      - ./qdrant_storage:/qdrant/storage #Binds local directory to container for data persistence across restarts
    networks:
      - agent-network  #Attaches container to isolated network for communication
    healthcheck:   #Executes internal HTTP ping against health check endpoint
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s  #Run health check every 10 seconds
      timeout: 5s    #Fail if check takes longer than 5 seconds
      retries: 5     #Startup grace period before recording failures

networks:    #Private bridged network for secure container-to-container communication
  agent-network:
    driver: bridge   #Standard single-host bridge driver

Docker Compose允许在单个文件中描述多容器应用程序。无需输入包含大量参数的冗长docker run命令,容器、网络、环境变量及卷等所有配置都可在docker-compose.yml中声明。此处即使用了官方的Qdrant镜像:

image: qdrant/qdrant:latest

将其拿起来检查容器:

$docker compose up -d

$  docker ps
CONTAINER ID   IMAGE                  COMMAND             CREATED        STATUS                    PORTS                                                             NAMES
8922a12f1029   qdrant/qdrant:latest   "./entrypoint.sh"   45 hours ago   Up 45 hours (unhealthy)   0.0.0.0:6333-6334->6333-6334/tcp, [::]:6333-6334->6333-6334/tcp   sbi_semantic_memory

$ curl http://localhost:6333/healthz
healthz check passed

#View recent logs
$docker logs sbi_semantic_memory

#Tail live logs in real time
$docker logs -f sbi_semantic_memory

#View the last 50 lines of logs
$docker logs --tail 50 sbi_semantic_memory

#Inspect health check failure reasons
$docker inspect --format='{{json .State.Health}}' sbi_semantic_memory

#Check container resource consumption (CPU/RAM):
$docker stats sbi_semantic_memory

#Inspect container runtime details and exit codes:
$docker inspect sbi_semantic_memory

#Execute an interactive shell inside the container
$docker exec -it sbi_semantic_memory sh

#Restart the container:
$docker restart sbi_semantic_memory

#Stop, remove, and recreate the container:
$docker compose down && docker compose up -d

#Force-kill a stuck container:
$docker kill sbi_semantic_memory

向量存储

在容器运行且健康检查通过后,下一个文件是vector_store.py。该文件中的Python类会建立与本地或远程Qdrant实例的客户端连接,并确保存在名为agent_memories的集合,且该集合具有正确的向量几何结构。向量的维度为1536,这是OpenAI的text-embedding-3-small和text-embedding-ada-002模型所使用的标准维度,而距离则是通过余弦相似度来计算的。

import os
from dotenv import load_dotenv
from qdrant_client import QdrantClient
from qdrant_client.http import models

# Load environment variables
load_dotenv()

class SemanticMemory:
    def __init__(self):
        host = os.getenv("QDRANT_HOST", "localhost")
        port = int(os.getenv("QDRANT_PORT", 6333))

        self.client = QdrantClient(host=host, port=port)
        self.collection_name = "agent_memories"
        self._ensure_collection()

    def _ensure_collection(self):
        collections = self.client.get_collections().collections
        exists = any(c.name == self.collection_name for c in collections)

        if not exists:
            self.client.create_collection(
                collection_name=self.collection_name,
                vectors_config=models.VectorParams(
                    size=1536,
                    distance=models.Distance.COSINE
                ),
            )

memory_vault = SemanticMemory()

余弦相似度是通过计算两个向量之间夹角的余弦值来得出的。由于它会除以向量的长度,因此长度差异会被忽略,只有方向才起作用。这衡量的是概念上的匹配程度,而非单词数量或句子长度。该图展示了不同相似度指标的对比以及它们各自最适用的情况:

.env 文件用于存储 OpenAI 密钥以及 Qdrant 的连接设置:

# OpenAI API Key
OPENAI_API_KEY=

# Qdrant Database Configuration
QDRANT_HOST=localhost
QDRANT_PORT=6333
QDRANT_COLLECTION_NAME=agent_memories

# FastAPI Server Setup
HOST=0.0.0.0
PORT=8000

图表

graph.py 定义了三个节点并构建了工作流程。

recall_node 负责检索记忆并将其注入系统。它使用 text-embedding-3-small 将用户的最新查询转换为1536维向量,从 agent_memories 集合中查找最相关的三条事实,然后将包含这些事实的系统指令插入到 state["messages"] 的索引0位置。

agent_node 负责推理并给出答案。它会带着更新后的历史记录和注入的记忆异步调用 gpt-4o,随后将模型的回复添加到 state["messages"] 中。

memorize_node功能会过滤并保存数据。它会遍历state[“messages”],仅找出最新的原始用户语句,跳过系统指令和AI回复。随后,它会为该输入创建一个向量,并使用唯一的UUID、文本、thread_id以及元数据将一个新的PointStruct插入到Qdrant中。

用于测试的API

main.py提供了一个FastAPI接口,可供测试使用:

from typing import Dict, Any, List
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

# Ensure environment variables are loaded at application start
load_dotenv(override=True)

from graph import app_graph

app = FastAPI(title="Qdrant Semantic Memory Service")

class ChatRequest(BaseModel):
    message: str
    thread_id: str
    metadata: Dict[str, Any] = {}

class ChatResponse(BaseModel):
    thread_id: str
    messages: List[Dict[str, Any]]

@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(req: ChatRequest):
    try:
        initial_state = {
            "messages": [{"role": "user", "content": req.message}],
            "thread_id": req.thread_id,
            "metadata": req.metadata
        }

        final_state = await app_graph.ainvoke(initial_state)

        return ChatResponse(
            thread_id=req.thread_id,
            messages=final_state["messages"]
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

使用 uvicorn main:app --reload 启动服务器,然后在端口 8000 的 /docs 地址打开交互式文档。首先是线程 001 中发送的陈述事实的请求:

{
  "message": "My favorite color is Obsidian Blue.",
  "thread_id": "thread-001",
  "metadata": {"source": "mobile_app"}
}

回复内容:

{
  "thread_id": "thread-001",
  "messages": [
    {
      "role": "system",
      "content": "You have access to the following long-term memory facts about the user:\n- My favorite color is Obsidian Blue.\n- My favorite color is Obsidian Blue.\n- Obsidian Blue is a deep, rich shade of blue that often resembles the color of the volcanic glass obsidian. It's a striking and elegant color choice! If you have any questions or need assistance related to colors or anything else, feel free to ask!\n\nUse the facts above to directly answer the user's question."
    },
    {
      "role": "user",
      "content": "My favorite color is Obsidian Blue."
    },
    {
      "role": "assistant",
      "content": "That's a great choice! Obsidian Blue is a deep, rich shade of blue that resembles the color of volcanic glass. It's both striking and elegant. If you have any questions or need assistance related to colors or anything else, feel free to ask!"
    }
  ]
}

然后在另一个帖子 thread-002 中有一个问题:

{
  "message": "What is my favorite color?",
  "thread_id": "thread-002",
  "metadata": {"source": "web_dashboard" }
}

答案与之前帖子中提到的颜色一致:

{
  "thread_id": "thread-002",
  "messages": [
    {
      "role": "system",
      "content": "You have access to the following long-term memory facts about the user:\n- What is my favorite color?\n- My favorite color is Obsidian Blue.\n- My favorite color is Obsidian Blue.\n\nUse the facts above to directly answer the user's question."
    },
    {
      "role": "user",
      "content": "What is my favorite color?"
    },
    {
      "role": "assistant",
      "content": "Your favorite color is Obsidian Blue."
    }
  ]
}

为何召回会涉及线程

所有嵌入数据都存储在同一个集合 agent_memories 中。每条记录在其载荷中都会存储一个 thread_id,但在 recall_node 中进行搜索时并不会依据该值进行过滤:

# 1. Generate query vector from user's message
query_vector = embeddings.embed_query(user_query)

# 2. Search Qdrant WITHOUT a payload filter
relevant_docs = memory_vault.client.query_points(
    collection_name="agent_memories",
    query=query_vector,
    limit=2  # Grabs top 2 matches globally across all threads
).points

由于 query_points 只接收集合名称和查询向量,Qdrant 会将该查询与数据库中的每一个点进行比对,无论这些点是由哪个线程创建的。thread-002 生成的查询会被转换为一个 1,536 维的向量,Qdrant 会计算该向量与所有存储的向量(包括 thread-001 生成的向量)之间的余弦角度。由于“我喜欢的颜色是什么?”与“我喜欢的颜色是黑曜石蓝”之间的相似度超过了匹配阈值,因此 Qdrant 会返回对应的点,而 recall_node 会在 GPT-4o 运行之前将相关文本插入到新的提示词中。

若要限制智能体仅使用当前线程,可在 thread_id 有效载荷字段上添加 Filter:

from qdrant_client.http import models

# Returns matches ONLY if thread_id matches current thread
relevant_docs = memory_vault.client.query_points(
    collection_name="agent_memories",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="thread_id",
                match=models.MatchValue(value=state["thread_id"])
            )
        ]
    ),
    limit=2
).points

其核心优势

首要优势在于跨会话的长期记忆保存。存储在 PostgreSQL 或 SQLite 检查点中的短期情景记忆会在 thread_id 结束或变更时消失,而 Qdrant 能在独立的线程之间全局保留这些记忆。

其次,搜索能够遵循用户意图。嵌入模型将文本映射到 1,536 维空间,通过余弦距离根据语义而非精确字符串来查找记忆。

第三,检索范围可由用户选择。不使用负载过滤器可实现全局跨线程检索;若需要在特定情况下进行严格隔离,则可在 user_id 或 tenant_id 等键上添加 models.Filter。

多租户过滤功能

针对多租户场景,经过更新的 graph.py 会在每次查询时应用负载过滤器:

import os
import uuid
from typing import TypedDict, List, Dict, Any
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from qdrant_client.http import models
from langgraph.graph import StateGraph, START, END
from app.memory.vector_store import memory_vault

load_dotenv()

class AgentState(TypedDict):
    messages: List[Dict[str, Any]]
    thread_id: str
    tenant_id: str  # Enforces multi-tenancy boundaries
    user_id: str    # Identifies the end-user
    metadata: Dict[str, Any]

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
llm = ChatOpenAI(model="gpt-4o", temperature=0)

async def recall_node(state: AgentState) -> dict:
    """Retrieves semantic memories STRICTLY bounded by tenant_id and user_id."""
    user_query = state["messages"][-1]["content"]
    query_vector = embeddings.embed_query(user_query)

    # Multi-Tenant Payload Filter Enforcement
    tenant_filter = models.Filter(
        must=[
            models.FieldCondition(
                key="tenant_id",
                match=models.MatchValue(value=state["tenant_id"])
            ),
            models.FieldCondition(
                key="user_id",
                match=models.MatchValue(value=state["user_id"])
            )
        ]
    )

    # Scoped Query Execution
    relevant_docs = memory_vault.client.query_points(
        collection_name="agent_memories",
        query=query_vector,
        query_filter=tenant_filter,  # Prevents cross-tenant data leaks
        limit=3
    ).points

    if relevant_docs:
        memory_context = "\n".join([f"- {hit.payload['text']}" for hit in relevant_docs])

        system_instruction = {
            "role": "system",
            "content": (
                "You have access to the following long-term memory facts about the user:\n"
                f"{memory_context}\n\n"
                "Use the facts above to directly answer the user's question."
            )
        }
        state["messages"].insert(0, system_instruction)

    return {"messages": state["messages"]}

async def agent_node(state: AgentState) -> dict:
    """LLM reasoning node."""
    response = await llm.ainvoke(state["messages"])
    state["messages"].append({"role": "assistant", "content": response.content})
    return {"messages": state["messages"]}

async def memorize_node(state: AgentState) -> dict:
    """Embeds user facts along with mandatory multi-tenant metadata payloads."""
    user_message = next(
        (m["content"] for m in reversed(state["messages"]) if m.get("role") == "user"),
        None
    )

    if user_message:
        vector = embeddings.embed_query(user_message)

        memory_vault.client.upsert(
            collection_name="agent_memories",
            points=[
                models.PointStruct(
                    id=str(uuid.uuid4()),
                    vector=vector,
                    payload={
                        "text": user_message,
                        "tenant_id": state["tenant_id"],  # Tenant payload tag
                        "user_id": state["user_id"],      # User payload tag
                        "thread_id": state["thread_id"],
                        "metadata": state.get("metadata", {})
                    }
                )
            ]
        )
    return state

# Graph Workflow Wiring
workflow = StateGraph(AgentState)

workflow.add_node("recall", recall_node)
workflow.add_node("agent", agent_node)
workflow.add_node("memorize", memorize_node)

workflow.add_edge(START, "recall")
workflow.add_edge("recall", "agent")
workflow.add_edge("agent", "memorize")
workflow.add_edge("memorize", END)

app_graph = workflow.compile()

其优势十分实用。用户或组织只能获取自己的记忆点,因此一个租户的上下文绝不会泄露给其他租户。单个集合即可满足所有人的需求,从而避免了需要数千个独立集合所带来的内存和CPU消耗问题。而且一个运行的Qdrant实例就能服务数千个租户,无需为每个客户端都配备专用节点。

相关阅读

  • 用通俗语言讲解 LangGraph 概念:状态、内存与协调机制 — 在编写代码之前:了解在 LangGraph 中协调机制、运行时、有状态智能体、短期与长期记忆、多智能体系统以及人类干预分别意味着什么。
  • 实用指南:揭秘智能体网格:如何在之上扩展 AI 智能体与大语言模型 — 该实用指南的详细操作步骤:包括相关契约、检查项,以及供采用该架构的团队直接使用的代码片段。
  • 基于LangExtract、Neo4j、Qdrant和Ollama的完全本地化GraphRAG流程——利用本地模型构建GraphRAG:通过LangExtract提取实体,将其存储在Neo4j中,然后在Qdrant中进行索引,最后扩展子图以获得基于图的答案。
  • 具备溯源功能的AI智能体四层记忆设计——短期记忆与长期记忆的分离、四层结构(缓冲层、事件层、语义层、程序层)、存储方案选择、异步写入机制以及防止记忆污染的策略。
  • Kubernetes上的多智能体银行流程:LangGraph、注册表与AgentGateway — 构建FastAPI专用智能体,打造带有LLM规划器的LangGraph调度器,将其部署到带有注册表的集群中,再通过AgentGateway实现路由与速率限制功能。
  • AI智能体的评估方法:黄金数据集、LLM评估器与CI回归检测机制 — 利用AgentCore、LangFuse、Ragas和DeepEval对智能体进行离线与在线评估,同时结合黄金数据集、CI检测机制、A/B测试以及记忆反馈功能。
  • 实用笔记:代理式开发者技术栈,逐层解析 — 为采用该架构的团队提供的实用笔记详细操作指南,涵盖合约、校验机制以及可直接插入的代码模块。