Lab:Runnable 协议实现与 LCEL 管道组合

从零实现 LangChain 最核心的三个抽象:Runnable 协议、管道组合(LCEL)和统一运行时接口,并通过 8 项客观可衡量的测试验证你的实现与官方行为一致。

阶段 预估耗时 建议
设计 Runnable 基类与组合逻辑 30--45 min 先理清 invoke/stream/batch/__or__ 四个核心方法的职责
实现三个具体组件 30--45 min PromptTemplate、FakeLLM、RunnableLambda 均需继承 Runnable
编写测试脚本并调试 30--60 min 建议先跑测试 1--3,再补充分支和并行
执行测试与填写报告 20--30 min 结合运行现象回答分析题
总计 2--4 小时

一. 实验目标

  1. 实现 Runnable 抽象基类,定义 invokestreambatch__or__(管道)四个核心方法,理解协议统一的意义。
  2. 实现管道组合(| 操作符),掌握 LCEL(LangChain Expression Language)中数据从左向右流动的内部原理。
  3. 构建三个具体组件------PromptTemplateFakeLLMRunnableLambda------体会"一切皆 Runnable"的设计哲学。
  4. 实现 stream 流式输出与 batch 批处理,理解同步与异步执行接口的统一。
  5. 通过 8 项严格测试验证你的实现与 LangChain 官方行为一致,让 runnable/base.py 源码不再神秘。

二. 前置知识

  • Python 基础:类、生成器(yield)、异常处理、asyncio 初步。
  • 了解 concurrent.futures.ThreadPoolExecutor 的基本用法(用于 batch 并行)。
  • 浏览 LangChain 官方文档中关于 Runnable 的概念。
  • 本实验不依赖任何深度学习框架,纯 Python 实现。

三. 项目脚手架

复制代码
runnable_core_lab/
├── src/
│   ├── runnable.py       # 定义 Runnable 基类(含 invoke/stream/batch/__or__)
│   ├── components.py     # 实现 PromptTemplate, FakeLLM, RunnableLambda
│   └── tracer.py         # (可选)简易调用追踪器,用于测试6
├── tests/
│   └── test_protocol.py  # 包含全部 8 项测试的脚本
├── requirements.txt      # pytest, typing-extensions
└── README.md

你必须实现的接口(严禁修改签名):

src/runnable.py

python 复制代码
from abc import ABC, abstractmethod
from typing import Any, Iterator, Iterator

class Runnable(ABC):

    @abstractmethod
    def invoke(self, input: Any) -> Any:
        """同步执行,返回结果。"""
        ...

    def stream(self, input: Any) -> Iterator[Any]:
        """流式输出,默认实现为 yield invoke 的结果。"""
        yield self.invoke(input)

    def batch(self, inputs: list) -> list:
        """批处理,默认实现为逐个 invoke。"""
        return [self.invoke(inp) for inp in inputs]

    async def ainvoke(self, input: Any) -> Any:
        """异步执行,默认实现为调用同步 invoke。"""
        return self.invoke(input)

    def __or__(self, other: 'Runnable') -> 'Runnable':
        """管道组合操作符 |"""
        ...

src/components.py

python 复制代码
class PromptTemplate(Runnable):
    def __init__(self, template: str):
        ...

    def invoke(self, input: dict) -> str:
        """将模板中的 {key} 替换为 input 字典中的值,返回格式化后的字符串。"""
        ...


class FakeLLMResponse:
    """FakeLLM 的响应对象,包含 content 字段。"""
    def __init__(self, content: str):
        self.content = content


class FakeLLM(Runnable):
    def __init__(self, responses: dict = None):
        # responses: 输入字符串 → 输出字符串的映射
        ...

    def invoke(self, input: str) -> FakeLLMResponse:
        """根据 input 查找预设响应,返回 FakeLLMResponse 对象。"""
        ...

    def stream(self, input: str) -> Iterator[FakeLLMResponse]:
        """逐字输出,每次 yield 一个字符的 FakeLLMResponse。"""
        ...


class RunnableLambda(Runnable):
    def __init__(self, func):
        ...

    def invoke(self, input: Any) -> Any:
        """调用包装的函数并返回结果。"""
        ...

src/tracer.py

python 复制代码
class Tracer:
    def __init__(self):
        self.calls = []

    def add(self, name: str):
        self.calls.append(name)

!请遵循以下约束:

1.禁止使用 langchain 库,所有核心类必须自行编写。

2.所有组件必须继承自 Runnable

3.管道组合通过 __or__ 方法实现,返回一个新的 Runnable 对象。

4.FakeLLM 需支持 stream 方法逐字输出。

5.鼓励在实现 batch 时使用 concurrent.futures.ThreadPoolExecutor 体验真并行。

四. 核心设计指引

1. Runnable 基类与协议统一

Runnable 是所有组件的抽象基类。你需要定义 invoke(同步执行)、stream(流式输出)、batch(批处理)和 __or__(管道组合)四个核心方法。其中 streambatchainvoke 可以提供默认实现,子类按需覆盖。

思考:为什么 stream 的默认实现可以是 yield self.invoke(input)?这对组件的渐进式实现有什么好处?

2. 管道组合(LCEL)

__or__ 方法应返回一个新的 Runnable 对象(可命名为 RunnableSequence),其 invoke 方法按顺序调用链中每个组件,将前一个的输出作为后一个的输入。伪代码如下:

复制代码
class RunnableSequence(Runnable):
    def __init__(self, first, second):
        self.steps = [first, second]

    def invoke(self, input):
        result = input
        for step in self.steps:
            result = step.invoke(result)
        return result

思考:当多个管道组合时(a | b | c),RunnableSequence 是嵌套的还是扁平的?哪种更好?

3. 类型追踪

管道链需要暴露 input_typeoutput_type 属性。最简单的方式是:input_type 取自链中第一个组件,output_type 取自最后一个组件。你需要为每个组件定义这两个属性。

4. 流式输出 (stream)

FakeLLM.stream 应逐字产出 FakeLLMResponse,每次 yield 一个字符。注意:当管道中某个组件需要完整输入才能工作时,流式行为会如何变化?LangChain 通过"自动降级"解决------如果下游组件不支持流式,则收集完整结果后再传递。

5. 批处理 (batch)

batch 方法应能并行处理多个输入。建议使用 concurrent.futures.ThreadPoolExecutor 实现真并行,并确保结果顺序与输入一致。

6. 调用追踪(Tracer)

为支持测试 6,各组件需要实现 with_tracer(tracer) 方法,返回一个绑定了追踪器的新实例。在 invoke 时,向 tracer.calls 添加自己的标识(如 "PromptTemplate.invoke")。

7. 错误传播

当链中某个组件抛出异常时,异常应原样向上传播,不做吞没。Python 的异常机制天然支持这一点,但需确保管道执行中没有 try/except 意外捕获。

8. 常见阻碍

如果卡住超过 15 分钟,请思考以下关键问题:

  • __or__ 返回的是新对象还是修改了自身?(必须是新对象)
  • FakeLLM.invoke 如何在 responses 中查找匹配?(精确匹配输入字符串)
  • stream 产出的每个 chunk 是否都是 FakeLLMResponse 对象?
  • batch 的结果顺序是否与输入顺序一致?

五. 测试用例

请将以下代码放入 tests/test_protocol.py。每个测试均附有目标和通过标准,帮助你明确要验证的内核特性。

测试 1:Runnable 协议一致性

目标 :验证所有自定义组件都正确实现了 invoke 方法。

通过标准hasattr(obj, "invoke") 返回 TrueRunnableLambda 调用返回正确结果。

python 复制代码
def test_runnable_protocol():
    from src.components import PromptTemplate, FakeLLM, RunnableLambda

    prompt = PromptTemplate("Hello {name}")
    assert hasattr(prompt, "invoke")

    llm = FakeLLM()
    assert hasattr(llm, "invoke")
    assert hasattr(llm, "stream")

    func = RunnableLambda(lambda x: x.upper())
    assert func.invoke("test") == "TEST"

测试 2:管道组合(| 操作符)语义

目标 :验证 __or__ 能将两个 Runnable 串联,且数据从左向右流动。

通过标准prompt | llm 链的 invoke 返回内容符合预设。

python 复制代码
def test_pipe_semantics():
    from src.components import PromptTemplate, FakeLLM

    prompt = PromptTemplate("Say {word}")
    llm = FakeLLM(responses={"Say hello": "HELLO"})
    chain = prompt | llm
    result = chain.invoke({"word": "hello"})
    assert result.content == "HELLO"

测试 3:统一输入输出类型追踪

目标:验证链中每个环节的输入输出类型可被外部获取。

通过标准chain.input_type == dictchain.output_type == str

python 复制代码
def test_type_tracking():
    from src.components import PromptTemplate, FakeLLM, RunnableLambda

    prompt = PromptTemplate("Q: {q}")
    llm = FakeLLM()
    extract = RunnableLambda(lambda resp: resp.content)
    chain = prompt | llm | extract
    assert chain.input_type == dict
    assert chain.output_type == str

测试 4:batch 批处理正确性

目标 :验证 batch 方法能并行处理多个输入,且结果顺序与输入一致。

通过标准results == [2, 4, 6],总耗时 < 0.3 秒(模拟并行收益)。

python 复制代码
import time

def test_batch_correctness():
    from src.components import RunnableLambda

    def slow_echo(x):
        time.sleep(0.1)
        return x * 2

    run = RunnableLambda(slow_echo)
    inputs = [1, 2, 3]
    start = time.perf_counter()
    results = run.batch(inputs)
    elapsed = time.perf_counter() - start
    assert results == [2, 4, 6]
    assert elapsed < 0.3

测试 5:stream 流式输出行为

目标 :验证 stream 方法能逐步产出数据,且总输出与 invoke 一致。

通过标准 :拼接后内容正确,且 len(chunks) > 1

python 复制代码
def test_stream_behavior():
    from src.components import FakeLLM

    llm = FakeLLM(responses={"hello": "Hello World!"})
    chunks = list(llm.stream("hello"))
    full_response = "".join(chunk.content for chunk in chunks)
    assert full_response == "Hello World!"
    assert len(chunks) > 1

测试 6:调用链顺序验证(深度)

目标:验证管道组合后的执行顺序符合预期,且每个组件仅被调用一次。

通过标准 :追踪到的调用顺序为 ["PromptTemplate.invoke", "FakeLLM.invoke", "RunnableLambda.invoke"]

实现提示 :创建一个简易 Tracer 类,内部维护一个列表,各组件 invoke 时向列表添加自己的名称。

python 复制代码
def test_execution_order():
    from src.tracer import Tracer
    from src.components import PromptTemplate, FakeLLM, RunnableLambda

    tracer = Tracer()
    prompt = PromptTemplate("Q: {q}").with_tracer(tracer)
    llm = FakeLLM().with_tracer(tracer)
    extract = RunnableLambda(lambda x: x.content).with_tracer(tracer)
    chain = prompt | llm | extract
    chain.invoke({"q": "test"})
    expected = ["PromptTemplate.invoke", "FakeLLM.invoke", "RunnableLambda.invoke"]
    assert tracer.calls == expected

测试 7:错误传播机制(深度)

目标:验证当链中某个组件抛出异常时,异常能正确地向上传播。

通过标准 :捕获 ValueError 且异常消息包含组件标识。

python 复制代码
import pytest
from src.components import RunnableLambda

def test_error_propagation():
    def fail_on_empty(x):
        if x == "":
            raise ValueError("Input cannot be empty in fail_on_empty")
        return x

    safe_chain = RunnableLambda(str.upper) | RunnableLambda(fail_on_empty)
    with pytest.raises(ValueError, match="Input cannot be empty"):
        safe_chain.invoke("")

测试 8:异步接口等价性(深度)

目标 :验证异步方法 ainvoke 与同步方法的行为完全一致。

通过标准sync_result == async_result

python 复制代码
import asyncio
import pytest
from src.components import PromptTemplate, FakeLLM, RunnableLambda

@pytest.mark.asyncio
async def test_async_equivalence():
    prompt = PromptTemplate("Say {word}")
    llm = FakeLLM(responses={"Say async": "ASYNC WORKS"})
    chain = prompt | llm | RunnableLambda(lambda r: r.content)
    sync_result = chain.invoke({"word": "async"})
    async_result = await chain.ainvoke({"word": "async"})
    assert sync_result == async_result == "ASYNC WORKS"

完整测试脚本整合示例

以下是 tests/test_protocol.py 的完整整合版本,你可直接复制使用:

python 复制代码
import time
import asyncio
import pytest

from src.runnable import Runnable
from src.components import PromptTemplate, FakeLLM, RunnableLambda
from src.tracer import Tracer


def test_runnable_protocol():
    prompt = PromptTemplate("Hello {name}")
    assert hasattr(prompt, "invoke")
    llm = FakeLLM()
    assert hasattr(llm, "invoke")
    assert hasattr(llm, "stream")
    func = RunnableLambda(lambda x: x.upper())
    assert func.invoke("test") == "TEST"


def test_pipe_semantics():
    prompt = PromptTemplate("Say {word}")
    llm = FakeLLM(responses={"Say hello": "HELLO"})
    chain = prompt | llm
    result = chain.invoke({"word": "hello"})
    assert result.content == "HELLO"


def test_type_tracking():
    prompt = PromptTemplate("Q: {q}")
    llm = FakeLLM()
    extract = RunnableLambda(lambda resp: resp.content)
    chain = prompt | llm | extract
    assert chain.input_type == dict
    assert chain.output_type == str


def test_batch_correctness():
    def slow_echo(x):
        time.sleep(0.1)
        return x * 2

    run = RunnableLambda(slow_echo)
    inputs = [1, 2, 3]
    start = time.perf_counter()
    results = run.batch(inputs)
    elapsed = time.perf_counter() - start
    assert results == [2, 4, 6]
    assert elapsed < 0.3


def test_stream_behavior():
    llm = FakeLLM(responses={"hello": "Hello World!"})
    chunks = list(llm.stream("hello"))
    full_response = "".join(chunk.content for chunk in chunks)
    assert full_response == "Hello World!"
    assert len(chunks) > 1


def test_execution_order():
    tracer = Tracer()
    prompt = PromptTemplate("Q: {q}").with_tracer(tracer)
    llm = FakeLLM().with_tracer(tracer)
    extract = RunnableLambda(lambda x: x.content).with_tracer(tracer)
    chain = prompt | llm | extract
    chain.invoke({"q": "test"})
    expected = ["PromptTemplate.invoke", "FakeLLM.invoke", "RunnableLambda.invoke"]
    assert tracer.calls == expected


def test_error_propagation():
    def fail_on_empty(x):
        if x == "":
            raise ValueError("Input cannot be empty in fail_on_empty")
        return x

    safe_chain = RunnableLambda(str.upper) | RunnableLambda(fail_on_empty)
    with pytest.raises(ValueError, match="Input cannot be empty"):
        safe_chain.invoke("")


@pytest.mark.asyncio
async def test_async_equivalence():
    prompt = PromptTemplate("Say {word}")
    llm = FakeLLM(responses={"Say async": "ASYNC WORKS"})
    chain = prompt | llm | RunnableLambda(lambda r: r.content)
    sync_result = chain.invoke({"word": "async"})
    async_result = await chain.ainvoke({"word": "async"})
    assert sync_result == async_result == "ASYNC WORKS"

六. 思考检验

1. 管道组合原理

在你的实现中,prompt | llm 创建了一个新的 Runnable 对象。当调用它的 invoke 时,内部执行流程是怎样的?用伪代码描述关键步骤。

2. 批处理实现

你的 batch 方法是真并行还是假并行?简述原因。若换成 LangChain 真实 batch,你认为它会采用哪种方式?

3. 流式与批处理的矛盾

如果你的 FakeLLM.stream 每次 yield 一个字符,而后续组件需要一个完整字符串才能工作,用 | 连接会发生什么?LangChain 是如何解决的?

资源附录