news 2026/10/14 7:00:53

LangChain实战:从零搭建带记忆与本地知识库的智能问答工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangChain实战:从零搭建带记忆与本地知识库的智能问答工具

手写调大模型接口的代码,相信很多人都经历过。我最早接一个大模型需求时,整套流程其实就是“拼Prompt → 请求接口 → 解析返回 → 处理异常”,单看每一步都不难,可一旦要换模型、加上下文、挂知识库、接工具,代码立刻膨胀成一大坨难以维护的“面条”。后来接触了LangChain,才意识到这类应用缺的不是模型能力,而是把模型能力“编排”起来的那层胶水。

LangChain是目前最主流的大模型应用编排框架之一,它本身不提供模型,而是把模型调用、提示词模板、输出解析、对话记忆、知识库检索、工具调用这些常用零件全部标准化,开发者只需要按约定把零件接起来,就能快速搭出问答机器人、文档分析助手、Agent工作流等应用。这篇文章会用最直接的方式带你从零跑通一个真实可用的命令行问答工具,过程中把LangChain的核心概念一个个拆开讲透。适合两类人看:一类是被各种术语绕晕的新手,另一类是已经写过一点LangChain但遇到报错和设计问题想找排查思路的开发者。

1. LangChain到底在解决什么问题——动手前必读的理解框架

1.1 一次手搓LLM调用的真实体验

在讲LangChain之前,先还原一个最朴素的场景:你想让大模型从一段用户反馈里提取“用户姓名”和“反馈类型”,并输出成JSON。手写的代码大概长这样:

import requests def call_llm(prompt): resp = requests.post( "http://your-llm-endpoint", json={"prompt": prompt, "temperature": 0.7} ) return resp.json()["text"] def extract_info(user_input): prompt = f""" 请从下面这段反馈中提取用户姓名和反馈类型,输出JSON格式。 反馈内容:{user_input} 输出格式: {{ "name": "姓名", "category": "反馈类型" }} """ raw = call_llm(prompt) # 这里还要写字符串切割、JSON反序列化、异常兜底... return raw

这段代码看着能跑,但问题是一眼就能数出来的:第一,prompt和业务逻辑完全耦合在一起,换一个需求就要重写整个函数;第二,任何模型的输入输出格式稍有变化,解析逻辑就要跟着改;第三,如果想接对话历史、多轮记忆、检索增强,都得自己重新造轮子。更难受的是,当你把模型从“接口A”换成“接口B”时,会发现大部分代码都要推倒重来。

这不是一个代码写得差的问题,而是这个领域天然需要一层“抽象的调度层”。LangChain做的事情,简单说就是把这层抽象做成了标准化的框架:无论是哪个大模型,不管是云上接口还是本地模型,对你的代码来说暴露的调用方式都是统一的;无论是普通字符串还是带格式要求的结构化输出,都有对应的模板和解析器帮你处理。

1.2 LangChain的核心设计:把“封装”与“编排”分开

LangChain的架构思路,本质上就一句话:把大模型应用拆成多个标准零件,然后定义好零件之间的接口。这些零件按职责可以分成几类:Model I/O负责模型出入参,Retrieval负责文档加载与检索,Memory负责上下文记忆,Chains负责串联流程,Agents负责让模型自主选择工具。

我建议新手接触LangChain时,脑子里先装一张“地图”:你写任何应用,都可以看作在Model I/O这条主线上不断加装配件。比如最简单的“Prompt→Model→OutputParser”就是一条基础流水线;要让他回答有依据,就在中间插入Retrieval零件;要让它能持续对话,就再加一个Memory零件;要让它能自己查数据库、调接口,就需要用Agents来接管流程选择。

这种设计带来的直接好处是替换成本极低。我在一个实际项目里最初用的是本地模型,后来业务量上来后切换到了云端兼容接口,业务逻辑一行没改,只换了入口处的模型实例。这一点在手写代码阶段是完全不敢想象的。

当然也要说一句公道话:LangChain不是银弹。如果你的需求只是“调一次模型,把文本打印出来”,连框架都不用上,直接写几行代码反而更轻。它真正的价值体现在需要多步骤、多零件、需要持续迭代的应用场景中。明确这一点也能帮你避免“为了用框架而用框架”的陷阱。

2. 快速上手先记住这6个核心概念,代码级拆解

2.1 模型IO:所有交互的基础

LangChain里把模型相关的部分统称为Model I/O,核心类通常是ChatModel。不管是哪家模型,在LangChain里都会以聊天模型的形式暴露,输入是消息对象列表,输出是一个包含了响应内容的AIMessage对象。先用一个最小示例把这条链路跑起来:

from langchain_ollama import ChatOllama from langchain_core.messages import HumanMessage llm = ChatOllama( model="llama3.1", base_url="http://localhost:11434" ) resp = llm.invoke([HumanMessage(content="用一句话解释什么是RAG")]) print(resp.content)

这里有个很重要的认知转换:LangChain里的模型不是“文本进,文本出”,而是“消息进,消息出”。消息是有角色的,比如系统消息SystemMessage定义模型的身份和回答风格,人类消息HumanMessage表示用户输入,AI消息AIMessage表示模型之前的回答。这种设计对处理多轮对话至关重要,因为模型本身是无状态的,你需要通过消息列表把历史上下文重新“喂”给它。

除了invoke,还有一个经常用的方法是stream。invoke会等模型把完整答案生成完再一次性返回,适合离线批处理;stream则是一块一块地返回生成内容,适合聊天场景,打字机一样的效果会让用户体验好很多。后面做实战时,我建议直接把stream用起来。

2.2 提示词模板:把程序变量塞进“人话”里

如果你直接在代码里拼接字符串,很快就会发现两件事:一是模板变长之后维护体验很差,二是很多细节需求(比如系统设定、少量示例)混在一起很难复用。LangChain提供了PromptTemplate和ChatPromptTemplate两种模板类来解决问题。

from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的技术助手,回答必须简洁,不要使用夸张的措辞。"), ("human", "用户的问题是:{question}") ])

ChatPromptTemplate里的每个元素是一组消息角色和内容,内容部分可以包含{变量名}这样的占位符。调用时,只需要传入对应的字典:

filled = prompt.invoke({"question": "什么是向量数据库?"})

为什么要用模板而不是直接拼接?因为模板把“指令”和“数据”分开了。系统提示词、少样本示例这些属于“指令”,业务参数属于“数据”,分离之后不仅方便复用,也能让你在调试时很容易看清最终发给模型的内容到底是什么。框架还支持partial_variables预填变量,以及从配置文件或远端读取模板,这些对做产品化很有用。

2.3 输出解析器:让模型的回答变成程序能消费的数据

模型返回的永远是字符串,但程序需要的是JSON、字段、列表这类结构化数据。输出解析器(Output Parser)专门负责这件事。最基础的StrOutputParser就是把AIMessage里的文本内容取出来;JsonOutputParser则会把模型返回的JSON字符串反序列化成Python字典。

from langchain_core.output_parsers import JsonOutputParser from langchain_core.prompts import PromptTemplate parser = JsonOutputParser() prompt = PromptTemplate( template="请提取以下内容中的姓名和年龄,以JSON格式输出。\n{input_text}\n{format_instructions}", input_variables=["input_text"], partial_variables={"format_instructions": parser.get_format_instructions()}, )

注意那个format_instructions,这是解析器自动生成的格式说明,会指导模型按指定结构输出。实际使用中,模型偶尔会输出不规范的JSON,我会在解析器外面再包一层异常兜底逻辑,比如解析失败时降级为纯文本返回,避免让整个流程崩掉。

2.4 链与LCEL:用管道符组装一条处理流水线

LangChain从较新的版本开始,推荐使用LCEL(LangChain Expression Language)来组装处理流程。LCEL的神奇之处在于,它把原来面向对象的链式调用简化成了直观的管道符:

from langchain_core.output_parsers import StrOutputParser chain = prompt | llm | StrOutputParser() answer = chain.invoke({"question": "什么是RAG?"}) print(answer)

这段代码理解起来和Unix管道一模一样:prompt接收字典,输出被格式化的消息;消息传给llm,生成AIMessage;AIMessage再传给StrOutputParser,最终得到一个纯字符串。LangChain内部自动帮你处理了类型匹配,你不需要写任何“把上一步结果取出再传给下一步”的胶水代码。

LCEL还天然支持流式处理和批量处理。你用管道符组装好的chain,直接调用.stream()就能按块输出结果,调用.batch()可以并行处理多条输入。这一点对手写管道逻辑来说省了很多时间,也更不容易出错。

2.5 记忆:从一问一答到连续对话

模型本身没有记忆,每次调用都是独立的。想让它记住上下文,办法只有一个:把历史对话作为消息一并传入。手动维护的写法非常直观:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage messages = [SystemMessage(content="你是一个友好的助手。")] while True: user_input = input("你:") messages.append(HumanMessage(content=user_input)) resp = llm.invoke(messages) messages.append(AIMessage(content=resp.content)) print("助手:", resp.content)

每次用户提问时,我们都把完整历史列表传给模型,然后把模型的回答也存回列表,这样下一轮时模型就能“看到”刚才的对话。这段代码虽然简单,却揭示了所有记忆方案的本质:历史消息的存取与拼接。

框架也提供了RunnableWithMessageHistory这样的高级封装,支持会话ID、自动持久化历史记录到数据库。但我的建议是,新手先用上面的手动方式跑通,理解了本质后再看框架封装,否则很容易被会话ID、回调参数这些概念绕晕。

2.6 RAG:给大模型外挂一个“资料库”

RAG(Retrieval-Augmented Generation)是目前落地最广的大模型应用模式。它的核心思路是:不把企业资料塞进模型,而是先检索出与问题最相关的片段,再把片段塞进Prompt,让模型基于这些片段作答。这么做既避免了昂贵的模型微调,也能让模型回答自带引用来源。

RAG的流程可以拆成四步:第一步加载文档,第二步切分文本块,第三步把文本块向量化并存入向量数据库,第四步检索时找到与问题最相似的几个文本块。LangChain对每一步都有标准组件,比如TextLoader负责加载文件,RecursiveCharacterTextSplitter负责按语义边界切分,Chroma负责向量存储,as_retriever负责把向量库包装成检索器。

这部分在实战中会完整演示,先在这里建立一个总体印象:LangChain里RAG不是某个魔法方法,而是几个标准零件的有序组合。搞明白了零件与零件之间的接口,你就能自由替换其中的任意环节。

3. 实战:从零搭一个带记忆和本地知识库的命令行问答工具

3.1 准备环境:依赖、模型、数据文件

本地跑通这套流程,你需要准备一个Python 3.9以上的环境,再装几个依赖包:

pip install langchain langchain-core langchain-community langchain-chroma langchain-ollama chromadb

模型部分我用Ollama在本地跑一个开源模型。你可以先确认Ollama服务已经启动,然后拉取模型:

ollama pull llama3.1

Ollama的好处是模型完全在本地运行,不涉及密钥配置,也适合新手用来测试代码链路。如果你已经使用了云端大模型接口,也不用担心,LangChain对模型做了统一抽象,把ChatOllama替换成对应的云模型集成类就行,后面所有链和代码都保持不变。

我还建议准备一个纯文本格式的资料文件,比如docs/help.txt,里面放几段产品帮助文档。如果手头没有现成资料,随便写几段“常见问题解答”也能用,关键是文件内容要尽量覆盖你测试时会问的问题。

3.2 第一版:单轮问答,先把链路跑通

第一个版本先不追求复杂功能,目标是把“Prompt模板 + 模型 + 输出解析器”这条最基础的链跑通。新建一个app.py,写入以下代码:

from langchain_ollama import ChatOllama from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser llm = ChatOllama( model="llama3.1", base_url="http://localhost:11434", temperature=0.7, ) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个乐于助人的技术顾问,回答尽量清楚、有条理。"), ("human", "我的问题是:{question}"), ]) chain = prompt | llm | StrOutputParser() while True: question = input("你:") if question.strip().lower() in ("quit", "exit"): break answer = chain.invoke({"question": question}) print("助手:", answer)

跑一下就能看到效果了。这里有几个值得注意的细节:temperature控制的是模型的随机性,值越小回答越保守,做客服问答我建议调到0.3以下;系统提示词越具体,模型就越不容易“跑偏”;StrOutputParser在这里负责把消息对象转换为纯文本,让后续处理更简单。

如果这一步遇到连接失败,先检查Ollama服务是否真的启动了,再检查base_url的端口是不是11434。模型第一次加载会稍慢,属于正常现象。

3.3 第二版:加记忆,让它记住上下文

单轮问答里,模型记不住你上轮说了什么,这在实际使用中是不能接受的。现在把记忆加进去,改造成连续对话版本:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage messages = [SystemMessage(content="你是一个乐于助人的技术顾问。")] while True: question = input("你:") if question.strip().lower() in ("quit", "exit"): break messages.append(HumanMessage(content=question)) resp = llm.invoke(messages) messages.append(AIMessage(content=resp.content)) print("助手:", resp.content)

这段代码的逻辑很直白:每次提问,把用户消息追加进列表;把整个列表传给模型;再把模型的回答作为AI消息追加进列表。模型接收到的消息越多,就越能“记得”之前聊过什么。

手动维护消息列表的好处是透明、可控,坏处是历史消息会无限增长。生产环境里,一般会做这三件事:只保留最近的N轮对话;把更早的内容做摘要;或者按会话ID把历史持久化到数据库。LangChain的RunnableWithMessageHistory就是为此设计的,但理解上面的手动版本,你才能知道它到底在替你做什么。

3.4 第三版:接本地知识库,让回答有依据

接下来做更有价值的一步:让助手基于我们自己的资料文件来回答。先写一个脚本把资料加载、切分、向量化并存入Chroma向量库:

from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings from langchain_chroma import Chroma loader = TextLoader("docs/help.txt") documents = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, ) chunks = splitter.split_documents(documents) embeddings = OllamaEmbeddings(model="llama3.1") vectorstore = Chroma.from_documents( chunks, embeddings, persist_directory="./chroma_db", ) retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

切分参数我解释一下:chunk_size=500表示每个文本块约500个字符,太短会丢失上下文,太长会浪费模型上下文窗口;chunk_overlap=50表示相邻块之间有50个字符的重叠,用来防止重要的信息刚好被切断在边界上。这两个值不需要一上来就调到最优,先跑通,再根据回答效果调整。

检索逻辑本身也很简单:把用户问题向量化,然后在向量库里找出相似度最高的3个文本块。这里k=3是经验值,太小会信息不足,太大会给模型塞入过多无关内容。

接下来把检索结果和Prompt结合起来,组成最终的问答链:

from langchain_core.runnables import RunnableLambda def retrieve_and_pack(query: str): docs = retriever.invoke(query) context = "\n\n".join(doc.page_content for doc in docs) return {"question": query, "context": context} rag_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个客服助手。请只根据提供的资料回答问题,资料不足以回答时,明确说不知道。"), ("human", "资料:\n{context}\n\n问题:{question}"), ]) rag_chain = ( RunnableLambda(retrieve_and_pack) | rag_prompt | llm | StrOutputParser() ) while True: question = input("你:") if question.strip().lower() in ("quit", "exit"): break print("助手:", rag_chain.invoke(question))

这里的RunnableLambda用来包一个自定义函数,它接收用户问题,调用检索器拿到资料,然后返回一个包含question和context的字典。后续的rag_prompt会自动从这个字典里取值填充模板。整个流程就是经典的RAG闭环。

跑起来后,你问一个资料里明确写了的问题,模型会基于资料内容回答;再问一个资料里没有的问题,模型会按照系统提示词告诉你不知道。这就是RAG和普通问答最大的区别:回答有了依据,模型也敢于承认自己不知道。

4. 常见报错与排查心得——新手最容易卡住的点

4.1 高频报错速查表

实战过程中我整理过一份LangChain新手报错清单,大部分卡壳的问题都能在下面这张表里找到答案:

报错或现象常见原因解决办法
连接本地模型服务失败Ollama服务没有启动,或端口配置错误先单独确认服务能访问,base_url写对端口
Could not resolve value for a fieldPrompt模板里有{变量}但调用时没传对应字典键检查模板占位符和invoke传入的字典是否一一对应
模型返回内容被截断,回答不完整模型上下文窗口不够,或输出长度上限设置太低减小chunk_size,控制历史消息长度,调大输出上限参数
JSON输出解析失败模型生成的内容不是合法JSON换更强模型,或在模板里加输出示例,解析时加异常兜底
向量库维度不匹配向量化时用的Embedding模型和查询时不一致统一Embedding模型,并删除旧的向量库目录重新生成
回答明显跑偏,与资料无关检索结果不相关,或系统提示词太弱调整切分参数、增大k值、加强系统提示词的约束
上下文超长报错多轮历史消息和资料片段加起来超过模型限制只保留最近N轮对话,对长资料做摘要后重写

排查这些问题的通用思路是“逐段隔离”:先把链条简化为“模型直接回答”,确认模型本身没问题;再加入模板,确认模板填充正确;最后加入检索器,单独测试检索器返回的内容是否合理。哪一步异常,问题就出在哪一步。

4.2 容易被忽略但影响很大的三个细节

第一个细节是流式输出。很多人测试时用invoke没觉得不妥,但一旦面向真实用户,等待完整生成的体验会很难受。把链改成stream方式调用,代码量只多几行,用户体验却完全不同。LCEL链天然支持stream,这也是我推荐用LCEL而不是自己写流程编排的原因之一。

第二个细节是系统提示词的编写质量。模型跑偏的根源大多是系统提示词写得太笼统。不要只写“你是助手”,而要写清楚身份、语气、禁止事项、知识边界。举个例子,RAG场景下明确写上“资料中没有的内容不要编造”,几乎能消除一半的幻觉问题。多花十分钟打磨系统提示词,远比反复调温度参数有效。

第三个细节是保留一层自己的抽象。我在项目里习惯在LangChain的链外面套一层自己定义的函数或类,比如KnowledgeQA.ask(question)。这样做的目的很实际:框架版本的升级、接口的调整,只影响这一层封装内部,业务代码不受波及。很多同学把LangChain组件直接散落在业务代码里,一旦框架版本大升级,改起来就是牵一发动全身。

我自己在落地完这个项目后最深的体会是:LangChain的学习曲线其实并不陡,真正容易让人迷失的是概念太多、版本迭代太快。与其试图背完所有文档,不如始终盯住“输入什么、输出什么、中间零件的边界在哪”这三个问题。带着问题去查文档,效率比从头刷教程高得多。最后再分享一个实用习惯:给链起名字时用业务语义命名,比如summary_chain、rag_retrieval_chain,而不是chain1、chain2。代码是写给未来的自己看的,几个月后再回来改Bug时,能让你少挠一会儿头。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 7:00:33

端到端自动驾驶工程落地四大核心:模型×数据×训练×平台

1. 这不是“自动驾驶论文合集”,而是一份可落地的工程路线图很多人看到“端到端自动驾驶”第一反应是:又一篇堆砌Transformer、BEV、Occupancy的综述?或者干脆划走——觉得离量产太远,全是实验室玩具。我完全理解这种反应。过去三…

作者头像 李华
网站建设 2026/10/14 7:00:26

Serverless有状态后门:无服务器架构中的持久化与状态攻击解析

Serverless应用的持久化与状态攻击:在无服务器架构中植入“有状态”的后门很多安全团队对Serverless的第一印象是"无状态、短生命周期、没法持久化",觉得函数执行完就销毁,攻击者就算打进来也落不了脚。这个认知放在两年前勉强成立…

作者头像 李华
网站建设 2026/10/14 7:00:09

具身智能与智能体开发:新职业目录下的技术栈与实验室建设指南

1. 新职业目录背后的信号:具身智能与智能体到底在招什么人第八批新职业目录公布那天,我正蹲在一个嵌入式实验室里调机械臂的逆解。手机弹窗跳出来,扫了一眼,具身智能训练师、智能体开发工程师赫然在列。说实话,第一反应…

作者头像 李华
网站建设 2026/10/14 6:59:58

并查集详解:动态连通性与路径压缩优化实战

并查集这名字,我第一次看到的时候真觉得挺唬人,总感觉是什么高深莫测的高级数据结构。结果认真学完之后才发现,它大概是所有“听起来高大上”的数据结构里最朴素的一个:一个数组,几个循环,却能解决一类非常…

作者头像 李华
网站建设 2026/10/14 6:59:57

Flutter表格实现横向滚动与固定表头/第一列的完整方案

做Flutter桌面端或者管理后台项目时,只要涉及数据展示,基本都逃不开用表格。可等你真上手用Table做东西,大概率会撞上同一个尴尬时刻:内容一多,Table不会横向滚动,表头也不会固定,更不要说固定第…

作者头像 李华
网站建设 2026/10/14 6:59:55

C++ extern解决重定义:从原理到多文件实战

开头搞C开发的朋友,多文件编程几乎是跨不过去的坎。你写了一个globals.h,里面定义了个全局变量,然后两个.cpp文件都#include了它,一编译,链接器当场给你甩个“重定义”错误。新手看到这个错误一脸懵,老手虽…

作者头像 李华