第5章 语言模型抽象层
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态
- 第5章 语言模型抽象层 (当前)
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出
- 第8章 工具系统
- 第9章 文档加载与文本分割
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
本章基于 LangChain 1.0.3 / langchain-core 1.2.26 源码分析。核心代码位于
langchain_core/language_models/目录。
语言模型是 LangChain 框架的核心驱动力。在 LangChain 的整个生态系统中,语言模型抽象层扮演着"发动机"的角色------无论是简单的文本问答、复杂的多轮工具调用对话、结构化数据提取,还是多 Agent 协作系统,它们的底层运作都依赖于这套精心设计的模型抽象来屏蔽不同提供商 API 的巨大差异。
设计一个好的语言模型抽象层需要在多个维度上取得平衡:既要足够抽象以覆盖各种提供商的能力差异,又不能过于抽象以至于丢失重要的提供商特定功能;既要让简单场景保持简单(一行代码调用模型),又要让复杂场景成为可能(流式输出、工具调用、结构化输出、速率限制、缓存等)。
LangChain 的解决方案是构建一个从 BaseLanguageModel 到 BaseChatModel 再到具体提供商实现的三层抽象体系。每一层都精确地定义了"合同"(必须实现的接口)与"自由度"(可以选择性覆盖的行为)的边界。本章将从最顶层的抽象基类开始,逐层深入到 BaseChatModel 的 invoke/stream/batch 实现、_generate 与 _agenerate 的扩展点设计、token 计数机制,以及 bind_tools 和 with_structured_output 两个关键的声明式方法。
::: tip 本章要点
- 三层抽象体系 :
BaseLanguageModel->BaseChatModel-> 具体实现的继承层级与职责划分 - Runnable 协议集成:语言模型如何作为 Runnable 参与 LCEL 链式调用
- invoke/stream/batch 实现:三大核心方法的完整调用链路与流式处理机制
- _generate vs _agenerate:子类扩展点的设计哲学与同步/异步策略
- bind_tools 与 with_structured_output:工具绑定和结构化输出的声明式 API :::
5.1 BaseLanguageModel:最顶层的抽象
5.1.1 类型参数与 Runnable 继承
python
# langchain_core/language_models/base.py
class BaseLanguageModel(
RunnableSerializable[LanguageModelInput, LanguageModelOutputVar], ABC
):
pass
BaseLanguageModel 同时继承了 RunnableSerializable 和 ABC(抽象基类)。RunnableSerializable 赋予了它 LCEL 链式调用的全部能力,包括 invoke、stream、batch、ainvoke、astream、abatch 等标准方法,以及 bind、with_config、with_retry、with_fallbacks 等声明式组合方法。ABC 确保了它是一个不可直接实例化的抽象类------只有实现了所有抽象方法的具体子类才能被创建。
类型参数 LanguageModelInput 和 LanguageModelOutputVar 定义了语言模型的输入输出契约,这些类型信息不仅用于静态类型检查,还被 LangServe 等工具用来自动生成 API 文档和请求验证逻辑:
python
LanguageModelInput = PromptValue | str | Sequence[MessageLikeRepresentation]
"""语言模型的输入:PromptValue、字符串、或消息序列"""
LanguageModelOutput = BaseMessage | str
"""语言模型的输出:消息或字符串"""
LanguageModelOutputVar = TypeVar("LanguageModelOutputVar", AIMessage, str)
"""输出类型变量,ChatModel 产出 AIMessage,LLM 产出 str"""
LanguageModelInput 的联合类型设计意味着语言模型可以接受三种输入格式:已经封装好的 PromptValue(适用于模板引擎的输出)、原始字符串(最简单的调用方式)、或消息序列(最灵活的方式)。这种"宽进严出"的策略极大地提升了 API 的易用性------用户可以根据场景选择最自然的输入方式,而 LangChain 负责在内部将它们统一转换为模型能够处理的格式。
LanguageModelOutputVar 作为输出的类型变量,它在 BaseChatModel 中被绑定为 AIMessage,在 BaseLLM 中被绑定为 str。这种泛型绑定使得类型检查器能够根据模型类型准确推断出 invoke 方法的返回类型。
5.1.2 核心配置字段
python
class BaseLanguageModel(...):
cache: BaseCache | bool | None = Field(default=None, exclude=True)
"""缓存策略:True=全局缓存,False=不缓存,None=使用全局设置,BaseCache=指定缓存实例"""
verbose: bool = Field(default_factory=_get_verbosity, exclude=True, repr=False)
"""是否打印响应文本"""
callbacks: Callbacks = Field(default=None, exclude=True)
"""回调处理器"""
tags: list[str] | None = Field(default=None, exclude=True)
"""追踪标签"""
metadata: dict[str, Any] | None = Field(default=None, exclude=True)
"""追踪元数据"""
custom_get_token_ids: Callable[[str], list[int]] | None = Field(
default=None, exclude=True
)
"""自定义 token 编码器"""
所有配置字段都标记了 exclude=True,这意味着它们不会出现在模型的序列化(model_dump/model_dump_json)输出中。这是一个重要的设计决策:像 cache、callbacks、verbose 这样的配置属于运行时行为,不是模型身份的一部分。当 LangChain 需要为缓存生成模型的"签名字符串"时,排除这些字段可以确保相同配置的模型产生相同的缓存键,而不受运行时配置差异的影响。
cache 字段支持四种配置模式,体现了层次化配置的设计理念:True 使用全局缓存(通过 langchain.cache 设置),False 完全禁用缓存,None(默认)表示"如果全局缓存存在则使用",传入 BaseCache 实例则使用指定的缓存后端。这种设计让使用者可以在全局、模型实例、甚至单次调用的粒度上控制缓存行为。
5.1.3 InputType 的精细化类型
python
@property
@override
def InputType(self) -> TypeAlias:
return str | StringPromptValue | ChatPromptValueConcrete | list[AnyMessage]
InputType 属性覆盖了 Runnable 基类的版本,用更具体的类型替换了抽象的 BaseMessage。这使得自动生成的 JSON Schema 更加精确,对 LangServe 等依赖 schema 的工具尤为重要。
5.1.4 Token 计数机制
python
def get_token_ids(self, text: str) -> list[int]:
if self.custom_get_token_ids is not None:
return self.custom_get_token_ids(text)
return _get_token_ids_default_method(text)
def get_num_tokens(self, text: str) -> int:
return len(self.get_token_ids(text))
def get_num_tokens_from_messages(
self, messages: list[BaseMessage], tools: Sequence | None = None
) -> int:
if tools is not None:
warnings.warn("Counting tokens in tool schemas is not yet supported.")
return sum(self.get_num_tokens(get_buffer_string([m])) for m in messages)
Token 计数是语言模型应用中一个看似简单实则复杂的问题。不同的模型使用不同的 tokenizer,同一段文本在不同模型中的 token 数可能相差甚远。LangChain 的 token 计数采用三级策略:
- 如果提供了
custom_get_token_ids,使用自定义编码器 - 否则回退到 GPT-2 tokenizer(并发出一次性警告)
- 子类应覆盖这些方法以提供准确的计数
5.2 BaseChatModel:Chat 模型的核心引擎
BaseChatModel 是 LangChain 1.0 时代最重要的模型基类。几乎所有现代 LLM 提供商的集成------OpenAI、Anthropic、Google、AWS Bedrock、Azure、Ollama 等------都继承自它。它在 BaseLanguageModel 的基础上,为基于消息的对话模型提供了完整的运行时基础设施,包括输入转换、缓存管理、流式处理、回调触发和结果后处理等功能。
5.2.1 关键配置字段
python
# langchain_core/language_models/chat_models.py
class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
rate_limiter: BaseRateLimiter | None = Field(default=None, exclude=True)
"""可选的速率限制器"""
disable_streaming: bool | Literal["tool_calling"] = False
"""控制流式行为:
- True: 始终禁用流式
- "tool_calling": 仅在工具调用时禁用流式
- False: 启用流式(默认)"""
output_version: str | None = Field(
default_factory=from_env("LC_OUTPUT_VERSION", default=None)
)
"""AIMessage 输出格式版本:
- 'v0': 提供商原生格式(默认)
- 'v1': 标准化 content_blocks 格式"""
profile: ModelProfile | None = Field(default=None, exclude=True)
"""模型能力档案:上下文窗口、支持的模态、工具调用能力等"""
disable_streaming 的三值设计(bool | Literal["tool_calling"])体现了对实际使用场景的深入理解。"tool_calling" 选项的存在是因为某些模型(特别是一些开源模型的 API)在流式工具调用时可能产生不稳定的结果或格式异常,而纯文本的流式输出则没有问题。通过设置 disable_streaming="tool_calling",应用可以在享受文本流式输出的同时,在工具调用场景下自动回退到更稳定的非流式模式。源码中对此的解释是:"在有 tools 关键字参数时自动切换到非流式行为(invoke),提供了两全其美的体验"。
output_version 字段支持通过环境变量 LC_OUTPUT_VERSION 全局设置,这是一个面向运维的便利设计。在不修改任何应用代码的情况下,运维人员可以通过设置环境变量让整个应用切换到标准化的 v1 输出格式。当设置为 "v1" 时,AIMessage.content 字段会存储与 content_blocks 属性一致的标准化内容块列表,而不是提供商的原始格式。这对于需要将模型输出序列化存储的场景尤为有用,因为标准化格式的可移植性远优于提供商特定格式。
5.2.2 ModelProfile:模型能力档案
python
profile: ModelProfile | None = Field(default=None, exclude=True)
ModelProfile 是一个 TypedDict,描述模型的能力。BaseChatModel 通过两个 @model_validator 自动处理 profile 的初始化:
_set_model_profile:在构造时调用_resolve_model_profile()获取默认 profile_check_profile_keys:对未知的 profile 键发出警告
子类通过覆盖 _resolve_model_profile() 来提供各自的默认 profile。这种"先构造后验证"的模式将 profile 的获取逻辑与 Pydantic 验证器的机制解耦。
5.2.3 _convert_input:输入规范化
python
def _convert_input(self, model_input: LanguageModelInput) -> PromptValue:
if isinstance(model_input, PromptValue):
return model_input
if isinstance(model_input, str):
return StringPromptValue(text=model_input)
if isinstance(model_input, Sequence):
return ChatPromptValue(messages=convert_to_messages(model_input))
raise ValueError(...)
所有输入在进入模型前都被规范化为 PromptValue。这是一个关键的"漏斗"设计:无论用户传入字符串、消息列表还是 PromptValue,模型内部统一通过 PromptValue.to_messages() 获取消息列表。这种规范化发生在所有外部方法(invoke、stream、generate 等)的入口处,确保了后续的内部逻辑(缓存、回调、格式化等)只需要处理统一的 PromptValue 类型。对于字符串输入,它会被包装为 StringPromptValue;对于消息序列,会先通过 convert_to_messages 转换为标准消息列表,再包装为 ChatPromptValue。
5.3 invoke:同步调用的完整链路
5.3.1 从 invoke 到 _generate
python
@override
def invoke(
self,
input: LanguageModelInput,
config: RunnableConfig | None = None,
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> AIMessage:
config = ensure_config(config)
return cast(
"AIMessage",
cast(
"ChatGeneration",
self.generate_prompt(
[self._convert_input(input)],
stop=stop,
callbacks=config.get("callbacks"),
tags=config.get("tags"),
metadata=config.get("metadata"),
run_name=config.get("run_name"),
run_id=config.pop("run_id", None),
**kwargs,
).generations[0][0],
).message,
)
通过源码可以看到,invoke 方法的实现并不直接调用 _generate,而是通过 generate_prompt -> generate -> _generate_with_cache -> _generate 的层层委托链路来执行。每一层都添加了不同的横切关注点:generate_prompt 处理 PromptValue 到消息列表的转换,generate 处理回调管理器的配置和运行追踪,_generate_with_cache 处理缓存查找、速率限制和流式决策。这种分层设计虽然增加了调用深度,但让每一层的职责清晰且可独立测试。
完整的调用链路如下:
5.3.2 generate 方法的批处理支持
python
def generate(
self,
messages: list[list[BaseMessage]],
stop: list[str] | None = None,
callbacks: Callbacks = None,
**kwargs: Any,
) -> LLMResult:
generate 方法接受的是 list[list[BaseMessage]] -- 即多组消息的批量调用。它为每组消息设置独立的 run_manager,然后逐个调用 _generate_with_cache。这与 agenerate 使用 asyncio.gather 并行处理形成对比。
5.3.3 _generate_with_cache:缓存与流式的决策中心
_generate_with_cache 是 BaseChatModel 中最复杂的私有方法,它是缓存查找、速率限制和流式决策的交汇点:
python
def _generate_with_cache(self, messages, stop=None, run_manager=None, **kwargs):
llm_cache = self.cache if isinstance(self.cache, BaseCache) else get_llm_cache()
check_cache = self.cache or self.cache is None
# 第一步:检查缓存
if check_cache and llm_cache:
llm_string = self._get_llm_string(stop=stop, **kwargs)
# 将消息 ID 清零以确保缓存键的一致性
normalized_messages = [
msg.model_copy(update={"id": None})
if getattr(msg, "id", None) is not None else msg
for msg in messages
]
prompt = dumps(normalized_messages)
cache_val = llm_cache.lookup(prompt, llm_string)
if isinstance(cache_val, list):
return ChatResult(generations=self._convert_cached_generations(cache_val))
# 第二步:速率限制(在缓存之后、API 调用之前)
if self.rate_limiter:
self.rate_limiter.acquire(blocking=True)
# 第三步:决定是调用 _stream 还是 _generate
if self._should_stream(async_api=False, run_manager=run_manager, **kwargs):
# 流式路径:收集所有 chunks 后合并
chunks = []
for chunk in self._stream(messages, stop=stop, **kwargs):
run_manager.on_llm_new_token(chunk.message.content, chunk=chunk)
chunks.append(chunk)
result = generate_from_stream(iter(chunks))
else:
# 非流式路径:直接调用 _generate
result = self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
# 第四步:更新缓存
if check_cache and llm_cache:
llm_cache.update(prompt, llm_string, result.generations)
return result
缓存键的构建有一个值得深入理解的细节:消息的 id 字段在生成缓存键之前被统一清零。这是因为 LangChain 会为每条消息自动生成基于运行 ID 的唯一标识符(格式如 lc_run-{uuid}),即使是内容完全相同的两次调用,消息的 id 也会不同。如果不清零 id,同样的提示永远不会命中缓存,缓存功能形同虚设。清零后,只有消息的实质内容(content、type、name 等)参与缓存键的计算,确保了语义相同的请求能够正确命中缓存。
缓存键由两部分组成:prompt(序列化后的消息列表)和 llm_string(模型配置的序列化表示,包括模型名称、温度、停止词等参数)。这意味着同一段提示词在不同的模型配置下会产生不同的缓存条目,避免了配置变更导致的缓存污染。
缓存命中后的处理也值得注意。_convert_cached_generations 方法负责将缓存中的 Generation 对象转换为 ChatGeneration 对象。这处理了一种因序列化/反序列化问题导致的边缘情况:缓存中可能存储的是基类 Generation 而非子类 ChatGeneration。此外,缓存命中的结果会将 usage_metadata 中的 total_cost 设为 0,因为缓存命中不消耗实际的 API 费用。
5.4 stream:流式输出的完整实现
5.4.1 _should_stream 决策逻辑
python
def _should_stream(self, *, async_api: bool, run_manager=None, **kwargs) -> bool:
# 1. 检查 _stream/_astream 是否被子类实现
sync_not_implemented = type(self)._stream == BaseChatModel._stream
async_not_implemented = type(self)._astream == BaseChatModel._astream
if (not async_api) and sync_not_implemented:
return False
if async_api and async_not_implemented and sync_not_implemented:
return False
# 2. 检查 disable_streaming 配置
if self.disable_streaming is True:
return False
if self.disable_streaming == "tool_calling" and kwargs.get("tools"):
return False
# 3. 检查运行时 stream 参数
if "stream" in kwargs:
return bool(kwargs["stream"])
# 4. 检查 streaming 字段(某些提供商使用)
if "streaming" in self.model_fields_set:
streaming_value = getattr(self, "streaming", None)
if isinstance(streaming_value, bool):
return streaming_value
# 5. 检查是否有流式回调处理器
handlers = run_manager.handlers if run_manager else []
return any(isinstance(h, _StreamingCallbackHandler) for h in handlers)
这个决策函数的五级优先级设计充分反映了 LangChain 的配置哲学:能力检测 > 实例级配置 > 运行时参数 > 字段级配置 > 隐式推断。
首先检查子类是否实现了流式能力(第1步),这是最基本的前提。然后检查实例级的 disable_streaming 配置(第2步),这是开发者的明确意图。接着检查运行时传入的 stream 参数(第3步),允许单次调用级别的控制。第4步检查某些提供商使用的 streaming 字段,保持向后兼容。最后一步(第5步)是最巧妙的:当检测到 _StreamingCallbackHandler 类型的回调处理器时(这通常由 astream_events 或 astream_log 注入),即使没有显式请求流式输出,也会自动切换到流式模式,确保事件流能够接收到逐 token 的更新。
5.4.2 stream 方法的实现
python
@override
def stream(
self,
input: LanguageModelInput,
config: RunnableConfig | None = None,
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> Iterator[AIMessageChunk]:
if not self._should_stream(async_api=False, **{**kwargs, "stream": True}):
# 不支持流式时,退化为 invoke
yield cast("AIMessageChunk", self.invoke(input, config=config, stop=stop, **kwargs))
else:
# 流式路径
config = ensure_config(config)
messages = self._convert_input(input).to_messages()
# ... 设置 callback_manager ...
chunks: list[ChatGenerationChunk] = []
for chunk in self._stream(input_messages, stop=stop, **kwargs):
if chunk.message.id is None:
chunk.message.id = run_id
chunk.message.response_metadata = _gen_info_and_msg_metadata(chunk)
if self.output_version == "v1":
chunk.message = _update_message_content_to_blocks(chunk.message, "v1")
run_manager.on_llm_new_token(chunk.message.content, chunk=chunk)
chunks.append(chunk)
yield cast("AIMessageChunk", chunk.message)
# 如果最后一个 chunk 没有标记 last,补发一个空的 last chunk
if yielded and not chunk.message.chunk_position:
msg_chunk = AIMessageChunk(content="", chunk_position="last", id=run_id)
yield msg_chunk
# 合并所有 chunks 后触发 on_llm_end
generation = merge_chat_generation_chunks(chunks)
run_manager.on_llm_end(LLMResult(generations=[[generation]]))
stream 方法是用户最常直接调用的流式接口。它的实现远比想象中复杂,需要处理多种边界情况。以下是几个关键的实现细节:
-
自动补发 last chunk :如果子类的
_stream实现没有发送chunk_position="last"的终止信号,stream方法会自动补发一个空的终止 chunk。这保证了消费者可以可靠地检测到流的结束。 -
output_version="v1" 处理 :在流式模式下也会实时转换每个 chunk 的内容为标准化格式,并自动分配递增的
index。 -
回调触发 :每个 chunk 通过
on_llm_new_token通知回调系统,流结束后通过on_llm_end传递合并后的完整结果。
yield 单个结果"] Setup["配置 callbacks
转换 input -> messages"] Loop["for chunk in _stream(messages):"] SetID["设置 chunk.message.id"] V1{"output_version == 'v1'?"} Convert["转换为标准化 content_blocks"] Notify["on_llm_new_token(chunk)"] Yield["yield chunk.message"] CheckLast{"最后 chunk 有 last 标记?"} YieldLast["yield 空的 last chunk"] Merge["merge_chat_generation_chunks"] End["on_llm_end(result)"] Start --> Check Check -->|No| Invoke Check -->|Yes| Setup Setup --> Loop Loop --> SetID SetID --> V1 V1 -->|Yes| Convert V1 -->|No| Notify Convert --> Notify Notify --> Yield Yield --> Loop Loop -->|完成| CheckLast CheckLast -->|No| YieldLast CheckLast -->|Yes| Merge YieldLast --> Merge Merge --> End
5.5 _generate 与 _agenerate:子类扩展点
5.5.1 _generate:唯一的必需扩展点
python
@abstractmethod
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
"""子类必须实现的核心生成方法"""
_generate 是子类唯一必须实现的抽象方法,也是整个模型抽象层最核心的扩展点。它接收标准化的消息列表,返回 ChatResult。ChatResult 包含一个 generations 列表,每个元素是一个 ChatGeneration,它将一个 AIMessage 与可选的 generation_info(提供商特定的生成元数据)打包在一起。虽然大多数场景下一次调用只产生一个 generation,但 API 设计保留了返回多个候选回复的能力(某些提供商支持 n 参数来请求多个回复)。
子类在实现 _generate 时,需要负责:将 BaseMessage 列表转换为提供商特定的 API 请求格式、调用提供商 API、将 API 响应转换为 ChatResult。子类不需要关心缓存、回调、速率限制或 ID 分配------这些横切关注点全部由 BaseChatModel 的上层方法处理。
5.5.2 _agenerate:可选的异步优化
python
async def _agenerate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
return await run_in_executor(
None,
self._generate,
messages,
stop,
run_manager.get_sync() if run_manager else None,
**kwargs,
)
默认实现通过 run_in_executor 将同步的 _generate 方法放入线程池中执行,这是 Python 异步编程中处理阻塞 I/O 的标准模式。对于使用同步 HTTP 客户端(如 requests)的提供商,这种默认行为已经足够好------线程池确保了异步事件循环不会被阻塞。但对于提供了原生异步 SDK(如使用 httpx 或 aiohttp)的提供商,覆盖 _agenerate 以使用原生异步调用可以避免线程池的额外开销,获得更好的性能和资源利用率。
注意源码中 run_manager.get_sync() 调用的巧妙设计:它将异步的 AsyncCallbackManagerForLLMRun 转换为同步版本 CallbackManagerForLLMRun,使得在线程池中运行的同步代码也能正确触发回调。这种"异步到同步"的适配器模式在 LangChain 的回调系统中被广泛使用。
5.5.3 _stream 与 _astream:可选的流式扩展点
python
def _stream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> Iterator[ChatGenerationChunk]:
raise NotImplementedError
async def _astream(
self, messages, stop=None, run_manager=None, **kwargs
) -> AsyncIterator[ChatGenerationChunk]:
# 默认:在线程池中运行同步 _stream
iterator = await run_in_executor(None, self._stream, messages, stop, ...)
done = object()
while True:
item = await run_in_executor(None, next, iterator, done)
if item is done:
break
yield item
_stream 不是抽象方法,而是默认抛出 NotImplementedError。_should_stream 函数通过 Python 的方法解析顺序(MRO)检查 type(self)._stream == BaseChatModel._stream 来判断当前子类是否覆盖了 _stream 方法。如果子类没有提供自己的实现,stream 方法会自动退化为 invoke------调用 invoke 获取完整结果,然后将其作为单个 chunk yield 出去。这种优雅的降级行为保证了应用代码可以安全地对任何模型调用 stream,而不需要事先检查模型是否支持流式。
_astream 的默认实现展现了一种有趣的异步适配模式:它将同步的 _stream 迭代器封装为异步迭代器,通过 run_in_executor 在循环中逐个获取元素。每次迭代调用 next(iterator, done) 来获取下一个 chunk,当返回值是哨兵对象 done 时表示迭代结束。这种"按需拉取"的模式保持了流式语义,但由于每次 next 调用都需要经过线程池调度,相比原生异步实现会有一定的延迟开销。因此,对于性能敏感的高吞吐量场景,提供商应当优先提供原生的异步流式实现。
5.5.4 扩展点职责表
| 方法 | 是否必须 | 默认行为 | 典型覆盖场景 |
|---|---|---|---|
_generate |
必须 | 抽象方法 | 所有子类 |
_agenerate |
可选 | 线程池调用 _generate |
有原生异步 API 的提供商 |
_stream |
可选 | 抛出 NotImplementedError | 支持流式的提供商 |
_astream |
可选 | 线程池调用 _stream |
有原生异步流式 API 的提供商 |
_llm_type |
必须 | 抽象属性 | 所有子类(如 "openai-chat") |
5.6 Token 计数与 UsageMetadata
5.6.1 基础 Token 计数
BaseLanguageModel 提供了基于 GPT-2 tokenizer 的后备 token 计数实现:
python
@cache # 缓存 tokenizer 实例
def get_tokenizer() -> Any:
if not _HAS_TRANSFORMERS:
raise ImportError(...)
return GPT2TokenizerFast.from_pretrained("gpt2")
def _get_token_ids_default_method(text: str) -> list[int]:
global _GPT2_TOKENIZER_WARNED
if not _GPT2_TOKENIZER_WARNED:
warnings.warn(
"Using fallback GPT-2 tokenizer for token counting. "
"Token counts may be inaccurate for non-GPT-2 models."
)
_GPT2_TOKENIZER_WARNED = True
tokenizer = get_tokenizer()
return tokenizer.encode(text, verbose=False)
GPT-2 tokenizer 的选择是一个务实的决策。虽然它对非 GPT-2 模型的 token 计数不够准确(不同模型的 tokenizer 差异可能导致数倍的偏差),但作为后备方案,它提供了一个"有总比没有好"的粗略估计。@cache 装饰器(来自 functools)确保 tokenizer 的模型权重只加载一次,避免了重复的 I/O 和内存开销。全局的 _GPT2_TOKENIZER_WARNED 布尔标志确保"使用后备 GPT-2 tokenizer"的警告只在第一次调用时打印一次,不会在高频场景下产生日志洪水。
值得注意的是,get_tokenizer 函数中传入了 verbose=False 参数给 HuggingFace 的 encode 方法。这是为了抑制 GPT-2 tokenizer 在输入超过 1024 token 时产生的"Token indices sequence length is longer than the specified maximum sequence length"警告。由于 LangChain 只是用 tokenizer 来计数,不是用来生成模型输入,这个警告是无意义的。
5.6.2 UsageMetadata 的累加
在流式场景中,token 使用量可能分散在多个 chunk 中。add_usage 函数通过递归的整数运算实现了 UsageMetadata 的累加:
python
def add_usage(left: UsageMetadata | None, right: UsageMetadata | None) -> UsageMetadata:
if not (left or right):
return UsageMetadata(input_tokens=0, output_tokens=0, total_tokens=0)
if not (left and right):
return cast("UsageMetadata", left or right)
return UsageMetadata(
**cast("UsageMetadata", _dict_int_op(cast("dict", left), cast("dict", right), operator.add))
)
_dict_int_op 是一个递归的字典整数运算工具,它能够深入嵌套的 input_token_details 和 output_token_details 进行逐字段累加。对应的 subtract_usage 函数使用 max(left - right, 0) 防止 token 计数出现负数。
5.6.3 LangSmithParams:追踪参数标准化
python
class LangSmithParams(TypedDict, total=False):
ls_provider: str # 提供商名称
ls_model_name: str # 模型名称
ls_model_type: Literal["chat", "llm"]
ls_temperature: float | None
ls_max_tokens: int | None
ls_stop: list[str] | None
ls_integration: str # 集成标识
BaseChatModel._get_ls_params 方法自动从模型实例中提取这些参数。提供商名称从类名推断(移除 "Chat" 前缀/后缀并转小写),模型名称从 model 或 model_name 属性获取。这些参数随后被注入到追踪元数据中,使得 LangSmith 平台能够按模型维度进行分析。
5.7 bind_tools:工具绑定
5.7.1 基类的合同定义
python
def bind_tools(
self,
tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
*,
tool_choice: str | None = None,
**kwargs: Any,
) -> Runnable[LanguageModelInput, AIMessage]:
raise NotImplementedError
bind_tools 在 BaseChatModel 中仅定义了方法签名并抛出 NotImplementedError。它故意不使用 @abstractmethod 装饰器------这是一个重要的设计选择。如果将其设为抽象方法,那么所有 Chat 模型子类都必须实现它,包括那些根本不支持工具调用的简单模型。通过使用非抽象的默认实现(抛出 NotImplementedError),LangChain 允许子类选择性地实现工具调用能力,同时通过运行时错误(而非类定义时的错误)提醒用户某个模型不支持此功能。
tools 参数的设计体现了 LangChain 的"宽进"哲学,它接受四种格式的工具定义:
- dict:OpenAI 工具 schema 格式
- type:Pydantic Model 或 TypedDict 类
- Callable:Python 函数(通过 docstring 和类型注解推断 schema)
- BaseTool:LangChain Tool 实例
5.7.2 bind_tools 的底层机制
所有四种格式的工具定义最终都会通过 convert_to_openai_tool 函数被转换为 OpenAI 的工具 schema 格式(这是目前最广泛采用的工具定义标准)。对于 Pydantic Model 和 TypedDict,LangChain 会自动从类型注解和 docstring 中提取字段描述;对于 Python 函数,会从函数签名的类型注解和 docstring 中推断参数 schema。
bind_tools 的典型实现(在各个提供商包中)调用 self.bind(**kwargs) 将转换后的工具 schema 附加到模型的调用参数中。bind 是 Runnable 基类提供的方法,它返回一个 RunnableBinding 对象。RunnableBinding 在每次 invoke/stream 调用时,会将预设的 kwargs 与调用时传入的 kwargs 合并后传递给底层模型。这种"延迟绑定"的机制使得工具 schema 不需要在每次调用时重复传入:
python
# 提供商的典型实现
def bind_tools(self, tools, tool_choice=None, **kwargs):
formatted_tools = [convert_to_openai_tool(tool) for tool in tools]
if tool_choice:
kwargs["tool_choice"] = tool_choice
return self.bind(tools=formatted_tools, **kwargs)
5.8 with_structured_output:结构化输出
5.8.1 默认实现
with_structured_output 是 LangChain 中使用频率最高的声明式 API 之一。它解决了一个极其常见的需求:让语言模型的输出符合预定义的数据结构,而不是自由格式的文本。BaseChatModel 提供了一个基于 bind_tools 机制的默认实现,其核心思路是巧妙地将"结构化输出"问题转化为"工具调用"问题:
python
def with_structured_output(
self,
schema: dict[str, Any] | type,
*,
include_raw: bool = False,
**kwargs: Any,
) -> Runnable[LanguageModelInput, dict[str, Any] | BaseModel]:
# 消耗并忽略已废弃的 method/strict 参数
_ = kwargs.pop("method", None)
_ = kwargs.pop("strict", None)
if kwargs:
raise ValueError(f"Received unsupported arguments {kwargs}")
# 检查 bind_tools 是否被子类实现
if type(self).bind_tools is BaseChatModel.bind_tools:
raise NotImplementedError("with_structured_output is not implemented for this model.")
# 核心:将 schema 绑定为工具,并强制模型调用
llm = self.bind_tools(
[schema],
tool_choice="any",
ls_structured_output_format={"kwargs": {"method": "function_calling"}, "schema": schema},
)
# 根据 schema 类型选择解析器
if isinstance(schema, type) and is_basemodel_subclass(schema):
output_parser = PydanticToolsParser(tools=[schema], first_tool_only=True)
else:
key_name = convert_to_openai_tool(schema)["function"]["name"]
output_parser = JsonOutputKeyToolsParser(key_name=key_name, first_tool_only=True)
# 构建管道
if include_raw:
parser_assign = RunnablePassthrough.assign(
parsed=itemgetter("raw") | output_parser,
parsing_error=lambda _: None
)
parser_none = RunnablePassthrough.assign(parsed=lambda _: None)
parser_with_fallback = parser_assign.with_fallbacks(
[parser_none], exception_key="parsing_error"
)
return RunnableMap(raw=llm) | parser_with_fallback
return llm | output_parser
5.8.2 管道构建分析
tool_choice='any'"] B1 --> C1["output_parser
(Pydantic 或 JSON)"] C1 --> D1["结构化输出"] end subgraph "include_raw=True" A2["输入"] --> B2["RunnableMap
raw=llm"] B2 --> C2["parser_assign
parsed=raw|parser"] C2 --> D2["{raw, parsed, parsing_error}"] C2 -.->|"解析失败"| E2["parser_none
parsed=None"] E2 --> D2 end
include_raw=True 的管道设计特别精巧。它使用 RunnableMap(raw=llm) 将模型输出放入 raw 键,然后通过 RunnablePassthrough.assign 添加 parsed 和 parsing_error 键。with_fallbacks 确保解析失败时不会抛出异常,而是将错误捕获到 parsing_error 字段,同时 parsed 设为 None。
tool_choice="any" 是整个机制的关键所在。它强制模型必须调用某个工具(而不是生成纯文本回复),从而保证模型的输出是符合工具 schema 的结构化 JSON。这种将"结构化输出"实现为"强制工具调用"的策略,是 LangChain 的一个精巧设计:它不需要每个提供商都原生支持结构化输出 API,只要支持工具调用就能实现同等效果。当然,某些提供商(如 OpenAI)提供了原生的结构化输出 API(如 response_format),它们的 with_structured_output 实现会覆盖基类默认实现以使用更高效的原生机制。
5.8.3 两种输出解析器
- PydanticToolsParser:当 schema 是 Pydantic 类时使用,返回验证后的 Pydantic 实例
- JsonOutputKeyToolsParser:当 schema 是字典或 TypedDict 时使用,按工具名称提取 JSON 输出
两者都设置了 first_tool_only=True,因为结构化输出场景下只绑定了一个工具。
5.9 异步模型调用的完整链路
ainvoke 和 astream 的实现结构与同步版本基本一致,但有几个重要的差异值得关注。
ainvoke 通过 agenerate_prompt -> agenerate -> _agenerate_with_cache -> _agenerate 的异步链路执行。agenerate 方法使用 asyncio.gather 并行处理多组消息,这与同步版本的 generate(逐个串行处理)形成了鲜明对比。并行处理在批量场景下可以带来显著的性能提升,尤其是当底层 API 支持原生异步调用时。
agenerate 中的错误处理也更为复杂。由于使用了 asyncio.gather(return_exceptions=True),所有组的调用结果(包括异常)都会被收集到一个列表中。代码随后遍历这个列表,为每个失败的组触发 on_llm_error 回调,同时为成功的组触发 on_llm_end 回调。最终,如果有任何异常,会重新抛出第一个异常。这种"全部运行、收集错误、最终抛出"的策略确保了即使某些组失败,其他组的回调也能正确触发,不会留下"悬挂"的追踪记录。
astream 方法的异步版本使用 async for 语法来消费 _astream 的输出。如果模型没有实现原生的 _astream,默认实现会在线程池中运行同步的 _stream,通过逐个 run_in_executor(None, next, iterator, done) 调用来将同步迭代器适配为异步迭代器。虽然这种方式功能上是完备的,但由于每个 chunk 都需要经过一次线程池调度,在高频 token 输出的场景下可能成为性能瓶颈。因此,对于生产级的提供商集成,实现原生的 _astream 方法是推荐的做法。
5.10 生成结果的后处理
5.9.1 output_version="v1" 的内容转换
当 output_version 设置为 "v1" 时,_generate_with_cache 和 stream 都会在返回结果前将消息内容转换为标准化格式:
python
if self.output_version == "v1":
for generation in result.generations:
generation.message = _update_message_content_to_blocks(
generation.message, "v1"
)
在流式模式下,每个 chunk 都会实时转换,并自动分配递增的 index 值。这保证了消费者无论是否使用流式,都能获得格式一致的输出。
5.9.2 response_metadata 的注入
python
def _gen_info_and_msg_metadata(generation):
return {
**(generation.generation_info or {}),
**generation.message.response_metadata,
}
每个生成结果的 response_metadata 会被合并上 generation_info 中的数据。对于单个生成的情况,还会进一步合并 llm_output 级别的信息(如 API 限流头、请求 ID 等)。
5.9.3 消息 ID 的分配策略
python
for idx, generation in enumerate(result.generations):
if run_manager and generation.message.id is None:
generation.message.id = f"{LC_ID_PREFIX}-{run_manager.run_id}-{idx}"
如果提供商没有返回消息 ID,LangChain 会自动分配一个基于运行 ID 的标识符(格式为 lc_run-{run_id}-{index})。这保证了每条消息都有唯一标识,便于追踪和调试。
5.11 设计决策分析
5.11.1 为什么不把 _stream 设为抽象方法
这是 LangChain 模型抽象层中最重要的设计决策之一。如果 _stream 被声明为 @abstractmethod,那么所有继承 BaseChatModel 的子类都必须实现它,即使它们底层的模型 API 根本不支持流式输出。这会给提供商集成的开发者带来不必要的负担------他们要么实现一个假的流式方法(内部调用非流式 API 然后一次性返回),要么无法使用 BaseChatModel 作为基类。
通过使用 NotImplementedError(非抽象)+ _should_stream 运行时检测的组合,LangChain 实现了"有则用之,无则退化"的优雅策略:如果子类实现了 _stream,流式调用使用真正的流式 API;如果没有实现,自动退化为非流式调用后一次性返回。这将实现新提供商集成的最低门槛降到了仅需实现 _generate 和 _llm_type 两个成员,同时不牺牲任何流式能力。
5.11.2 缓存与速率限制的执行顺序
_generate_with_cache 中缓存查找和速率限制的执行顺序是经过深思熟虑的。速率限制器被放在缓存检查之后执行,这是因为缓存命中不消耗 API 配额,不应该受到速率限制的制约。只有缓存未命中、确实需要调用 API 时,才检查速率限制。如果把速率限制放在缓存之前,那么即使是能够立即从缓存返回的请求也会被速率限制延迟,这在高并发场景下会造成不必要的吞吐量损失。
5.11.3 generate 方法的批处理设计
generate 方法接受 list[list[BaseMessage]] 作为输入,支持一次调用处理多组消息。在同步版本中,多组消息是串行处理的(逐个调用 _generate_with_cache);而在异步版本 agenerate 中,使用 asyncio.gather 并行处理所有组。这种不对称的设计反映了同步和异步编程模型的本质差异:同步代码中的并行需要多线程(引入额外复杂性),而异步代码天然支持并发。对于需要同步批处理的场景,用户可以考虑使用 Runnable.batch 方法,它内部使用线程池来并行执行多个 invoke 调用。
5.11.4 SimpleChatModel 的存在意义
SimpleChatModel 是 BaseChatModel 的简化版,它将 _generate 委托给一个更简单的 _call 方法(只需返回字符串而非 ChatResult)。这个类主要是为了向后兼容和快速原型开发:
python
class SimpleChatModel(BaseChatModel):
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
output_str = self._call(messages, stop=stop, run_manager=run_manager, **kwargs)
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=output_str))])
@abstractmethod
def _call(self, messages, stop=None, run_manager=None, **kwargs) -> str:
"""只需返回字符串"""
5.12 小结
LangChain 的语言模型抽象层通过精心设计的三层继承体系,在抽象性和灵活性之间找到了理想的平衡点:
-
BaseLanguageModel 定义了最通用的语言模型接口,包括输入输出类型、缓存配置和 token 计数基础设施。它同时是
RunnableSerializable,确保所有语言模型都能无缝融入 LCEL 链。 -
BaseChatModel 实现了 Chat 模型的完整生命周期管理,从输入规范化 (
_convert_input) 到缓存查找 (_generate_with_cache)、流式处理 (stream)、回调触发,再到结果后处理。它只要求子类实现_generate和_llm_type两个抽象成员。 -
invoke/stream/batch 三大方法提供了一致的调用体验。
stream的实现尤为精巧,通过_should_stream的五级检测策略和自动 last chunk 补发机制,确保了流式行为的可靠性。 -
bind_tools 和 with_structured_output 将工具调用和结构化输出抽象为声明式 API。
with_structured_output的默认实现展示了 LCEL 的组合能力:通过bind_tools+output_parser+with_fallbacks的管道组合,实现了健壮的结构化输出提取。 -
设计哲学:宽进严出的输入处理、有则用之的可选扩展点、缓存后限流的执行顺序,以及运行时参数优先的配置策略,共同构成了一个既易于使用又易于扩展的模型抽象层。