☰
LangChain低阶与高阶API深度解析:从Runnable到LCEL的实战指南
2026/10/10 15:21:27 网站建设 项目流程

作为一个从老版LLMChain一路用过来的开发者,我太理解初学LangChain时那种“同一个功能,为什么有人几行代码写完,我却要写上百行”的困惑了。

这一切的根源都在于LangChain存在两层API设计:低阶API(LangChain Core / LangChain Community)和高阶API(LangChain Expression Language、封装好的Chain、以及LangGraph这种新一代编排模型)。理解这两层API的区别,基本上就等于拿到了LangChain的“使用地图”。

这篇我打算掰开揉碎聊聊我对这两层API的理解:它们分别是什么、各自解决什么场景、实际写代码时怎么选、以及我从低阶切到高阶再回到低阶、最终形成“混合开发”习惯的过程。内容基于我常用的LangChain 0.3.x版本,如果你用的是老版本,个别细节可能需要微调,但设计思想完全通用。

1. 为什么LangChain要拆成“低阶”和“高阶”两层API

先说一个生活化的类比。我把高阶API想象成“自动驾驶”,低阶API想象成“手动挡驾驶”。

  • 高阶API帮你把“踩油门、打方向盘”这类常规动作预设好了。你要做的只是设定目的地(prompt)、启动引擎(invoke)。像RetrievalQA、create_sql_agent这类集成,就是把“查数据库、推理、组装结果”一整套流程打包好。开高速稳、省心、速度快,你不需要知道发动机原理。
  • 低阶API则把这些操作全部摊开。你能看到每个齿轮(BaseMessage)、每根传动轴(ChatPromptTemplate)、每次换挡逻辑(RunnableLambda)。你可以中途改变速度逻辑,甚至自己造一个变速箱(自定义CallbackHandler)。

设计这么两套东西,LangChain团队真正的逻辑是:用高阶满足80%的标准化需求,用低阶承接剩下20%的定制化需求,同时让低阶成为高阶的地基。

举个例子,ChatOpenAI这个类既是底层的东西(它实现了BaseChatModel),也是高层API的组成部分。你用LangChain的“表达式语言(LCEL)”(即|管道符)连接的Runnable,本质是低阶组件的抽象接口。这也就意味着——两个阶层并没有泾渭分明的界限,只是同一个工具箱里的不同扳手。

因此,正确的理解方式不是“哪个更好”,而是“你正处在哪个开发阶段,对应使用哪一层”。

1.1 高阶API的蓝图:从“快速演示”到“复杂Agent”

LangChain的高阶API,我粗分三层来看。

第一层:预构建的“经典Chain”像RetrievalQA、ConversationRetrievalChain、SQLDatabaseChain。优点是你调用一行,它就给你完整链路,适合快速跑通POC。缺点是黑盒很重,你想改内部细节,往往发现它又新起了另一个分支Chain,嵌套地狱在这里等着你。

第二层:面向模块的“表达语言(LCEL)”核心是Runnable协议。它像极了你拼接水管:prompt | model | parser。什么样的东西都实现了Runnable,用一个|连接后返回新的Runnable。这是我从“老的链式语法”迁移后的主力开发方式。它既可以极简(两三行跑通),也可以逐步拆开(每个节点用函数控制),信息密度与可读性都极高。

第三层:新一代Agent编排——LangGraphLangGraph严格说不是“API”那么轻,它是一个完整的编排框架。官方几个高阶Agent抽象(如create_react_agent)跑在它之上。如果你要写复杂的循环、分支、人机交互、记忆管理,LangGraph是官方推荐的长线方案。在这个框架里,你能同时看到低阶的细粒度控制(State更新、节点条件跳转),又有高阶的图级抽象。

1.2 低阶API的骨架:所有概念的“原材料”

低阶API并不复杂,它几乎是“某种消息在某种过程中如何流转”的完整定义。核心由三类素材组成:

  • Message数据模型:BaseMessage及其子类(SystemMessage、HumanMessage、AIMessage、ToolMessage)。这是所有聊天的“砖块”。
  • 核心可运行组件:PromptTemplate、ChatModel(LLM)、OutputParser、Retriever。
  • 运行与监控机制:Runnable调用链、Callbacks(事件回调)、MessageHistory(记忆)。

低阶让你清楚地面对这些“砖块”的每一次移动与变化。比如,你会自定义一个RunnableLambda来获取中间状态,也会自己维护ChatMessageHistory去追加消息,而不是直接用某个封装好的带记忆Chain。

我的经验:接触LangChain的第一步往往从高阶(官方文档的Quick Start)入手;但是,当项目复杂度起来后(多工具选择、异常重试、长链路状态恢复),只能在低阶层面才能得到充分的活性。把低阶作为底盘思想,是写LangChain项目走向专业的必经之路。

2. 低阶API的核心组件拆解与实操要点

这部分是我早期踩坑踩得最深的区域。低阶不意味着简单,反而是因为知识粒度细,组合起来非常灵活,但也容易出错。

2.1 消息类:BaseMessage与角色状态

在ChatModel(对话类模型)的语境里,一切输入输出都被抽象成BaseMessage子类。你传入的是消息列表,返回的也是消息。它们之间的关系是:

  • SystemMessage:设定模型人格、回答边界。
  • HumanMessage:用户的输入(包括普通文本、图片、音视频等)。
  • AIMessage:模型的回答内容,同时,工具调用的请求也会挂在它的tool_calls属性上。
  • ToolMessage:工具执行后的返回结果,必须与对应的tool_call_id关联。

我在低阶实践早期遇到的最大坑就是:“为什么这个记忆代码存了上一轮对话,但模型完全不记得?”因为我只是把历史作为字符串拼接进大模型,没有区分AIMessage和HumanMessage。后来我改为用ChatMessageHistory维护消息数组,严格按照“用户发一句,AI回一句,再轮到用户”的顺序传回模型,对话连贯性马上就好了。

注意:在低阶中务必明确一点,对于多模态输入,把图片用HumanMessage(content=[{"type":"text","text":"..."},{"type":"image_url","image_url":{...}}])塞进去。这是很多初学者把低阶当高阶用,照搬prompt | model就报格式错误的原因——你不清楚不同模态在“消息类”里是如何表示的。

2.2 模板类:PromptTemplate与ChatPromptTemplate的边界

PromptTemplate(字符串模板)适合文本补全类模型(如旧版text-davinci-003),但今天使用Chat模型的主力是ChatPromptTemplate,它接受一系列消息模板,且模板里可以使用变量。

from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个{role},请用{style}的风格回答用户的问题。"), ("human", "{question}"), ])

这里有一个低阶实操细节,模板消息使用from_messages时,传进的是可变参数,模板内联变量是隐藏在字符串里的。如果我想对输出做校验重试,就需要在低阶层面自行注入新消息。比如:

from langchain_core.messages import AIMessage, HumanMessage partial_prompt = prompt.partial(role="资深顾问", style="简洁专业")

partial是管道式构建中经常用到的一环,它相当于预填部分变量,其他变量在真正调用时再一次性传入。

2.3 Model IO:从底层理解“为什么会有输入输出解析器”

模型拿到的是字符串或消息。你希望程序拿到的是结构化数据(dict、list、Pydantic对象),这就轮到OutputParser上场。低阶API中的PydanticOutputParser是我用得最多的。

from langchain_core.output_parsers import PydanticOutputParser from pydantic import BaseModel, Field class Joke(BaseModel): setup: str = Field(description="问题的设定") punchline: str = Field(description="问题的爆笑回答") parser = PydanticOutputParser(pydantic_object=Joke) format_instructions = parser.get_format_instructions()

这里有个关键点,模型输出必须严格遵循指令给出的JSON格式。为了保证成功率,往往需要在低阶增加“校验爬虫”逻辑:把模型输出丢给parser.parse,失败就提示模型重新格式化输出,甚至让模型自己描述哪里错了,然后把这套描述作为新提示继续调用模型。

低阶的Model I/O中,你与“失败”是直接面对面的。这正是它的价值:大部分高阶失败是因为模型返回格式错误,但低阶可以让你直接捕获异常,在模型层做重试或降级。

2.4 回调机制:低阶世界里的“日志、追踪与拦截”

我接LangChain项目的时候第一件事通常是装LangSmith、Langfuse或至少打开verbose=True,这背后都是回调系统在工作。

你可以在自定义BaseCallbackHandler里捕获:

  • on_llm_start:拿到提示信息,查看系统提示到底写了什么。
  • on_llm_new_token:做流式输出的实时交互。
  • on_chain_end:查看整条链的最终输出。

一个真实经验:我曾经遇到“为什么某次模型调用老是不输出”?排查时把回调打开,发现某个Runnable组件没有正确将中间步骤传给下一步,才意识到是变量命名和RunnableLambda的input传递问题。

回调代码参考:

from langchain_core.callbacks import BaseCallbackHandler class MyCallback(BaseCallbackHandler): def on_llm_start(self, serialized, prompts, **kwargs): print("提示词:", prompts[0][:100]) def on_llm_end(self, response, **kwargs): print("生成内容:", response.generations[0][0].text[:100])

回调体系是低阶API最值钱的隐藏武器。很多高阶链的调试手段就是靠这份事件流,了解挂载方式之后就等于拥有了对全局过程的洞察力。

3. 高阶API的全景拆解与核心用法

如果说低阶是“造车”,那高阶就是“开车”。这里我讲两种主流的高阶用法:LCEL管道和经典Agent封装。

3.1 LCEL:为什么一个竖线“|”就能串起整个流程

LangChain的LCEL其实是低阶实现中衍生出的“高阶表达”。它的设计动机只有一句话:让组件像UNIX管道一样组合。每个组件都实现Runnable接口(invoke/batch/stream/ainvoke),通过|重载操作符把左边输出交给右边输入。

from langchain_openai import ChatOpenAI from langchain_core.output_parsers import StrOutputParser llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7) chain = ( {"question": lambda x: x["question"]} | ChatPromptTemplate.from_template("请回答:{question}") | llm | StrOutputParser() )

这种链的好处是:你可以调用.stream(),逐token打印;可以调用.batch()并行处理多个问题;也可以把某个环节替换成自定义函数而不破坏链的整体结构(比如在中间插入RunnableLambda)。它不算传统意义上的“高阶黑盒”,却比低阶挨个调用优雅得多。

这里也有人会问到:“低阶API和LCEL的区别是什么?”核心差异在于,低阶API强调组件的可组合性和内部数据结构;LCEL是这个组合性的“最高阶表现形式”,什么都作为Runnable赋能。

3.2 经典封装Chain:为何我慎重推荐使用

像load_qa_chain、RetrievalQA这些封装,对于一小时做出Demo来说是神器。但放在生产环境,我吃过好几次苦头。

我以前做过一个内部知识库问答系统,用RetrievalQA.from_chain_type(llm, chain_type="stuff", retriever=retriever)轻轻松松跑通。但后来发现以下致命问题:

  • “stuff”类型把所有文档塞进一个提示词里,上下文稍长就超限;
  • “map_reduce”类型对每段分别调用LLM,成本高,且汇总时细节会被“压缩”;
  • 你无法方便地在中间插入“判定文档相关性”“给检索结果打分”等自定义逻辑。

在这个项目中后期,我改成纯LCEL手写链,把检索交给一个调用里,命令行人为控制prompt拼接——代码长了,但意图变得清晰,维护也不再是噩梦。

我的建议:

场景推荐方式理由
快速demo、内部工具经典Chain(如RetrievalQA)两三行搞定,效果直观
生产级RAG、需要检索优化LCEL自行组装逻辑清晰,可控性高,每步可测试
多工具循环、决策分支LangGraph或低阶工具循环需要状态管理与循环能力
简单结构化输出高阶PydanticOutputParser搭配LCEL快速、可验证

3.3 Agent高阶封装:从create_react_agent看到的“框架之力”

Agent曾经是LangChain最玄学的部分。当前比较稳妥的高阶玩法是langgraph.prebuilt.create_react_agent,它把“ReAct循环”内置了:模型决定调用哪个工具,工具返回结果,模型再决定下一步,直到输出最终答案。

from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent def get_weather(city: str) -> str: """查询指定城市的天气""" return f"{city}今天晴,24度,适合出行~" llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) agent = create_react_agent(llm, tools=[get_weather]) result = agent.invoke({"messages": [{"role": "user", "content": "北京今天适合出门吗?"}]})

这个抽象的地基是中国人口中的“低阶”——你看到的是get_weather包装成了Tool、函数通过docstring生成参数schema、模型与工具之间的消息被自动管理。不了解低阶,你会以为它很“魔法”;了解低阶,你会明白只是“消息协议+函数调用约束”的自动化。

4. 实操案例:用同一需求对比低阶手写与高阶封装

讲到这里还是上代码最直观。我用一个“从网络/数据库获取信息并让模型总结”的任务,分别用低阶和LCEL实现一遍。

4.1 低阶API:完整手写工具调用循环

这段代码展示了真正“低阶到每个节点”的控制,每一步我自己掌控。

import json from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import tool from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) @tool def get_stock_price(symbol: str) -> str: """获取指定股票代码的当前价格""" # 这里换成真实行情接口 price_map = {"AAPL": "190.20", "GOOGL": "142.50"} return price_map.get(symbol.upper(), "未知股票") tools = [get_stock_price] llm_with_tools = llm.bind_tools(tools) messages = [ SystemMessage(content="你是一个财经助手。获取实时数据后回答用户问题。"), HumanMessage(content="苹果公司当前的股价是多少?") ] # 第一轮:模型决定是否调用工具 response = llm_with_tools.invoke(messages) messages.append(response) # 如果模型产生工具调用,执行并回填 if response.tool_calls: for tool_call in response.tool_calls: tool_name = tool_call["name"] tool_args = tool_call["args"] tool_output = get_stock_price.invoke(tool_args) messages.append(ToolMessage(content=tool_output, tool_call_id=tool_call["id"])) # 第二轮:把工具结果交给模型,让模型整理最终话术 final_response = llm_with_tools.invoke(messages) print(final_response.content)

麻烦吗?麻烦。但你能做到“精确到每一轮模型说什么、工具返回什么、消息列表如何追加”。如果我要在低阶增加“连续调用多个工具”“工具出错自动修正参数”“超时重试”,这个循环可以被我改得面目全非——这正是定制化需求的沃土。

4.2 高阶LCEL:同样功能写成管道

from langchain_core.runnables import RunnableLambda, RunnablePassthrough from langchain_openai import ChatOpenAI from langchain_core.tools import tool from langchain_core.output_parsers import StrOutputParser @tool def get_stock_price(symbol: str) -> str: """获取指定股票代码的当前价格""" price_map = {"AAPL": "190.20", "GOOGL": "142.50"} return price_map.get(symbol.upper(), "未知股票") llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools([get_stock_price]) def route(state): """判断是否需要继续调用工具""" last_message = state["messages"][-1] if last_message.tool_calls: # 这里用低阶逻辑处理工具调用并追加消息 for tc in last_message.tool_calls: result = get_stock_price.invoke(tc["args"]) state["messages"].append(...) # 构造ToolMessage return {"messages": state["messages"]} return {"messages": state["messages"]} chain = ( RunnablePassthrough.assign(messages=lambda x: [HumanMessage(content=x["question"])]) | RunnableLambda(lambda state: llm.invoke(state["messages"])) | RunnableLambda(route) | RunnableLambda(lambda state: state["messages"][-1].content) | StrOutputParser() ) result = chain.invoke({"question": "苹果公司当前的股价是多少?"}) print(result)

从这个对比里你能直觉感受到什么?LCEL加了代码结构上的整洁,但没有减少底层概念的复杂度。它让中间节点以可复用、可组合的方式呈现,适合构建稳定标准化的流程;低阶则适合深挖分支逻辑和动态行为。两者并不互斥,而是在我做复杂Agent时交替使用。

5. 常见问题与排查经验实录

写了这几年LangChain,也帮不少群友排查过问题。下面这些是高频雷区。

5.1 “为什么我用了高阶API但总是跨域/返回格式报错?”

常见的坑就是模型返回JSON格式不标准。高阶层会把输出交给PydanticOutputParser,但底层调用的“先生成文本再解析”不可依赖。我的建议是务必将模型返回强制成JSON模式(如response_format={"type":"json_object"}),然后用低阶RunnableLambda做一次解析重试;解析失败就把错误反馈重新丢回模型。

from langchain_core.runnables import RunnableLambda def safe_parse(text: str): try: return json.loads(text) except Exception: # 返回占位,等待后续逻辑重新调用 return {"error": "parse failed", "raw": text} chain = prompt | llm | StrOutputParser() | RunnableLambda(safe_parse)

5.2 “工具调用结果没有进入上下文,模型总在复读”

这个我在3.x常见,通常是因为ToolMessage的tool_call_id与AIMessage.tool_calls里的id不匹配。很多封装里也能遇到这个。排查时最有效的方法是打印messages数组,看每个AIMessage的tool_calls是否关联唯一ID。

5.3 “使用高阶Agent忘加回收站——陷入无限循环”

我早期写create_react_agent,遇到模型反复调用同一工具输出相同结果,导致Token耗尽。解法是加“最大迭代次数”(recursion_limit)和“检测重复调用”的低阶回调。比如在工具里加逻辑:

if tool_name in previous_tool_set: return "该工具已调用过,结果不变,请直接根据已有信息回答。"

5.4 “高阶层调用了低阶API却不可调和?”

遇到链报错不是链的问题,往往是提示词里“{context}”变量没有传全。我用LCEL时最容易错的就是invoke时传入键名和ChatPromptTemplate的变量名不一致。排错宝典第一条:把入参字典调整为模板变量的并集。

5.5 “版本升级API天翻地覆”

LangChain版本更新快,RetrievalQA在高版本中已标记deprecated(不推荐使用),未来会转向create_retrieval_chain、LCEL甚至LangGraph。

提示:如果项目长期维护,建议锁定版本(langchain、langchain-openai、langchain-community全部固定),或在依赖文件中明确指定langchain>=0.3。

6. 低阶与高阶的混用心得:我从“选边站”变化为“按层治理”

写这个标题,其实我很想分享的最终结论不是“一定要用哪个”,而是**“分清楚你在哪一层工作”**。

对我来说,比较稳定的开发策略已然形成:

  1. 先用低阶画核心循环。最初的Agent循环、工具调用、状态更新,我在纸上先画出来:消息如何流动,哪些步骤需要动态分支,哪些地方必须手动维护历史。这个阶段,我只关心低阶的Message与Runnable,不用封装。
  2. 再用LCEL将稳定部分固化为Pipeline。比如“格式化用户输入 -> 检索 -> 组装Prompt -> 调用模型 -> 解析输出”这几个固定步骤,用LCEL封装成一个函数。参数只需传必要的业务字段,内部用RunnableLambda引用工具函数。
  3. 最终用高阶Agent来承载复杂跳转,但不迷信它的默认行为。create_react_agent这个API名字虽是“高阶”,但它允许在内部塞低阶工具回调。控制它的关键仍在工具函数的可靠性、提示词质量与状态清理。

比如说我之前写一个“研发团队周报分析机器人”:周报数据源在Jira,分析动作在模型。用低阶API手写了工具函数的每次调用、Jira接口筛选逻辑、多轮追问的状态保存;但对外暴露时,我用LCEL构建了三段清晰的链:查询汇总、结构解析、格式化输出。

这中间的“分层体验”是,调试前期问题我用的是低阶的全量输出,后期性能优化我用到了高阶的并行batch。低阶不是高阶的替代品,而是高阶的“下一层”,就像地基与房子一样。

我觉得LangChain最值得称道的地方,反而是它并没有强制你“非此即彼”;它将组件统一抽象成Runnable,把底层知识榨干后,你完全可以在同一个文件里一会儿写低阶函数,一会儿写管道链。这种混搭不是代码风格混乱,而是合理利用每一层API本身的长处。

最后分享一个小技巧:无论你用哪一层,都要时刻关注消息类型与变量注入。LangChain99%的运行报错,都出在这两处。把握住它们,低阶和高阶在手里便只是工具的差异,不再有任何门槛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询