如果你是第一次接触 LangChain,不妨先想一个画面:一个孩子面前摆着一大箱乐高。每个零件各有形状——有门板、有车轮、有小人——但只要它们是正品,任意两块都能咔哒一声拼在一起。为什么?因为乐高定死了拼接口的直径和卡槽深度。LangChain 要做的事,就是把 AI 工具箱里那些“零件”——大模型、提示词、工具、解析器——全部统一成一套拼接口,让你像拼乐高一样,把一个个组件拼成一条完整的工作流水线。 这套“统一拼接口”在 LangChain 里叫 Runnable 协议。只要是符合这个协议的组件,无论内部多复杂,外部都能用同一套方式调用:同步调用、批量调用、流式输出、异步执行。更进一步,LangChain 还发明了一套“拼插语法” LCEL(LangChain Expression Language),用竖线 | 就能把组件串成链。而工具与链(Chain)的开发,本质上就是制造“标准乐高零件”的过程。 本文就用“乐高流水线工厂”这个比喻贯穿始终,带你从零理解 Runnable 接口规范、流式输出、异步执行、LCEL 组合范式,再到生产环境的落地与可观测,最后手写一个组件并跑通整条链。

岗位关联

对 AI 从业者来说,掌握工具与链的开发规范,意味着你不只是“调用”别人的模型,而是能像乐高工程师一样,自由设计、组装、调试自己的 AI 流水线——这正是 LangChain 贡献给整个生态的核心工程能力。

目录
  1. 可视化演示
  2. 一、Runnable 统一接口
  3. 二、流式输出支持
  4. 三、异步执行适配
  5. 四、LCEL 组合范式
  6. 五、组件复用规范
  7. 六、生产环境落地
  8. 七、可观测与调试
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示LangChain中遵循Runnable接口的组件链,按输入注入、Prompt模板、LLM调用与输出解析顺序执行,并支持流式输出与异步编排。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Runnable链式编排 输入注入 Prompt LLM调用 输出解析 可组合可观测

一、Runnable 统一接口

乐高积木为什么能跨越几十年的产品线互相拼插?因为每一块积木的拼接口从诞生那天起就没变过。LangChain 面对的局面比乐高更乱:有几百个模型厂商、几十种工具调用方式、各式各样的输出解析需求。如果每个组件都用自己的“私用接口”,那组装 AI 应用就成了焊水管,而不是插乐高。于是 LangChain 制定了 Runnable 协议——一套所有组件都必须遵守的“拼接口规格”。

任何工具或链,只要实现了 Runnable,就承诺提供一组标准动作:invoke(拿起一个输入,产出一个输出)、batch(一次处理一批输入)、stream(边算边吐结果)、ainvoke(异步执行)。调用方不需要关心内部是大模型、是 Python 函数还是数据库查询,只需要对着拼接口喊一声,零件自己会把活干完。这就是“面向接口编程”在 AI 工程里的又一次胜利。

协议还要求声明输入和输出的类型,比如 Runnable[str, dict] 表示“吃进字符串,吐出字典”。这好比乐高包装盒上印的适用年龄和能力标签,让组装者一眼判断这块零件能不能接进当前管线。

为什么这套协议如此重要?核心在于“确定性替换”。只要两个组件都符合 Runnable[str, dict] 的规格,它们就能在链中互换位置而不破坏整条流水线。这种标准化让你在换模型、换工具、加解析器时,只需要动拼接口的那一小块,而不是重写整条链。

回看乐高工厂:零件的生命力在于它能被装进任何一套套装。Runnable 协议就是 LangChain 给所有 AI 零件刻上的统一卡槽——有了它,组件生态才能像乐高那样无限组合。

invoke:X→Y\text{invoke}: \mathcal{X} \rightarrow \mathcal{Y}
这行式子的意思是:invoke 就像一个传送按钮,把某个属于输入集合 X 的东西,变成属于输出集合 Y 的东西。
  1. 输入集合$\mathcal{X}$ 表示所有合法输入的集合。它可以是字符串、消息列表、数据库记录,甚至是一段视频的元数据。
    X={x1,x2,x3,… }\mathcal{X} = \{ x_1, x_2, x_3, \dots \}
  2. 输出集合$\mathcal{Y}$ 表示所有可能输出的集合。组件内部不管多复杂,最终都要落到一个明确的输出类型上。
    Y={y1,y2,y3,… }\mathcal{Y} = \{ y_1, y_2, y_3, \dots \}
  3. 映射关系$\rightarrow$ 是箭头符号,表示“转换”。它强调:一次 invoke 就是一次确定的转换过程,正如乐高凸起一定能卡进凹槽那样,合法输入一定能得到对应输出。
    x↦y=invoke(x)x \mapsto y = \text{invoke}(x)

二、流式输出支持

想象一下你去餐厅点了一份现做的披萨。你可以选择干等 20 分钟,然后收到完整的一张;也可以选一家“边烤边切”的店,先吃第一块热乎的,后续的慢慢跟上。大模型生成文本就是这样——它不是一个词一个词瞬间吐完,而是像流水线上逐个滚下的零件,一个 token 一个 token 地生产。流式输出(stream)就是让你不必等整条链跑完,而是边生产边消费。

invoke 返回的是一个完整结果;而 stream 返回的是一个迭代器,用 Python 的 yield 逐块产出中间结果。对用户而言,最直观的变化是“首字延迟”骤降:原本要等 3 秒才看到一句话,现在 0.3 秒就看到了第一个字,剩下的字像打字机一样蹦出来。这种体验上的提升,让 AI 应用从“生成中……请等待”变成了“正在输入”,本质上是把用户焦虑期变成了期待期。

在组件实现层面,流式输出要求开发者把输出拆成多个“块”并依次送出。如果链条上的每个组件都支持流式,整条链就会像接力传递一样,前一个组件的输出块刚落地,后一个组件立刻接过继续处理。这个机制让大模型驱动的聊天机器人、代码补全、翻译工具都获得了“边想边说”的自然感。

流式输出不是简单的“切片返回”。它的灵魂在于惰性生产:只有消费者迭代到哪,生产者才计算到哪。这意味着如果你只需要前三个 token,就可以提前终止调用,省下大量算力。这也要求在管道中传递流时,不能预先攒齐所有结果再交棒,而是真的让数据块滚起来。

在乐高流水线上,这就是“传送带”思维:不是把一堆成品推到质检区再统一打包,而是每完成一个零件就立刻往下游送。对用户而言,等待时间的大头从“全部生产时间”变成了“首个零件到达时间”,这正是流式输出的核心竞争力。

stream(x)=⟨y0,y1,y2,…,yn⟩\text{stream}(x) = \langle y_0, y_1, y_2, \dots, y_n \rangle
尖括号表示一个有序序列:stream 不是一次性给出完整答案,而是给出一个接一个的结果块,像披萨一片一片端上桌。
  1. 流的起点$x$ 是进入管道的输入。它告诉组件“你要处理的是这个请求”。
    x=用户输入x = \text{用户输入}
  2. 流的片段$y_i$ 表示第 $i$ 个输出块。每个块可能是一个 token、一句话或一段结构化数据,取决于组件设计者如何切分。
    yi=第 i 个产物y_i = \text{第 } i \text{ 个产物}
  3. 流的终点$y_n$ 表示最后一个块。流是有限序列,末尾必须有一个明确收尾的标记(或直接停止产出)。
    n=产物总数n = \text{产物总数}

三、异步执行适配

一个优秀的厨师大厨不会只开一口锅:他会一边炖汤、一边炒菜、一边蒸饭,利用等待的时间穿插做事。在程序世界里,这就是异步执行。LangChain 的 Runnable 协议为每个同步方法都配了异步版本:ainvoke、abatch、astream。它们背后是 Python 的 async/await 与事件循环机制。

为什么要异步?因为大模型 API 调用是典型的 IO 密集型操作——大多数时间花在网络等待上,而不是 CPU 计算。如果同步阻塞,一个慢请求会卡住整个应用;而异步执行时,程序可以在“等待模型返回”的间隙去处理其他请求。就像大厨等水开的 3 分钟里,顺手切完了配菜。这样一来,单个服务进程就能同时支撑几十甚至上百个并发请求,吞吐量大幅提升。

但异步不是“加个关键字”就完事。它要求整条调用链都保持异步化:如果你在 ainvoke 内部调用了某个同步阻塞的第三方库,事件循环照样会被卡住。所以 LangChain 要求自定义组件同时实现 invoke 和 ainvoke,并确保两种模式的行为一致。这就好比:你可以选择“一道菜一道菜做”,也可以“多线并进地做”,但不管哪种模式,最终上桌的菜必须一样好吃。

异步设计带来的瓶颈往往不在语言层面,而在设计习惯。很多初学者在异步函数里偷偷调用了 time.sleep 或同步的 requests.get,这就好比大厨在做菜间隙刷手机刷到入迷,把整个厨房都拖着等——事件循环被阻塞,异步等于白做。

回到乐高工厂视角:同步是一条传送带,异步是十条传送带并行运转。LangChain 把异步能力标准化为协议的一部分,让“多线开工”成为每个组件与生俱来的能力,而组件开发者只需遵守“不要在异步路径上阻塞”这一条铁律。

ainvoke(x)=await( invoke(x) )\text{ainvoke}(x) = \text{await}\big(\, \text{invoke}(x) \,\big)
ainvoke 的意思是:把 invoke 放进异步世界,调用后立刻返回一个“承诺”,代表“我记下了这个请求,过会儿给你结果”,你可以用 await 去取。
  1. 异步调用$\text{ainvoke}(x)$ 是异步版本的调用入口。它处理与 invoke 相同的输入 $x$。
    ainvoke:x↦coroutine\text{ainvoke}: x \mapsto \text{coroutine}
  2. 协程对象$\text{await}$ 是等待符号。调用 ainvoke 得到的不是结果本身,而是一个协程对象,需要交给事件循环调度。
    coroutine=async(invoke(x))\text{coroutine} = \text{async}( \text{invoke}(x) )
  3. 结果回收当事件循环轮到这个协程时,它最终会执行内部的 invoke 逻辑,并产出一个与同步调用完全一致的结果类型。
    result∼invoke(x)\text{result} \sim \text{invoke}(x)

四、LCEL 组合范式

乐高的终极乐趣不在于单个零件,而在于按图纸把零件拼成机器。LCEL(LangChain Expression Language)就是 LangChain 的“官方图纸语法”。它用一个竖线运算符 | 来表达“把 A 的输出接到 B 的输入”——这比乐高更像水管工:上一段管子的出水口,恰好是下一段管子的入水口。

chain = prompt | model | parser 这样一行代码,就完成了一条完整的数据链。RunnableLambda 更进一步,它能把任何普通 Python 函数包装成 Runnable:你不需要把函数写成一个类,只需要 RunnableLambda(extract_keywords) 就能把它插进链条。这种做法叫声明式组合——你描述的是“数据怎么流动”,而不是“代码怎么执行”。

LCEL 的威力在于,由 | 拼接出的链本身仍然是一个 Runnable。这意味着:链还可以继续被拼进更大的链,链可以整体调用 stream、ainvoke,可以被批处理,可以被追踪。正如积木拼出的汽车还能被装进更大的飞船,组合成果又变成了更高一层的零件。这种自相似的递归结构,让系统的复杂度从“混乱的胶水代码”变成了“清晰的层级嵌套”。

LCEL 让组件开发从“面向继承”转向“面向数据流”。过去你想扩展一条链,得修改原始代码;现在你只需要在链的末尾再拼一个 | 新组件。组合不是侵入性的,而是累加性的——这就像乐高不提供“焊死”的结构,一切修改都发生在拼接口上。

回看乐高图纸:一张好图纸的价值不在于描述零件的形状,而在于演示它们如何连接。LCEL 就是这样的图纸语言——它让 AI 工程师把“实现组件”和“组装系统”两件事彻底分开,极大降低了大型 AI 应用的搭建成本。

chain(x)=g( f(x) )\text{chain}(x) = g\big(\, f(x) \,\big)
这条链就是:先让 f 处理输入 x,再把 f 的产出交给 g。竖线符号只是把这种嵌套关系写得像流水线一样直观。
  1. 前段组件$f$ 是第一个组件。它接收原始输入 $x$,产出中间结果。例如 prompt 把用户问题包装成带上下文的提示词。
    f:x↦mf: x \mapsto m
  2. 后段组件$g$ 是第二个组件。它消费中间结果 $m$,产出最终结果。例如 model 基于提示词生成文本。
    g:m↦yg: m \mapsto y
  3. 嵌套组合$g(f(x))$ 表示两个组件从内到外依次执行。LCEL 的 | 只是把这个嵌套写成更易读的顺序形式。
    x→fm→gyx \xrightarrow{f} m \xrightarrow{g} y

五、组件复用规范

为什么乐高零件能在不同套装之间通用?因为它们的拼接口统一,而且不携带“上一套装”的烙印。同理,一个优秀的 LangChain 组件应该做到“状态无关”:它不依赖全局变量、不保留调用之间的隐藏状态、不假设自己处在哪条链中。一次输入对应一次输出,可预测、可测试、可移植。

状态无关设计之外,LangChain 还提倡配置注入。组件在运行时可以通过 config 参数接收可选项,比如温度参数、超时时间、重试次数。这样一来,同一个组件的“默认行为”写在实现里,而“个性化行为”由组装者按需注入。就比如一个乐高小人的手,既可以在汽车里当驾驶员,也可以换到飞机里当机长——小人本身没变,是装配环境提供了不同角色。

测试友好也是复用规范的重要一环。一个 Runnable 组件的输入输出类型明确,就天然适合单元测试:给定固定输入,断言输出是否匹配。LangChain 开发者常为组件准备“黄金测试集”,确保重构后行为不变。这也像是乐高工厂的出厂质检——每个零件在出厂前都要在标准拼接口上试插一遍。

一个可复用组件就像一把瑞士军刀:刀体的形状固定,但可以换上不同的工具头。关键原则是不要把场景写死进组件——不要在组件里硬编码提示词模板、不要把 API key 存在全局里、不要假设输入的格式永远不会变。一旦组件与具体场景解耦,它就能从一条链中“拆下来”,装进另一条完全不同的链。

乐高的魅力同样在此:一套城堡套装里的士兵,可以放到太空飞船套装里当船员。LangChain 组件复用规范的本质,就是让组件像乐高零件一样“可用但不受限”——不携带记忆,只提供能力。

Runnable(x;θ)↦y,θ={config1,config2,… }\text{Runnable}(x; \theta) \mapsto y, \quad \theta = \{ \text{config}_1, \text{config}_2, \dots \}
分号后面的 θ 是“可调旋钮”列表:组件的核心逻辑固定,但行为可以通过配置参数调整,就像乐高车的轮子可以换成滑轮。
  1. 固定输入$x$ 是组件必须处理的输入。无论什么场景,组件的“主输入”类型不能变,这是协议的契约。
    x=核心输入x = \text{核心输入}
  2. 配置参数$\theta$ 是配置集合。它们不改变组件的用途,但可以调整组件的表现方式,例如切换模型温度。
    θ={config1,config2,… }\theta = \{ \text{config}_1, \text{config}_2, \dots \}
  3. 映射输出$\mapsto y$ 表示输出仍然是由输入决定的。配置只会影响“怎么产出”,不会改变“产出什么类型”。
    x↦y, 行为由 θ 微调x \mapsto y, \text{ 行为由 } \theta \text{ 微调}

六、生产环境落地

在乐高工厂的展间里,一个孩子可以慢慢拼一辆小车;但到了量产车间,要考虑的是:传送带速度、每小时产量、次品处理流程。LangChain 组件从“能跑”到“生产可用”之间,也隔着同样的鸿沟。最典型的三个问题分别是:批量、容错、规模。

批量处理。如果用户请求源源不断,逐个调用 invoke 会浪费大量网络往返。batch 方法允许一次接收一批输入,内部可作并发调度,聚合提升吞吐。这在处理离线数据集、批量审核、定时报告等场景下尤其有效。容错则是生产环境的底线:模型 API 可能超时、服务可能抖动。LangChain 提供 with_retry 等机制实现指数退避重试——第一次失败等 1 秒再试,第二次等 2 秒,第三次等 4 秒,避免风暴式重试压垮下游服务。规模则意味着从几行原型代码到服务化部署:限流保护依赖方、超时管理止损、并发控制防止资源耗尽。

生产环境的收益可以量化成一道简单的算术题:系统的吞吐量不仅取决于单次调用的速度,还取决于同时有多少请求在途。就像传送带上的零件越多,单位时间产出的成品也越多——前提是别把传送带塞爆。

生产环境的每一条经验都可以映射回乐高工厂:批量处理好比把多个小订单合成一个大批次开模,重试策略好比加装质检回退皮带,限流则是给传送带装上变频器。从原型到规模,不是把原型代码原样丢进生产,而是给每个环节加上“工程护栏”。

组件开发者的终极目标不是写出能跑的 demo,而是写出扛得住流量、经得起故障、说得清成本的生产级组件。这正是 LangChain 贡献给开源社区的核心方法论:让每个工具/链都能从个人玩具变成工业零件。

Throughput=NLˉ\text{Throughput} = \dfrac{N}{\bar{L}}
吞吐量等于并发数除以平均延迟。加大并发 N 或压降延迟 L,都能让工厂单位时间产出的成品更多。
  1. 并发数量$N$ 是同时在流水线上的请求数。它不是越高越好——受下游 API 配额和系统资源限制。
    N=在途并发请求数N = \text{在途并发请求数}
  2. 平均延迟$\bar{L}$ 是单个请求从进入管道到完成的平均耗时。延迟越高,传送带越慢。
    Lˉ=1k∑i=1kLi\bar{L} = \frac{1}{k}\sum_{i=1}^{k} L_i
  3. 吞吐比例除法关系说明吞吐与并发成正比、与延迟成反比。生产优化的两条路就是“更多并行”与“更快响应”。
    提升 N或降低 Lˉ\text{提升 } N \quad \text{或} \quad \text{降低 } \bar{L}

七、可观测与调试

想象你管理一条庞大的乐高流水线:几十台机器,几百个环节。突然某个环节出了问题,成品质检不通过。如果没有监控,你只能停机、逐个机器拆开检查,一天就过去了。可观测性就是给流水线装上摄像头、传感器和记录仪——每个环节的状态一目了然,出了问题能立刻定位到是哪台机器、哪个时间点、哪个输入导致的。

LangChain 在 Runnable 协议中内置了回调机制(callbacks)。链条执行时,每个组件的开始、结束、失败、流块产出都会触发回调事件。你可以挂载一个自定义回调处理器,把事件记录到日志、数据库或链路追踪系统。这正是生产排查的关键:一次完整的链调用会展开成一棵“执行树”,哪个环节耗时最长、哪个环节抛错、哪个环节消耗了多少 token,全部有迹可循。

成本核算是 LangChain 应用的另一项必修课。LLM 调用按 token 计费,而一条链可能调用多次模型。没有可观测性,月底账单就是一笔糊涂账。通过在回调中统计每次调用的输入/输出 token 数,再乘以对应单价,就能精确算出每条链、每个用户的成本。这就像乐高工厂统计每个零件的原料成本——只有知道每个零件花了多少钱,才能决定要不要换更便宜的供应商。

可观测性不是“上线之后再加的奢侈品”,而是组件从第一天就该具备的素质。LangChain 的设计巧妙在于:只要你的组件实现了 Runnable,回调追踪就自动可用,无需额外侵入。这就像乐高积木本身自带 RFID 芯片——不管被拼进哪个套装,工厂都能追踪到它的位置和状态。

回到最初的那箱乐高。Runnable 协议是统一的拼接口,LCEL 是图纸语言,流式和异步是传送带的两种提速方式,复用规范是零件的质量标准,而可观测性是质检摄像头。当这些全部就位,你搭的就不再是一次性的积木模型,而是一条真正能交付价值的生产流水线。

---

cost=cin⋅Tin+cout⋅Tout\text{cost} = c_{\text{in}} \cdot T_{\text{in}} + c_{\text{out}} \cdot T_{\text{out}}
总成本等于输入 token 数乘以输入单价,加上输出 token 数乘以输出单价。可观测性让你把这个数字从“月末惊喜”变成“实时仪表盘”。
  1. 输入计费$c_{\text{in}}$ 是每千个输入 token 的价格,$T_{\text{in}}$ 是实际消耗的输入 token 数(包括提示词、历史消息、工具返回文本)。
    输入成本=cin⋅Tin\text{输入成本} = c_{\text{in}} \cdot T_{\text{in}}
  2. 输出计费$c_{\text{out}}$ 是每千个输出 token 的价格,$T_{\text{out}}$ 是模型生成的输出 token 数。通常输出单价高于输入单价。
    输出成本=cout⋅Tout\text{输出成本} = c_{\text{out}} \cdot T_{\text{out}}
  3. 成本求和加号把两部分费用汇总。注意,一条链中多次调用模型,就要把每次的公式结果累加。
    总成本=∑i(cin⋅Tin,i+cout⋅Tout,i)\text{总成本} = \sum_{i} \big( c_{\text{in}} \cdot T_{\text{in},i} + c_{\text{out}} \cdot T_{\text{out},i} \big)

AI 应用场景

智能客服知识库问答链

阶段:模型调用与后处理(Runnable 组件的组装与编排)

解决的问题:传统客服系统基于关键词匹配,答非所问率高。AI 问答需要“检索 → 增强 → 生成”多步协作,过去需要手工胶水代码串联,难以维护和扩展。借助 LCEL,可以把检索器、提示词模板、大模型、输出解析器用 | 拼成一条标准 Runnable 链,且天然支持流式与追踪。

具体案例:某电商平台搭建售后问答机器人:用户提问 → 检索商品知识库 → 拼装带上下文的提示词 → 调用大模型 → 解析出结构化回答。实际落地中,这条链用 batch 批量处理夜间积压的客户消息,用 ainvoke 支撑白天高并发聊天,并在回调中统计每次回答消耗的 token 成本。

流式代码助手

阶段:模型推理输出(stream 流式接口的工程应用)

解决的问题:传统“等待完整回复再显示”的交互方式,在代码补全场景下会让用户感到明显的卡顿。流式输出把整体延迟感分拆为“首 token 延迟”和“后续 token 速率”,用户看到第一行代码出现即可开始阅读。

具体案例:某 IDE 插件接入 LangChain 的 stream 接口,用户输入注释后,模型逐 token 生成代码建议。中间经过 RunnableLambda 做的格式校验组件,每产生一个代码块就立即检查语法并高亮,实现“边写边验”的沉浸体验。

批量文档摘要管道

阶段:数据预处理与批处理(batch 与异步)

解决的问题:企业每天产生数百份合同、周报、论文,逐份串行调用大模型既慢又贵。Runnable 的 batch 接口允许批量喂入文档,内部并发调度,配合限流和重试,让处理时间从“小时级”降到“分钟级”。

具体案例:某律所知识管理系统每天自动摘要 500 份合同。开发团队实现了一个 DocSummarizerRunnable,用 batch 并发处理并配合 with_retry 应对模型服务波动;每个摘要任务都在回调中记录输入文档数、输出 token 数,月底按项目回填成本。

Python 演示

python76 行
import asyncioimport timefrom typing import Any, Callable, Iterator  class Runnable:    """简化版 Runnable 协议:invoke / stream / ainvoke + 管道运算符。"""     def invoke(self, x: Any) -> Any:        raise NotImplementedError     def stream(self, x: Any) -> Iterator[Any]:        yield self.invoke(x)     async def ainvoke(self, x: Any) -> Any:        return self.invoke(x)     def __or__(self, other: "Runnable") -> "RunnableChain":        return RunnableChain(self, other)  class RunnableChain(Runnable):    def __init__(self, first: Runnable, second: Runnable) -> None:        self._first = first        self._second = second     def invoke(self, x: Any) -> Any:        return self._second.invoke(self._first.invoke(x))     def stream(self, x: Any) -> Iterator[Any]:        mid = ""        for piece in self._first.stream(x):            mid += piece        yield from self._second.stream(mid)  class UpperCase(Runnable):    def invoke(self, x: str) -> str:        return x.upper()     def stream(self, x: str) -> Iterator[str]:        for ch in x:            yield ch.upper()  class Exclaim(Runnable):    def invoke(self, x: str) -> str:        return x + "!!!"     def stream(self, x: str) -> Iterator[str]:        n = len(x)        for i, ch in enumerate(x):            if i == n - 1:                yield ch + "!!!"            else:                yield ch  def main() -> None:    chain = UpperCase() | Exclaim()    print("invoke:", chain.invoke("hello"))    print("stream:", end=" ")    for piece in chain.stream("hello"):        print(piece, end="")        time.sleep(0.05)    print()     async def demo() -> None:        result = await chain.ainvoke("async")        print("ainvoke:", result)     asyncio.run(demo())  if __name__ == "__main__":    main()
目的:用约 60 行代码实现一个最小版 Runnable 协议,演示 LCEL 的 | 组合、流式输出和异步调用三大核心机制。
  1. def __or__(self, other: "Runnable") -> "RunnableChain":重载竖线运算符,让组件能用 A | B 拼成链,这是 LCEL 组合的基础。
  2. class RunnableChain(Runnable)组合后的链本身仍然是一个 Runnable,可以继续被组合、被流式调用。
  3. def stream(self, x: str) -> Iterator[str]:用 yield 逐块产出结果,消费者可以边取边处理,不必等全部完成。
  4. for piece in self._first.stream(x): mid += piece链条的流式实现会先收集前段组件的全部产出,再交给后段组件,保整正确性。
  5. async def ainvoke(self, x: Any) -> Any:异步版本的调用入口,返回协程,由 asyncio.run 驱动事件循环执行。
  6. chain = UpperCase() | Exclaim()两行组件定义,一行组合,这行代码就是 LCEL 声明式风格的缩影。

常见误区

误区“我想写个自定义链,是不是得先 `class MyChain(Runnable)`?不继承是不是就不算 Runnable?”

正解LangChain 的协议更接近“鸭子类型”——只要一个对象实现了 invoke、stream、ainvoke 等核心方法,并且行为符合语义,它就是一个 Runnable,不需要继承任何父类。好处是同一套协议可以兼容你已有的普通类和第三方库函数,只需用 RunnableLambda 包装即可。继承只是获得默认实现和类型提示的便捷途径,不是硬性门槛。

为何常见:面向对象编程的教育让开发者习惯“继承即归属”,看到接口规范的第一反应就是找基类,而忽略了协议在 Python 中常常是“约定胜于继承”。

误区“流式输出不就是等模型生成完,然后把字符串按 10 个字符切一刀,分别发给前端吗?”

正解真正的流式核心是生产者边计算边交付。invoke 要等所有计算完成才返回,而 stream 在第一个 token 产出时就把结果交给消费者。如果内部还是先完整生成再切片,那只是“伪流式”——首 token 延迟并没有降低,内存占用也没有改善。只有使用 yield 让生成器真正惰性产出,才能获得流式的体验与性能收益。

为何常见:从外部看,流式结果确实是一段一段返回的,初学者容易把“表现层切片”误认为“生成层流式”,忽略了底层链路是否真正逐块生产。

误区“我写了 `async def ainvoke`,里面调用了 `requests.post`,再用 `await` 接收结果,这就算支持异步了吧?”

正解async def 只是声明这个函数是协程,但如果函数体内使用了同步阻塞的调用(如 requests、time.sleep),事件循环依然会被卡住,其他并发的协程全部排队等待。正确的做法是使用异步客户端(如 httpx.AsyncClient、aiohttp),并确保整条链路上的 IO 操作都是非阻塞的。这也解释了为什么 LangChain 的组件要单独实现 ainvoke——同步和异步是两套不同的执行路径。

为何常见:语法层面 await 让代码看起来像异步,但阻塞发生在更底层;很多人把“可以 await”误认为“不会阻塞”,忽视了事件循环的协作式调度原理。

误区“链里添加更多组件、更多 RunnableLambda 回调、更多分支逻辑,工程上显得更高级。”

正解每增加一个组件,就增加一层延迟、一类失败可能性和一块需要观测的面。生产环境推崇的是“最简可用链”:能一个组件解决的绝不拆成三个,能合并的解析步骤尽量合并。复杂逻辑应优先放在组件内部,而不是堆成超长链条。可观测数据也表明,链条越短,定位问题的平均时间越短,稳定性越高。

为何常见:技术圈常把“高复杂度”和“高水平”画等号,加之 LCEL 组合无比丝滑,容易让人把组合本身当成了目的,而忘了链是服务于业务目标的手段。

面试题

Q1. Runnable 协议的核心作用是什么?

单选题 · easy

  • A.让所有组件拥有统一的调用与组合方式← 正确答案
  • B.让所有模型使用完全相同的算法
  • C.让 LangChain 不再依赖 GPU 算力
  • D.让所有工具只能串行执行任务

答案:A。Runnable 协议定义了 invoke / batch / stream / ainvoke 等统一动作,让工具、链、模型变成可互换的乐高零件。B 混淆了“统一接口”与“统一实现”;C 与算力无关;D 相反,Runnable 天然支持流式、异步和并行。

Q2. 在 LCEL 中,表达式 prompt | model | parser 的含义是什么?

单选题 · mid

  • A.三个组件串联成一条数据管道← 正确答案
  • B.三个组件并行处理同一输入
  • C.从 prompt 中删除 model 与 parser
  • D.将 model 复制两份再拼接

答案:A。| 是 LCEL 的管道运算符,表示前一个组件的输出流入下一个组件的输入。A 准确描述了这一语义。B 是并发语义而非管道语义;C 与 D 是对运算符的误解。

Q3. 自定义 Runnable 组件时,以下哪些行为符合 LangChain 的协议规范?

多选题 · mid

  • A.实现 stream 方法并支持流式产出← 正确答案
  • B.同步方法内不阻塞事件循环← 正确答案
  • C.在 invoke 中修改全局可变状态
  • D.输入输出声明的类型清晰可预期← 正确答案

答案:ABD。规范组件应支持流式、异步友好、类型明确。C 违反状态无关设计原则,会让组件在不同链和并发环境下产生不可预测的行为,正确做法是保持组件无隐藏状态。

Q4. stream 方法与 invoke 方法的关键区别是什么?

单选题 · mid

  • A.stream 返回生成器,边计算边产出← 正确答案
  • B.stream 比 invoke 节省模型成本
  • C.stream 不需要提供任何输入参数
  • D.stream 只能在异步环境下运行

答案:A。stream 用 yield 惰性产出结果块,消费者拿到第一块时计算尚未完成。B 成本取决于总 token 数而非调用方式;C 仍需输入;D 同步异步都有 stream 版本。

Q5. 调用 ainvoke 方法后,立刻得到的是什么?

单选题 · mid

  • A.一个需要 await 的协程对象← 正确答案
  • B.一个包含全部结果的列表
  • C.一个已完成的最终字符串
  • D.一个自动执行的生成器

答案:A。异步函数调用后返回协程,要交给事件循环(如 await)才会执行并取得结果。B/C 是同步世界的结果形式;D 错在协程不会因调用而自动执行。

Q6. 实现组件的流式输出时,需要注意哪些要点?

多选题 · hard

  • A.使用 yield 逐块产出结果← 正确答案
  • B.保证产出顺序稳定有序← 正确答案
  • C.内部调用 time.sleep 模拟耗时
  • D.输出块类型与 invoke 语义一致← 正确答案

答案:ABD。流式需要惰性逐块产出,且块的顺序应稳定可预期,块的粒度不能改变输出语义。C 会阻塞事件循环,破坏异步能力,应用 asyncio.sleep 代替。

Q7. RunnableLambda 的主要用途是什么?

单选题 · easy

  • A.把普通函数包装成 Runnable 参与链← 正确答案
  • B.用来训练新的语言模型
  • C.自动为组件生成文档注释
  • D.把链转换为 JSON 配置文件

答案:A。RunnableLambda 是适配器,让无类声明的普通函数也能获得 | 组合、流式与追踪能力。B/C/D 都不是它的职责。

Q8. batch 方法相对于循环调用 invoke 的优势是什么?

单选题 · easy

  • A.一次接收多个输入并返回对应输出列表← 正确答案
  • B.自动把多个输出合并成一个结果
  • C.失败时自动忽略所有错误
  • D.只支持异步运行模式

答案:A。batch 封装了“多输入→多输出”的批量语义,为内部并发调度留出空间。B 是聚合语义而非批处理;C 错误处理仍需显式配置;D 同步版 batch 同样存在。

Q9. 将 Runnable 链部署到生产环境时,合理的做法包括?

多选题 · hard

  • A.对关键调用配置超时与重试← 正确答案
  • B.全链路日志记录追踪与成本← 正确答案
  • C.关闭流式输出以保证稳定性
  • D.利用批处理聚合低峰期请求← 正确答案

答案:ABD。生产需要超时兜底、可观测追踪与批量吞吐优化。C 没有必要,流式是 Runnable 的标准能力,关闭流式既不能提升稳定性也牺牲了用户体验。

Q10. LCEL 链中前一组件的流式输出如何传递给下一组件?

单选题 · extreme

  • A.前组件逐块产出时后组件逐块消费← 正确答案
  • B.前组件全部结束才交给后组件
  • C.前后组件并行运行互不等待
  • D.后组件主动拉取整段输入数据

答案:A。LangChain 的 RunnableSequence 在流式模式下会尽量保持逐块传递:前一个组件每 yield 一块,就尽力喂给下一个组件继续处理,实现端到端流式。B 是“缓冲再传”的降级路径;C/D 不符合 LCEL 顺序管道的语义。