1. 白话讲清楚:LangGraph、知识库和长期记忆分别是什么
在动手写代码之前,先把三个核心概念用大白话捋一遍。很多人一看到"LangGraph"这个名字就觉得是个高深莫测的框架,其实它解决的问题非常朴素:过去我们调用大模型,基本就是"问一句答一句",模型本身没有状态,也记不住上下文。LangGraph就是用来给AI助手搭建"业务流程"的——你想让AI先查资料、再推理、再格式化输出,或者让它记住你上次聊到哪了,这些都可以用LangGraph把步骤组织成一张有向图,让AI按流程干活。
知识库则是解决"模型不懂你的私有数据"这个问题。大模型训练时不可能收录你公司内部的文档、你个人的笔记,但你可以把文档切碎、向量化,存进向量数据库里。用户提问时,先从库里检索出最相关的片段,把这些片段和问题一起喂给模型,模型就能"临时读到"这些资料,回答自然就准了。
长期记忆就更直观了——你不可能每次跟AI聊天都从头自我介绍。把对话历史、用户偏好、关键结论存进记忆存储里,下次对话时自动加载,AI就能"想起"你是谁、你之前关心什么。把这三件事拼在一起,就是一个真正能用、有"人味儿"的AI助手,而不是一个每次对话都失忆的机器人。
这篇文章我不会只贴一份能跑的代码就完事,而是会拆开讲清楚每个环节为什么这么设计、背后走了哪些弯路、有哪些文档里查不到的坑。我尽量按新手也能跟上的节奏来,你已经玩过LangChain的话,前两节可以快速扫过,后面记忆和检索的工程细节才是重点。
2. 动手前的准备:环境搭建和基础架构设计
2.1 用到的技术栈和选型理由
我这次演示采用的组合是:LangGraph负责流程编排,LangChain生态做工具封装,向量库用开源的Chroma(零配置、够用),长期记忆直接基于LangGraph内置的检查点机制加一层自定义存储。选这套组合的考虑是——它把复杂度压到了最低,同时每个部件都能在后期平滑替换成生产级方案。
- LangGraph:核心编排框架,负责把检索、推理、记忆读写这些步骤串成一张图。
- LangChain:提供文档加载、文本分割、嵌入调用等现成工具,避免重复造轮子。
- Chroma:轻量级向量数据库,纯本地运行,新手友好,支持持久化。
- FastAPI:最后一层用FastAPI把AI助手包装成HTTP服务,方便实际调用。
为什么不直接裸写LangChain的ConversationChain或者RetrievalQA?这些封装虽然用起来快,但是把状态流转、错误恢复、分支逻辑都藏死了,等到你想加"在检索结果置信度低时直接说不知道"这种逻辑时,就得推翻重写。LangGraph把每一步都变成图上的节点,流程控制完全透明,后期加任何功能都是在图上加节点或改边,不用重构。
2.2 环境准备和基础配置
建议使用Python 3.10及以上版本。创建一个干净的虚拟环境,然后安装依赖:
python -m venv llm_assistant_env source llm_assistant_env/bin/activate接着安装核心依赖:
pip install langgraph langchain langchain-openai langchain-community pip install chromadb tiktoken fastapi uvicorn这里多说一句,如果你的模型走的是OpenAI兼容接口(国内很多服务商都提供),langchain-openai仍然适用,只需要在代码里配置base_url替换成你的API端点。实际项目中我建议从一开始就把模型调用封装成一个独立模块,后面换模型供应商只改一处配置,这个习惯能替你省下大量返工时间。
2.3 整体架构设计思路
所有AI工程的核心都逃不开一个原则:不要把状态分散得到处都是。我见过太多半路夭折的项目,毛病都出在"对话记录存在Redis、用户偏好存在MySQL、向量库放在本地文件"这种架构上,跑demo时没问题,一旦要维护就处处是雷。
我的方案是让LangGraph的State成为"唯一事实来源"。用户消息进来后,State里存着当前对话ID、历史消息列表、检索到的知识片段、模型回复。记忆系统的职责是把每次对话结束时的State快照写进存储,下一次对话开始时把相关快照捞回来填充进State。这个设计的妙处在于——工作流本身不关心记忆存在哪、怎么索引,它只处理自己手上的State。存储层的替换成本降到最低,你也可以非常方便地加日志、加审计,因为这些动作都发生在State读写的那一个点上。
3. 知识库构建:从文档到向量检索的完整链路
3.1 文档加载和切分实操
知识库构建的第一步是把乱七八糟格式的文档变成干净的文本块。我实际操作中发现,很多人在切分环节栽跟头——不是切得太碎导致检索结果缺乏上下文,就是切得太大导致向量检索时噪声太多。
先看一个我常用的文档加载写法:
from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 加载目录下所有txt和md文件 loader = DirectoryLoader( "./knowledge_docs", glob=["**/*.txt", "**/*.md"], loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) documents = loader.load()然后是切分。RecursiveCharacterTextSplitter是我最推荐的切分器,因为它是递归地尝试用一系列分隔符去切,优先按段落切,段落太长再按句子切,句子还长就按单词切。这种自适应策略对混合格式文档的兼容性远好于固定长度切分。
text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " "], ) chunks = text_splitter.split_documents(documents) print(f"共切分为 {len(chunks)} 个文本块")参数选择背后是有讲究的。chunk_size=500意味着每个文本块大约包含几百字,这个长度对大部分知识问答场景来说足够提供上下文,又不至于让向量检索的精度严重下降。chunk_overlap=80的作用是保留跨块断点处的上下文衔接——如果某段话恰好被从中间切断,下一块的起始部分还保留着上一块末尾的内容,检索时就能匹配到更完整的语义。我实测过,overlap从0调到80后,针对长文档的检索命中率提升了差不多10个百分点。
3.2 嵌入模型与向量存储
切分完毕,下一步就是把这些文本块变成向量。这一步的核心概念是"嵌入",即把一段文本映射成一个高维空间里的坐标,语义相近的文本在空间中也互相靠近。选择一个好的嵌入模型,直接决定了检索质量的底线。
以OpenAI的text-embedding-3-small为例:
from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma embeddings = OpenAIEmbeddings( model="text-embedding-3-small", base_url="你的兼容接口地址", # 如果走兼容接口,这里配置 ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", ) print("知识库构建完成,文本块数量:", len(chunks))如果你不想依赖云端嵌入接口,完全可以用本地开源模型,比如bge-small-zh或者text2vec-base-chinese,通过langchain_community.embeddings里的HuggingFaceEmbeddings加载。本地嵌入的好处是零API调用成本、数据不出内网,坏处是中文长文本的语义理解能力普遍比商业嵌入模型差一截。我的经验是:知识库规模在几万段以内,直接用商业嵌入模型,性价比最高;几十万段级别的私有化部署才值得去调优本地模型。
向量数据库这里为什么选Chroma而不是功能更强的Milvus或Qdrant?理由是:本地demo阶段,索引几万个文本块,Chroma的查询延迟在几十毫秒量级,完全够用,而且它无需额外部署服务,减少一个故障点。等知识库规模上来之后,只需要把retriever对象替换成Milvus或PGVector的retriever,上层逻辑一个字符都不用改,因为LangChain的向量库接口是统一的。
3.3 检索增强生成(RAG)的实现
知识库不是建完就结束了,真正的核心是"怎么把检索结果用起来"。RAG的标准流程是:用户问题 -> 检索相关文本块 -> 把问题和文本块一起塞给大模型 -> 模型基于这些材料回答问题。
LangGraph中,我把检索实现成一个独立的节点函数:
def retrieve_node(state): question = state["current_message"] retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} ) docs = retriever.invoke(question) context_text = "\n\n---\n\n".join([doc.page_content for doc in docs]) return {"retrieved_context": context_text, "source_docs": docs}k=4表示取最相似的4个文本块。这个数字不是拍脑袋定的,太少了可能漏掉关键信息,太多了模型会被无关信息干扰,甚至出现"上下文淹没"——模型被大量无关文本分散注意力,反而答非所问。对500字左右的chunk size,4-6个文本块是一个比较稳的区间。
但这里有个新手最容易踩的坑:直接检索用户原始问题,往往效果一般。原因是口语化提问和书面文档之间存在词汇鸿沟。比如用户问"你们这个软件能自动整理报表吗",而文档里写的是"系统支持对结构化报表数据进行自动化汇总分析",字面上完全对不上,但语义是一样的。改进方法是对用户问题进行改写:让模型先把口语问题改写成适合检索的形式,然后再去检索。这被称为"查询改写"或"多查询检索"。
from langchain_core.prompts import ChatPromptTemplate query_rewrite_prompt = ChatPromptTemplate.from_template( """你是一个检索助手。请将用户的原始问题改写为更适合文档检索的书面表述, 保持原意不变,输出一句简洁的检索式问题,不要回答其他内容。 原始问题:{question}""" )这一步加上之后,我自己的项目里检索命中率提升非常明显,尤其是在用户提问带口头禅、语法省略比较多的场景下。
3.4 构造知识增强的生成节点
检索到相关内容后,最后一步是让模型基于知识来回答问题。这个过程需要精心设计Prompt模板,让模型明确什么事情该做、什么事情不该做:
from langchain_core.prompts import ChatPromptTemplate rag_prompt = ChatPromptTemplate.from_template( """你是一个智能助手。请严格基于以下提供的参考资料回答问题。 参考资料: {context} 回答要求: 1. 如果参考资料中有足够信息,请基于资料作答,并适当组织语言。 2. 如果参考资料不足以回答问题,明确回答"我没有找到相关信息",禁止编造。 3. 不要提及"根据参考资料"或"根据文档"这类字眼。 用户问题:{question} 你的回答:""" )构造这种Prompt的时候,我强烈建议你明确加上"禁止编造"的指令。这是大模型幻觉问题的第一道防线。模型天生倾向于给出看起来合理的答案,如果你不告诉它可以拒绝回答,它会基于自己的预训练知识强行编造。加了这条约束后,碰到知识库里没有的问题,它才能老实承认自己不知道。
到这里,知识库部分已经可以独立工作。你可以写一个简单的循环测试:问它几个知识库内的问题,看回答是否准确;再问几个超出知识库范围的问题,看它会不会诚实地说不知道。我自己的经验是,如果这个误拒率太高——该答的没答上来——优先去调chunk_size和k值;如果该拒绝的没有拒绝——编造率太高——优先去加强Prompt约束,而不是盲目换模型。
4. 长期记忆:让AI助手从"聊天"到"懂你"
4.1 记忆分类:短期工作记忆 vs 长期持久记忆
很多教程把"记忆"当成一个黑盒,好像加上就完事了。实际上,记忆必须分层设计,否则性能和准确性都会出问题。
LangGraph的State本身承担了"短期工作记忆"的角色——在单次对话运行期间,上下文消息、检索结果、中间状态都存在State里,跑完一轮就没了。但"长期记忆"要解决的是跨会话的问题:用户昨天跟你讨论过什么话题、你的偏好设置、上次没聊完的事情,这些信息必须被持久化存储,并在下一次对话时取回来。
我在项目里把长期记忆按用途分成了三类:
- 对话摘要记忆:保存历史对话的压缩摘要,用于在长会话中提供连续性。
- 用户偏好记忆:保存用户明确告知的偏好,比如"我喜欢简洁回答""请用中文解释"。
- 关键事实记忆:保存对话中提到的具体事实,比如"用户在北京工作""用户正在使用某产品的企业版"。
三者对检索的敏感性不同,因此存储和索引方式也应不同。下面详细展开。
4.2 基于LangGraph检查点机制的会话恢复
LangGraph有一个非常方便的内置机制叫"检查点"(checkpoint),它会在图的每个节点执行后自动保存当前State。这意味着如果程序崩溃或用户中途离开,你随时可以从最近一次节点执行完的那一刻恢复工作流,而不必重新跑完整张图。
from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.graph import StateGraph, START, END memory = SqliteSaver.from_conn_string(":memory:") # 生产环境换成持久化文件路径 graph = workflow.compile(checkpointer=memory)这里有一个关键配置项是thread_id,它决定了哪一段历史对话属于同一个会话。每个用户会话传入不同的thread_id,LangGraph就会自动从该会话的历史中恢复上下文:
config = {"configurable": {"thread_id": "user_123_session_456"}} result = graph.invoke({"current_message": "继续上次的话题"}, config=config)这个机制等于把"短期对话窗口"也变成了可持久化的。不过要诚实说一句:SQLite存档的检查点方案适合单机应用,如果未来要做高并发的生产部署,需要把checkpointer替换成Redis或PostgreSQL版本,LangGraph这块的抽象做得不错,替换成本不算高。
4.3 自定义长期记忆层的设计
但仅有检查点还不够,因为检查点保存的是原始对话历史,如果每次对话都把过去所有原始消息一股脑塞进去,Token消耗会爆炸,模型也会被超长上下文拖垮。需要在会话结束后做一步"记忆提炼":从原始对话里抽取出真正值得长期保留的信息。
这一步我用一个单独的节点来实现,它利用大模型做信息抽取,把对话历史转化为结构化的记忆条目:
from langchain_core.pydantic_v1 import BaseModel, Field from langchain_core.output_parsers import JsonOutputParser class MemoryItem(BaseModel): memory_type: str = Field(description="memory类型:preference / fact / summary") content: str = Field(description="记忆的具体内容") importance: int = Field(description="重要程度,1-5,5为最重要") memory_prompt = ChatPromptTemplate.from_template( """请阅读以下对话历史,提取值得长期记住的信息。 要求: 1. 用户明确表达的偏好,标记为 preference 2. 关于用户背景、项目状态的事实,标记为 fact 3. 如果对话较长,提炼一个一句话摘要,标记为 summary 4. 过滤掉临时性、与用户无关的信息 只输出JSON数组,格式:[{{"memory_type": "...", "content": "...", "importance": 3}}] 对话历史: {conversation}""" )这一步的价值在于把"原始数据"变成"结构化知识"。举个例子:对话中用户说"我下周要出差,回来后产品方案再定",如果只存原始消息,模型下次无法高效利用这条信息;而提炼成{"memory_type": "fact", "content": "用户下周出差,产品方案讨论需要推迟到出差后", "importance": 4}之后,系统可以在用户下次提问时直接加载这条记忆,做出更贴切的响应。
4.4 记忆召回:只在需要时取回,不是每次都全量加载
新手最容易犯的错误是"把所有记忆每次全部塞给模型"。这样做的后果不只是Token浪费——大量无关记忆会成为新的噪声,让模型抓不住重点。正确做法是让记忆也成为一次检索操作。
我在项目中为记忆建立了一个独立的向量索引。每条记忆条目存入时都生成向量,召回时用当前用户消息去向量库里检索最相关的记忆,取回前5条左右。这个思路和知识库的RAG完全一样,只是数据源从文档换成了记忆。
class MemoryStore: def __init__(self, vectorstore): self.vectorstore = vectorstore async def add_memory(self, memory_item: MemoryItem): text = f"{memory_item.memory_type}: {memory_item.content}" # 这里把memory_item转成向量存入向量库,带上metadata self.vectorstore.add_texts( texts=[text], metadatas=[{ "memory_type": memory_item.memory_type, "importance": memory_item.importance, "timestamp": datetime.now().isoformat() }] ) async def retrieve_relevant_memories(self, query: str, k: int = 5): docs = self.vectorstore.similarity_search_with_score(query, k=k) return [doc for doc, score in docs if score > 0.7] # 置信度过滤score > 0.7这个阈值建议你在自己的数据上调优。阈值太高会漏掉相关记忆,太低会混入无关记忆。我的经验是先用一批人工标注的测试集跑一遍,画一条精度召回曲线,再选一个平衡点。
5. 组装LangGraph工作流:从零到可用
5.1 定义State结构
LangGraph的一切都围绕State展开。State就是你整个工作流中流转的数据载体。为了同时支持知识库和记忆,State定义如下:
from typing import TypedDict, Annotated, List class AssistantState(TypedDict): current_message: str # 当前用户消息 conversation_history: list # 本次会话的历史消息 retrieved_context: str # 知识库检索到的文本块 memory_context: str # 召回的历史记忆 final_answer: str # 最终回复 user_id: str # 用户标识,用于记忆隔离注意,State字段尽量保持精简,不要把所有中间变量都塞进去。每次调用结束,工作流会把整个State序列化并保存到检查点,字段越多,存储和恢复的开销越大。必要的中间变量可以用局部变量传递,不必都进State。
5.2 构建图的节点和边
接下来,把前面拆解的功能封装成图节点,用LangGraph把它们串起来。整个流程的设计如下:
from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_message def rewrite_query_node(state: AssistantState): # 查询改写,让检索更精准 rewritten = rewrite_query_invoke(state["current_message"]) return {"current_message": rewritten} def retrieve_node(state: AssistantState): # 知识库检索 docs = retrieve_from_vectorstore(state["current_message"]) context = format_docs(docs) return {"retrieved_context": context} def retrieve_memory_node(state: AssistantState): # 长期记忆召回 memories = memory_store.retrieve_relevant_memories(state["current_message"], user_id=state["user_id"]) memory_text = format_memories(memories) return {"memory_context": memory_text} def generate_node(state: AssistantState): # 模型生成最终回答 prompt = build_final_prompt( question=state["current_message"], context=state["retrieved_context"], memories=state["memory_context"], history=state["conversation_history"] ) answer = llm.invoke(prompt) return {"final_answer": answer} def summarize_and_store_node(state: AssistantState): # 对话结束前,提取新记忆并入库 new_memories = extract_memories(state["conversation_history"]) memory_store.add_memories(user_id=state["user_id"], memories=new_memories) return {}然后是把这些节点装配成图:
workflow = StateGraph(AssistantState) # 添加节点 workflow.add_node("rewrite_query", rewrite_query_node) workflow.add_node("retrieve", retrieve_node) workflow.add_node("retrieve_memory", retrieve_memory_node) workflow.add_node("generate", generate_node) workflow.add_node("store_memory", summarize_and_store_node) # 连接边 workflow.add_edge(START, "rewrite_query") workflow.add_edge("rewrite_query", "retrieve_memory") workflow.add_edge("rewrite_query", "retrieve") workflow.add_edge("retrieve_memory", "generate") workflow.add_edge("retrieve", "generate") workflow.add_edge("generate", "store_memory") workflow.add_edge("store_memory", END) app = workflow.compile()这里我特别说明一下为什么"retrieve"和"retrieve_memory"是并行地从"rewrite_query"分出两条边,而不是串行执行。因为知识库检索和记忆检索互相之间没有依赖,并行执行可以节省近一半的检索耗时。LangGraph的节点如果被多个前驱指向,它不会在两个分支完成后到达该节点,这有点不对劲…… 让我再想一下。
实际上LangGraph中,如果一个节点有多条入边,它会被执行一次,框架会等待所有前驱节点完成后才调用该节点,因此"generate"会等待retrieve和retrieve_memory都完成。这样设计是对的,并行分支在"generate"处汇合。
这里有个工程细节需要注意:并行分支里的两个retrieve节点如果在同一个图里,默认是顺序执行的,真正要并行需要用到SendAPI或者用asyncio把函数做成异步协程。如果你对延迟不敏感,不做并行也完全没问题。我在实际部署时用了异步版本,把两个检索函数改成async,然后用asyncio.gather同时执行,检索总耗时从接近1秒降到了400毫秒左右。
5.3 "写记忆"节点放在哪?放在生成之后
很多人会本能地把"存记忆"放在收到用户消息之后、生成回复之前。这其实是错误的。原因是:用户这条消息刚进来时,你还没有完成这一轮的交互,无法判断哪些信息值得沉淀为长期记忆。只有等到模型生成完回复、整个交互闭环结束了,你才能回顾这一轮的完整对话,提取有价值的记忆。
所以在我的设计里,"store_memory"节点是最后一步,generate完成后,它把完整的conversation_history发给模型做一次信息抽取,得到的记忆条目入库。
5.4 用FastAPI封装成服务
最后把工作流包装成一个API服务。这里有一个容易忽略的点:thread_id必须正确映射到用户会话。我的做法是用一个简单映射函数,把HTTP请求里的user_id和session_id组合成LangGraph的thread_id。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI(title="AI Assistant API") class ChatRequest(BaseModel): user_id: str session_id: str message: str class ChatResponse(BaseModel): answer: str @app.post("/chat", response_model=ChatResponse) async def chat(req: ChatRequest): config = { "configurable": { "thread_id": f"{req.user_id}_{req.session_id}" } } result = await app.graph.ainvoke( {"current_message": req.message, "user_id": req.user_id}, config=config ) return ChatResponse(answer=result["final_answer"])启动服务:
uvicorn api_server:app --host 0.0.0.0 --port 8000到这里,一个具备知识库问答和长期记忆能力的AI助手后端已经完整落地了。
6. 实测效果:一次完整的对话
空谈架构不如看一次实际运行。下面是我用这个系统做的一次完整对话测试,可以看到知识库和记忆是如何协同工作的。
假设知识库里导入了某公司内部的产品FAQ文档。用户连续发来几条消息:
第一轮:用户问"合同审批流程是什么?"
- 系统先改写查询:
"合同审批流程"->"合同审批的具体流程和步骤",检索到知识库中关于合同审批的文本块,同时从记忆库中召回用户历史记忆(发现这是用户第一次问合同相关问题,没有相关记忆)。 - 模型结合知识库内容回答:详细列出合同审批的6个步骤,并说明了电子签章环节的特殊要求。
第二轮:用户说"我们这个月合同特别多,我的审批流程能不能简化?"
- 系统检索到知识库关于合同审批的文本块(和第一轮相同的块),同时从记忆中召回了第一轮对话摘要:
"用户正在了解合同审批流程"。 - 模型基于知识库内容回答:说明合同审批流程可按照合同金额分级处理,50万以下由部门负责人直接审批,同时提示系统支持批量审批功能。
第三轮:用户说"我刚才问的那个,我这边是行政部门,适用吗?"
- 此时长期记忆发挥作用了。系统召回了前两轮的对话摘要与用户信息。模型结合知识库信息回答:"您所在部门不影响上述流程适用性,行政部门的合同审批同样可以通过系统提交。需要注意的是,行政类合同需要有预算编号,这一点在合同提交表单中有标注。"
第三轮这个回答的质量,就是长期记忆价值的直观体现——没有记忆的系统,面对"我刚才问的那个"这种指代性表述,大概率会答非所问。因为模型根本不知道"那个"指的是什么。
实测中还观察到一个重要现象:加了记忆之后,用户会自然地把AI当成一个"记得住事"的助手来用,沟通效率明显提升。这启发我们,好的AI产品设计应该主动引导用户提供背景信息,比如在首次对话时询问"你是哪个部门的""想了解什么场景",然后把答案存入长期记忆。这几乎是零成本就能大幅提升体验的优化点。
7. 避坑指南:我替你们踩过的那些坑
这个项目在开发过程中踩过的坑,比顺利的部分多得多。挑几个最具代表性的记录下来,希望能帮后来者少走弯路。
7.1 检索结果为空或质量差,不要急着换模型
很多人遇到回答不准确的第一反应是"模型不够聪明,换个更强的模型"。实际上,在RAG体系里,80%的回答质量问题根源在检索环节,而不是生成环节。检索不到正确的文本块,再强的模型也只能瞎编。
排查思路按顺序来:
- 打印出检索到的文本块内容,肉眼判断是否相关。
- 如果明显不相关,检查chunk_size是不是太大、overlap是不是太小。
- 如果检索结果本身相关但模型答偏了,才去调整生成节点的Prompt。
- 如果以上都没问题,才考虑换更强的模型或换嵌入模型。
我见过太多项目组上来就换GPT-4,结果成本翻了好几倍,问题照样在。检索调好了,哪怕是本地的小参数模型,回答质量也能达到可用的水平。
7.2 记忆污染问题:旧记忆干扰新对话
长期记忆有一个特有的麻烦:记忆一旦写进去就长期存在,但是用户的情况是会变化的。比如用户半年前说过"我在上海工作",现在人已经搬到北京了。如果系统把"用户在上海"这条旧记忆和新消息一起塞给模型,模型就可能给出基于错误前提的回答。
我的解决方案有两层:
第一层,时间衰减:召回记忆时,按时间戳加权,太旧的记忆权重降低。
第二层,冲突检测:新记忆写入时,检查是否与已有记忆冲突。比如新记忆是"用户在深圳工作",旧记忆"在上海工作",这时候应该保留新记忆、明确废弃旧记忆,并且给模型一个提示:该用户的常驻城市从上海变更为了深圳。
7.3 线程安全的坑:全局变量和并发请求冲突
这是个工程细节,但很致命。如果你的FastAPI服务是多worker运行的,LangGraph图的实例对象如果被定义为全局变量,多个请求并发时,State会互相污染。解决办法是确保每个请求使用的是独立的State实例。LangGraph的checkpointer机制天然支持这一点,只要每个请求传入不同的config,State就是隔离的。但如果你的代码里用了全局变量缓存中间结果,并发时就会出问题。
我的建议是:所有有状态的变量都不要定义为模块级全局变量,而是存进State里,或者作为请求级局部变量创建。宁可每次创建开销稍微大一点,也不能让并发请求相互串数据。
7.4 Token消耗失控的检查点
检查点机制保存整个State,而State里有完整的对话历史。如果一个用户聊了很久,对话历史越来越长,检查点的体积也会越来越大,存储开销和恢复延迟都会上升。
一个实用的优化是:每N轮对话后,把完整对话历史压缩成摘要,然后清空原始历史。只保留摘要作为记忆的一部分。当用户发来新消息时,系统参考的是摘要而不是全部原始消息。这会大幅减少Token消耗,但我建议务必在"摘要丢失细节"和"Token成本"之间做权衡。实际操作中,我在官方教程文档和帮助FAQ这两类场景用过这套方案,用户满意度几乎没有下降,但Token成本下降了60%以上。
7.5 知识库更新的问题
知识库不是静态的,文档会改、会删、会新增。最简单的策略是每隔一段时间全量重建向量库,但文档多了以后成本很高。另一个做法是增量更新:每次有文档变动,只重新切分变动部分,并更新对应向量。增量更新需要维护一份文档ID到向量ID的映射关系,Chroma的metadata天然支持这一点。
# 删除过期文档(按metadata中的source字段匹配) vectorstore.delete(where={"source": "doc_20250214.md"}) # 添加新文档 new_chunks = split_document(new_doc) vectorstore.add_texts( texts=[chunk.page_content for chunk in new_chunks], metadatas=[{"source": "doc_20250214.md", "chunk_index": i} for i, chunk in enumerate(new_chunks)] )8. 进阶优化:如何让这套系统在生产环境真正"好使"
8.1 混合检索:向量检索 + 关键词检索
纯向量检索有一个软肋:对专有名词、产品名、代码变量这类精确匹配场景不太友好。这些词在语义空间中可能和其他词的距离很近,导致检索结果虽然语义接近但不是用户想要的精确对象。
解决方案是混合检索:向量检索负责"语义相关",BM25关键词检索负责"字面匹配",两者结果做加权融合。LangChain里可以用EnsembleRetriever来实现:
from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever from langchain_community.vectorstores import Chroma bm25_retriever = BM25Retriever.from_documents(chunks, k=2) vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.4, 0.6] )权重分配建议按实际数据调优。官方文档类知识库,关键词权重可以拉到0.5上下;而语义泛化要求高的场景,向量权重应该更大。
8.2 引用溯源:让回答可信可查
生产级知识库问答必须提供引用来源。做法是在检索时把文本块对应的源文件名、原段落位置记录在metadata里,模型生成回答时,Prompt要求它回答的每段话后面标注引用编号,程序再根据编号映射到真实来源。这个设计的好处不只是合规,更重要的是——用户在发现AI回答可疑时,可以直接点进去看原文,人工复核成本大大降低。
8.3 评估体系:给AI助手装一个"质检员"
我强烈建议从第一天就建立评估脚本,而不是靠感觉判断"还算好用"。评估分为两部分:
第一,检索质量评估。准备50到100个已知答案的问答对,测试每个问题能否检索到包含答案的那个文本块。这个指标叫"召回命中率",低于80%就需要调检索参数。
第二,回答质量评估。如果把最终生成质量也纳入自动化评估,可以用大模型做裁判(让GPT-4打分或对比参考答案),也可以直接人工抽检。前者成本低但误判率高,后者准确但费人。我的建议是先用人工抽检跑通流程,等积累了一批标注数据后,再让模型裁判去逼近人工标注的结果。
如果你跳过评估直接上线,我几乎可以保证你会在某个深夜被用户投诉"AI答错问题",然后不得不连夜回滚。
9. 最后的个人体会
这套系统从想法到落地,我前前后后花了两周。复盘来看,最耗时间的不是写代码——LangGraph把流程编排这块做得足够简单——而是调检索参数和设计记忆策略。这些环节没有标准答案,完全依赖你的使用场景。同样是"添加知识库",客服场景和研发助手场景的参数选择可能完全相反。
所以我的建议是:别追求一步到位的通用方案,先用最简单的版本跑通你的场景,把知识库和记忆基础打上去,再用实际数据驱动调优。你收集到的真实用户问题、真实失败案例,才是这套系统持续变好的真正养料。我这个版本也只是个起点,后续你完全可以按自己的需要,把记忆存储换成关系数据库,把向量库换成分布式方案,在检索节点里接入重排序模型。结构就在那里,剩下的是你的问题了。