我接触LangChain不算早,早期很长一段时间都停留在“写提示词”的阶段——把模型API的调用包一层,能聊天、能翻译,就觉得差不多了。真正让我决定认真把整条路子走一遍,是第一次接到一个“让AI自动查库存并生成补货建议”的需求。那一次我才意识到,提示词写得再好,也解决不了模型根本不知道库存数据在哪的问题。
后来我把LangChain从提示词工程一路捋到智能体,自己搭过问答链、RAG流程、工具调用、带记忆的多轮对话,也踩过不少版本和调试上的坑。这篇文章就是一份完整的LangChain开发路线图:每一站解决什么问题、核心组件怎么用、代码骨架长什么样、我最后怎么收口。适合已经开始接触大模型、但还没有系统做过Agent类应用的开发者,也适合想从零搭一个可维护AI应用的团队做路线参考。需要先说明的是,LangChain版本迭代挺快,具体API会有细节差异,但这条路线的主线一直没变:提示词工程解决单次质量,链解决多步流程,RAG解决数据外挂,智能体解决动态决策。
整条路线可以拆成六站:提示词工程、LangChain基础抽象、链与记忆、RAG、工具调用、智能体。下面逐个展开。
1. 为什么提示词工程不是终点:先看清整段路的边界
1.1 提示词工程解决的是输出质量,不是系统集成
从模型API的角度看,能做的只是“输入一段文本,返回一段文本”。提示词工程优化的是:同样的模型,怎么让它更稳定、更准确地输出我想要的内容。这件事性价比很高,也确实考验功力,但边界也非常清楚:提示词不会帮你解决数据从哪来、历史放哪里、要不要调用外部接口。换句话说,它优化的是“最后一跳”的质量,而不是整条链路的构成。
我在实际项目里见过两种典型卡点。第一种是把模型当数据库用,问它内部业务数据,模型答不上来就开始堆提示词,堆到后面连基础的问答都开始变形。第二种是希望模型自己决定“要不要查一下实时价格”,但提示词里根本无法描述清楚实时系统的各种分支。这两个问题都不是提示词工程能解的,靠的是后面的RAG和智能体。所以这个路线图的第一课不是教你写更多提示词,而是帮你把提示词的边界画清楚。
1.2 智能体改的是“谁来决策”,不是“能不能用工具”
很多朋友第一次接触智能体,觉得它就是“给模型接几个工具”。这个理解不准确。工具本身只是能力,智能体的核心变化是决策权:在普通链里,调用哪些工具、按什么顺序调用,是开发者在代码里写死的;在智能体里,模型会在每一轮先看用户请求,再决定“现在该调用哪个工具、传什么参数、要不要继续还是直接结束”。
举个最简单的例子:用户问“帮我查一下订单状态,然后催一下发货”。如果是固定链,你得先写死“查订单→催发货”。可如果用户只问一句“最近订单怎么这么慢”,固定链就不知道要不要触发催发货。智能体的思路是,模型先判断用户意图,再决定是否调用查单工具、催单工具,甚至先问用户一句“您希望我先查哪一笔订单”。这种动态决策,才是从“能说”变成“能做”的本质,也是为什么整个路线图最终要导向智能体。
1.3 六站依赖关系一览
我用一张表展示路线的依赖顺序和每站的交付物:
| 站点 | 核心问题 | 交付物 |
|---|---|---|
| 提示词工程 | 模型输出不稳定、格式不统一 | 可复用的稳定提示词模板 |
| LangChain基础抽象 | 换模型、改业务时代码到处是胶水 | 统一模型调用与消息结构 |
| 链与记忆 | 单次调用没法完成多步任务 | 可编排的链式流程与会话状态 |
| RAG | 模型不知道私有数据和最新数据 | 外部知识检索增强的问答能力 |
| 工具调用 | 模型只能输出文本,无法操作真实系统 | 标准化的外部动作接口 |
| 智能体 | 流程固定无法应对开放需求 | 能自主决策调用链路的智能体 |
这张表基本就是我的学习顺序。每一站都建立在前一站之上:提示词工程解决单次质量,LangChain抽象解决可维护性,链解决多步编排,RAG解决数据外挂,工具调用解决动作执行,智能体解决动态决策。如果跳过其中任何一站,后面都会返工。比如直接上智能体却不懂链,你会发现智能体的内部结构完全是个黑盒,出了问题不知道在哪一层断的。
2. 第一站:提示词工程——把需求翻译成模型能听懂的语言
2.1 别追求“聪明”,追求“可预测”
我在各个项目里沉淀下来的提示词结构一般是四到六段:角色定位、背景信息、目标任务、处理步骤、输出格式、禁忌项。别小看这个结构,它的价值在于“可复用”,而不是“这一条多聪明”。比如客服助手的模板写成这样:
你是一位客服助手,负责处理用户的订单咨询。 背景:用户可能来自不同渠道,问题涉及订单状态、退款进度、物流轨迹。 任务:理解用户诉求,判断问题类型,并给出简洁友好的答复。 步骤: 1. 先识别用户意图,归类到[订单查询/退款/物流/其他]。 2. 如果信息不足,明确告知用户需要补充什么。 3. 最后给出下一步建议。 输出格式:严格按以下JSON返回: {"category":"...", "reply":"..."} 禁忌:不要编造订单状态;不知道就明确说不知道。这类模板看起来朴素,实操中非常管用。关键不是让它“更像人”,而是让它“可预测”。可预测的意思是:同一类输入,输出结构和风格稳定,后续程序才好接。如果模型今天给你一句“亲,您的快递已经在路上啦”,明天给你一长段小作文,那下游解析和产品体验都会非常痛苦。
2.2 输出格式比输出内容更容易被忽略
落地提示词工程时,我越来越关注一个点:输出格式。因为内容写得再好,如果返回的是自由文本,下游程序很难稳定解析;如果能让模型稳定输出JSON、固定字段,整个系统的可靠性立刻上一个台阶。
做法上,除了在提示词里声明格式,还建议给一两个例子——Few-shot。比如上面那段客服模板里,补一条“输入: 我的快递怎么还没到;输出: {"category":"物流", "reply":"您好,请提供订单号,我帮您核实。"}”,模型照着例子走,准确率高很多。另一种更硬核的做法是写JSON Schema,让模型只输出符合结构的字段。这块在LangChain里有专门的输出解析器,放在下一站讲,但提示词阶段就应该先把“要JSON”这个意识立起来。
2.3 提示词迭代的要领与止盈点
我见过很多团队在提示词上无限堆规则。这里说一个反直觉的经验:规则越堆越多,模型会倾向于过度理解,甚至开始“编造”条款。我的做法是每轮只改一个变量,用同一批测试样本对比,切面稳定了再动下一个。温度可以设低一点,比如0.0到0.3,减少随机性,让差别主要体现在提示词上,而不是模型当天的心情。
另一个止盈点是:如果一个问题你已经花了几十条提示词,不如换个更强的模型,或者把它拆成两个环节。提示词工程的价值不是“用提示词解决所有问题”,而是用最便宜的代价把输出质量拉高到能接下一步流程。一旦发现代价失控,就该往前走——上流程、上结构、上框架。
3. 第二站:LangChain核心抽象——多模型下的一致体验
3.1 封装的价值不在少写代码,而在可替换性
如果没有LangChain,很多项目的典型写法是:业务代码里直接调模型SDK,prompt字符串散落在各处,返回的文本自生自灭。听起来还能忍,可一旦发生两件事就崩了——换一个模型服务商要改所有调用点;多个组件需要共享同一个模型的会话上下文时,代码到处是胶水。
LangChain把这类问题收敛成几组抽象:ChatModel负责模型调用,Message负责对话消息,PromptTemplate负责提示词模板,OutputParser负责解析输出,Runnable负责把上面这些串成可复用的流程。你写的业务代码依赖的是这套抽象,而不是某个具体SDK。换底层模型时,只换一个工厂函数,链路本身不用动。
3.2 用ChatModel和Message组织一次对话
以Python为例,一次最简单的对话大致长这样:
from langchain_core.messages import SystemMessage, HumanMessage # create_chat_model 表示接具体模型服务商的封装 # 由你自己实现,或按服务商提供的SDK替换 chat_model = create_chat_model( "your-model-id", temperature=0.2, ) messages = [ SystemMessage(content="你是一个数据库运维助手。"), HumanMessage(content="帮我解释一下连接池耗尽的原因。"), ] response = chat_model.invoke(messages) print(response.content)这三行里,消息类型决定了模型的输入结构:SystemMessage放系统设定,HumanMessage放用户输入,模型返回的是AIMessage。很多新手习惯把全部东西拼成一个长字符串扔进去,能用,但不利于系统管理。把消息结构化之后,后面要加历史记录、要区分用户和助手轮次,都很自然。
3.3 提示模板:把提示词变成可复用模板
实际项目里,提示词一定不会写死在代码里。用ChatPromptTemplate把上面客服案例写出来:
from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一位客服助手,负责处理用户的订单咨询。"), ("user", "用户问题:{question}\n请按JSON格式返回{{'category': ..., 'reply': ...}}"), ]) pipeline = { "question": ... # 实际传给模板的变量 }注意两点。一是用占位符{question}把变量抽出来,同样的模板可复用在多个接口;二是模板本身也是一个Runnable,后面可以直接拼进链路。我在项目里的习惯是模板集中放在单独的文件里,不散落在业务逻辑中,这样产品改一句话也不用翻代码。
3.4 输出解析器:从文本到结构化结果
提示词让模型“尽量JSON”,程序还是得自己解析字符串。LangChain提供输出解析器来收口这一层。比如用结构化解析器,声明好字段,解析不合法时抛错或自动修复。代码示意:
from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate chain = prompt | chat_model | StrOutputParser() result = chain.invoke({"question": "我的订单怎么还没发货"})这里用|把prompt、模型、解析器串成一个整体,是LangChain里最有特色的设计——一切都Runnable。StrOutputParser只是取content字段,后面你还会遇到能解析成结构化对象的Parser。建议把“解析器”看作数据校验层,尽可能早地暴露模型输出的格式问题,而不是等业务代码里到处做防御式解析。
4. 第三站:链与记忆——从单次对话到多步流程
4.1 为什么不能一直靠“单次invoke”
单次调用很清晰,但真实任务很少是单次的。比如“总结这周工单,并给出分类统计”,需要:取工单数据、逐个或批量做分类、汇总结果、生成报告。如果把这么长的逻辑写在一个prompt里,上下文会越塞越长,模型输出质量也会下降。正确思路是拆成多个小环节,每个环节做一件事,通过链串起来。
LangChain里的链,最推荐的写法是LCEL,也就是用竖线把Runnable连起来。前面见过prompt | model | parser,这就是一条最小的链。还可以插入函数、条件分支、并行分支。关键收益是:每一步都是可组合的对象,可以单独测试、替换、加回调。
4.2 加记忆:别把所有历史都塞进上下文
多轮对话场景躲不开记忆。最常见的做法是把对话历史拼进messages里。但这里有个新手很容易踩的坑:把所有历史无脑塞进上下文,很快token就不够,模型也会“忘记”更重要的系统指令。
我常用的分层做法是这样。短记忆中放最近几轮原始消息,直接作为输入;中记忆用摘要压缩更早的历史;长记忆从对话里抽取用户偏好、故障信息、关键结论,存到外部存储。LangChain提供多种记忆组件,不过我后来更倾向于只接“消息列表”这类基础记忆,其余用业务系统自己管。原因很简单:记忆策略和你的业务强相关,通用组件往往不够精细。
4.3 一条链的实操示例:工单分类流水线
给一个具体例子会更直观。假设要做工单自动分类加摘要:
from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser classify_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个工单分类器,只输出工单类别。"), ("user", "工单内容:{ticket}"), ]) summarize_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个工单摘要器,用一句话概括问题和处理建议。"), ("user", "工单内容:{ticket}\n类别:{category}"), ]) def load_tickets(): # 从业务系统读取待处理工单 return [...] def process_one(ticket: str): category = (classify_prompt | chat_model | StrOutputParser()).invoke( {"ticket": ticket} ) summary = (summarize_prompt | chat_model | StrOutputParser()).invoke( {"ticket": ticket, "category": category} ) return {"ticket": ticket, "category": category, "summary": summary} results = [process_one(t) for t in load_tickets()]这个例子本身不难,难在要不要为每一步单独建链。我的建议是:只要一个环节可能出现独立的调整需求,就拆成一条子链。比如之后想给分类环节加Few-shot,只需要改classify_prompt所在那一段,不影响摘要环节。
4.4 运行配置与可观测性
链一旦长了,调试就变成痛点。LangChain提供了运行时配置对象,可以传回调、线程ID、用户ID等。我最常用的是回调,用来打印每一步的token数、耗时和中间结果。一个朴素的观察是:大部分跑偏问题不是模型不行,而是某个中间环节返回了脏数据,链上根本没暴露出来。
所以我的建议是:从搭第一条链开始,就把“日志打印每一步输入输出”当成标配,不要等项目大了再补。链式设计的另一个好处是,每一步都可以单独invoke,调试时直接把某个环节拿出来喂固定数据,省掉很多猜谜时间。
5. 第四站:RAG——让模型学会查资料再回答
5.1 RAG解决的是“模型不知道你不知道的事”
任何大模型的训练数据都有截止时间,也不可能包含企业内部文档。想让模型回答“上个月项目复盘结论是什么”,要么把结论写进提示词,要么让它去检索。RAG的思路是后者:先从一个知识库里检索出相关的片段,把片段拼进上下文,再让模型基于这些片段作答。
RAG不是LangChain发明的,但LangChain把整条链路组件化了:文档加载、文本切分、向量化、相似度检索、生成。优势在于每一步都可以替换,比如换一个向量库、换一种切分策略,链路骨架不用推倒重来。
5.2 高质量检索的前提是合理切分
很多人觉得RAG效果差是“模型不够聪明”,实际排查下来,有相当比例是文档切分和召回阶段出了问题。文本切分不是简单按字符数截断;切得太短,语义不完整;切得太长,检索噪声大,还可能超出窗口。
我的经验是:优先按文档结构切,比如Markdown标题、段落、表格;没有结构时用带重叠窗口的字符切分,重叠量一般取切分块大小的10%-20%。比如块大小设500字符、重叠80字符,这样跨边界的上下文不至于被拦腰截断。切完后建议人工抽20条典型问题,跑一遍检索,看召回的内容是不是真的有助于回答,这一步永远值得做。
5.3 向量化与向量存储的选型思路
向量化的核心是Embedding模型,它把文本变成向量,语义相近的文本在向量空间里距离更近。选型上,小规模试点用现成的开源或云上向量化服务都可以,关键是做一次领域对比:把你们业务里的术语问卷、产品文档片段放进测试集,算一下检索命中率。不要只看模型榜单分数。
向量库的选择我给个朴素标准:百万级向量以内、团队不想额外运维,用轻量本地方案;再往上,或者需要高并发、跨地域,考虑分布式向量服务。多租户隔离、单条数据更新频率也是选型关键,尤其企业内部知识库经常要“删除某条过期数据”,如果向量库不支持按条件删除,运维会非常痛苦。
5.4 一条完整RAG链的最小实现
下面代码我不写具体SDK导入,因为换成你手头的向量库和Embedding方案也就是两行的事,关键是链路形态:
from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate # 示意占位 from some_embedding_sdk import create_embeddings from some_vector_store import VectorStore docs = load_your_documents() # 1. 加载业务文档 splitter = SemanticChunker(...) # 2. 按结构切分 chunks = splitter.split_documents(docs) vector_store = VectorStore(embeddings=create_embeddings(...)) vector_store.add_documents(chunks) # 3. 写入向量库 retriever = vector_store.as_retriever(search_kwargs={"k": 5}) rag_prompt = ChatPromptTemplate.from_messages([ ("system", "只依据资料回答,不要编造。资料不足就说明不知道。"), ("user", "资料:\n{context}\n\n问题:{question}"), ]) def format_docs(docs): return "\n\n".join(d.page_content for d in docs) rag_chain = ( {"context": retriever | format_docs, "question": ...} | rag_prompt | chat_model | StrOutputParser() )这段代码把Retriever当Runnable用,在LCEL里直接作为数据源输入。注意format_docs这一步很重要,它把检索到的多条文档拼成一段干净的上下文,保证模型看到的是整块资料而不是列表对象。实际项目中,我会在rag_prompt里再加“引用来源编号”的要求,方便后续展示溯源。
6. 第五站:工具与智能体——把“能说”变成“能做”
6.1 工具调用:让模型输出结构化调用意图
如果说RAG让模型能看到之前看不到的数据,工具调用就让模型能操作系统里的动作。底层机制是:开发者把一批工具的描述和参数Schema告诉模型,模型在生成回答时,如果判断需要某个能力,就输出一个结构化的调用请求,框架帮你解析并执行,然后把执行结果回传给模型继续生成。
这个过程很像人拿到一部电话簿,不看内容,只看“哪个号码能解决什么问题”。模型的优势不是执行精确计算,而是“决策调用哪个工具、传什么参数”,执行由真实系统完成。这里有一个很关键的观念转换:不要指望模型在prompt里“模拟”调用工具,要让模型输出调用意图,你去执行真工具,再把真实结果反馈给它。
6.2 用装饰器把业务函数变成工具
大致写法:
from langchain_core.tools import tool @tool def query_inventory(product_id: str) -> str: """查询实时库存SKU数量。参数: product_id 商品ID""" stock = your_stock_api(product_id) return f"{product_id} 当前库存 {stock} 件" tools = [query_inventory]关键不在装饰器本身,而在docstring的质量。模型的调用决策主要依赖函数名和说明文字,写得含糊,模型会瞎猜。我把docstring当成“给模型看的接口说明”来写,包含:功能边界、参数格式、返回内容、什么情况下适合调用。这比注释给人类看还要重要。
6.3 手写一个工具调用循环:先别上高层封装
有了工具之后,最简单的实现是手写循环:
messages = [SystemMessage(...), HumanMessage(...)] for step in range(MAX_STEPS): resp = chat_model.invoke(messages) if resp.tool_calls: for call in resp.tool_calls: result = run_tool(call) messages.append(ToolMessage(result, call_id=call.id)) else: final_answer = resp.content break这个循环是智能体的心脏:模型说“我要调用工具A”→你执行→把结果放回messages→模型再看。一直循环到模型不再请求工具,输出最终答案。上限步数必须设,通常5到10步足够,防止模型陷入死循环。
LangChain提供了更高层的Agent封装,把循环、解析、规划都包好了,但我不建议一上来就用高层封装。先把上面这个循环手写两三遍,你会对“模型输出调用意图→框架执行→结果回填”有非常清晰的感受,再去看高层Agent会容易得多。
6.4 别把Agent复杂化:它只是一条带决策的链
这点多啰嗦一句。很多人一做Agent就想上复杂框架,其实通用的智能体在概念上就是“循环执行工具调用的链”。你需要关心的只有三件事:给模型哪些工具、工具描述是否清晰、循环终止条件。其他都是工程问题。把这三件事想清楚,Agent就自然而然地出现了。
7. 第六站:从零搭一个可复用的智能体骨架
7.1 目标场景:内部支持助手
我们要做一个内部支持助手:既能回答知识库问题,又能调用查询工具。用户问“帮我查一下XX项目的交付日期”,它就检索项目知识库,同时调用项目管理系统查询真实日期。这个场景覆盖了RAG和工具调用,非常适合作为智能体骨架。
我强调一下,做这类系统不要把“会回答”当目标,要有明确的评估指标:正确检索覆盖率、工具调用准确率、最终回答的引用是否真实。我在实际项目中会把这三项每周拉一次指标,长期迭代。指标不清的Agent项目,最后都会变成“感觉还行但不知道哪里不行”。
7.2 核心代码骨架
下面给出一个合并RAG和工具调用的最小结构,去掉业务细节,但保留关键设计:
from langchain_core.messages import SystemMessage, HumanMessage from langchain_core.tools import tool @tool def query_delivery_date(project_id: str) -> str: """查询项目交付日期。参数project_id为项目编号。""" return your_pms_query(project_id) tools = [query_delivery_date] tool_map = {t.name: t for t in tools} SYSTEM_PROMPT = """ 你是内部支持助手。可以检索知识库资料,也可以调用工具获取真实数据。 工具调用必须传合法参数。如果资料和数据冲突,以工具返回的数据为准。 """ def retrieve_context(question: str) -> str: return format_docs(retriever.invoke(question)) def agent_step(messages): resp = chat_model.invoke(messages) if not resp.tool_calls: return resp.content for call in resp.tool_calls: fn = tool_map[call["name"]] output = fn.invoke(call["args"]) messages.append(ToolMessage(output, tool_call_id=call["id"])) return agent_step(messages) def run_agent(question: str): context = retrieve_context(question) messages = [ SystemMessage(SYSTEM_PROMPT), HumanMessage(context + "\n问题:" + question), ] return agent_step(messages)这里面有两个关键设计。一是先检索,把知识库片段拼进系统消息,让模型在回答时拥有背景知识;二是工具结果作为ToolMessage回填,模型可以依据真实数据修正初始判断。把项目管理系统换成邮件、工单、日历,骨架不需要大改。
7.3 运行中的常见坑
第一个坑是参数格式。模型可能把project_id传成“项目一”这种自然语言参数。解决办法是在docstring里明确“参数必须严格使用系统里的项目编号,如PJ-2024-001,不要用项目名称”,必要时再加一层参数校验。第二个坑是工具返回过长,占用上下文。建议工具返回前先做摘要,只保留关键字段。第三个坑是Agent在循环里反复调用同一个失败工具。我的解是在ToolMessage里明确返回“该工具不可用,不要重试”,这比单纯设limit步数更有效,因为它直接给了模型决策依据。
8. 避坑清单与学习节奏:我的最后建议
8.1 高频踩坑清单
| 坑 | 现象 | 解题思路 |
|---|---|---|
| 提示词堆太满 | 模型开始编造 | 少加规则,多加例子 |
| 消息全部拼成一个字符串 | 系统指令和用户内容混合 | 用Message区分角色 |
| 历史无脑全放上下文 | 上下文爆炸 | 分层记忆 |
| 文档切分不看质量 | RAG召回内容答非所问 | 按结构切分加重叠窗口 |
| 工具描述写得像给人看 | 模型不会调用 | docstring当模型说明书 |
| Agent不设步数上限 | 死循环烧token | 设MAX_STEPS并限定重试动作 |
这张表我基本是踩一遍记一遍。前三个坑集中在前期,后三个坑集中在后期,但它们的共性问题是一样的:把问题都压给模型解决,没有在工程上做分流。
8.2 学习节奏建议
我给还没走完这条路的朋友一个可操作的节奏。第一阶段花一周,专心把提示词工程稳定下来,并掌握LangChain的消息、模板、解析器。第二阶段用两周做一个垂直场景的小应用,强制自己用RAG接一份真实内部文档,不看教程也要能把链路搭通。第三阶段开始接工具,先手写循环,再尝试高层Agent封装。整体建议三到四周,关键是每一步都留一个可运行的最小Demo,而不是只读文档。
8.3 我个人的几点体会
我在实际项目里的感受是,LangChain最有价值的不是那些华丽的组件,而是它逼着你想清楚抽象边界。每一层做什么、输入输出长什么样,全在Runnable这个统一接口里。想明白这一层,即使哪天不用LangChain、自己封装一套工具编排体系,核心思路也一样。
最后分享一个小技巧:给模型准备工具时,先把业务系统的字段字典整理好,做成一个“工具资料包”,既能让模型调用更准确,也能让新工具接入变得标准化。这个方法我们踩了几次坑才沉淀下来,后来在好几个项目里都直接复用。
这条路线看起来很长,其实每一站都是围着同一个问题转:让模型在合适的时机,获取合适的信息,做出合适的动作。走完一遍之后,再回头看提示词工程,你会发现它依然是地基,但你已经知道上面能盖多高的楼了。