从零实现 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 小时 |
一. 实验目标
- 实现
Runnable抽象基类,定义invoke、stream、batch、__or__(管道)四个核心方法,理解协议统一的意义。 - 实现管道组合(
|操作符),掌握 LCEL(LangChain Expression Language)中数据从左向右流动的内部原理。 - 构建三个具体组件------
PromptTemplate、FakeLLM、RunnableLambda------体会"一切皆 Runnable"的设计哲学。 - 实现
stream流式输出与batch批处理,理解同步与异步执行接口的统一。 - 通过 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__(管道组合)四个核心方法。其中 stream、batch、ainvoke 可以提供默认实现,子类按需覆盖。
思考:为什么 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_type 和 output_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") 返回 True,RunnableLambda 调用返回正确结果。
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 == dict,chain.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 是如何解决的?
资源附录
- 实现源码:agent-grok-labs
- 实验文档:密码:84k2