Day 05 / 共 20 天 · 阶段2 模型·消息·提示·输出

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(选修)。

📍 你在 20 天里的位置(阶段2:模型·消息·提示·输出 · D05-08)
D04 invoke 旅程 D05 ChatModel 抽象 D06 消息体系 D07 Prompt 模板 D08 输出解析 S3 数据与 RAG S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 类比一:BaseChatModel连锁餐厅的总部标准化手册——点单系统、收银、卫生检查、排队叫号(对应 invoke 糖衣、回调汇报、缓存、限流)总部统一做好;加盟店(ChatOpenAI、ChatAnthropic)只需要做两件事:会炒菜(_generate,最好还会一份份上菜(_stream。开一家新加盟店(接一家新厂商)成本极低,而所有店的服务体验完全一致。类比二:缓存像餐厅的"老单重做"制度——完全相同的客人点完全相同的菜(相同消息 + 相同模型参数),直接把上次的成品端出来,不再进后厨(不再花钱调 API);判断"是不是同一单"的凭据,是把订单和厨师配置序列化成一个字符串钥匙(prompt + llm_string)。
L01

痛点:接一家新厂商,到底要写多少代码?

🤔 痛点假设没有基类,每家厂商适配包都要自己实现:字符串/字典/消息对象的输入兼容、结果缓存(省钱)、速率限制(防封号)、回调汇报(让 D04 的树能长出来)、"用户要流式但我只实现了非流式"的兜底、异常时给追踪系统报错……这些逻辑每家写一遍,就是 N 份几乎相同的代码 + N 种不一致的 bug。LangChain 要接入几十家厂商(D01 见过 partners/ 下十几间客房),这条路走不通。
💡 本质:模板方法模式——骨架在基类,空格留给子类解法是经典的模板方法(Template Method):基类把"一次模型调用"的完整流程写死成骨架(查缓存→限流→判断流式→调用→存缓存→汇报),流程中真正"和厂商 API 打交道"的那一小步留成抽象方法 _generate/_stream,让子类填空。和 Dify 的 ModelInstance(门面+轮询包装)异曲同工,但 LangChain 走得更极致:连"应该流式还是非流式"都由基类替子类决定。
L02

类说明书:官方 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)是"厨房",只有基类会调,用户永远不直接碰。门面负责流程,厨房负责炒菜。
L03

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)拿到的就是它。
大白话invoke 自己一点"模型知识"都没有:归一化输入 → 委托给 generate → 剥壳取货。真正的门道从 generate 开始往下钻。
L04

generate:开监理 + 逐条处理 + 兜异常(真源码第 3 段)

generatechat_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)。命名的意思是"带缓存地生成"。
和 D04 连线:Sequence 主循环给 step2 的子监理(seq:step:2)通过 callbacks 传进来,在这里被 CallbackManager.configure 组装、由 on_chat_model_start 生成模型 run——所以 LangSmith 树上模型节点是链节点的孩子。
L05

_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无论走哪条路,成功的结果都写回缓存。下次同钥匙直接命中。
BaseChatModel 主线:invoke → generate → _generate_with_cache → 子类 invoke(薄糖衣 :463) generate(开监理 :1564) _generate_with_cache(总调度 :1864) 缓存命中 :1888 直接返回,0 花费 rate_limiter :1907 未命中才排队 路B _stream :1953 有人听流式 → 逐 token 汇报 路C _generate :1994 普通一次性生成 两条路殊途同归 → llm_cache.update 回填缓存(:2020)→ 返回 ChatResult 虚线框以下才是厂商子类的代码(ChatOpenAI 只写 _generate/_stream)
图注:从 invoke 到子类 _generate 隔着两层——generate 管"批量与监理",_generate_with_cache 管"省钱与选路"。
⚠️ 坑:开了缓存,temperature>0 的"随机性"就没了缓存钥匙只看"输入 + 参数",不管你 temperature 设多高——同样的问题第二次问,永远返回缓存里那个答案。开发调试时全局 set_llm_cache(InMemoryCache()) 很省钱,但做"多样性生成"实验时记得关掉,或在模型上设 cache=False(三态里的明确关闭)。
L06

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),而不是让用户明说?用户其实说了(调 stream 就是要流式),难的是 invoke 场景:链的末端用户调 invoke,但中间挂着 astream_events 想看逐 token 事件——组件之间隔着好几层,用户没法给模型"捎话"。于是基类用 _should_stream 综合判断:子类会不会流式、有没有 handler 在听、有没有被显式禁用。代价是一点点"魔法感"(invoke 有时内部走流式),换来的是"挂上监听器就有逐 token 事件、不需要改任何调用代码"。
L07

串起来 + 今日小结

📝 真实值:同一行代码调两次,第二次一分钱不花 from langchain_core.caches import InMemoryCachefrom 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 万公里…")
第二次同样调用:同样的钥匙 → lookupchat_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
明日预告 · Day 06:今天到处出现的 HumanMessage / AIMessage / ChatGenerationChunk 一直被当"黑箱包裹"用。明天拆开消息体系 langchain_core/messages/:五种消息角色、AIMessage 里的 tool_calls 长什么样、流式的 Chunk 为什么能用 + 号相加合并。
← Day 04 一次 invoke 的完整旅程 Day 06 · 消息体系 →