做AI应用开发的人,大概都有过这么一段经历:刚开始觉得自己会写prompt了,给模型一段话,拿回结果,完事。等需求稍微复杂一点,比如"先提炼产品卖点,再根据卖点生成文案,最后把文案翻译成多语言",单次调用就完全撑不住了。我在实际项目里见过太多类似的场景,第一次硬着头皮把所有要求塞进一个prompt里,结果不是标题太长就是描述太短,要么格式乱成一锅粥,要么生成一半被截断只能从头再来。
这类问题在LangChain里的标准解法是Chain,也就是"链"。它把模型调用、Prompt拼接、输出处理这些步骤串成一条可复用的管线,让上一步的结果自动变成下一步的输入。这篇文章是"大模型LangChain实战"系列的第二篇,重点拆解三类最常用的链:基础链LLMChain、顺序链(SimpleSequentialChain和SequentialChain)、分支链(RouterChain路由链),最后用一个完整的自动写稿管线演示怎么把它们组合起来。适合已经跑通LangChain基础Demo、开始考虑"怎么用Chain组织真实业务逻辑"的开发者。
1. Chain解决的问题:从单次模型调用到流程编排
1.1 一个prompt搞不定的事
我自己做过一个电商商品详情页自动生成的小Demo,最初的方案很直白:把商品信息拼进prompt,让模型一次生成标题、卖点、描述、FAQ,一起吐出来。跑了两轮就发现这条路走不通,原因不是模型能力不够,而是单次生成的问题:
第一,输出不稳定。标题、卖点、描述混在一段文本里,有时有列表符号,有时没有,后面做结构化存储非常痛苦,写解析逻辑比写prompt还累。
第二,约束互相打架。想让标题短、又想让描述详细、还要求语言风格统一,所有这些约束塞到同一个prompt里,模型很难平衡,经常顾此失彼。
第三,失败代价太大。生成到一半被上下文截断,整个页面内容作废,没有分步重试的余地。
后来我改成拆链:第一步生成卖点列表,第二步根据卖点扩写描述,第三步单独生成标题,第四步做结构化整理。每一步都是独立任务,prompt短、约束清晰、哪一步出问题就只重跑那一步。这就是Chain最原始的价值——把一个复杂目标拆成多个可独立控制的小步骤,再把这些步骤编排起来。
1.2 Chain本质上是加工工位
从代码层面看,一个Chain就是一组"输入变量->PromptTemplate->模型->输出处理"的封装。LLMChain是最小的单元,输入一个字典,输出一个字典。多个LLMChain可以拼成更大的Chain,LangChain负责把你声明的变量按规则传递。
我习惯用一个比喻:LLMChain相当于一个加工工位,工人是模型,只干一件活,工位上贴着操作手册(prompt),有明确的进料口和出料口(输入输出字段)。SimpleSequentialChain就是一条传送带,把上一工位的成品直接送到下一工位;SequentialChain是多了几个中转仓库的传送带,部分零件要从中间仓调出来,参与后面的工序;RouterChain则是传送带前的分拣员,看一眼货单,决定把货送去哪条产线。
1.3 三类链的定位关系
先把本系列要讲的三个概念理清楚:
- 基础链LLMChain是积木,几乎所有场景都要用到它;
- 顺序链是"按顺序执行"的拼法,把多个LLMChain串成流水线;
- 分支链是"看情况执行"的拼法,按输入内容或中间结果,选择走哪条子链。
这三者不是替代关系,而是不同层面的组合手段。实战中最常见的结构是:先定义若干LLMChain,再用SequentialChain把某几条LLMChain编排成一条路线,最外层用RouterChain决定请求走哪条路线。下面逐个拆开讲。
2. 先吃透基础链LLMChain:积木本体与两个容易踩的默认值
2.1 一个能跑的最小例子
LLMChain的代码结构非常固定,我日常写最小可用示例就是下面这样:
from langchain.chains import LLMChain from langchain.prompts import PromptTemplate from langchain_openai import ChatOpenAI prompt = PromptTemplate( template="你是一名文案专家。请为「{product}」写一句20字以内的宣传语。", input_variables=["product"], ) llm = ChatOpenAI(model="你配置的模型名", temperature=0.7) chain = LLMChain(llm=llm, prompt=prompt) result = chain.invoke({"product": "带定时功能的便携咖啡壶"}) print(result)invoke返回的是一个字典,里面既有你的原始输入,也有一个默认叫text的键,对应模型的文本输出。如果只想要那段文本,可以用chain.run({...}),但我建议业务代码里一律用invoke,原因很实际:后续大多数链都操作字典,从invoke的返回结构里取字段,比在run和链式调用之间来回转换更不容易出错。
2.2 output_key:不设置就只能用一次
LLMChain的prompt里声明了几个变量,invoke时就得传几个键。少传直接报错,多传的额外字段会被忽略。这一步看着简单,真正组合时就容易出事,最典型的就是output_key。
chain = LLMChain( llm=llm, prompt=prompt, output_key="features", )如果不写output_key,默认值就是text。单独一条链无所谓,但一旦把多条链串起来,所有链默认都把结果塞进同一个text键,后一条链的输出就会把前一条链的结果覆盖掉。我在第一个串联项目里就栽过这个跟头:第一条链生成卖点,第二条链拿卖点写文案,结果第二条链拿到的输入不是我想要的卖点列表,而是模型返回的一段混杂文本,前一条链的text早就被覆盖了。
还有一个容易被忽略的点:很多人以为LLMChain只能输出纯文本。其实通过output_parser,可以让它输出结构化数据,比如JSON格式的卖点列表,解析失败时还能自动重试。我在生成结构化信息时经常搭配Pydantic模型做字段校验,这套组合在业务里非常实用。只是它属于输出解析的专题,这里先不展开,记住LLMChain的出口不一定非得是文本就行。
2.3 LLMChain和LCEL表达式的关系
如果你看过新版本LangChain的文档,会发现官方现在更推荐LCEL写法:
from langchain_core.output_parsers import StrOutputParser chain = prompt | llm | StrOutputParser() result = chain.invoke({"product": "带定时功能的便携咖啡壶"})这行代码做的事和上面的LLMChain基本一样。事实上LCEL组合出来的对象继承了Runnable的能力,在简单场景下可以和LLMChain互相替换。
说下我的个人经验:理解了LCEL之后再回头看Chain,会觉得Chain只是封装好的便捷类。老项目里已经用了LLMChain,不必急着全部重写;但新项目建议直接把LCEL作为默认选项,因为Runnable支持流式、异步、批量调用,这对生产系统太关键了。本系列既然以Chain为主线,我这里还是用Chain API做示例,方便和概念一一对应;你落地时完全可以用LCEL替代。两种写法都熟悉,排查别人代码的时候也能站得住脚。
3. 顺序链:从"一条道走到黑"到"多进多出精确编排"
3.1 SimpleSequentialChain:适合作最简单的前后接力
顺序链里最简单的是SimpleSequentialChain。它要求整个流程只有一个输入、一个输出,且前一条链的输出自动成为后一条链的输入。我用前面的"宣传语翻译"场景举个例子:
from langchain.chains import SequentialChain, SimpleSequentialChain first_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="请为「{product}」写一句20字以内的中文宣传语。", input_variables=["product"], ), ) second_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="将下面的中文宣传语翻译成地道的英文:\n{text}", input_variables=["text"], ), ) overall = SimpleSequentialChain( chains=[first_chain, second_chain], verbose=True, ) result = overall.invoke({"input": "带定时功能的便携咖啡壶"})关键细节在第二个prompt里:它接收的变量名必须是text。这是SimpleSequentialChain的硬性约定——前一条链的输出默认放到text键里,后一条链只能从这个键取值。如果后一条链的模板写了{output}或者{translation},运行时会直接报KeyError。
这类链的优点是概念简单,缺点同样明显:中间结果不保留。你想看看第一步生成了什么、或者把第一步结果存下来,它做不到。所以我基本只在"两步到底、中间态无所谓"的场景用它,比如翻译、润色、格式转换。稍微复杂一点就直接上SequentialChain。
3.2 SequentialChain:用output_key搭起变量管道
SequentialChain是顺序链里的正主。它支持多输入多输出,中间变量可以保留,后续子链还能按需引用之前的中间结果。
我做一个"三段式文案工厂"来演示:输入产品名,第一步生成卖点列表,第二步根据卖点写一段描述,第三步把描述改写成中文、英文双语标题。
from langchain.chains import SequentialChain chain_1 = LLMChain( llm=llm, prompt=PromptTemplate( template="请列出「{product}」的3个核心卖点,每行一个。", input_variables=["product"], ), output_key="features", ) chain_2 = LLMChain( llm=llm, prompt=PromptTemplate( template="根据以下卖点,写一段50字左右的产品描述。\n卖点:\n{features}", input_variables=["features"], ), output_key="description", ) chain_3 = LLMChain( llm=llm, prompt=PromptTemplate( template="根据下面的描述,分别写一个中文标题和一个英文标题,用『中:...』和『英:...』两行输出。\n描述:{description}", input_variables=["description"], ), output_key="titles", ) overall = SequentialChain( chains=[chain_1, chain_2, chain_3], input_variables=["product"], output_variables=["features", "description", "titles"], verbose=True, ) result = overall.invoke({"product": "带定时功能的便携咖啡壶"})运行后result字典里会同时包含features、description、titles三个键。这就是SequentialChain区别于SimpleSequentialChain的核心能力:变量通过output_key命名,后一条链只要在prompt里使用相同名字的变量,LangChain就会自动从变量池里取值。
这里要多说一句input_variables的用法。它只用来声明整个链需要的外部输入,也就是最开始时你传给invoke的键。中间变量绝对不能往里写,写了反而报错。比如上面这个例子,input_variables里只有product,features和description虽然是链条里的输入,但它们来自上一步,不归外部输入管。
3.3 调试顺序链最容易出的三类错
顺序链的报错信息不算友好,我总结了三类高频问题:
第一类是上一步的output_key和下一步模板里的变量名对不上。症状是不一定报KeyError,有时模型拿到的是上一段原文而不是你想要的字段。解决办法是给每条子链设置明确的英文output_key,模板里严格使用同一个单词。
第二类是output_variables漏声明。有些中间变量其实已经计算出来了,但你没写进output_variables,invoke返回的字典里就找不到它。实际上链内是算过的,只是最后组装结果时被丢弃了。我建议把所有想保留的中间结果都列进output_variables,宁多勿少。
第三类是verbose没开。顺序链一旦跑通但结果不对,你根本不知道哪一步出了问题。打开verbose=True后,LangChain会把每一步构造出来的完整prompt和模型输出都打印出来,这是定位变量传递错误的最直接手段。我在本地调试时默认全部打开,确认没问题再关掉。
4. 分支链:路由到正确泳道的三种实现方式
4.1 分支的本质与适用边界
顺序链解决的是"依次做完所有步骤",但很多业务场景是"根据不同的输入走完全不同的路线"。比如一个客服机器人,用户问退款政策,应当走退款流程链;用户问维修进度,应该走订单查询链;如果两条路都用同一套prompt拼,效果必然稀烂。
LangChain对这类需求的标准解法是RouterChain,或者叫路由链。它的核心思想是先有一层"分拣逻辑",拿到输入后先判断应该交给哪个子链,然后再调用对应的子链。这层分拣逻辑有三种实现方式:
- 纯规则判断,就是代码里写if/else,用关键词或正则匹配;
- LLM判断,让模型读路由描述,输出应该走哪条路;
- Embedding向量匹配,计算输入和每条路由描述的相似度,选最像的。
不要觉得if/else太土。在路由规则非常明确时,纯规则是最稳定、零成本的方式。RouterChain的价值在于处理"规则没法穷尽"的语义场景。这也是我判断要不要上路由链的标准:如果关键词能解决,就别让模型来选。
4.2 LLMRouterChain与EmbeddingRouterChain怎么选
先看用LLM做路由的例子。LangChain提供了一个多prompt路由的现成模板,我直接用它定义两个目标链:
from langchain.chains.router import MultiRouteChain from langchain.chains.router.llm_router import LLMRouterChain, RouterOutputParser from langchain.chains.router.multi_prompt_prompt import MULTI_PROMPT_ROUTER_TEMPLATE math_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="请解答数学问题并给出推导过程:\n{input}", input_variables=["input"], ), ) history_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="请回答历史相关的问题:\n{input}", input_variables=["input"], ), ) router_prompt = PromptTemplate( template=MULTI_PROMPT_ROUTER_TEMPLATE, input_variables=["input"], output_parser=RouterOutputParser(), ) router_chain = LLMRouterChain.from_llm(llm=llm, prompt=router_prompt) overall = MultiRouteChain( router_chain=router_chain, destination_chains={ "math": math_chain, "history": history_chain, }, default_chain=default_chain, verbose=True, )这里最容易翻车的地方是命名对应关系:路由模板里定义的选项名"math""history",必须和destination_chains字典里的键完全一致。拼写差一个字母,路由就永远命中不了,所有请求都会落到default_chain上,而且不报任何错误。
再来看Embedding方案。它不需要让模型反复判断,而是把每条路由的描述文本先向量化,输入进来后也向量化,然后计算相似度,取最高分对应的链路:
from langchain.chains.router.embedding_router import EmbeddingRouterChain router_chain = EmbeddingRouterChain.from_names_and_descriptions( names_and_descriptions=[ ("math", "适合回答数学计算、公式推导、应用题类问题"), ("history", "适合回答历史事件、人物、年代、背景类问题"), ("writing", "适合写作、翻译、润色、创意文案类任务"), ], llm=llm, embedding=你配置好的Embedding模型实例, )Embedding路由的优点是速度快、不消耗路由判断的token,缺点是路由质量完全依赖描述文本写得好不好。描述写得模糊,向量就分不开;描述里尽量包含用户可能会用的原词,比如"回答"、"计算"、"写作"这类信号词,实测命中率会高很多。
4.3 路由翻车记录:误判、解析失败、key不匹配
路由链看着漂亮,实际用起来问题不小,说几个我亲身踩过的:
第一个是误判。嵌入路由对语义近似的输入特别容易迷糊。比如"请帮我写一个关于咖啡机的产品介绍页",我的商品路由和写作路由描述里都有"写",向量距离相近,结果被分到技术教程链,生成了一篇教程式内容。后来我把三个名称和描述彻底改写成互斥的风格,比如商品链强调"营销、卖点、购买决策",写作链强调"文章、段落、风格润色",误判才明显下降。
第二个是LLM路由的JSON解析失败。RouterOutputParser要求模型输出特定格式的JSON,包含destination和next_inputs两个键。模型偶尔会在JSON前后多输出几句话,或者把键名写成next_input,解析器就会抛异常。应对方法是显式few-shot:在自己定义的路由模板里给出两个成功示例,让模型照着输出格式来。
第三个是静默失效。正如前面说的,destination名称拼写不一致不会报错,只会全部落到默认链。排查这类问题,最有效的办法是设置verbose=True,看路由层打印出来的route结果。如果发现destination永远是default,先去检查字典键名,再去检查路由模板里的选项名。
5. 一起搭一条会"看情况走"的自动写稿管线
5.1 需求与设计
这一节把前面的知识点串起来,做一个能直接套用的组合实战:一个主题自动成稿管线。
需求是:用户输入一个写作主题,系统判断这个主题属于"技术教程""商品文案""行业资讯"中的哪一类,然后走对应的专用写作流程,最终输出一篇文章正文和标题摘要。
整体设计分成两层:
- 第一层是分支,用EmbeddingRouterChain,因为它快且省token;
- 第二层是三个目的地,每个目的地都是一条SequentialChain。以技术教程链为例,它是三段式:先生成文章大纲,然后根据大纲分段扩写正文,最后生成标题和摘要。
这样做的好处是:路由只负责分拣,写作质量由每个子链的专用prompt保证。新增一种内容类型时,只需要加一条新的SequentialChain,再往路由表里加一对名称和描述,不用改动其他代码。
5.2 代码组合
核心代码结构如下,省略重复的prompt定义:
from langchain.chains.router.embedding_router import EmbeddingRouterChain from langchain.chains.router import MultiRouteChain # ---- 1. 定义技术教程链:三段式顺序链 ---- tech_outline_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="为文章「{topic}」写一份3段大纲,每段一句话。", input_variables=["topic"], ), output_key="outline", ) tech_draft_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="根据大纲写正文,字数控制在300字内。\n大纲:{outline}", input_variables=["outline"], ), output_key="body", ) tech_title_chain = LLMChain( llm=llm, prompt=PromptTemplate( template="基于正文生成标题和一句话摘要。\n正文:{body}", input_variables=["body"], ), output_key="title_summary", ) tech_seq_chain = SequentialChain( chains=[tech_outline_chain, tech_draft_chain, tech_title_chain], input_variables=["topic"], output_variables=["outline", "body", "title_summary"], ) # ---- 2. 定义商品文案链、资讯链(结构同上,换成对应prompt即可) ---- # ---- 3. 建立路由 ---- router_chain = EmbeddingRouterChain.from_names_and_descriptions( names_and_descriptions=[ ("tech", "技术教程、编程技巧、工具使用、代码示例类写作任务"), ("product_copy", "产品介绍、商品卖点、广告宣传、购买营销类文案"), ("news", "行业动态、资讯汇总、事件点评、新闻解读类写作"), ], llm=llm, embedding=你配置好的Embedding模型实例, ) final_chain = MultiRouteChain( router_chain=router_chain, destination_chains={ "tech": tech_seq_chain, "product_copy": copy_seq_chain, "news": news_seq_chain, }, default_chain=default_seq_chain, verbose=True, ) result = final_chain.invoke({ "input": "写一篇python列表去重技巧的教程", })几点说明:MultiRouteChain的输入键固定叫input,传入时不要写成topic或其他名字。路由命中后,目的地链收到的也是input这个键,所以目的地链的prompt模板里接收变量要用{topic}还是{input},取决于你子链的输入变量名。我在上面的技术链里写的是{topic},但路由传入的是input,这意味着实际跑的时候会有KeyError。要么把子链第一段的input_variables改成input,要么在路由的next_inputs里转换。标准做法是让目的地链直接接收input,我这里为了结构可读性略作了简化,真正落地时建议统一命名,避免多一层转换坑自己。
5.3 实测记录
跑到这一步,效果已经能看了。我用"python列表去重技巧的教程"测试,语音路由打印显示命中tech,后续三步顺序执行,最终result里有outline、body、title_summary,正文内容也确实是教程风格。
不过我测出一层新问题:当输入是"帮我写一个咖啡机的详情页"时,路由竟然也命中了tech。原因就是我前面说的,技术描述里"工具使用"这个词范围太宽,咖啡机详情页被当成工具使用了。我调整了三类描述,把"工具使用"改成"开发工具、命令行、框架库"这些词,并在产品描述里加重"营销、卖点、购买"的信号词,再把"产品相关文本"加进去,第二次测试就回归正常。
还有一点值得提:默认链一定要有。就算路由再准,线上总会出现边缘case。我的default_chain就是一个通用的LLMChain,提示词是"请直接回答用户的问题"。这样路由失效时,用户至少还能得到一个可用结果,而不是直接报错。这类兜底设计在生成式应用里是刚需。
6. 别急着套所有链:选型对比与高频坑
6.1 三类链横向对照
我整理了一张表格,方便你选择时直接对照:
| 对比维度 | LLMChain | SimpleSequentialChain | SequentialChain | MultiRouteChain(路由链) |
|---|---|---|---|---|
| 编排方式 | 单步处理 | 线性单链 | 线性多进多出 | 按输入路由到多条子链 |
| 输入输出 | 输入字典,输出字典,默认text | 单一输入,单一输出 | 可用output_key声明多个中间变量 | 路由层输入input,最终输出text |
| 中间结果 | 不涉及 | 丢弃 | 声明后保留 | 由目的地链自行决定 |
| 最适合场景 | 翻译、单轮问答、单次生成 | 两步到底的简单接力 | 多段加工、需要中间变量 | 意图分流、多专家/多模板 |
| 调试成本 | 低 | 中 | 中高,变量KeyError多 | 高,误判和JSON解析失败多 |
| 新项目替代写法 | prompt | llm | parser | 两个Runnable直接竖线连接 | LCEL加RunnablePassthrough、itemgetter | LCEL里用RunnableBranch |
表格最后一行是我特别加进去的。LCEL的RunnableBranch可以等价实现路由分支逻辑,写法更轻,不至于为一个小分叉引入整个路由链。我的习惯是:小分支用if/else或者RunnableBranch,分支多了、需要按语义分、并且每个分支差异很大时,再用RouterChain。
6.2 高频坑清单
把前面零散提到的坑汇总一下,方便你排查时按图索骥:
- 忘写
output_key。多链串联时默认text互相覆盖,症状是最终结果缺字段或子链输入串味。 output_variables漏声明。中间结果明明算了但不在返回字典里。- 路由名称拼写不一致。
destination_chains的键和路由模板里的选项名对不上,症状是全部走到默认链,无报错。 RouterOutputParser解析失败。模型输出的JSON格式不合规,尤其是键名写错或多输出文字。解法是加few-shot示例。- verbose关太早。链一旦跑通,出错点就难定位,我建议调试期全程开着,稳定后再关。
6.3 一点个人体会
Chain这套API在我看来更像一种"任务拆解的思维框架",而不只是代码库。它逼着你把一个模糊的需求拆成一条条边界清晰的小链:每段prompt只干一件事、每个输出有明确命名、每条路有兜底方案。思路理清之后,用Chain API还是LCEL,其实只是编码偏好问题。
最后再补一句实在话:链不是越复杂越好。一次任务能三步走完,就不要拆成八步;能用规则判断,就别上大模型路由。我见过太多项目把简单需求套上层层Chain,结果调试成本比收益还高。链是组织逻辑的工具,不是炫技的道具,复杂度永远应该跟着业务走。