LangChain设计与实现-第16章-序列化与配置系统

第16章 序列化与配置系统

本书章节导航


开篇引言

在 LangChain 的实际应用中,我们经常需要将构建好的链、模型和提示模板保存下来,以便在不同环境中复用,或者在运行时根据需求动态切换配置。这两个需求看似简单,实则牵涉到一系列深层次的设计问题:如何安全地序列化包含 API 密钥的对象?如何在反序列化时防止恶意代码注入?如何在不重建对象的情况下切换底层模型?

LangChain 的解决方案分为两个互补的子系统:序列化系统langchain_core.load)负责对象的持久化和恢复,配置系统ConfigurableField / RunnableConfig)负责运行时的动态参数调整。两者共同构成了 LangChain 的"状态管理"基础设施。

本章将深入这两个系统的源码实现,从 Serializable 基类的设计到 Reviver 的安全模型,从 ConfigurableField 的声明式配置到 DynamicRunnable 的延迟绑定机制。

:::tip 本章要点

  • Serializable 基类的设计:lc_id、lc_secrets、lc_attributes 的作用
  • dumps/dumpd 的序列化流程与注入防护(escape 机制)
  • loads/load 的反序列化流程与白名单安全模型
  • Reviver 类的多层安全防护:命名空间验证、类路径白名单、init_validator
  • ConfigurableField/ConfigurableFieldSpec 的声明式配置
  • DynamicRunnable 的延迟绑定机制
  • RunnableConfig 的结构与传播 :::

16.1 Serializable 基类

序列化系统的根基是 Serializable 类,定义在 langchain_core/load/serializable.py 中。它继承自 Pydantic 的 BaseModel 和 Python 的 ABC

16.1.1 类设计

python 复制代码
# langchain_core/load/serializable.py

class Serializable(BaseModel, ABC):
    """序列化基类"""

    @classmethod
    def is_lc_serializable(cls) -> bool:
        """此类是否可序列化?默认 False"""
        return False

    @classmethod
    def get_lc_namespace(cls) -> list[str]:
        """获取命名空间,用于序列化标识符"""
        return cls.__module__.split(".")

    @property
    def lc_secrets(self) -> dict[str, str]:
        """构造参数名到密钥 ID 的映射"""
        return {}

    @property
    def lc_attributes(self) -> dict:
        """额外需要序列化的属性"""
        return {}

    @classmethod
    def lc_id(cls) -> list[str]:
        """返回唯一标识符"""
        return [*cls.get_lc_namespace(), cls.__name__]

    model_config = ConfigDict(extra="ignore")

这段代码中包含了几个关键的设计决策,每一个都值得深入分析。

第一个决策是默认不可序列化。is_lc_serializable 方法默认返回 False。即使一个类继承了 Serializable,它也不会自动获得序列化能力,必须显式地覆盖这个方法并返回 True。这是一种"安全默认"策略。序列化意味着类的内部状态(包括可能包含的敏感信息)会被暴露为明文数据,反序列化意味着类可以被远程实例化。只有开发者明确意识到这些安全影响并同意承担时,才应该启用序列化。如果反过来,默认允许序列化,那么开发者可能在不知情的情况下暴露了不该暴露的信息。

第二个决策是使用命名空间作为身份标识。get_lc_namespace 从模块路径自动生成命名空间。例如 langchain_openai.chat_models.base.ChatOpenAI 的命名空间就是 ["langchain_openai", "chat_models", "base"],再加上类名组成完整的 lc_id。这种基于模块路径的自动生成机制避免了手动维护标识符的负担,但也意味着如果类在模块之间移动,它的标识符会改变。LangChain 通过 SERIALIZABLE_MAPPING 映射表来处理这种情况,在旧标识符和新标识符之间建立桥接。

第三个决策是显式的密钥保护机制。lc_secrets 属性声明了哪些构造参数包含敏感信息,以及它们对应的环境变量名。序列化时,这些字段的值会被替换为 SerializedSecret 标记,标记中只包含环境变量名而非实际值。这种设计确保了序列化后的数据可以安全地存储和传输,而密钥的实际值只在反序列化时通过 secrets_map 或环境变量恢复。

第四个决策是 lc_attributes 的存在。有些重要的状态信息不是构造参数,而是在初始化后计算或设置的属性。lc_attributes 允许将这些属性也纳入序列化范围,但前提是它们必须可以通过构造函数重新设置。这种限制确保了反序列化的一致性 -- 所有状态都通过构造函数重建,不存在"额外初始化"的步骤。

16.1.2 to_json -- 序列化核心

to_json 方法将对象转换为可序列化的字典:

python 复制代码
def to_json(self) -> SerializedConstructor | SerializedNotImplemented:
    if not self.is_lc_serializable():
        return self.to_json_not_implemented()

    model_fields = type(self).model_fields
    secrets = {}
    lc_kwargs = {}

    # 收集所有有用的字段值
    for k, v in self:
        if not _is_field_useful(self, k, v):
            continue
        if k in model_fields and model_fields[k].exclude:
            continue
        lc_kwargs[k] = getattr(self, k, v)

    # 从 MRO 链中合并 lc_secrets 和 lc_attributes
    for cls in [None, *self.__class__.mro()]:
        if cls is Serializable:
            break
        this = cast(Serializable, self if cls is None else super(cls, self))
        secrets.update(this.lc_secrets)
        # 处理别名
        for key in list(secrets):
            value = secrets[key]
            if (key in model_fields) and (
                alias := model_fields[key].alias
            ) is not None:
                secrets[alias] = value
        lc_kwargs.update(this.lc_attributes)

    # 确保所有密钥字段都被包含
    for key in secrets:
        secret_value = getattr(self, key, None) or lc_kwargs.get(key)
        if secret_value is not None:
            lc_kwargs.update({key: secret_value})

    return {
        "lc": 1,
        "type": "constructor",
        "id": self.lc_id(),
        "kwargs": lc_kwargs if not secrets
                  else _replace_secrets(lc_kwargs, secrets),
    }

序列化输出的结构是一个固定格式的字典:

python 复制代码
{
    "lc": 1,                              # 序列化格式版本
    "type": "constructor",                 # 类型标识
    "id": ["langchain_openai", "chat_models", "base", "ChatOpenAI"],
    "kwargs": {
        "model_name": "gpt-4",
        "temperature": 0.7,
        "openai_api_key": {               # 密钥被替换为标记
            "lc": 1,
            "type": "secret",
            "id": ["OPENAI_API_KEY"]
        }
    }
}
flowchart TD A["obj.to_json()"] --> B{is_lc_serializable?} B -->|否| C["to_json_not_implemented()"] B -->|是| D["遍历模型字段
收集 lc_kwargs"] D --> E["遍历 MRO
合并 lc_secrets + lc_attributes"] E --> F{"有密钥字段?"} F -->|是| G["_replace_secrets
将密钥值替换为 secret 标记"] F -->|否| H["直接使用 lc_kwargs"] G --> I["返回 SerializedConstructor
lc=1, type='constructor'
id=lc_id(), kwargs=..."] H --> I C --> J["返回 SerializedNotImplemented
lc=1, type='not_implemented'"]

16.1.3 _is_field_useful -- 智能字段过滤

并非所有字段都需要序列化。_is_field_useful 函数实现了智能过滤:

python 复制代码
def _is_field_useful(inst: Serializable, key: str, value: Any) -> bool:
    field = type(inst).model_fields.get(key)
    if not field:
        return False
    if field.is_required():
        return True              # 必填字段始终包含

    try:
        value_is_truthy = bool(value)
    except Exception:
        value_is_truthy = False

    if value_is_truthy:
        return True              # 非空值包含

    # 空列表/空字典如果是默认值,跳过
    if field.default_factory is dict and isinstance(value, dict):
        return False
    if field.default_factory is list and isinstance(value, list):
        return False

    # 与默认值不同的 falsy 值也包含(如 0、False)
    return _try_neq_default(value, field)

这种过滤策略确保序列化输出尽可能紧凑:默认值的字段不包含在内,但非默认的 falsy 值(如 temperature=0)会被正确保留。

过滤逻辑的复杂性反映了序列化场景下的多种边界情况。必填字段始终保留,因为反序列化时它们是构造函数所必需的。有值的可选字段保留,因为它们可能被用户有意设置为非默认值。空列表和空字典如果是默认值则跳过,因为它们可以在构造时自动创建。最精妙的是对 falsy 但非默认值的处理 -- 比如当用户将温度设为零时,虽然 bool(0)False,但零不等于默认值 0.7,因此应该被保留。如果忽略了这种情况,反序列化后的对象就会使用默认温度而非用户指定的零温度,导致行为不一致。

特别值得一提的是对 Pandas DataFrame 等特殊对象的容错处理。这些对象的布尔求值和相等性比较可能抛出异常或返回非布尔值。代码中的多重 try-except 确保了即使遇到这种异常的对象类型,过滤逻辑也不会崩溃。这种防御性编程风格在序列化这种"基础设施级"代码中尤为重要,因为它需要处理任意用户定义的数据类型。

16.2 dumps 与 dumpd -- 序列化 API

dumpsdumpd 是面向用户的序列化 API。

16.2.1 dumpd -- 转字典

python 复制代码
# langchain_core/load/dump.py

def dumpd(obj: Any) -> Any:
    """将对象转换为可 JSON 序列化的字典"""
    obj = _dump_pydantic_models(obj)  # 处理嵌套 Pydantic 模型
    return _serialize_value(obj)

16.2.2 dumps -- 转 JSON 字符串

python 复制代码
def dumps(obj: Any, *, pretty: bool = False, **kwargs: Any) -> str:
    """将对象转换为 JSON 字符串"""
    if "default" in kwargs:
        raise ValueError("`default` should not be passed to dumps")

    obj = _dump_pydantic_models(obj)
    serialized = _serialize_value(obj)

    if pretty:
        indent = kwargs.pop("indent", 2)
        return json.dumps(serialized, indent=indent, **kwargs)
    return json.dumps(serialized, **kwargs)

16.2.3 注入防护 -- 转义机制

_serialize_value 是序列化的核心递归函数,它实现了关键的注入防护:

python 复制代码
# langchain_core/load/_validation.py

_LC_ESCAPED_KEY = "__lc_escaped__"

def _needs_escaping(obj: dict[str, Any]) -> bool:
    """检查字典是否需要转义"""
    return "lc" in obj or (len(obj) == 1 and _LC_ESCAPED_KEY in obj)

def _serialize_value(obj: Any) -> Any:
    if isinstance(obj, Serializable):
        return _serialize_lc_object(obj)  # LC 对象正常序列化
    if isinstance(obj, dict):
        if _needs_escaping(obj):
            return {_LC_ESCAPED_KEY: obj}  # 危险字典被转义
        return {k: _serialize_value(v) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [_serialize_value(item) for item in obj]
    if isinstance(obj, (str, int, float, bool, type(None))):
        return obj
    return to_json_not_implemented(obj)

这段代码的安全逻辑是:当一个普通字典恰好包含 "lc" 键时(这可能是用户数据碰巧包含了与 LC 序列化格式相同的结构),它会被包装为 {"__lc_escaped__": {...}}。反序列化时,遇到这种包装会直接还原为普通字典,而不会被误认为是 LC 对象而被实例化。

flowchart TD A["_serialize_value(obj)"] --> B{obj 类型} B -->|Serializable| C["_serialize_lc_object(obj)
正常 LC 序列化"] B -->|dict| D{包含 'lc' 键?} D -->|是| E["转义: {'__lc_escaped__': obj}
防止被误解为 LC 对象"] D -->|否| F["递归: {k: _serialize_value(v)}"] B -->|list/tuple| G["递归: [_serialize_value(item)]"] B -->|基本类型| H["原样返回"] B -->|其他| I["to_json_not_implemented(obj)"]

16.3 loads 与 load -- 反序列化 API

反序列化是安全敏感的操作:它需要根据序列化数据实例化 Python 对象,执行构造函数。如果不加控制,恶意数据可能导致任意代码执行。

16.3.1 loads 和 load 函数

python 复制代码
# langchain_core/load/load.py

@beta()
def loads(
    text: str,
    *,
    allowed_objects: Iterable[AllowedObject] | Literal["all", "core"] = "core",
    secrets_map: dict[str, str] | None = None,
    valid_namespaces: list[str] | None = None,
    secrets_from_env: bool = False,
    additional_import_mappings: dict | None = None,
    ignore_unserializable_fields: bool = False,
    init_validator: InitValidator | None = default_init_validator,
) -> Any:
    raw_obj = json.loads(text)
    return load(raw_obj, ...)

@beta()
def load(
    obj: Any,
    *,
    allowed_objects = "core",
    secrets_map = None,
    ...
) -> Any:
    reviver = Reviver(
        allowed_objects, secrets_map, valid_namespaces,
        secrets_from_env, additional_import_mappings,
        ignore_unserializable_fields=ignore_unserializable_fields,
        init_validator=init_validator,
    )

    def _load(obj: Any) -> Any:
        if isinstance(obj, dict):
            # 首先检查是否是转义字典
            if _is_escaped_dict(obj):
                return _unescape_value(obj)  # 还原为普通字典
            # 递归处理子元素,然后应用 Reviver
            loaded_obj = {k: _load(v) for k, v in obj.items()}
            return reviver(loaded_obj)
        if isinstance(obj, list):
            return [_load(o) for o in obj]
        return obj

    return _load(obj)

load 函数的处理流程分为三步:

  1. 检查转义字典并还原
  2. 递归处理嵌套结构
  3. 通过 Reviver 将 LC 对象字典实例化为 Python 对象

16.3.2 Reviver -- 安全的对象恢复

Reviver 是反序列化的核心,它实现了多层安全防护:

python 复制代码
class Reviver:
    def __init__(
        self,
        allowed_objects: Iterable[AllowedObject] | Literal["all", "core"] = "core",
        secrets_map: dict[str, str] | None = None,
        valid_namespaces: list[str] | None = None,
        secrets_from_env: bool = False,
        additional_import_mappings: dict | None = None,
        *,
        ignore_unserializable_fields: bool = False,
        init_validator: InitValidator | None = default_init_validator,
    ) -> None:
        self.secrets_from_env = secrets_from_env
        self.secrets_map = secrets_map or {}
        # 默认可信命名空间
        self.valid_namespaces = (
            [*DEFAULT_NAMESPACES, *valid_namespaces]
            if valid_namespaces else DEFAULT_NAMESPACES
        )
        # 计算允许的类路径
        if allowed_objects in ("all", "core"):
            self.allowed_class_paths = (
                _get_default_allowed_class_paths(allowed_objects).copy()
            )
        else:
            self.allowed_class_paths = _compute_allowed_class_paths(
                allowed_objects, self.import_mappings
            )
        self.init_validator = init_validator

默认的可信命名空间包括:

python 复制代码
DEFAULT_NAMESPACES = [
    "langchain",
    "langchain_core",
    "langchain_community",
    "langchain_anthropic",
    "langchain_groq",
    "langchain_google_genai",
    "langchain_aws",
    "langchain_openai",
    "langchain_google_vertexai",
    "langchain_mistralai",
    "langchain_fireworks",
    "langchain_xai",
    "langchain_sambanova",
    "langchain_perplexity",
]

16.3.3 Reviver.call -- 三阶段安全验证

python 复制代码
def __call__(self, value: dict[str, Any]) -> Any:
    # 阶段 1:处理密钥
    if value.get("lc") == 1 and value.get("type") == "secret":
        [key] = value["id"]
        if key in self.secrets_map:
            return self.secrets_map[key]
        if self.secrets_from_env and key in os.environ:
            return os.environ[key]
        return None

    # 阶段 2:处理不可序列化标记
    if value.get("lc") == 1 and value.get("type") == "not_implemented":
        if self.ignore_unserializable_fields:
            return None
        raise NotImplementedError(...)

    # 阶段 3:处理构造函数类型
    if value.get("lc") == 1 and value.get("type") == "constructor":
        [*namespace, name] = value["id"]
        mapping_key = tuple(value["id"])

        # 安全检查 1:白名单验证
        if (self.allowed_class_paths is not None
            and mapping_key not in self.allowed_class_paths):
            raise ValueError(
                f"Deserialization of {mapping_key!r} is not allowed."
            )

        # 安全检查 2:命名空间验证
        if namespace[0] not in self.valid_namespaces:
            raise ValueError(f"Invalid namespace: {value}")

        # 安全检查 3:导入路径验证
        if mapping_key in self.import_mappings:
            import_path = self.import_mappings[mapping_key]
            import_dir, name = import_path[:-1], import_path[-1]
        elif namespace[0] in DISALLOW_LOAD_FROM_PATH:
            raise ValueError(...)
        else:
            import_dir = namespace

        if import_dir[0] not in self.valid_namespaces:
            raise ValueError(f"Invalid namespace: {value}")

        kwargs = value.get("kwargs", {})

        # 安全检查 4:类特定验证器
        if mapping_key in CLASS_INIT_VALIDATORS:
            CLASS_INIT_VALIDATORS[mapping_key](mapping_key, kwargs)

        # 安全检查 5:通用验证器(如阻止 jinja2 模板)
        if self.init_validator is not None:
            self.init_validator(mapping_key, kwargs)

        # 安全检查通过,执行导入和实例化
        mod = importlib.import_module(".".join(import_dir))
        cls = getattr(mod, name)

        # 最终检查:必须是 Serializable 子类
        if not issubclass(cls, Serializable):
            raise ValueError(f"Invalid namespace: {value}")

        return cls(**kwargs)

    return value
flowchart TD A["Reviver(value)"] --> B{type == 'secret'?} B -->|是| C["从 secrets_map 或环境变量获取密钥值"] B -->|否| D{type == 'not_implemented'?} D -->|是| E["抛出 NotImplementedError
或返回 None"] D -->|否| F{type == 'constructor'?} F -->|否| G["原样返回 value"] F -->|是| H["安全检查 1: 白名单"] H --> I["安全检查 2: 命名空间"] I --> J["安全检查 3: 导入路径"] J --> K["安全检查 4: 类特定验证器"] K --> L["安全检查 5: 通用验证器
(阻止 jinja2 等)"] L --> M["importlib.import_module"] M --> N{是 Serializable 子类?} N -->|是| O["cls(**kwargs)
实例化对象"] N -->|否| P["抛出 ValueError"]

16.3.4 allowed_objects 的三种模式

python 复制代码
allowed_objects: Iterable[AllowedObject] | Literal["all", "core"] = "core"
模式 说明 安全等级
"core" 仅允许 langchain_core 中的类 最高
"all" 允许所有映射中注册的类(含 Partner 包) 中等
[AIMessage, ...] 仅允许指定的类 自定义

推荐在生产环境中使用显式列表模式,精确控制可反序列化的类型。这种最小权限原则是安全工程的基本实践 -- 只允许确实需要的类型,而非宽泛地信任整个命名空间。

三种模式的选择反映了不同的信任等级。"core" 模式适合处理来自外部的序列化数据(如用户上传的配置),因为 langchain_core 中的类(消息、文档、提示模板等)在初始化时不会执行网络请求或文件操作。"all" 模式适合内部系统之间的数据交换,因为 Partner 包中的类可能在初始化时建立数据库连接或 HTTP 客户端,但这些行为在受信环境中是可接受的。显式列表模式适合安全要求最高的场景,如处理不可信的 webhook 数据。

另一个重要的安全细节是 DISALLOW_LOAD_FROM_PATH 列表。某些命名空间(如 langchain_communitylangchain)只允许通过映射表加载,不允许直接按路径导入。这是因为这些命名空间中可能包含大量未经审核的第三方集成代码,允许按路径导入可能会实例化不安全的类。通过映射表加载则确保了只有明确注册过的类才能被反序列化。

16.3.5 密钥恢复的安全考量

python 复制代码
# 反序列化时恢复密钥
secrets_from_env: bool = False  # 默认关闭

secrets_from_env=False 是一个重要的安全默认值。如果设为 True,恶意的序列化数据可以在 secret 字段中指定任意环境变量名,导致在反序列化时泄露敏感信息。只有在完全信任数据来源时才应启用。

16.4 ConfigurableField 与动态配置

LangChain 的配置系统允许在不重建对象的情况下,运行时动态调整参数。

16.4.1 ConfigurableField 家族

python 复制代码
# langchain_core/runnables/utils.py

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

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

这四种配置字段类型覆盖了不同的使用场景:

  • ConfigurableField: 基础配置,允许直接设置值
  • ConfigurableFieldSingleOption: 从预定义选项中选一个
  • ConfigurableFieldMultiOption: 从预定义选项中选多个
  • ConfigurableFieldSpec: 完整的字段规范,包含类型注解和依赖关系

16.4.2 configurable_fields -- 声明可配置项

Runnable 的 configurable_fields 方法用于声明哪些字段是可配置的:

python 复制代码
# 使用示例
from langchain_core.runnables import ConfigurableField
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

# 声明 model_name 和 temperature 为可配置字段
configurable_model = model.configurable_fields(
    model_name=ConfigurableField(
        id="model_name",
        name="模型名称",
        description="要使用的 OpenAI 模型",
    ),
    temperature=ConfigurableField(
        id="temperature",
        name="温度",
        description="生成的随机性控制",
    ),
)

# 运行时动态配置
result = configurable_model.invoke(
    "Hello",
    config={"configurable": {"model_name": "gpt-4", "temperature": 0.9}},
)

16.4.3 configurable_alternatives -- 整体替换

configurable_alternatives 允许在运行时切换整个 Runnable 实现:

python 复制代码
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4").configurable_alternatives(
    ConfigurableField(id="llm"),
    default_key="openai",
    anthropic=ChatAnthropic(model="claude-3-sonnet-20240229"),
)

# 使用默认的 OpenAI
result1 = model.invoke("Hello")

# 切换到 Anthropic
result2 = model.invoke(
    "Hello",
    config={"configurable": {"llm": "anthropic"}},
)
flowchart TD subgraph "configurable_fields" A["ChatOpenAI(model='gpt-3.5')"] -->|".configurable_fields()"| B["DynamicRunnable"] B -->|"config: model_name='gpt-4'"| C["ChatOpenAI(model='gpt-4')"] B -->|"config: temperature=0.9"| D["ChatOpenAI(temperature=0.9)"] end subgraph "configurable_alternatives" E["ChatOpenAI(默认)"] -->|".configurable_alternatives()"| F["DynamicRunnable"] F -->|"config: llm='openai'"| G["ChatOpenAI"] F -->|"config: llm='anthropic'"| H["ChatAnthropic"] end

16.4.4 DynamicRunnable -- 延迟绑定

DynamicRunnable 是配置系统的运行时载体:

python 复制代码
# langchain_core/runnables/configurable.py

class DynamicRunnable(RunnableSerializable[Input, Output]):
    default: RunnableSerializable[Input, Output]
    config: RunnableConfig | None = None

    def prepare(
        self, config: RunnableConfig | None = None
    ) -> tuple[Runnable[Input, Output], RunnableConfig]:
        """根据配置准备实际的 Runnable"""
        runnable: Runnable[Input, Output] = self
        while isinstance(runnable, DynamicRunnable):
            runnable, config = runnable._prepare(
                merge_configs(runnable.config, config)
            )
        return runnable, cast(RunnableConfig, config)

    def invoke(
        self, input: Input, config: RunnableConfig | None = None, **kwargs
    ) -> Output:
        runnable, config = self.prepare(config)
        return runnable.invoke(input, config, **kwargs)

    async def ainvoke(
        self, input: Input, config: RunnableConfig | None = None, **kwargs
    ) -> Output:
        runnable, config = self.prepare(config)
        return await runnable.ainvoke(input, config, **kwargs)

prepare 方法是关键。每次调用 invoke 时,它根据传入的配置动态解析出实际应该使用的 Runnable。如果有嵌套的 DynamicRunnable(多层配置),它会循环解包直到获得最终的具体 Runnable。

这种"延迟绑定"设计意味着 DynamicRunnable 本身不执行任何逻辑,它只是一个"配置分发器"。真正的执行总是委托给解析出的具体 Runnable。

理解这个设计需要区分"构建时"和"运行时"两个阶段。在构建时(调用 configurable_fieldsconfigurable_alternatives 时),系统创建一个 DynamicRunnable 作为占位符,记录可配置的字段和备选方案。在运行时(调用 invoke 时),系统根据实际传入的 RunnableConfig 解析出应该使用的具体 Runnable 实例,然后将调用委托给它。

这种两阶段设计带来了一个重要的好处:线程安全。DynamicRunnable 不持有任何可变状态,prepare 方法每次都根据传入的 config 参数创建新的实例。多个线程可以同时对同一个 DynamicRunnable 调用 invoke,传入不同的配置,而不会产生竞态条件。这在 Web 服务器环境中非常重要,因为不同的请求可能需要使用不同的模型配置,但它们共享同一个 DynamicRunnable 实例。

另一个微妙的设计是 while isinstance(runnable, DynamicRunnable) 循环。这意味着 DynamicRunnable 可以嵌套 -- 一个可配置的 Runnable 的某个备选方案本身也可以是可配置的。循环解包确保了无论嵌套多深,最终都能获得一个具体的可执行 Runnable。这种递归解包的设计使得配置系统具有了无限的组合能力。

16.5 RunnableConfig -- 运行时配置传播

RunnableConfig 是贯穿整个 Runnable 调用链的配置容器:

python 复制代码
# langchain_core/runnables/config.py

class RunnableConfig(TypedDict, total=False):
    tags: list[str]
    """标签,用于过滤和追踪"""

    metadata: dict[str, Any]
    """元数据,传递给回调"""

    callbacks: Callbacks
    """回调处理器"""

    run_name: str
    """运行名称,用于追踪"""

    max_concurrency: int | None
    """最大并发数"""

    run_id: uuid.UUID
    """运行唯一标识"""

    configurable: dict[str, Any]
    """可配置参数,用于 ConfigurableField"""

total=False 的 TypedDict 设计允许部分配置,通过 merge_configs 进行合并:

python 复制代码
# 配置合并:子配置继承父配置
parent_config = {"tags": ["production"], "metadata": {"user": "alice"}}
child_config = {"tags": ["chain-step-1"]}

merged = merge_configs(parent_config, child_config)
# 结果: {"tags": ["production", "chain-step-1"], "metadata": {"user": "alice"}}

configurable 字段是配置系统的入口。当用户传入 config={"configurable": {"model_name": "gpt-4"}} 时,DynamicRunnable.prepare() 从这个字段中读取配置值,创建相应的 Runnable 实例。

flowchart TB subgraph "RunnableConfig 结构" A["tags: ['prod']"] B["metadata: {'user': 'alice'}"] C["callbacks: [handler]"] D["run_name: 'my-chain'"] E["max_concurrency: 5"] F["configurable: {'model': 'gpt-4'}"] end subgraph "传播路径" G["chain.invoke(input, config)"] --> H["chain 读取 config"] H --> I["child.invoke(input, child_config)"] I --> J["child 继承并合并 config"] end A --> G B --> G C --> G D --> G E --> G F --> G

16.6 安全模型深度分析

LangChain 的序列化安全模型是多层防御的:

第一层:转义防护

序列化时,包含 "lc" 键的普通字典被自动转义为 {"__lc_escaped__": ...}。反序列化时,转义字典被还原为普通字典,绝不会被实例化为对象。这确保了用户数据(如 metadata)中碰巧包含 "lc" 键的情况不会被误解。

第二层:白名单控制

allowed_objects 参数控制哪些类可以被反序列化。默认的 "core" 模式只允许 langchain_core 中的类,是最严格的策略。即使恶意数据包含完整的类路径和构造参数,如果类不在白名单中,反序列化就会被拒绝。

第三层:命名空间验证

即使类在白名单中,其命名空间也必须属于可信列表。这防止了通过篡改类路径指向恶意模块的攻击。

第四层:init_validator

反序列化前会调用验证器检查构造参数。默认验证器阻止 template_format="jinja2",防止 Jinja2 模板注入攻击(Jinja2 模板可以执行任意 Python 代码)。

第五层:Serializable 子类检查

最终的安全网:导入的类必须是 Serializable 的子类。这确保只有明确声明为可序列化的类才能被实例化。

flowchart TB A["反序列化数据"] --> B{"转义字典?
__lc_escaped__"} B -->|是| C["还原为普通字典
(不实例化)"] B -->|否| D{"类路径在白名单?
allowed_class_paths"} D -->|否| E["拒绝: ValueError"] D -->|是| F{"命名空间可信?
valid_namespaces"} F -->|否| E F -->|是| G{"CLASS_INIT_VALIDATORS
类特定验证"} G -->|失败| E G -->|通过| H{"init_validator
通用验证 (如 jinja2 阻止)"} H -->|失败| E H -->|通过| I["importlib.import_module"] I --> J{"issubclass(cls, Serializable)?"} J -->|否| E J -->|是| K["cls(**kwargs)
安全实例化"]

16.7 设计决策分析

为什么序列化默认关闭?

is_lc_serializable() 默认返回 False,这是一个重要的安全决策。序列化意味着类的构造参数会被暴露,反序列化意味着类可以被远程实例化。只有开发者明确意识到并同意这些影响时,才应该启用序列化。

TypedDict vs Pydantic 用于 RunnableConfig

RunnableConfig 使用 TypedDict 而非 Pydantic 模型,原因有二:首先,config 在每次 Runnable 调用时都会被创建和传播,TypedDict 比 Pydantic 模型轻量得多;其次,total=False 的 TypedDict 天然支持部分配置,不需要所有字段都有值。

映射表 vs 反射 用于类路径解析

LangChain 使用 SERIALIZABLE_MAPPING 映射表来解析类路径,而非纯粹依赖 Python 的反射机制。这使得类可以在包之间迁移(例如从 langchain 迁移到 langchain_openai),旧的序列化数据仍然可以被正确反序列化。映射表也充当了安全白名单的角色。

配置系统的"不可变"语义

DynamicRunnable 不修改原始 Runnable,而是在每次调用时创建新的配置实例。这种"写时复制"语义确保了线程安全:多个并发调用可以使用不同的配置而互不干扰。

16.8 序列化格式深度分析

LangChain 的序列化格式是一种自描述的 JSON 结构,其设计经过了多轮迭代。让我们深入分析这种格式的设计考量。

序列化输出总是一个包含四个固定键的字典:lc(版本号)、type(类型标识)、id(类路径)和 kwargs(构造参数)。lc: 1 是当前的格式版本,预留了未来格式升级的空间。如果格式需要不兼容的变更,版本号可以递增为 2,反序列化器可以根据版本号选择不同的处理逻辑。

type 字段只有三种合法值:"constructor"(可以被重建的对象)、"secret"(密钥标记)和 "not_implemented"(无法序列化的对象)。这三种类型覆盖了所有可能的序列化需求。constructor 类型包含完整的重建信息,反序列化器可以据此实例化对象。secret 类型是一个占位符,反序列化时需要从外部来源获取实际值。not_implemented 类型是一种优雅的降级 -- 对于确实无法序列化的对象(如匿名函数、文件句柄),记录其文本表示以供调试,但明确标记为不可恢复。

id 字段是一个字符串列表,表示类的完整路径。例如 ["langchain_openai", "chat_models", "base", "ChatOpenAI"]。使用列表而非点分字符串的原因是避免歧义 -- 如果类名中包含点号(虽然不常见但在 Python 中是合法的),点分字符串会产生歧义。

kwargs 字段包含了重建对象所需的所有构造参数。这些参数是递归序列化的,意味着嵌套的 Serializable 对象会被递归地转换为相同的 JSON 结构。这使得整个序列化输出是一棵自包含的树,任何节点都可以独立反序列化。

这种设计使得序列化输出具有很好的可读性和可调试性。开发者可以用肉眼阅读 JSON,理解对象的类型和配置。这在调试序列化问题时非常有价值,而二进制序列化格式(如 pickle)就做不到这一点。

16.9 序列化映射表:跨版��兼容

序列化系统中一个容易被忽视但极其重要的组件是 SERIALIZABLE_MAPPING(定义在 langchain_core/load/mapping.py 中)。这张映射表记录了从旧类路径到新类路径的对应关系,使得在包之间迁移类时,旧的序列化数据仍然可以被正确反序列化。

例如,当 ChatOpenAIlangchain.chat_models.openai 迁移到 langchain_openai.chat_models.base 时,映射表中会保留一条记录,将旧路径指向新路径。Reviver 在解析类路径时,先查映射表,找到实际的导入路径后再执行导入。

这张映射表也充当了白名单的角色。_get_default_allowed_class_paths 函数从映射表中提取所有已知的类路径,作为默认的允许列表。这意味着只有在映射表中注册过的类才能被反序列化,新增的类必须先注册才能支持序列化恢复。

对于自定义类,可以通过 additional_import_mappings 参数向 load 函数注入额外的映射,而不需要修改全局映射表。这种设计保持了核心映射表的稳定性,同时允许用户扩展。

16.9 配置系统的实际应用场景

配置系统在实际开发中有几个典型的应用场景,值得深入讨论。

A/B 测试

通过 configurable_alternatives,你可以在不修改代码的情况下切换底层模型,实现 A/B 测试:

python 复制代码
model = ChatOpenAI(model="gpt-4").configurable_alternatives(
    ConfigurableField(id="model_provider"),
    default_key="openai",
    anthropic=ChatAnthropic(model="claude-3-sonnet-20240229"),
    groq=ChatGroq(model="llama3-70b-8192"),
)

# 根据用户分组选择不同的模型
config = {"configurable": {"model_provider": user_group}}
result = chain.invoke(input, config=config)

这种方式比硬编码的 if-else 分支更加清晰,而且配置的传播通过 RunnableConfig 自动完成,整条链中所有用到该模型的地方都会同步切换。

多租户温度控制

在多租户场景中,不同客户可能有不同的参数需求。通过 configurable_fields,你可以让同一个模型实例为不同客户提供不同的温度、最大 token 数等参数:

python 复制代码
model = ChatOpenAI(model="gpt-4", temperature=0.7).configurable_fields(
    temperature=ConfigurableField(id="temperature"),
    max_tokens=ConfigurableField(id="max_tokens"),
)

# 为创意写作客户设高温度
creative_config = {"configurable": {"temperature": 0.9, "max_tokens": 2000}}
# 为数据分析客户设低温度
analytical_config = {"configurable": {"temperature": 0.1, "max_tokens": 500}}

开发/生产环境切换

在开发环境使用便宜的小模型快速迭代,在生产环境切换到高质量的大模型:

python 复制代码
model = ChatOpenAI(model="gpt-3.5-turbo").configurable_fields(
    model_name=ConfigurableField(id="model"),
)

dev_config = {"configurable": {"model": "gpt-3.5-turbo"}}
prod_config = {"configurable": {"model": "gpt-4"}}

配置可以从环境变量、配置文件或请求参数中读取,与代码逻辑完全解耦。

16.10 序列化系统与 LangSmith 的关系

LangChain 的序列化系统与 LangSmith(追踪和监控平台)之间存在紧密的联系。当一个 Runnable 被执行时,其序列化表示会被作为元数据发送到 LangSmith,使得在追踪界面中可以看到每个节点的完整配置。

这也是为什么 to_json 方法要处理密钥替换 -- 密钥值不能出现在追踪数据中。SerializedSecret 类型({"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]})确保了只有密钥的名称而非实际值被记录。

同时,to_json_not_implemented 用于处理不可序列化的对象(如自定义函数、lambda 表达式)。这些对象在序列化表示中被标记为 not_implemented,附带 repr 字符串供人工阅读,但不能被反序列化恢复。在追踪场景下,这种退化处理是可接受的 -- 开发者仍然能在 LangSmith 中看到对象的文字描述。

小结

本章深入剖析了 LangChain 的序列化与配置两大子系统。

序列化系统以 Serializable 基类为根,通过 to_json 生成标准化的序列化表示,通过 Reviver 实现安全的反序列化。五层安全防护(转义、白名单、命名空间、init_validator、子类检查)构成了一套纵深防御体系。映射表机制确保了类在跨包迁移后旧数据仍可恢复,体现了对向后兼容性的重视。

配置系统以 ConfigurableField 家族为声明式接口,通过 DynamicRunnable 实现运行时的延迟绑定。RunnableConfig 贯穿整个调用链,将配置参数从顶层传播到每一个子 Runnable。在 A/B 测试、多租户参数控制、环境切换等场景下,配置系统让同一套代码能够服务于不同的需求,而无需条件分支或代码重构。

两个系统共同支撑了 LangChain 应用的"可移植性":序列化使对象可以跨环境传输和持久化,配置使行为可以在运行时动态调整。这种将"对象状态"和"运行时行为"清晰分离的设计,是构建灵活 AI 应用框架的重要基石。下一章,我们将转向 LangChain 的生态层面,看看 Partner 集成架构如何将第三方服务标准化地接入 LangChain 体系。

相关推荐
杨艺韬2 小时前
LangChain设计与实现-第18章-设计模式与架构决策
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第6章-提示词模板引擎
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第15章-工具调用与Agent模式
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第9章-文档加载与文本分割
langchain·agent
杨艺韬2 小时前
langchain设计与实现-前言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第2章-架构总揽
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第3章-Runnable 与 LCEL 表达式语言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第4章-消息系统与多模态
langchain·agent
龙侠九重天2 小时前
OpenClaw 多 Agent 隔离机制:工作空间、状态与绑定路由
人工智能·机器学习·ai·agent·openclaw