这套系列教程走到第九章,前八章把提示词编写、模型接口调用、参数调优、输出不稳定这些基础问题都过了一遍。但真到动手做项目时你会发现,那些示例代码和“能用”之间还差着一大截:鉴权逻辑散落在每个函数里、提示词和业务代码缠在一起、换个模型服务商就要把所有调用点翻出来改一遍,更不用说记忆和检索这种需要自己维护的东西。这一章我们聊一个专门解决这些麻烦的框架:LangChain。
LangChain 不直接提供大模型,它把模型接入、提示词管理、输出解析、检索、记忆、工具调用这些高频能力做成统一模块,再用一套表达式把它们串成“链”。你可以把它理解成后端开发里的 ORM——只靠手写 SQL 当然也能跑,但项目一多、表一多,你就需要一个标准化的中间层。这篇内容适合有 Python 基础、会调模型 API、但还没系统搭过完整应用的开发者,读完至少能搭出一个带知识库的多轮问答应用,也清楚后面该往哪儿深入。
1. LangChain 在设计之初想解决的问题
1.1 一次普通的模型调用,能有多麻烦
大部分入门教程教你的调用方式长这样:
import requests def call_llm(prompt, history=None): messages = history or [] messages.append({"role": "user", "content": prompt}) payload = { "model": "model-name", "messages": messages, "temperature": 0.7, } resp = requests.post( "https://api.example.com/v1/chat/completions", headers={"Authorization": "Bearer YOUR_API_KEY"}, json=payload, timeout=30, ) if resp.status_code != 200: raise RuntimeError(f"API error: {resp.status_code}, {resp.text}") return resp.json()["choices"][0]["message"]["content"]这段代码看起来能跑,但拿到工程里就是灾难。首先是鉴权逻辑散落在每个函数里,项目里有十个调用点就得写十遍请求头;其次是提示词和业务代码缠在一起,你想调整系统提示词,就得钻进函数内部去改字符串拼接;再者是输出解析完全没有,模型返回的是原始文本,想要 JSON 结构化还得自己写正则或 json.loads;最后是错误处理,超时、限流、上下文超长这些异常每个都要单独写分支。
做个生活化的类比:这就像做菜时每次都要重新洗菜、切菜、配调料,而不是先把半成品预制好。一个模型应用如果只有一两个调用点,裸调倒也没什么;但当你开始做知识库问答、Agent 工具调用、多轮对话记忆时,重复劳动会指数级增长。LangChain 的价值就在于此:把大模型应用里那些绕不开的“公共麻烦”抽成模块,让开发者专注于业务逻辑本身。
1.2 LLM 应用的共同结构
沉下心看几个不同类型的应用——聊天机器人、文档问答、内容总结、数据分析助手——你会发现它们的骨架惊人地相似。无非是这六个环节:模型接入、提示词组装、输出处理、上下文/记忆、外部数据、工具调用。
裸调时每个环节都在重复造轮子,LangChain 则给每个环节都安排了对应模块:
| 应用环节 | 裸调时要重复做的事 | LangChain 对应模块 |
|---|---|---|
| 模型接入 | 配置地址、鉴权、超时重试 | Model(统一模型接口) |
| 提示词 | 手拼字符串、硬编码在业务代码里 | Prompt Template |
| 输出处理 | 正则解析、JSON 抽取、异常兜底 | Output Parser |
| 上下文/记忆 | 手动维护消息数组、截断历史 | Messages / Memory |
| 外部数据 | 自己写文本切分、向量化、检索 | Retriever / VectorStore |
| 工具调用 | 自己写函数循环判断、处理结果 | Agent / Tool |
这张表基本就是 LangChain 的模块地图。你不需要一次性把所有模块都学会,先抓住主线——Model、Prompt Template、Output Parser、Chain——就能拼出一个能用的应用。检索、记忆、Agent 都是在这个主干上做加法。
2. 环境准备与项目搭建
2.1 安装与版本选择
先交代一下环境:Python 建议用 3.9 及以上,虚拟环境是标配,别直接装到系统 Python 里。安装命令很简单:
pip install langchain langchain-core langchain-community pip install langchain-xxx # 替换为你实际使用的模型服务商适配包这里有个新手很容易踩的坑:LangChain 的版本迭代速度很快,0.1 版本和 0.3 版本的 API 差异很大。早期版本里from langchain.llms import OpenAI这种写法在后续版本中逐渐被拆分成langchain-openai、langchain-anthropic等独立适配包。不同功能也被拆到了langchain-core(核心抽象)、langchain-community(社区集成)里。
我的建议是:新项目直接用当前最新稳定版,但一定要在 requirements 里锁定版本号。比如langchain==0.3.x、langchain-core==0.3.x。为什么这么强调?因为 LangChain 的链式 API 在版本升级时经常调整,你上个月写的链用invoke能跑,升级后可能要改成ainvoke或者干脆换了包名。加上“第九章”在系列中的位置,前几章的很多示例代码其实都是在固定版本下验证过的,锁定版本才能保证前后一致,避免本来好好的代码升级完突然报错。
2.2 配置文件与模型连接
大模型的 API Key 不能写死在代码里,这是个基本素养。我习惯用.env文件配合环境变量方式来管理:
import os from dotenv import load_dotenv load_dotenv() api_key = os.getenv("LLM_API_KEY")然后初始化模型:
from langchain_xxx import ChatXxx model = ChatXxx( model="model-name", api_key=api_key, temperature=0.3, )注意这里我用了ChatXxx占位符,实际使用时替换为对应模型服务商的适配类。不同服务商的包名、类名、模型名都不一样,但核心用法几乎一致,这就是 LangChain 统一接口的意义:你只需要改一行初始化的代码,就能在几个模型服务商之间切换,业务逻辑完全不用动。
两个实际情况提醒你。第一,模型名必须严格按官方文档填,填错往往不会在初始化时报错,而是在第一次 invoke 时才给你抛异常,排查起来很不直观。第二,temperature这类生成参数可以直接在初始化时设置,但也可以在调用时临时覆盖,这个灵活性在调试不同业务场景时非常有用,比如写代码的链用低温度,创意写作的链用高温度。
3. 核心概念逐个拆解:从模型接口到链式组装
3.1 统一模型接口:invoke / stream / batch
模型模块在 LangChain 里被抽象成ChatModel(对话模型),最常用的方法有三个:invoke(单次调用)、stream(流式返回)、batch(批量调用)。不少人只用了invoke,但流式输出在真实体验中非常关键。
# 流式输出 for chunk in model.stream("给我讲个笑话"): print(chunk.content, end="", flush=True)这行代码实现了打字机式的输出效果。如果你做过聊天应用就懂,用户看到逐字输出和等三秒一次性弹出全文,心理体验是天壤之别。invoke返回的是一个消息对象,不是纯字符串,要拿到纯文本通常是.content属性——这也是新手常见困惑点,打印出来看是一大堆content=...,以为自己写错了。
batch适合批量处理离线任务,比如一次性给 20 篇文档写摘要。它的内部会自动处理并发请求,但注意批量太大时容易触发服务商的限流,具体并发数要根据你的实际情况调整。把这些接口理解成统一入口之后就简单了,无论背后是哪个模型服务商,开发体验都是一样的。
3.2 提示词模板:把不变量和变量分开
直接拼接提示词字符串,写几个 demo 没问题,但项目一旦复杂就会失控。核心问题是把“不变量”和“变量”混在一起了——系统指令是不变量,用户的输入、检索到的资料是变量。提示词模板就是把这层关系显式化:
from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个产品问答助手。请用简洁、专业的中文回答问题。如果资料中没有相关信息,直接说不知道。"), ("human", "资料:\n{context}\n\n问题:{question}"), ])ChatPromptTemplate支持三种消息角色:system系统消息、human人类用户消息、ai模型助手消息。模板里的{context}、{question}是占位符,调用时传入真实值即可。用了模板之后,系统提示词集中管理,业务代码里看到的都是清晰的变量名,而不是一长串字符串拼接。这在多人协作时的优势尤其明显,产品想调整提示词语气,开发不用改代码逻辑,只改模板文件就行。
3.3 LCEL 与链:用管道符描述业务流程
LangChain 最有辨识度的地方是它的表达式语言,简称 LCEL。它用|管道符把各个模块串起来,整个业务流程一眼看穿:
from langchain_core.output_parsers import StrOutputParser parser = StrOutputParser() chain = prompt | model | parser answer = chain.invoke({ "question": "你好,请介绍一下你自己", "context": "", }) print(answer)prompt | model | parser读起来就是“提示词模板传给模型,模型输出传给解析器”。每个“环节”对应一个可运行对象,有点像 Unix 命令行里的管道——cat file | grep keyword这种思维。StrOutputParser是输出解析器里最基础的一个,作用是把模型返回的消息对象转成纯字符串。
为什么强调 LCEL?因为它让代码变得“可声明”。以前你要写response = model(prompt),再把结果逐个传给下一步;现在你只需要声明链条结构,框架自己处理数据流转。调试时也可以带着中间的print去观察每一层的输入输出,定位问题的效率高很多。等你熟悉了这条主线,后面所有复杂的应用——检索增强、Agent 循环——都可以看成是往这条管道里插入新环节。
4. 实操:从零构建一个带知识库的产品问答助手
4.1 需求拆解与模块设计
直接上一个可以落地的项目:做一个简单的产品问答助手。假设某个产品线收到大量重复咨询——发票怎么开、怎么退货、保修多久,客服压力很大。我们做一个能基于已有 FAQ 文档回答问题的 Mini Demo。
这类应用有个专业名字叫 RAG(检索增强生成),核心思路是“先检索,再生成”。用户提问后,先从资料库里找出相关的几个片段,把这些片段作为上下文塞进提示词,由模型基于资料生成回答。这样模型不需要提前记忆你的产品信息,也极大降低了乱编的概率。
拆解下来需要五个模块:文档加载(读入 FAQ 文本)、文本切分(把长文档切成小块)、向量化(把文本转成向量)、检索(找出最相关的块)、生成(模型基于上下文回答)。其中向量化这一步在生产环境通常要借助专门的向量数据库,但完整跑起来涉及第三方服务,对入门体验不太友好。所以我下面用 LangChain 的Document+ 一个简化检索器代替,重点先让你看懂链路怎么通。
from langchain_core.documents import Document # 模拟加载 FAQ 文档 faq_text = [ "发票开具:付款成功后,可在个人中心申请电子发票,通常在24小时内发送到邮箱。", "退货政策:签收后7天内支持无理由退货,商品需保持完好且不影响二次销售。", "保修服务:整机保修一年,主要零部件保修三年,非人为损坏免费维修。", ] docs = [Document(page_content=t) for t in faq_text]这段代码把三句 FAQ 变成了三个Document对象。真实项目里你会用文档加载器读取几十页的 PDF、Word 或网页,LangChain 提供了大量现成的加载器,这一步思路是一样的。
4.2 核心链路组装与运行
先定义一个简化检索器,功能是按关键词打分挑出最相关的资料块:
class SimpleRetriever: def __init__(self, docs): self.docs = docs def __call__(self, question: str) -> str: # 对所有资料块打分,返回得分最高的前两个 scored = [] keywords = ["发票", "退货", "保修"] for doc in self.docs: score = sum(1 for kw in keywords if kw in doc.page_content and kw in question) scored.append((score, doc.page_content)) scored.sort(key=lambda x: x[0], reverse=True) top = scored[:2] return "\n\n".join(block for _, block in top if block)这里为了演示,我用简单的关键词匹配。真实项目里这一步应该是“向量相似度检索”,用 embedding 模型把 query 和所有文档块转成向量,然后算余弦相似度取 Top-K。用简化版是想先让你理解 Retriever 的本质——它就是一个“喂进问题,返回资料块”的可调用对象,至于是关键词选还是向量选,只是内部实现差异。
接下来组装整条链:
from langchain_xxx import ChatXxx from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough # 1. 初始化模型 model = ChatXxx( model="model-name", temperature=0.3, ) # 2. 提示词模板:明确要求模型只依据资料回答 prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个产品问答助手。请使用中文回答问题。只依据给定资料,不要编造信息。资料不足时明确说明。"), ("human", "资料:\n{context}\n\n问题:{question}"), ]) # 3. 构造检索器 retriever = SimpleRetriever(docs=docs) # 4. 组装链 chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | model | StrOutputParser() ) # 5. 调用测试 question = "我买的设备有问题,想退货怎么处理?" answer = chain.invoke(question) print(answer)这段代码是整章的核心,我拆开解释一下组装过程。{"context": retriever, "question": RunnablePassthrough()}是一个“字典输入加速器”,它同时负责两件事:把用户的问题原封不动传给question字段,同时把问题交给retriever去检索资料,检索结果存入context字段。这两个结果会一起流到下一步的prompt里填充模板占位符。
RunnablePassthrough()可以理解成一个透明通道,“什么都不做,原样传递”。这种写法在 LCEL 里很常见,它的作用是明确告诉链:有一部分输入直接透传,有一部分需要经过某个处理环节。这样的好处是各环节职责清晰,以后你想换成真正的向量数据库检索器,只需要替换retriever这个对象,链的其他部分完全不用动。
跑一下示例。问“我买的设备有问题,想退货怎么处理?”,模型会基于检索到的退货政策资料回答,而不是自己发挥。这正好体现了 RAG 的核心价值:答案有依据,可溯源,不依赖模型的内部知识。
4.3 运行效果与调优方向
在实际调试时,你会发现有几个参数对回答质量影响很大。第一个是temperature,问答类场景建议 0.2~0.4。设太高模型容易自由发挥,甚至脱离资料;设太低又显得机械。第二个是检索返回的文档块数量,我的示例返回了前 2 块,如果资料库很大,可能需要返回 5~8 块才能覆盖足够的信息面,但也别太多,超出上下文窗口限制不说,无关内容越多,模型越容易被带偏。
文档切分是一个被人低估的细节。假设你有一份完整的产品说明书,几万字的文档直接整个塞给模型,既超上下文窗口,检索效果也差。通常做法是按章节切分,每块控制在 500~1000 字左右,相邻块之间留一部分重叠,否则中间断开的信息会丢失语义。切分太大检索定位就不精确,切分太小上下文信息就不完整,这个平衡需要根据你的文档类型慢慢试。
还有提示词里必须明确“只依据资料回答,不知道就说不知道”。不加这句,模型碰到资料里没有的信息时会强行脑补,编出看似合理实则错误的答案。加了这句之后回答质量会明显上一个台阶,这是成本最低但是收益最高的调优措施。
5. 进阶场景:记忆与工具调用
5.1 用显式消息历史实现多轮对话记忆
上面的问答助手是无状态的,每次调用都是孤立的。你问“退货政策是什么”,它回答了;你再追问“具体怎么申请?”,它并不知道你刚才问过退货。多轮对话需要把历史消息传给模型,LangChain 里最推荐的做法是用MessagesPlaceholder在模板中留一个“历史消息”插槽:
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.messages import HumanMessage, AIMessage prompt_with_history = ChatPromptTemplate.from_messages([ ("system", "你是一个耐心的产品助手。"), MessagesPlaceholder(variable_name="history"), ("human", "{question}"), ]) chain = prompt_with_history | model # 调用时把历史消息作为列表传入 answer = chain.invoke({ "history": [ HumanMessage(content="我想退货"), AIMessage(content="请问您在哪个平台购买的?"), ], "question": "我在商城买的,怎么申请?", })这种做法比早期版本的ConversationBufferMemory更直观。旧式 Memory 把历史状态藏在对象内部,调用时还要单独维护记忆链,状态一多很容易糊涂。显式把history作为输入传进去,语义清晰,也方便调试。你可以把历史的构建逻辑封装成一个函数,比如从数据库或 Redis 里读取最近 N 轮消息,拼装成列表再传给链。
这里有两个容易忽略的成本问题。第一,历史消息越长,每次请求消耗的 token 越多,每次多花的钱都是真实成本;第二,模型服务商的上下文窗口有上限,历史太长会把窗口占满,导致当前问题被截断。务实的做法是只保留最近 5~10 轮消息,或者用一个摘要器把旧对话压缩成一段话的概要。这些都属于工程优化,入门阶段先把显式历史消息跑通,后面再逐步加策略。
5.2 让模型学会调用外部工具
Agent 是 LangChain 里最有想象力、也最容易让人沉迷的功能。它的思路是把若干“工具”交给模型,由模型自己判断什么时候调用哪个工具,而不是我们写死调用逻辑。
比如给模型准备两个工具:查天气、算数。用户问“帮我算一下 3 箱每箱 12 瓶一共多少瓶?”,模型识别出这是计算任务,就会调用计算工具拿到结果,再组织语言回答。这种行为循环有个专门的称呼叫 ReAct——Reasoning(思考)+ Acting(行动),大致过程是:模型先想“这个问题需要工具”,然后调用工具拿到观测结果,最后基于观测结果给出答复。
LangChain 中定义一个工具非常简单:
from langchain_core.tools import tool @tool def calculate(expression: str) -> str: """计算数学表达式,比如 '3*12' 返回 36。""" # 生产环境建议用安全的表达式解析库,不要直接用 eval return str(eval(expression))重点在函数的文档字符串,模型就是靠这段描述来决定何时调用这个工具的,所以工具说明要写清楚“是干什么的”和“输入格式是什么”。接入 Agent 之后,模型会在内部反复执行“思考-调用-观察”的循环,直到得出最终答案。这个机制很强大,但也有明显弱点:循环次数不可控,延迟和费用都会增加;模型选错工具的概率也不是零;生产环境对 Agent 的工具调用结果必须做严格的校验和限制。我的建议是先把前面主线玩熟,再来碰 Agent,否则很容易陷入“功能很炫但工程上不稳定”的泥潭。
6. 常见问题与排查技巧实录
6.1 高频报错与处理方案
我把新手阶段最常遇到的问题整理成了表格,每个我都实际踩过或见过:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 调用时报认证失败 | API Key 没配好或已失效 | 检查环境变量是否正确加载,确认 Key 有权限且未过期 |
| 初始化成功但 invoke 报模型不存在 | 模型名填写错误 | 对照官方当前模型列表,确认服务商是否支持该模型 |
| ImportError: 找不到某个模块 | 缺少对应适配包或导包路径错误 | 确认已安装langchain-xxx适配包,检查 import 路径是否带core前缀 |
| 链运行时提示版本 API 差异 | 升级了 LangChain 导致接口变更 | 查看 release notes,按新版调整,最稳妥的是回退到验证过的版本 |
| 提示上下文超长 | 文档块太多或历史消息太长 | 减少检索返回块数量、截断历史消息、增大文本切分块大小 |
| 输出不是预期结构 | 没有用 Output Parser 或模板约束不严 | 在提示词里给出明确的输出格式示例,配合 PydanticOutputParser 做结构化输出 |
| 检索结果为空或无关 | 切分粒度不合适、检索器打分策略太粗糙 | 调整切分块大小与重叠长度,换用真正的向量检索,或扩大返回数量 |
以“上下文超长”为例详细说一下。如果你完整地把一份几万字的产品手册塞给模型,服务商会返回长度超限的错误。这本质上是在提醒你:你的输入已经超过模型的窗口能力。解决思路有两个方向,一是减少输入,二是换更大的窗口模型。减少输入最直接的手段就是控制检索块的数量和长度,这也就是为什么 RAG 系统要把文档切碎而不是整篇灌进去。
6.2 稳定性与成本控制经验
实操中最大的感慨是:Demo 能跑和系统能稳定跑是完全不同的事。几个经验分享给你。
第一,固定版本是底线。LangChain 更新的速度堪称疯狂,0.1 到 0.3 的 API 变化已经让大量旧代码失效。团队项目里一旦升级版本,往往要花一天时间全局排查,性价比极低。生产项目锁版本,升级必须单独评估。
第二,链路中任何一个环节都可能出错,所以要分层排查。我会先单独测试模型:直接model.invoke("你好"),确认模型接入没问题;再测 prompt:打印填充后的消息内容,确认变量都没漏;最后才测整条链。像 4.2 里的链式写法,在哪一层断掉的,检查对应的组件即可,这比对着堆栈逐行猜快得多。
第三,注意成本。LLM 应用的成本是隐形的,线上问答助手跑一天,账单数字可能超出预期。做两件事来控成本:设置每个用户每小时的调用频率上限;对高重复度问题加一层缓存,检索结果和模型输出一样可以缓存。这一块算是从“会搭”走向“能上线”的关键门槛。
第四,保持一份“提示词与链路变更记录”。改 prompt 和改代码,在效果上的影响是同样重要的。但日常开发中,代码有版本管理,prompt 往往改来改去就忘了上一版为什么改。养成把每次 prompt 调整的原因和效果写下来的习惯,后期调优会省大量时间。
最后一个实践心得:刚开始学的时候,不要把注意力全放在 Agent 上。先把模型接入、提示词模板、输出解析、链式组装这条主线玩熟,再接触检索和记忆。RAG 应用已经能覆盖大量真实场景了,Agent 是锦上添花而不是必需品。工具调用玩了三天之后我最大的体会就是:模型选工具的能力确实比想象中聪明,但产品落地时,稳定性永远是排在第一位的。