LCEL表达式语言,LangChain的管道式组合让代码更灵活
LCEL表达式语言,LangChain的管道式组合让代码更灵活
上一篇用LCEL写了三种链,管道符一路接到底,代码跑得也顺。但有个问题一直悬在那,管道符到底干了什么,RunnablePassthrough和RunnableLambda这些组件什么时候该用,itemgetter和bind又是怎么回事。这篇就把LCEL表达式语言掰开揉碎讲一遍,把这些零件的脾气摸清楚。
LCEL是什么
LCEL全称LangChain表达式语言,英文叫LangChain Expression Language。它是一套写法约定,让你用管道符把Runnable组件串起来,像搭积木一样拼出完整流程。
为什么要搞这么一套东西。早期LangChain写链要继承一堆类,LLMChain、SequentialChain、RouterChain,每个类参数不同,组合起来代码又长又乱。LCEL把这些全统一成一个接口,所有组件都是Runnable,都能用竖线连接。你只要记住一个符号,就是竖线。
竖线左边组件的输出,自动变成竖线右边组件的输入。和Unix管道一个道理,cat file | grep keyword,数据从左往右流。这种写法组合性强,想加一步就在中间插一个管道,想换掉某一步就替换那一段,其余代码一行不动。
管道符背后发生了什么
每个Runnable组件都实现了__or__方法,这是管道符的底层机制。你写a | b,Python实际调用的是a.__or__(b),返回一个新的Runnable,内部记录了a和b的顺序。invoke的时候先跑a,把结果喂给b。
这样设计有个好处,整条链本身也是一个Runnable对象,你能对它做任何Runnable能做的事。invoke同步调用,stream流式输出,batch批量处理,异步版ainvoke、astream、abatch都有。拼好一条链之后,同步切异步只要换个方法名,链本身一行不用动。我第一次发现这事的时候挺惊喜,之前为了上异步还重写过整条流程。
stream这个方法做聊天界面时特别实用。用户等不及整段输出,stream能把模型生成的字一个一个往外吐,前端接上就是打字机效果,等待感弱很多。batch也顺手,一坨输入丢进去批量跑,比循环调invoke快不少。
RunnablePassthrough,原样传递
RunnablePassthrough干的事情很简单,把输入原封不动传给输出。听着像废话,拼链的时候却特别有用。
RAG场景里,检索器需要用户的原始问题,提示词模板又需要检索到的文档,两个地方都要拿到输入。这时候就得让原始问题一路透传下去,RunnablePassthrough就是干这个的。
fromlangchain_core.runnablesimportRunnablePassthrough# 输入什么就输出什么print(RunnablePassthrough().invoke({"question":"什么是LCEL"}))# {'question': '什么是LCEL'}它还有个assign方法,能在保留原输入的基础上追加新字段。下面RAG示例里你会看到它的用法,拿来做字段拼接很顺手。
RunnableLambda,把普通函数塞进流水线
RunnableLambda把任意Python函数包装成Runnable,这样你的自定义逻辑也能用管道符串起来。
fromlangchain_core.runnablesimportRunnableLambdadefto_upper(text):returntext.upper()chain=RunnableLambda(to_upper)print(chain.invoke("hello"))# HELLO我踩过一个坑。函数接收的参数得和上一步输出对得上。上一步输出字符串,你的函数就得接收字符串,上一步输出字典,你的函数就得接收字典。类型不匹配报错信息有时候挺绕的,新手容易卡半天。建议写函数前先想清楚上一步吐出来的是什么。其实新版本LCEL有个贴心设计,你直接把普通函数写进管道里,它会自动包成RunnableLambda,retriever | format_docs这种写法能直接跑,不用显式套一层。
RunnableParallel和itemgetter,并行与取值
RunnableParallel让多个组件同时跑,结果按key拼成一个字典返回。RAG里检索和问题改写可以并行,能省时间。
fromlangchain_core.runnablesimportRunnableParallel parallel=RunnableParallel({"original":RunnablePassthrough(),"upper":RunnableLambda(lambdax:x.upper()),})print(parallel.invoke("hi"))# {'original': 'hi', 'upper': 'HI'}LCEL还有个偷懒写法,直接传字典就等于RunnableParallel,不用显式写类名。上一篇顺序链里就用了这个,{"outline": outline_chain},字典本身就是并行执行。刚开始我看到字典出现在管道左边还愣了一下,搞懂之后就觉得很自然。
itemgetter是Python标准库operator里的函数,经常和RunnableParallel配合,从字典里取某个字段,比写lambda简洁。
fromoperatorimportitemgetter prompt=ChatPromptTemplate.from_template("回答问题{question}")chain=({"question":itemgetter("topic"),"context":RunnablePassthrough()}|prompt|model)itemgetter(“topic”)会从输入字典里取出topic字段,省得写lambda x: x["topic"]。字段一多,itemgetter写法干净不少。
bind,给组件绑定参数
bind方法给Runnable预先绑定一些参数,调用时自动带上。最常见的用途是给模型绑定工具,或者绑定停止词。
fromlangchain_core.toolsimporttool@tooldefsearch(query:str)->str:"""搜索网络内容"""returnf"搜索结果:{query}"model=ChatOpenAI(model="gpt-4o-mini")# 把工具绑定到模型上model_with_tools=model.bind_tools([search])chain=prompt|model_with_tools|StrOutputParser()bind之后这个模型对象就带着工具了,每次调用都会把工具信息发给模型。你也可以用bind绑定temperature这类参数,model.bind(temperature=0.7),同一段代码里同一个模型就能有不同配置,不用为每个配置新建实例。还有个常见用法是绑定停止词,model.bind(stop=["\\n\\n"]),让模型生成到指定符号就停。做结构化输出时挺管用,能挡住模型在结果后面啰嗦一大段。
完整RAG示例
把上面的东西凑一块,写个完整的RAG链。检索相关文档,和原始问题一起塞进提示词,让模型回答。
fromlangchain_openaiimportChatOpenAI,OpenAIEmbeddingsfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParserfromlangchain_core.runnablesimportRunnablePassthroughfromlangchain_community.vectorstoresimportChromafromoperatorimportitemgetter embeddings=OpenAIEmbeddings()store=Chroma(embedding_function=embeddings)store.add_texts(["LCEL用管道符组合组件","RunnablePassthrough原样传递输入","RunnableParallel并行执行多个组件",])retriever=store.as_retriever(search_kwargs={"k":2})prompt=ChatPromptTemplate.from_template("根据以下背景回答问题\n背景:{context}\n问题:{question}")defformat_docs(docs):return"\n".join(doc.page_contentfordocindocs)rag_chain=({"context":retriever|format_docs,"question":itemgetter("question")}|prompt|ChatOpenAI(model="gpt-4o-mini")|StrOutputParser())print(rag_chain.invoke({"question":"LCEL怎么组合组件"}))这段代码值得拆开看。字典里context走检索器再格式化成字符串,question用itemgetter从输入取出来,两路并行执行。然后塞进提示词,过模型,解析输出。一个小小的字典加几个管道符,就把检索、格式化、拼提示词、调模型、解析输出全串好了。
调试这种链有个习惯,把链断成几段分别invoke,确认每一段输出对得上再接起来。RAG最容易出问题的是检索这一步,context拿不到东西,模型就开始瞎编。建议先把retriever单独跑一遍看返回的文档质量,没问题再接进链里。还有一个排查手段,把prompt单独invoke一下,看填充后的提示词长什么样。有时候变量没传进去,模板里留着空占位符,模型自然答非所问,这种问题瞄一眼渲染结果就暴露了。
这篇把LCEL的几个核心组件过了一遍,管道符的底层机制、RunnablePassthrough的透传、RunnableLambda的函数包装、RunnableParallel的并行、itemgetter的取值、bind的参数绑定,最后拼了个RAG链。你会发现LCEL的套路就这些,再复杂的流程也是这些零件搭出来的,多练几次手感就有了。
下一篇聊回调与监控,看看链跑起来之后怎么追踪每一步的执行情况,token花在哪里,耗时多少,出问题怎么定位到具体那一段。
