ChatModel 抽象:厂商只写两个方法,其余脏活基类全包
Day04 的主循环里,step2 那句 model.invoke(...) 是整条链最厚的一节。今天打开 libs/core/langchain_core/language_models/chat_models.py(2711 行),看 BaseChatModel 怎么用一条 invoke → generate → _generate_with_cache → 子类 _generate 的主线,把缓存查询、速率限制、流式判断、回调汇报这些每家厂商都需要的脏活,全部收编进基类——最终让 ChatOpenAI 这样的厂商类只需实现 _generate(必修)和 _stream(选修)。
BaseChatModel 像连锁餐厅的总部标准化手册——点单系统、收银、卫生检查、排队叫号(对应 invoke 糖衣、回调汇报、缓存、限流)总部统一做好;加盟店(ChatOpenAI、ChatAnthropic)只需要做两件事:会炒菜(_generate),最好还会一份份上菜(_stream)。开一家新加盟店(接一家新厂商)成本极低,而所有店的服务体验完全一致。类比二:缓存像餐厅的"老单重做"制度——完全相同的客人点完全相同的菜(相同消息 + 相同模型参数),直接把上次的成品端出来,不再进后厨(不再花钱调 API);判断"是不是同一单"的凭据,是把订单和厨师配置序列化成一个字符串钥匙(prompt + llm_string)。痛点:接一家新厂商,到底要写多少代码?
_generate/_stream,让子类填空。和 Dify 的 ModelInstance(门面+轮询包装)异曲同工,但 LangChain 走得更极致:连"应该流式还是非流式"都由基类替子类决定。类说明书:官方 docstring 里的"必修/选修"表(真源码第 1 段)
language_models/chat_models.py:272,类 docstring 自带两张表,这里裁出最关键的第三张——"写一个自定义聊天模型要实现什么":
# libs/core/langchain_core/language_models/chat_models.py:272
class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
r"""Base class for chat models.
Key imperative methods: (真正调模型的方法)
invoke: str | list[dict|tuple|BaseMessage] | PromptValue → BaseMessage
stream: 同上输入 → Iterator[BaseMessageChunk]
batch: list[...] → list[BaseMessage]
...
Creating custom chat model:
| Method/Property | Description | Required |
| _generate | 从一组消息生成一个聊天结果 | Required | ★必修
| _llm_type (property) | 模型类型唯一标识(用于日志) | Required | ★必修
| _identifying_params | 模型参数化描述(用于追踪) | Optional |
| _stream | 实现流式 | Optional | ★选修
| _agenerate / _astream | 原生异步版 | Optional |
"""
BaseLanguageModel[AIMessage]泛型参数 AIMessage:声明这个 Runnable 的 Output 是 AIMessage。所以聊天模型在 D03 的水管世界里是 Runnable[LanguageModelInput, AIMessage]——能直接入管。必修只有两个★_generate(收一组消息,还一个 ChatResult)和 _llm_type(返回类似 "openai-chat" 的标识字符串)。一个最小可用的自定义模型 30 行就能写完。_stream 是选修不实现也能被用户 .stream()——基类兜底成"调一次 invoke、整块吐出"(L06 看源码)。实现了,基类还会反过来在 invoke 场景里主动用它(L05 的 _should_stream 分支)。下划线约定无下划线的方法(invoke/stream/generate)是基类写好的"门面",用户调它们;带下划线的(_generate/_stream)是"厨房",只有基类会调,用户永远不直接碰。门面负责流程,厨房负责炒菜。invoke:其实只是 generate 的一层薄糖衣(真源码第 2 段)
D04 留下的钩子今天补上——BaseChatModel.invoke 全文(chat_models.py:463-487):
# libs/core/langchain_core/language_models/chat_models.py:463(完整正文)
def invoke(self, input, config=None, *, stop=None, **kwargs) -> AIMessage:
config = ensure_config(config) # D04 的老朋友:补齐工单
return cast(
"AIMessage",
cast(
"ChatGeneration",
self.generate_prompt(
[self._convert_input(input)], # ① 输入归一化,且包成"单元素列表"
stop=stop,
callbacks=config.get("callbacks"), # ② 把工单拆开递给 generate 体系
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], # ③ 批量结果里取第 [0][0] 份
).message, # ④ 从 Generation 里剥出 AIMessage
)
_convert_input(input)输入三形态归一化:纯字符串 → 包成 HumanMessage;消息列表 → 原样;PromptValue(prompt 节的产物)→ 转成消息列表。这就是 D02 里"直接传字符串也能跑"的机关。[ ... ] 单元素列表★注意入参包了一层列表:因为底层 generate 天生是批量接口(一次处理多组对话)。invoke = "批量大小为 1 的 generate",最后取 generations[0][0](第 0 组输入的第 0 个候选回复)。单发是批发的特例——只维护一条主线。config 拆开传D04 说过模型不走 _call_with_config,这里看到实情:把 callbacks/tags/metadata/run_name 逐项拆出来传给 generate_prompt——因为 generate 体系有自己更复杂的监理流程(L04)。返回 AIMessage层层 cast 剥壳后返回纯 AIMessage——正好是 D03 泛型声明的 Output 类型,管道下一节(parser)拿到的就是它。generate:开监理 + 逐条处理 + 兜异常(真源码第 3 段)
generate(chat_models.py:1564)签名收的是 list[list[BaseMessage]](多组对话)。它的核心段落——给每组输入开监理、逐组调用缓存版生成(chat_models.py:1642-1673,裁剪):
# libs/core/langchain_core/language_models/chat_models.py:1642 附近(裁剪)
run_managers = callback_manager.on_chat_model_start( # ① 每组输入开一个 run(监理)
self._serialized, messages_to_trace,
invocation_params=params, options=options,
name=run_name, run_id=run_id, batch_size=len(messages),
)
results = []
input_messages = [_normalize_messages(m) for m in messages]
for i, m in enumerate(input_messages): # ② 逐组处理
try:
results.append(
self._generate_with_cache( # ③ ★交给"总调度"(L05)
m, stop=stop,
run_manager=run_managers[i] if run_managers else None,
**kwargs,
)
)
except BaseException as e:
if run_managers:
run_managers[i].on_llm_error(e, response=...) # ④ 哪组失败哪组报
raise
# 之后:把逐组的 ChatResult 汇总成 LLMResult,并逐组 on_llm_end
on_chat_model_start模型专属的开工汇报(区别于普通组件的 on_chain_start),带上 invocation_params(temperature、model 名等)——LangSmith 里模型节点能展示参数详情就靠它。它返回每组输入一个的 run_manager 列表。逐组 for + 各自 try批量里第 3 组失败:第 1、2 组的成果已收进 results,第 3 组的监理单独报错,然后整体 raise。汇报精确到组,追踪系统里能看清到底哪组炸了。_generate_with_cache★注意 generate 自己不调 self._generate!中间还有一层"总调度"——缓存、限流、流式判断全在那(L05)。命名的意思是"带缓存地生成"。CallbackManager.configure 组装、由 on_chat_model_start 生成模型 run——所以 LangSmith 树上模型节点是链节点的孩子。_generate_with_cache:查缓存、限流、选路的总调度(真源码第 4 段)
今天最重要的函数(chat_models.py:1864)。三步走——查缓存(:1871-1901)、限流(:1907)、选路执行(:1941-1998),最后回填缓存(:2020):
# libs/core/langchain_core/language_models/chat_models.py:1864(裁剪,行号真实)
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 # False=明确关闭才不查
if check_cache and llm_cache:
llm_string = self._get_llm_string(stop=stop, **kwargs) # 模型+参数 → 指纹
prompt = dumps(normalized_messages) # 消息序列化 → 钥匙
cache_val = llm_cache.lookup(prompt, llm_string) # :1888 ★查缓存
if isinstance(cache_val, list):
return ChatResult(generations=...) # 命中 → 直接返回,零 API
# :1907 Apply the rate limiter after checking the cache(源码注释:
# 缓存查询不限流,真要发 API 请求才限流)
if self.rate_limiter:
self.rate_limiter.acquire(blocking=True)
# 选路:三条路挑一条
if self._should_use_protocol_streaming(...): # 路A:v2 协议事件流(新)
...
elif self._should_stream(async_api=False, run_manager=run_manager, **kwargs):
for chunk in self._stream(messages, stop=stop, **kwargs): # :1953 路B
run_manager.on_llm_new_token(chunk.message.content, chunk=chunk)
chunks.append(chunk)
result = generate_from_stream(iter(chunks)) # 攒块拼成完整结果
elif inspect.signature(self._generate).parameters.get("run_manager"):
result = self._generate(messages, stop=stop, # :1994 路C:普通一次性生成
run_manager=run_manager, **kwargs)
else:
result = self._generate(messages, stop=stop, **kwargs) # :1998
...
if check_cache and llm_cache:
llm_cache.update(prompt, llm_string, result.generations) # :2020 ★回填缓存
return result
缓存钥匙 = prompt + llm_string两把钥匙合一:消息内容序列化(还把消息的 id 字段抹掉再序列化,防止随机 id 导致永不命中)+ 模型参数指纹(同样的问题问 gpt-5.5 和问 claude 当然不能共用答案)。self.cache 三态:BaseCache 实例=用它;None=用全局缓存(get_llm_cache(),用 set_llm_cache 全局开启);False=明确不缓存。限流放在缓存之后★源码注释原话:"Apply the rate limiter after checking the cache"——命中缓存的请求不该占用 API 配额。顺序即设计:先看免费的,再排队买贵的。_should_stream 选路★最妙的一步:明明用户调的是 invoke(要完整结果),基类仍会判断"有没有人在听流式事件"(比如挂了 astream_events 的 handler)。有人听 + 子类实现了 _stream → 走路 B:内部用流式跑,一边逐 token 汇报 on_llm_new_token,一边攒块,最后拼成完整结果返回。调用方拿到的东西一样,旁观者的体验完全不同。inspect.signature(...)路 C 前用反射看子类的 _generate 签名收不收 run_manager——老版本厂商包没这个参数,就不传。用一次反射换来向后兼容,老加盟店不用连夜改菜单。回填缓存 :2020无论走哪条路,成功的结果都写回缓存。下次同钥匙直接命中。set_llm_cache(InMemoryCache()) 很省钱,但做"多样性生成"实验时记得关掉,或在模型上设 cache=False(三态里的明确关闭)。stream:真流式的门面与"不会流式"的兜底(真源码第 5 段)
用户直接调 model.stream(...) 时走这里(chat_models.py:715-780,裁剪):
# libs/core/langchain_core/language_models/chat_models.py:715(裁剪)
def stream(self, input, config=None, *, stop=None, **kwargs):
if not self._should_stream(async_api=False, **{**kwargs, "stream": True}):
# Model doesn't implement streaming, so use default implementation
yield cast("AIMessageChunk",
self.invoke(input, config=config, stop=stop, **kwargs)) # ① 兜底
else:
config = ensure_config(config)
messages = self._convert_input(input).to_messages()
...
(run_manager,) = callback_manager.on_chat_model_start(...) # ② 开监理
chunks: list[ChatGenerationChunk] = []
if self.rate_limiter:
self.rate_limiter.acquire(blocking=True) # ③ 限流
try:
for chunk in self._stream(input_messages, stop=stop, **kwargs): # ④ ★真流式
...
run_manager.on_llm_new_token(chunk.message.content, chunk=chunk)
chunks.append(chunk) # ⑤ 边吐边攒
yield cast("AIMessageChunk", chunk.message) # ⑥ 吐给调用方
...
# 结束后:attach 一个 chunk_position="last" 的收尾块,并 on_llm_end
① 兜底分支子类没实现 _stream(或被 disable_streaming 关掉)→ 退化成"调一次 invoke、把完整结果当一整块 yield"。接口永远可用,体验按实现打折——正是 D03 见过的默认 stream 思想在模型层的翻版。④⑥ 边吐边攒每块 chunk:先汇报监理(on_llm_new_token,D17 的事件流源头)→ 存进 chunks(结束后拼完整消息给 on_llm_end / 供缓存)→ yield 给你。一份水流,三个去处。与 L05 路 B 的关系同一个 self._stream 厨房,两个门面:用户主动 stream() 走这里、逐块给用户;用户 invoke() 但有人旁听时走 L05 路 B、逐块给旁听者最后整块给用户。厨房只写一次。_should_stream 综合判断:子类会不会流式、有没有 handler 在听、有没有被显式禁用。代价是一点点"魔法感"(invoke 有时内部走流式),换来的是"挂上监听器就有逐 token 事件、不需要改任何调用代码"。串起来 + 今日小结
from langchain_core.caches import InMemoryCache、from langchain_core.globals import set_llm_cache,然后 set_llm_cache(InMemoryCache())。第一次
model.invoke("地球到月球多远?"):invoke 包成单元素批 → generate 开监理 → _generate_with_cache 算钥匙 prompt=dumps([HumanMessage("地球到月球多远?")]) + llm_string="...model_name=gpt-5.5,temperature=0.7..." → lookup 未命中 → 限流排队 → 走路 C 调 ChatOpenAI._generate 真发 API(耗时约 1.2s,花钱)→ update 回填 → 返回 AIMessage("平均约 38.4 万公里…")。第二次同样调用:同样的钥匙 →
lookup(chat_models.py:1888)命中 → 直接返回同一个答案(耗时约 0.001s,零花费、零限流)。把问题改一个字、或 temperature 改成 0.8:钥匙变了,重新走全流程。
👶 小白:generate 和 _generate 就差一个下划线,我总记混。到底谁调谁、我该碰哪个?
👨🏫 老师:记"门面-厨房"链就不乱:你(或链)只碰 invoke/stream/batch/generate 这些无下划线门面;门面层层委托——invoke → generate(批量+监理)→ _generate_with_cache(缓存+限流+选路)→ _generate/_stream(厨房,厂商子类写的)。方向永远单向:门面调厨房,厨房从不回头调门面。你写自定义模型时只写厨房(_generate 必修、_stream 选修);你用模型时只按门面的门铃。
🧠 今天你应该能回答
- 厂商子类必须实现哪两个成员?(
_generate和_llm_type;_stream是选修) - invoke 和 generate 什么关系?(invoke = 批量大小为 1 的 generate,取 generations[0][0] 剥出 AIMessage)
- 缓存的钥匙由什么组成?(序列化后的消息 prompt + 模型参数指纹 llm_string;self.cache 有实例/None/False 三态)
- 为什么限流器在缓存检查之后?(缓存命中不占 API 配额,先看免费的再排队买贵的)
- 用户调 invoke 也可能走 _stream?(会——_should_stream 发现有人在听流式事件且子类支持时,内部流式跑、攒块拼整)
- 子类没实现 _stream,用户调 stream 会怎样?(不报错,退化成 invoke 一次、整块 yield)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/langchain
# 1. 类说明书(必修/选修表就在 docstring 里)
sed -n '272,322p' libs/core/langchain_core/language_models/chat_models.py
# 2. 主线四层
sed -n '463,490p' libs/core/langchain_core/language_models/chat_models.py # invoke 糖衣
sed -n '1640,1675p' libs/core/langchain_core/language_models/chat_models.py # generate 逐组循环
sed -n '1864,1912p' libs/core/langchain_core/language_models/chat_models.py # 缓存+限流
sed -n '1941,2000p' libs/core/langchain_core/language_models/chat_models.py # 选路三分支
sed -n '2018,2022p' libs/core/langchain_core/language_models/chat_models.py # 回填缓存
# 3. stream 门面 + 兜底
sed -n '715,760p' libs/core/langchain_core/language_models/chat_models.py
# 4. 看真实厂商怎么"填空"(只找厨房方法)
grep -n "def _generate\|def _stream\|def _llm_type" \
libs/partners/openai/langchain_openai/chat_models/base.py | head
langchain_core/messages/:五种消息角色、AIMessage 里的 tool_calls 长什么样、流式的 Chunk 为什么能用 + 号相加合并。