
1. 从“玩具”到“工程”为什么我们需要LCEL如果你最近在折腾LangChain大概率听过LCELLangChain Expression Language这个名字。它被官方大力推崇文档里随处可见社区讨论热度也很高。但很多刚接触的朋友包括几个月前的我都会有一个共同的困惑这东西看起来不就是把几个组件用管道符|连起来吗我用Python函数一层层调用或者用langchain.chains里的LLMChain、SequentialChain也能实现为什么要多学一套语法这个问题的答案恰恰是区分“原型验证”和“生产部署”的关键。当你还在用几个API快速拼凑一个Demo时传统的写法确实够用。但一旦你的AI应用需要处理复杂的逻辑分支、需要优雅地处理错误、需要流式输出、或者需要被部署为一个可观测、可调试的服务时传统写法的短板就会立刻暴露出来。LCEL的本质是一套为构建可靠、可维护、高性能AI应用链路而设计的声明式DSL领域特定语言。它把“链”这个抽象从简单的函数调用序列升级为了一等公民的、可组合、可观察的计算图。|符号不仅仅是语法糖它代表了一种数据流的绑定和组合关系这让LCEL链天生支持异步、批处理、流式传输并且内置了完善的日志、追踪和回退机制。简单说不用LCEL你也能写出能跑的链但用好LCEL你才能写出易于团队协作、方便线上排查、能应对复杂场景的工程化AI应用。接下来我将抛开官方文档那些宽泛的介绍直接切入四种在真实项目中最高频出现的链路模式拆解它们的LCEL工程化写法并分享从Demo到上线一路踩坑换来的实战经验。2. 模式一基础问答链的“工业级”加固最简单的需求用户提问模型回答。用LCEL写基础代码可能就两行from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI prompt ChatPromptTemplate.from_template(回答以下问题{question}) model ChatOpenAI(modelgpt-4, temperature0) chain prompt | model看起来和LLMChain差不多真正的工程化考量从这里才开始。2.1 输入输出校验与类型安全在工程中我们不能假设输入一定是完美的字符串。用户可能传入None可能传入一个字典或者数字。LCEL链的核心组件是Runnable每个Runnable都有明确的输入输出类型约定。我们可以利用RunnableLambda或自定义Runnable在链的起始端就做好校验和转换。from langchain_core.runnables import RunnableLambda from pydantic import BaseModel, Field from typing import Any class ChainInput(BaseModel): question: str Field(description用户提出的问题) def validate_input(raw_input: dict) - dict: # 实战技巧这里可以集成Pydantic进行严格校验并记录日志 if not isinstance(raw_input, dict) or question not in raw_input: raise ValueError(输入必须是一个包含question键的字典。) if not raw_input[question] or not raw_input[question].strip(): # 处理空问题可以返回一个默认响应或抛出特定业务异常 return {question: 您似乎没有输入问题请重新提问。} # 可以在这里做敏感词过滤、长度截断等预处理 processed_question raw_input[question].strip()[:1000] # 简单截断 return {question: processed_question} # 构建一个包含校验的链 validated_chain RunnableLambda(validate_input) | prompt | model这样做的好处是链的入口变得清晰且健壮。当链被集成到FastAPI或Django等Web框架中时接收到的HTTP请求体可以首先通过这个校验环节确保流入核心逻辑的数据是干净的。2.2 上下文管理与对话历史集成单轮问答意义有限多轮对话才是常态。工程上我们需要一个可靠的方式来管理对话历史上下文。LCEL的RunnableWithMessageHistory是专门为此设计的但它需要配合一个历史记录存储后端。from langchain_core.chat_history import BaseChatMessageHistory from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_community.chat_message_histories import RedisChatMessageHistory import uuid # 1. 定义存储层这里以Redis为例生产环境常用 def get_message_history(session_id: str) - BaseChatMessageHistory: return RedisChatMessageHistory(session_idsession_id, urlredis://localhost:6379/0) # 2. 构建一个需要历史上下文的Prompt contextual_prompt ChatPromptTemplate.from_messages([ (system, 你是一个有帮助的助手。请根据对话历史来回答用户的问题。), (placeholder, {chat_history}), # LCEL 会自动将历史消息填充到这里 (human, {question}) ]) # 3. 创建基础链 base_chain contextual_prompt | model # 4. 包装成带历史管理的链 conversational_chain RunnableWithMessageHistory( base_chain, get_message_history, input_messages_keyquestion, # 输入字典中用户新问题的键 history_messages_keychat_history, # 传递给prompt的历史消息的键 ) # 使用方式 config {configurable: {session_id: user_123_session}} # session_id是关键 result conversational_chain.invoke({question: 我上一句问了什么}, configconfig)关键经验session_id的设计至关重要。它不能是简单的用户ID否则不同设备的对话会混在一起。通常采用f”{user_id}_{device_id}”或f”{user_id}_{conversation_topic}”的格式。对于匿名用户可以生成一个临时UUID并保存在前端如Cookie或LocalStorage。存储层选择上Redis性能好但要注意设置合理的TTL生存时间如果对持久化要求高可以考虑PostgreSQL。2.3 流式输出与前端对接流式输出逐词或逐句返回对于提升用户体验至关重要。LCEL链原生支持流式这是它相对于传统LLMChain.invoke()的巨大优势。# 流式调用 for chunk in conversational_chain.stream({question: 请详细介绍一下太阳系。}, configconfig): # chunk可能是 AIMessageChunk, 也可能是其他中间步骤的输出 if hasattr(chunk, content): print(chunk.content, end, flushTrue) # 逐词打印在前端如使用ReactFastAPI你需要建立一个Server-Sent Events (SSE) 或 WebSocket 连接。后端代码大致如下# FastAPI 示例 from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio app FastAPI() app.post(/chat/stream) async def chat_stream(request: dict): question request.get(question) session_id request.get(session_id, default) config {configurable: {session_id: session_id}} async def event_generator(): async for chunk in conversational_chain.astream({question: question}, configconfig): # 通常我们只关心模型输出的内容块 if chunk and hasattr(chunk, content): yield fdata: {chunk.content}\n\n await asyncio.sleep(0.01) # 控制一下推送频率 yield data: [DONE]\n\n # 发送结束信号 return StreamingResponse(event_generator(), media_typetext/event-stream)踩坑点流式传输中chunk的类型可能是多样的除了AIMessageChunk还可能包含ChatPromptTemplate等中间步骤的对象。你需要根据业务逻辑仔细过滤。另外网络中断、客户端关闭连接等异常情况需要妥善处理避免后端任务泄露。3. 模式二检索增强生成链的模块化设计RAG是当前AI应用的核心模式。一个工程化的RAG链远不止“检索生成”两步它涉及文档加载、切分、向量化、检索、重排序、上下文组织等多个环节。LCEL的模块化特性在这里大放异彩。3.1 构建可插拔的检索流程理想的RAG链其检索器应该是可配置、可替换的。我们可以用LCEL将检索流程封装成一个独立的Runnable。from langchain_core.runnables import RunnablePassthrough from langchain_core.vectorstores import VectorStoreRetriever from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import Chroma # 示例用Chroma # 假设我们已经有一个填充好的向量库 embeddings OpenAIEmbeddings(modeltext-embedding-3-small) vectorstore Chroma(persist_directory./chroma_db, embedding_functionembeddings) retriever vectorstore.as_retriever(search_kwargs{k: 5}) # 检索5条 # 定义一个格式化工序将检索到的文档列表整理成字符串 def format_docs(docs): return \n\n.join([f来源 {i1}: {doc.page_content} for i, doc in enumerate(docs)]) # 构建检索子链 retrieval_chain RunnablePassthrough() | retriever | format_docs # RunnablePassthrough() 用于传递输入即用户问题现在retrieval_chain就是一个独立的模块。输入问题输出格式化后的上下文字符串。你可以轻松替换retriever比如换成混合检索器HybridSearch或者加入ContextualCompressionRetriever来压缩文档而无需改动主链的其他部分。3.2 集成重排序与上下文过滤直接检索到的Top-K文档相关性不一定是最优的。加入一个重排序模型如Cohere Rerank、BGE Reranker可以显著提升效果。LCEL可以优雅地将其串联。# 假设我们有一个重排模型的封装这里用伪代码表示接口 class Reranker: def rerank(self, query: str, documents: list) - list: # 调用重排API返回按相关性排序的文档列表 pass reranker Reranker() def retrieve_and_rerank(input_dict): query input_dict[question] raw_docs retriever.invoke(query) # 先检索 reranked_docs reranker.rerank(query, raw_docs) # 再重排 return format_docs(reranked_docs[:3]) # 取重排后的前3条作为最终上下文 # 构建包含重排的检索链 advanced_retrieval_chain RunnableLambda(retrieve_and_rerank)工程实践重排模型通常比较耗时且昂贵。一个折中的策略是“两阶段检索”先用向量检索快速找出50-100个候选文档再用轻量级或交叉编码器模型对这少量候选进行精排。LCEL的RunnableBranch后面会讲到可以用来实现这种条件逻辑。3.3 组装完整的、可观测的RAG链将检索链和生成链组合起来并加入输入输出处理。from langchain_core.output_parsers import StrOutputParser # 定义Prompt明确指示模型使用上下文 rag_prompt ChatPromptTemplate.from_template( 你是一个专业的问答助手。请严格根据以下提供的上下文信息来回答问题。如果上下文信息中没有答案请直接说“根据提供的资料我无法回答这个问题”不要编造信息。 上下文信息 {context} 用户问题{question} 请给出回答) # 组装完整RAG链 full_rag_chain ( RunnablePassthrough.assign(contextadvanced_retrieval_chain) # 关键步骤并行执行检索并将结果赋值给context键 | rag_prompt | model | StrOutputParser() # 将AIMessage对象解析为纯字符串 )这里的关键是RunnablePassthrough.assign(contextadvanced_retrieval_chain)。这行代码的意思是保留原始输入question同时将advanced_retrieval_chain的输出结果作为一个新的键值对”context”: 检索结果加入到流向下游的数据中。这是一种非常清晰的数据流编排。可观测性LCEL链可以方便地使用with_config添加元数据或与LangSmith集成。在开发阶段你可以用chain.invoke(…, config{“callbacks”: [ConsoleCallbackHandler()]})来在控制台打印每一步的输入输出这对于调试复杂链路至关重要。4. 模式三条件判断与动态路由链现实业务逻辑很少是线性的。根据用户输入的内容、意图或系统状态我们需要动态选择不同的处理分支。这就是LCEL的RunnableBranch和RunnableLambda大显身手的地方。4.1 基于意图分类的路由假设我们有一个客服AI需要区分用户是想“查询订单”、“投诉”还是“一般咨询”。from langchain_core.runnables import RunnableBranch # 1. 定义一个意图分类链简化版实际可能是一个微调的分类模型或复杂的Prompt classify_prompt ChatPromptTemplate.from_template( 判断用户的意图是什么。选项订单查询、投诉建议、一般咨询。 只输出选项名称不要有任何其他解释。 用户输入{question} 意图) classifier_chain classify_prompt | model | StrOutputParser() # 2. 定义各个分支的处理链 def handle_order_query(input_dict): # 模拟查询订单逻辑 order_id 模拟提取或查询的订单ID return f已为您查询订单{order_id}的状态已发货。 def handle_complaint(input_dict): # 模拟处理投诉逻辑 return “您的问题已记录客服专员将在24小时内联系您。” general_chain prompt | model | StrOutputParser() # 复用基础问答链 # 3. 使用RunnableBranch构建路由 branch_chain RunnableBranch( (lambda x: 订单查询 in x, RunnableLambda(handle_order_query)), # 条件1如果分类结果包含“订单查询” (lambda x: 投诉建议 in x, RunnableLambda(handle_complaint)), # 条件2如果分类结果包含“投诉建议” general_chain # 默认分支一般咨询 ) # 4. 将分类器和路由分支组合起来 dynamic_chain ( RunnablePassthrough.assign(intentclassifier_chain) # 并行获取意图 | RunnableLambda(lambda x: x[intent]) # 将意图传递给分支链 | branch_chain )使用方式result dynamic_chain.invoke({question: “我的订单到哪里了”}) # 流程classifier_chain判断意图为“订单查询” - branch_chain匹配第一个条件 - 执行handle_order_query4.2 基于内容长度的并行处理另一个常见场景是对于短文本直接回答对于长文本先总结再回答。这展示了如何根据输入数据的属性动态调整流程。from langchain_core.runnables import RunnableParallel def route_by_length(input_dict): question input_dict[question] if len(question) 20: # 短问题走快速通道 return short else: # 长问题需要总结 return long # 定义两个处理子链 short_processor prompt | model | StrOutputParser() summarize_prompt ChatPromptTemplate.from_template(请用一句话总结以下文本的核心内容{text}) answer_from_summary_prompt ChatPromptTemplate.from_template(基于以下总结{summary} 回答这个问题{question}) long_processor ( RunnablePassthrough.assign(summary(RunnableLambda(lambda x: x[question]) | summarize_prompt | model | StrOutputParser())) | answer_from_summary_prompt | model | StrOutputParser() ) # 构建路由链 length_based_chain RunnableBranch( (lambda x: route_by_length(x) short, short_processor), long_processor )经验之谈RunnableBranch的条件函数应尽量简单、快速避免在其中进行重型计算如调用大模型。复杂的条件判断最好像第一个例子一样通过一个前置的分类链来完成然后将结果传递给分支。5. 模式四复杂编排与数据流转当链路变得非常复杂涉及多个并行、串行、条件步骤时清晰的编排和数据流管理就成为了工程的核心。LCEL通过RunnableParallel、RunnablePassthrough.assign()等构件让这种编排变得直观。5.1 并行执行与结果聚合假设我们需要在回答用户关于某个实体如“苹果公司”的问题时同时获取它的维基百科摘要和最新的股价信息然后将两者结合生成回答。# 模拟两个信息获取函数实际中可能是API调用 def fetch_wiki_summary(entity: str) - str: # 调用维基百科API return f这是{entity}的维基百科摘要。 def fetch_stock_price(entity: str) - str: # 调用金融数据API return f{entity}的最新股价是$150.5。 # 1. 定义信息获取的并行流 information_gathering RunnableParallel( wikiRunnableLambda(lambda x: fetch_wiki_summary(x[entity])), stockRunnableLambda(lambda x: fetch_stock_price(x[entity])) ) # 注意输入需要是包含entity键的字典 # 2. 定义整合信息的Prompt和链 synthesis_prompt ChatPromptTemplate.from_template( 你是一位金融和科技分析员。请综合以下两方面的信息回答用户的问题。 背景信息 - 公司概况{wiki} - 最新股价{stock} 用户问题{question} 请给出专业、综合的回答) synthesis_chain synthesis_prompt | model | StrOutputParser() # 3. 组装完整链先提取实体再并行获取信息最后综合回答 from langchain.chains import create_extraction_chain_pydantic # 首先我们需要一个实体提取链这里用Pydantic模型定义要提取的结构 from pydantic import BaseModel, Field from typing import List class Entity(BaseModel): name: str Field(description公司或实体的名称) extraction_prompt ChatPromptTemplate.from_template(从以下文本中提取提到的公司或实体名称{question}) extraction_chain create_extraction_chain_pydantic(Entity, model, extraction_prompt) def extract_entity(input_dict): # 运行提取链获取实体列表 extracted_data extraction_chain.invoke(input_dict) entities extracted_data.get(text, []) if not entities: # 如果没有提取到实体返回一个默认值或抛出错误 return {entity: 未知公司, question: input_dict[question]} # 取第一个识别到的实体 main_entity entities[0].name return {entity: main_entity, question: input_dict[question]} # 最终的主链 complex_orchestration_chain ( RunnableLambda(extract_entity) # 步骤1提取实体 | RunnablePassthrough.assign( # 步骤2并行获取信息并保留原始输入 gathered_infoinformation_gathering ) | RunnableLambda(lambda x: { # 步骤3重新组织数据适配synthesis_chain的输入格式 wiki: x[gathered_info][wiki], stock: x[gathered_info][stock], question: x[question] }) | synthesis_chain # 步骤4综合生成最终答案 )这个链清晰地展示了数据如何流动和变形从原始问题中提取实体然后并行查询两个外部数据源最后将所有信息整合送入LLM生成答案。RunnableParallel确保了wiki和stock的获取是同时进行的提高了效率。5.2 错误处理与回退机制在生产环境中任何外部调用API、数据库都可能失败。LCEL链可以通过with_fallbacks方法实现优雅降级。from langchain_core.runnables import RunnableWithFallbacks # 定义一个可能失败的主链 primary_chain complex_orchestration_chain # 定义一个简单的回退链例如直接回答问题不获取外部信息 fallback_prompt ChatPromptTemplate.from_template(回答以下问题{question}) fallback_chain fallback_prompt | model | StrOutputParser() # 包装主链设置回退 robust_chain primary_chain.with_fallbacks([fallback_chain]) # 现在调用 robust_chain.invoke(...)如果 primary_chain 抛出异常会自动尝试 fallback_chain。更细粒度的错误处理你还可以在链的每个RunnableLambda或自定义组件内部进行try-catch返回一个表示错误或默认值的结果让下游组件决定如何处理。LCEL的数据流模型让这种错误状态的传递变得可控。6. 从开发到部署工程化最佳实践写出一条能跑的LCEL链只是第一步。要让它成为一个可维护、可监控的生产级服务还需要做很多工作。6.1 配置管理与环境隔离永远不要将API密钥等敏感信息硬编码在链定义中。使用环境变量或配置管理库如pydantic-settings。from pydantic_settings import BaseSettings class Settings(BaseSettings): openai_api_key: str openai_base_url: str | None None redis_url: str redis://localhost:6379/0 # ... 其他配置 class Config: env_file .env settings Settings() # 在链中使用配置 model ChatOpenAI( api_keysettings.openai_api_key, base_urlsettings.openai_base_url, modelgpt-4, temperature0 )为不同环境开发、测试、生产准备不同的.env文件或配置源。6.2 链的版本化与序列化LCEL链本身是Python对象但你可以将它们序列化如使用pickle保存到文件或数据库中便于版本管理和回滚。更工程化的做法是将链的构建逻辑封装在函数中通过代码版本控制Git来管理。def build_customer_service_chain(model_name: str, temperature: float) - Runnable: 工厂函数用于构建客服链。通过参数化配置便于管理不同版本的链。 model ChatOpenAI(modelmodel_name, temperaturetemperature) prompt ChatPromptTemplate.from_template(...) # ... 构建逻辑 return prompt | model # 在应用初始化时构建链 chain_v1 build_customer_service_chain(gpt-3.5-turbo, 0.7) chain_v2 build_customer_service_chain(gpt-4, 0.3) # 升级模型和参数6.3 集成监控与链路追踪使用LangSmith是监控LCEL链的最佳实践。它能记录每次调用的输入输出、每一步的耗时和token使用情况并可视化整个链的执行流程。import os os.environ[LANGCHAIN_TRACING_V2] true os.environ[LANGCHAIN_API_KEY] your_langsmith_api_key os.environ[LANGCHAIN_PROJECT] My Production Project # 设置项目名 # 现在所有 chain.invoke/stream/batch 调用都会被自动记录到LangSmith。对于自定义的RunnableLambda你可以通过chain装饰器或手动添加config来获得更细粒度的追踪。6.4 性能优化批处理、缓存与异步批处理对于大量相似请求如批量处理用户反馈使用chain.batch()可以显著提升吞吐量因为模型API通常对批处理有优化。questions [{question: q} for q in question_list] results chain.batch(questions) # 一次性处理所有问题缓存对于频繁出现的相同或相似查询引入缓存可以极大减少LLM调用成本和延迟。可以使用LangChain的RunnableWithCache或集成外部缓存如Redis。异步如果你的服务是异步框架如FastAPI务必使用链的异步方法ainvoke(),astream(),abatch()以避免阻塞事件循环。6.5 测试策略为LCEL链编写测试至关重要。测试应覆盖单元测试测试每个自定义的RunnableLambda或函数如format_docs,validate_input。集成测试测试几个Runnable组合起来的小链如retrieval_chain。端到端测试用一组有代表性的输入输出对测试整个完整链。注意由于LLM输出的非确定性端到端测试可能需要使用assert结合字符串包含或语义相似度比较如使用embedding计算余弦相似度。def test_rag_chain(): test_input {question: LangChain是什么} result full_rag_chain.invoke(test_input) # 不要断言完全相等的字符串 assert isinstance(result, str) assert len(result) 10 # 可以断言必须包含的关键词 assert 框架 in result or library in result.lower()7. 常见“坑”与调试技巧即使理解了所有概念在实际编码中依然会遇到问题。以下是一些高频问题和我总结的排查思路。问题1链的输入输出格式不对报KeyError。原因LCEL链中每个步骤都预期输入是特定格式的字典或可转为字典的对象。上一步的输出字典的键必须与下一步输入所期望的键匹配。排查使用chain.invoke(…, config{“callbacks”: [ConsoleCallbackHandler()]})在控制台查看每一步的输入和输出。重点关注RunnableParallel和RunnablePassthrough.assign步骤它们会改变字典的结构。问题2流式输出不工作或格式混乱。原因流式处理的是AIMessageChunk对象中间步骤的输出如PromptTemplate也可能被流出来。解决在流式循环中根据chunk的类型进行过滤。通常只处理isinstance(chunk, AIMessageChunk)或hasattr(chunk, ‘content’)的块。也可以使用chain.stream(…, config{“callbacks”: [StreamingStdOutCallbackHandler()]})来测试。问题3链在某个自定义函数处卡住或报错。原因自定义的RunnableLambda函数内部可能有异常或死循环。排查将该函数单独拿出来用预期的输入进行测试。确保函数返回值是字典或可序列化的对象。在函数内部添加详细的日志打印。问题4集成带历史的链时session_id不生效或历史混乱。原因config参数没有正确传递或get_message_history函数实现有误。排查确认每次调用invoke或stream时都传入了正确的config字典如{“configurable”: {“session_id”: “unique_id”}}。检查你的历史存储后端如Redis是否真的存取了数据。问题5性能瓶颈。排查顺序使用LangSmith查看链路追踪找到耗时最长的步骤。检查检索向量检索如果未建索引或相似度计算慢会是常见瓶颈。考虑使用更高效的向量库如FAISS、PgVector的IVFFlat索引或减少检索数量k。检查模型调用是否是同步调用阻塞了尝试改为异步ainvoke。是否可以通过调整Prompt或参数减少生成token数检查外部API链中调用的第三方API如重排、数据查询可能是瓶颈考虑增加超时、重试或缓存。掌握LCEL的工程化写法本质上是掌握一种“声明式编排AI计算任务”的思维。它强迫你将复杂的AI应用拆解成一个个职责单一、接口明确的模块Runnable然后通过清晰的运算符|,RunnableParallel,RunnableBranch将它们组装起来。这种模式不仅让代码更易读、易测试也让它能更好地融入现代的软件工程体系接受监控、部署和迭代。当你下次再面对一个复杂的AI需求时不妨先拿起笔画出数据流图思考如何用LCEL的这几个核心构件把它搭出来你会发现很多难题都迎刃而解了。