[LangChain之链]Runnable,不仅要可执行,还要可存储、可传输、可重建、可配置和可替换

0 阅读13分钟

在 LangChain 架构中,RunnableSerializable是一个至关重要的中间层基类。它继承自Runnable并混入了Serializable协议。在复杂的 AI 系统中,仅仅能“运行”是不够的,RunnableSerializable 使组件可保存、可传输、可重建。大多数常用的 LangChain 组件对应的类型都是它的派生类。``RunnableSerializable解决了以下核心问题:

  • 持久化:将复杂的链保存为 JSON 文件,稍后在另一个进程中重新加载;
  • 版本控制:由于每个组件都有明确的lc_idlc_kwargs,可以轻松追踪链的版本;
  • 可视化与监控: LangSmith 这样的平台依赖序列化信息来渲染图中每个节点的具体配置,如温度、模型名、提示词等;
  • 跨语言交互:通过 JSON 格式在 Python、JavaScript 和 Go 等不同语言实现的 LangChain 之间传递组件定义;

1. JSON序列化

我们先来看看混入的Serializable提供了哪些与序列化/反序列化相关的成员。如下面的代码片段所示,这个抽象类继承自pydantic.BaseModel。它具有三个类方法,is_lc_serializable用于确定指定的类型是否支持序列化,该方法默认返回False,相当于将序列化能力的判断交给了派生类。另一个类方法get_lc_namespace根据指定的类类型解析命名空间,默认的命名空间由模块名称决定。第三个方法lc_id生成该类的“唯一身份证”,由命名空间 + 类名组成,该方法处理了 Pydantic 泛型类可能改变__name__的情况,确保 ID 的一致性。

class Serializable(BaseModel, ABC):
    @classmethod
    def is_lc_serializable(cls) -> bool
    @classmethod
def get_lc_namespace(cls) -> list[str]
    @classmethod
    def lc_id(cls) -> list[str]:

    @property
    def lc_secrets(self) -> dict[str, str]
    @property
    def lc_attributes(self) -> dict

    model_config = ConfigDict(
        extra="ignore",
    )

    def to_json(self) -> SerializedConstructor | SerializedNotImplemented
    def to_json_not_implemented(self) -> SerializedNotImplemented

Serializable具有两个属性,lc_secrets提供一个需要脱敏的私密字段与环境变量名称之间的映射关系。在序列化时,这些值会被替换为占位符,防止密钥以明文形式被存储或展示。反序列化时可以从环境变量的值来对私密字段赋值,也可以直接提供一个映射字典。lc_attributes定义除Pydantic字段外其他参与序列化的数据成员。这两个属性默认返回一个空的字典。

针对JSON的序列化实现在to_json方法中,它会遍历 MRO,收集所有基类的lc_secretslc_attributes属性中的数据成员。该方法会排除标记为exclude的Pydantic字段,并调用内部函数替换敏感信息。如果is_lc_serializable属性返回False,该方法会返回一个SerializedNotImplemented对象,否则返回的是一个SerializedConstructor对象。SerializedConstructor对象可以视为用于重建原组件的构造器,所有其type字典返回 constructorkwargs以字典形式提供构造参数。它继承自BaseSerialized,后者定义了LangChain版本(lc)、组件ID(id)、名称(name)和结构拓扑图(graph)。SerializedNotImplemented也是它的子列,它的type字段返回 not implemented

class SerializedConstructor(BaseSerialized):
    type: Literal["constructor"]
kwargs: dict[str, Any]

class SerializedNotImplemented(BaseSerialized):
    type: Literal["not_implemented"]
    repr: str | None

class BaseSerialized(TypedDict):
    lc: int
    id: list[str]
    name: NotRequired[str]
    graph: NotRequired[dict[str, Any]]

Serializable采用基于Pydantic的序列化规则,其model_config返回一个用于控制序列化行为的ConfigDict对象。该对象会将extra字段设置为ignore,意味着会忽略定义在当前类型的额外字段,这样可以保证序列化结构的稳定性。

RunnableSerializable重写了to_json方法,并在调用基类同名方法返回的结果上添加了 名称”对应的成员。Pydantic 默认禁止用户定义以 model_ 开头的字段(防止与内部方法冲突),所以 LangChain 的许多模型字段(如model_name)会触发这个警告。model_config字段返回的ConfigDict通过将protected_namespaces设置为空元组,强制压制了 Pydantic 的保护命名空间警告,允许框架自由命名。

class RunnableSerializable(Serializable, Runnable[Input, Output]):
    name: str | None = None
    model_config = ConfigDict(
        # Suppress warnings from pydantic protected namespaces
        # (e.g., `model_`)
        protected_namespaces=(),
    )

    @override
    def to_json(self) -> SerializedConstructor | SerializedNotImplemented:
        dumped = super().to_json()
        with contextlib.suppress(Exception):
            dumped["name"] = self.get_name()
        return dumped

在如下的演示程序中,我们定义了继承RunnableSerializable[Input, Output]RunnableFoobar类型,并为其提供了foobar两个字段。由于Serializable的类方法is_lc_serializable返回False,为了让RunnableFoobar支持序列化,我们重写了此方法。我们将bar作为私密字段放在重新的lc_secrets方法返回的字典中,值为对应环境变量名BAR。我们还在lc_attributes属性中额外提供了一个名为baz的成员,它的值取自环境变量BAZ

from typing import Any, Optional
from langchain_core.runnables import RunnableSerializable, RunnableConfig
from langchain_core.runnables.utils import Input, Output
from langchain_core.load import load
import os

class RunnableFoobar(RunnableSerializable[Input, Output]):
    foo:str
    bar:str    

    @classmethod
    def is_lc_serializable(cls) -> bool:
        return True

    @property
    def lc_secrets(self) -> dict[str, str]:
        return {"bar": "BAR"}

    @property
    def lc_attributes(self) -> dict[str, Any]:
        return {"baz": os.environ.get("BAZ")}
    
    def invoke(
        self,
        input: Input,
        config: RunnableConfig | None = None,
        **kwargs: Any,
) -> Output:
        pass

    
os.environ["BAR"] = "supersecret"
os.environ["BAZ"] = "additional-info"
runnable = RunnableFoobar(foo="hello", bar=os.environ["BAR"])
serialized = runnable.to_json()
print(serialized)
# output:
# {'lc': 1, 'type': 'constructor', 'id': ['__main__', 'RunnableFoobar'], 'kwargs': {'foo': 'hello', 'bar': {'lc': 1, 'type': 'secret', 'id': ['BAR']}, 'baz': 'additional-info'}, 'name': 'RunnableFoobar'}

new_runnable = load(
    serialized, 
    valid_namespaces=["__main__"],
    allowed_objects=[RunnableFoobar],
    secrets_from_env=True
)
assert new_runnable == runnable

在初始化BAR和BAZ两个环境变量后,我们创建了一个RunnableFoobar对象并调用其to_json方法。从输出的结果来看,LangChain的版本(lc)、类型(type)、ID(id)和名称都被赋予了相应的值。作为构造参数的kwargs字段虽然包含三个数据成员,但是作为私密字段的bar的值并未出现。

我们将to_json方法生成的SerializedConstructor对象作为参数调用load函数将其反序列化成RunnableFoobar对象。由于load函数默认支持预定义的命名空间和类型,所以利用valid_namespaceallowed_objects参数绕过了默认的验证。我们同时指定secrets_from_env参数为True让它从环境变量中提取私密字段的值。

2. 动态修改字段

在传统的编程模式中,如果我们想修改一个已实例化对象的属性(例如ChatOpenAI的temperature),通常需要手动修改对象属性。但在LangChain的LCEL链式调用中,对象往往是嵌套在长链里的,直接修改非常困难。RunnableSerializableconfigurable_fields方法允许我们将这些参数 “接口化” ,统一通过配置字典进行管理。

以如下演示程序为例,我们定义了派生自RunnableSerializable[dict, dict]RunnableFoobar,并为它提供了foo、bar和baz三个字段。实现的invoke方法会将三个字段添加到作为输入的字典中,并将该字典作为返回值。我们随后创建了该对象,并调用了它的configurable_fields方法为foo和baz字段指定了对应的ConfigurableField对象,意图是将它们暴露成可配置字典。该方法会返回一个新的Runnable对象,我们通过指定配置调用其invoke方法,通过输出我们会发现RunnableFoobar对象的foo和bar字段被对应的配置项替换了,但是非配置字段baz的值被保留。

from typing import Any
from langchain_core.runnables import RunnableSerializable, RunnableConfig
from langchain_core.runnables.utils import ConfigurableField

class RunnableFoobar(RunnableSerializable[dict, dict]):
    foo:str
    bar:str 
    baz:str       
def invoke(
    self,input: dict,
    config: RunnableConfig | None = None,**kwargs: Any) -> dict:
        return {**input, "foo": self.foo, "bar": self.bar, "baz": self.baz}

runnable = RunnableFoobar(foo="123", bar="456", baz="789")
runnable = runnable.configurable_fields(
    foo=ConfigurableField(id="foo"),
    baz=ConfigurableField(id="baz"),
)
result = runnable.invoke(input={}, config={"configurable":{"foo":"FOO","bar":"BAR","baz":"BAZ"}})
assert result == {"foo":"FOO","bar":"456","baz":"BAZ"}

2.1 DynamicRunnable

要真正搞明白configurable_fields方法背后的原理,我们得先来了解DynamicRunnable类型。这也是一个继承自Runnable的抽象类,但它仅仅是另一个Runnable的代理。DynamicRunnable通过抽象方法_prepare返回被代理的Runnable对象和对应的RunnableConfigprepare方法在其内部会调用它。

class DynamicRunnable(RunnableSerializable[Input, Output]):    
    def prepare(
        self, config: RunnableConfig | None = None
) -> tuple[Runnable[Input, Output], RunnableConfig]:
        runnable: Runnable[Input, Output] = self
        while isinstance(runnable, DynamicRunnable):
            runnable, config = runnable._prepare(merge_configs(runnable.config, config))  
        return runnable, cast("RunnableConfig", config)

    @abstractmethod
    def _prepare(
        self, config: RunnableConfig | None = None
    ) -> tuple[Runnable[Input, Output], RunnableConfig]: 

对于DynamicRunnable的绝大部分方法,它们都会先调用prepare方法得到被代理的Runnable(或者它自己)和RunnableConfig,然后将方法调用“转嫁”给前者,并使用后者作为参数。所以DynamicRunnable的实现类型可以对执行链条进行100%的控制,这就是其得名的原因。

2.2 RunnableConfigurableFields

RunnableSerializableconfigurable_fields方法会创建一个RunnableConfigurableFields对象,对应的类型就是DynamicRunnable的子类。它的default字段表示当前这个RunnableSerializable对象,另一个fields字段返回一个字典,configurable_fields方法指定的关于可以被修改的字段名和对应AnyConfigurableField之间的映射关系就存储在这里。

class RunnableConfigurableFields(DynamicRunnable[Input, Output]):
    default: RunnableSerializable[Input, Output]
    fields: dict[str, AnyConfigurableField]
    def _prepare(
        self, config: RunnableConfig | None = None
    ) -> tuple[Runnable[Input, Output], RunnableConfig]:
        config = ensure_config(config)
        specs_by_id = {spec.id: (key, spec) for key, spec in self.fields.items()}
        configurable_fields = {
            specs_by_id[k][0]: v
            for k, v in config.get("configurable", {}).items()
            if k in specs_by_id and isinstance(specs_by_id[k][1], ConfigurableField)
        }
        configurable_single_options = {
            k: v.options[(config.get("configurable", {}).get(v.id) or v.default)]
            for k, v in self.fields.items()
            if isinstance(v, ConfigurableFieldSingleOption)
        }
        configurable_multi_options = {
            k: [
                v.options[o]
                for o in config.get("configurable", {}).get(v.id, v.default)
            ]
            for k, v in self.fields.items()
            if isinstance(v, ConfigurableFieldMultiOption)
        }
        configurable = {
            **configurable_fields,
            **configurable_single_options,
            **configurable_multi_options,
        }

        if configurable:
            init_params = {
                k: v
                for k, v in self.default.__dict__.items()
                if k in type(self.default).model_fields
            }
            return (
                self.default.__class__(**{**init_params, **configurable}),
                config,
            )
        return (self.default, config)

AnyConfigurableField是三个类型的联合,用于描述Runnable对象可配置的字段,它们都提供了idnamedescriptionis_shared四个公共的字段。其中id最为重要,它表示配置项的名称,is_shared字段表示该配置项是否能被多个Runnable共享,但是这个字段不会对程序执行带来任何影响,它主要是为一些外部工具设计的。由于末端配置大都体现为一个字符串,但是Runnable对应的字段可能是一个对象,所以ConfigurableFieldSingleOptionConfigurableFieldMultiOption利用options字段返回的字典提供两者之间的映射。和ConfigurableField不同的是,这两个对象都提供了默认值兜底的功能,前者的默认值是一个单值,后者是一个列表。

AnyConfigurableField = ( ConfigurableField | ConfigurableFieldSingleOption | 
ConfigurableFieldMultiOption)

class ConfigurableField(NamedTuple):
    id: str
    name: str | None = None
    description: str | None = None
    annotation: Any | None = None
    is_shared: bool = False

class ConfigurableFieldSingleOption(NamedTuple):
    id: str
    options: Mapping[str, Any]
    default: str
    name: str | None = None
    description: str | None = None
    is_shared: bool = False

class ConfigurableFieldMultiOption(NamedTuple):
    id: str
    options: Mapping[str, Any]
    default: Sequence[str]
    name: str | None = None
    description: str | None = None
    is_shared: bool = False

RunnableConfigurableFields的核心之处体现在它实现的_prepare方法。该方法会根据fields提供的可配置字段和RunnableConfig得到字段需要被更新的值。对于ConfigurableField对象来说,由于它不存在默认值,在没有提供对应的配置项的情况下会被忽略。但ConfigurableFieldSingleOptionConfigurableFieldMultiOption在配置项不存在情况下会使用默认值兜底。

通过这种方式能够得到的一个字典会包含可配置字段名称和值的映射。如果该字典不为空,当前Runnable对象(对应default字段)的所有字段将会与之合并,合并后的字典将作为构造函数的参数重新创建一个新的Runnable对象,并组成_prepare返回的二元组的前半部分。所以当我们调用RunnableConfigurableFields的invoke方法的时候,被转嫁的被代理对象其实是根据配置重新创建的。如下所示的是RunnableSerializableconfigurable_fields方法创建RunnableConfigurableFields的逻辑。

class RunnableSerializable(Serializable, Runnable[Input, Output]):
   def configurable_fields(
        self, **kwargs: AnyConfigurableField
    ) -> RunnableSerializable[Input, Output]:
        model_fields = type(self).model_fields
        for key in kwargs:
            if key not in model_fields:
                msg = (
                    f"Configuration key {key} not found in {self}: "
                    f"available keys are {model_fields.keys()}"
                )
                raise ValueError(msg)

        return RunnableConfigurableFields(default=self, fields=kwargs)

3. 替换Runnable

configurable_fields方法仅仅改变当前Runnable部分字段(虽然对象被重建了,类型和其他字段其实没有改变),另一个configurable_alternatives方法直接将Runnable对象给替换了。在如下这个演示实例中,我们定义了RunnableFooRunnableBarRunnableBaz这三个继承自RunnableSerializable[dict, dict] 的类型。在实现的invoke方法中,我们在作为输入的字典中添加一个Key为 type 的值,并将从字典作为返回值。我们由此来确定链当前使用的究竟是哪个Runnable类型。

from typing import Any
from langchain_core.runnables import RunnableSerializable, RunnableConfig
from langchain_core.runnables.utils import ConfigurableField

class RunnableFoo(RunnableSerializable[dict, dict]):    
    def invoke(
    self,input: dict,
    config: RunnableConfig | None = None,
    **kwargs: Any) -> dict:
        return {**input, "type": "foo"}

class RunnableBar(RunnableSerializable[dict, dict]):    
def invoke(
    self,input: dict,
    config: RunnableConfig | None = None,
    **kwargs: Any) -> dict:
        return {**input, "type": "bar"}
    
class RunnableBaz(RunnableSerializable[dict, dict]):
    def invoke(
    self,input: dict,
    config: RunnableConfig | None = None,
    **kwargs: Any) -> dict:
    return {**input, "type": "baz"}
       
runnable = RunnableFoo().configurable_alternatives(
    which=ConfigurableField(id="type"),
    prefix_keys= True,
    default_key= "default",
    foo = RunnableFoo(),
    bar = RunnableBar(),
    baz = RunnableBaz(),
)

result = runnable.invoke(input={}, config={"configurable":{"type":"foo"}})
assert result == {"type":"foo"}

result = runnable.invoke(input={}, config={"configurable":{"type":"bar"}})
assert result == {"type":"bar"}

result = runnable.invoke(input={}, config={"configurable":{"type":"baz"}})
assert result == {"type":"baz"}

result = runnable.invoke(input={}, config={"configurable":{"type":"default"}})
assert result == {"type":"foo"}

我们在创建的RunnableFoo对象上调用configurable_alternatives方法,并为which参数指定了一个ConfigurableField对象,然后设置了表示配置名称(type)的id字段和表示默认选项的配置项(“default”,如果type=“default”意味着使用当前这个Runnable)。我们利用关键字参数的形式提供了三个配置和对应Runnable对象的映射关系。也就是说,我们可以利用RunnableConfig中的type配置节分别指定foobarbaz动态选择三个自定的Runnable类型。

configurable_alternatives方法会创建一个RunnableConfigurableAlternatives对象,它对应的类型也继承自DynamicRunnable,后者在实现的_prepare方法中完成了针对当前Runnable对象的替换。prefix_keys字段对程序运行基本没影响,它和is_shared字段一样是为一些自动化工具服务的。

class RunnableConfigurableAlternatives(DynamicRunnable[Input, Output]):
    which: ConfigurableField
    alternatives: dict[
        str,
        Runnable[Input, Output] | Callable[[], Runnable[Input, Output]],
    ]   
    default_key: str = "default"
    prefix_keys: bool   
    def _prepare(
        self, config: RunnableConfig | None = None
    ) -> tuple[Runnable[Input, Output], RunnableConfig]:
        config = ensure_config(config)
        which = config.get("configurable", {}).get(self.which.id, self.default_key)
        # remap configurable keys for the chosen alternative
        if self.prefix_keys:
            config = cast(
                "RunnableConfig",
                {
                    **config,
                    "configurable": {
                        _strremoveprefix(k, f"{self.which.id}=={which}/"): v
                        for k, v in config.get("configurable", {}).items()
                    },
                },
            )
        # return the chosen alternative
        if which == self.default_key:
            return (self.default, config)
        if which in self.alternatives:
            alt = self.alternatives[which]
            if isinstance(alt, Runnable):
                return (alt, config)
            return (alt(), config)
        msg = f"Unknown alternative: {which}"
        raise ValueError(msg)

configurable_alternatives方法按照如下的方式创建了RunnableConfigurableAlternatives对象。

class RunnableSerializable(Serializable, Runnable[Input, Output]):
    def configurable_alternatives(
        self,
        which: ConfigurableField,
        *,
        default_key: str = "default",
        prefix_keys: bool = False,
        **kwargs: Runnable[Input, Output] | Callable[[], Runnable[Input, Output]],
    ) -> RunnableSerializable[Input, Output]:
        return RunnableConfigurableAlternatives(
            which=which,
            default=self,
            alternatives=kwargs,
            default_key=default_key,
            prefix_keys=prefix_keys,
        )

4. 配置规格说明书

虽然Runnable中没有定义configurable_fieldsconfigurable_alternatives方法,但是它具有如下这个config_specs属性。我们可以将它返回的返回一个ConfigurableFieldSpec列表视为“可配置字段规则说明”。当我们调用了configurable_fieldsconfigurable_alternatives方法后,内部会根据提供的AnyConfigurableField列表生成一个ConfigurableFieldSpec列表。它的作用是让系统(和开发者)知道:“这个组件有哪些字段是可以被动态修改的,它们的 ID、类型和默认值分别是什么?”

class Runnable(ABC, Generic[Input, Output]):
    @property
    def config_specs(self) -> list[ConfigurableFieldSpec]:
        return []

class ConfigurableFieldSpec(NamedTuple):
    id: str
    annotation: Any
    name: str | None = None
    description: str | None = None
    default: Any = None
    is_shared: bool = False
    dependencies: list[str] | None = None