Day 02 / 共 20 天 · 阶段1 全景与 LCEL

环境搭建 + 第一个链:一根竖线串起三节水管

Day01 认完门牌,今天真正跑起来。三件事:①装好环境并理解"装了哪几个包";②钻进 init_chat_model 源码,看"一行拿模型"是怎么按厂商前缀分发的;③写下第一根管道 prompt | model | parser,并认识三节管道各自的源码位置。今天出现的每个新面孔(ChatPromptTemplate、StrOutputParser、竖线 |),都是后面某一天的主角——今天先让水流通。

📍 你在 20 天里的位置(阶段1:全景与 LCEL · D01-04)
D01 项目全景 D02 第一个链 D03 Runnable/LCEL D04 invoke 旅程 S2 模型/消息/Prompt S3 数据与 RAG S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 类比一:prompt | model | parser 就是三节水管——第一节把"一桶散装原料"(字典 {"topic": "猫"})压成"标准水流"(消息列表);第二节是"净水器"(模型),进消息出 AIMessage;第三节是"出水口滤网"(解析器),把 AIMessage 里的纯文字滤出来给你。竖线 | 就是水管接口,两节管口径对上(前一节的输出类型 = 后一节的输入类型)就能拧上。类比二:init_chat_model("openai:gpt-5.5")全国连锁的手机卡商店——你报"运营商:套餐"(厂商:型号),店员(源码里的 _init_chat_model_helper)看运营商是谁,就去对应柜台(partners 包)给你开卡;你甚至可以不说运营商,店员会根据号段(模型名前缀 gpt-/claude)猜出来。
L01

痛点:换个模型厂商,代码全重写?

🤔 痛点不用框架直接裸调 SDK:用 OpenAI 要学 openai.chat.completions.create(...),换 Claude 要学 anthropic.messages.create(...)——参数名不同(max_tokens 位置都不一样)、返回结构不同、流式写法不同。老板说"下周换个便宜模型试试",你就得把调用层重写一遍。而且"拼提示词→调模型→抠出回复文本"这三步样板代码,每个项目都要手写一遍。
💡 本质:统一接口 + 一行工厂LangChain 的答案分两层:接口层——所有厂商模型都实现 BaseChatModel(D05 主角),对你来说都长一样:.invoke(消息) → AIMessage工厂层——init_chat_model("厂商:型号") 一行按字符串分发到对应厂商包。换模型 = 改一个字符串。至于"拼提示→调模型→抠文本"的样板,就用一根管道 prompt | model | parser 声明出来,写一次到处 invoke。
L02

装环境:三条命令 + 验证装了谁

# 1. 建一个干净的虚拟环境(uv 或 python -m venv 都行)
uv venv .venv && source .venv/bin/activate

# 2. 装主包 + 你要用的厂商小包(用哪家装哪家,见 D01 L04 的设计取舍)
uv pip install langchain langchain-openai      # 用 OpenAI
# uv pip install langchain-anthropic           # 用 Claude 则装这个
# uv pip install langchain-ollama              # 本地模型用 Ollama

# 3. 配 API Key(厂商包会自动读对应环境变量)
export OPENAI_API_KEY="sk-..."

# 验证:看看"装一个包、来一家人"(D01 讲过的依赖链)
uv pip list | grep langchain
# langchain                1.x     ← 新房(libs/langchain_v1)
# langchain-core           1.4.x   ← 地基,被自动拖下来
# langchain-text-splitters 1.1.x   ← 也被主包声明拖下来
# langchain-openai         1.x     ← 你手动装的厂商包
大白话装完后 pip list 里的四个包,正好对应 Day01 地图上的四栋楼:新房、地基、工具间、一间客房。没钱买 API?langchain-ollama + 本地跑个小模型,后面所有例子把 "openai:gpt-5.5" 换成 "ollama:qwen3" 一样能跟。教程重点是读源码,模型本身不重要。
L03

init_chat_model:一行拿模型的源码真相(真源码第 1、2 段)

它定义在新房 libs/langchain_v1/langchain/chat_models/base.py:210。docstring 长达两百多行(写满了各厂商映射表),真正的身体很短——裁掉文档后的核心(base.py:475-499):

# libs/langchain_v1/langchain/chat_models/base.py:475(裁剪,行为一致)
def init_chat_model(model=None, *, model_provider=None,
                    configurable_fields=None, config_prefix=None, **kwargs):
    if model is not None and not isinstance(model, str):
        raise TypeError("`model` must be a string (e.g., 'openai:gpt-5.5') ...")
    if not model and not configurable_fields:
        configurable_fields = ("model", "model_provider")   # 没给模型名 → 变成"运行时可配置"
    ...
    if not configurable_fields:
        return _init_chat_model_helper(                     # ★普通情况:直接造模型
            cast("str", model), model_provider=model_provider, **kwargs,
        )
    ...                                                     # 可配置情况:返回 _ConfigurableModel(D18 再讲)

真正干活的是 _init_chat_model_helperbase.py:510-518):

# libs/langchain_v1/langchain/chat_models/base.py:510
def _init_chat_model_helper(model, *, model_provider=None, **kwargs):
    model, model_provider = _parse_model(model, model_provider)  # ① 拆 "openai:gpt-5.5"
    creator_func = _get_chat_model_creator(model_provider)       # ② 按厂商找"造模型的函数"
    return creator_func(model=model, **kwargs)                   # ③ 造!返回 BaseChatModel

# 同文件 :521 起,_attempt_infer_model_provider:没写厂商前缀时按模型名猜
#   model 以 "gpt-" / "o1" / "o3" / "chatgpt" 开头 → "openai"
#   以 "claude" 开头 → "anthropic"  ……(一长串 if)
_parse_model"openai:gpt-5.5" 按第一个冒号拆成 ("gpt-5.5", "openai")。如果你只给了 "claude-opus-4-7" 没写厂商,就调 _attempt_infer_model_provider 按前缀猜(base.py:521)——猜是"尽力而为",所以官方推荐写全冒号形式。
_get_chat_model_creator★按厂商名返回一个"创建函数",里面本质是 from langchain_openai import ChatOpenAI 这样的延迟 import——没装对应厂商包会在这里报 ImportError,提示你 pip install langchain-openai。这就是"用哪家装哪家"能成立的机关:主包不 import 任何厂商代码,直到你真的要用。
creator_func(model=...)最终返回 ChatOpenAI(model="gpt-5.5", ...) 实例——它是 BaseChatModel 的子类(D05 主角),所以天生会 .invoke()/.stream()/.batch()
configurable_fields 分支如果你一个模型名都不给,它不报错,而是返回一个"运行时再决定用什么模型"的可配置壳(_ConfigurableModel)——invoke 时通过 config 传 {"configurable": {"model": "..."}} 现场切换。这是 D18 的内容,今天知道有这条岔路即可。
💡 设计取舍:为什么用字符串而不是直接 import ChatOpenAI?直接 ChatOpenAI(...) 类型更明确、IDE 补全更好;字符串工厂则让"换厂商"变成改配置而非改代码(可以放进环境变量/配置文件)。LangChain 两条路都留着:写死用类、想灵活用工厂。代价是字符串写错要到运行时才炸——所以源码里第一件事就是类型/格式检查(上面 :476 那个 TypeError)。
L04

第一个链:prompt | model | parser 跑起来

# first_chain.py —— 你的第一根管道
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个只会说大实话的喜剧演员。"),
    ("human", "讲一个关于{topic}的一句话笑话"),
])
model = init_chat_model("openai:gpt-5.5", temperature=0.7)
parser = StrOutputParser()

chain = prompt | model | parser        # ★一根竖线拧三节水管
print(chain.invoke({"topic": "猫"}))   # → '我家猫把我当铲屎官培训基地的实习生。'
第一根管道:数据像水一样流过三节 输入字典 {"topic": "猫"} prompt ChatPromptTemplate 字典 → 消息列表 model ChatOpenAI 消息 → AIMessage parser StrOutputParser AIMessage → str str |(竖线)= 管口,D03 揭秘 chain = prompt | model | parser 其实构造了一个 RunnableSequence(runnables/base.py:3063)
图注:每节管道"进什么、出什么"都是确定的,前一节的出口对上后一节的入口,竖线才拧得上。
大白话注意 chain = prompt | model | parser 这行执行时并没有调模型——它只是把三节水管拧好,做出一根新水管(RunnableSequence 对象)。真正放水是 chain.invoke({...}) 那一刻。"先声明结构、后执行",这是 LCEL 的核心气质,D03/D04 会反复见到。
L05

三节管道各自的源码位置(真源码第 3、4 段)

第一节 ChatPromptTemplate.from_messageslibs/core/langchain_core/prompts/chat.py:1124-1172,类定义在 chat.py:794):

# libs/core/langchain_core/prompts/chat.py:1124(裁剪 docstring)
@classmethod
def from_messages(cls, messages, template_format="f-string") -> ChatPromptTemplate:
    """...支持的消息写法:
       3. 2-tuple of (message type, template)   例如 ('human', '{user_input}')
       5. 纯字符串 = ('human', template) 的简写
    """
    return cls(messages, template_format=template_format)   # 就一行:交给构造器
docstring 明说了我们用的 ("system", "...")("human", "...{topic}") 这种 2 元组写法是官方正道;{topic} 是 f-string 风格占位符(template_format="f-string" 默认值)。它的 invoke 继承自 BasePromptTemplate.invokeprompts/base.py:210)——D04 走 invoke 旅程时会正式路过。

第三节 StrOutputParser,全类不到 70 行(libs/core/langchain_core/output_parsers/string.py:8,parse 在 string.py:61-63):

# libs/core/langchain_core/output_parsers/string.py:8
class StrOutputParser(BaseTransformOutputParser[str]):
    """Extract text content from model outputs as a string. ...
    Supports streaming, yielding text chunks as they're generated."""

    @override
    def parse(self, text: str) -> str:      # string.py:61
        """Returns the input text with no changes."""
        return text                          # ★核心逻辑就这一行!
parse 只是原样返回?对——真正的功夫在它继承的基类里:基类负责"从 AIMessage 里抠出 .content 文本",再把文本交给 parse 做最后加工。StrOutputParser 的加工 = 什么都不做。子类只写差异,共性全在基类——这个模式 D05 的 BaseChatModel 会玩到极致。
BaseTransformOutputParser名字里的 Transform 表示它支持流式:模型一块块吐字时,它能一块块解析转发,不用等全文。所以 chain.stream() 时最后一节不会卡住水流。
第二节 model 呢?ChatOpenAI 住在客房 libs/partners/openai/,但它 95% 的行为(invoke/缓存/回调/流式兜底)都继承自地基的 BaseChatModellibs/core/langchain_core/language_models/chat_models.py:272)——那是 D05 一整天的主菜。
💡 本质:三节管道是"三个都实现了 Runnable 的类"prompt、model、parser 分属三个完全不同的模块,却都能被 | 串起来、都能单独 .invoke()——因为它们都继承了同一个基类 Runnable。水管接口的"标准口径"就是 Runnable 协议。明天我们就去读这个协议本身。
L06

串起来 + 今日小结

📝 真实值:一次调用,数据在三节管里长什么样 chain.invoke({"topic": "猫"}) 的三段变身:
① prompt 收到 {"topic": "猫"},吐出消息列表 → [SystemMessage("你是一个只会说大实话的喜剧演员。"), HumanMessage("讲一个关于猫的一句话笑话")]
② model 收到消息列表,请求 OpenAI,吐出 → AIMessage(content="我家猫把我当铲屎官培训基地的实习生。", response_metadata={"model_name": "gpt-5.5", ...})
③ parser 收到 AIMessage,抠出 content,吐出 → '我家猫把我当铲屎官培训基地的实习生。'(纯 str)。
换成 chain.stream({"topic": "猫"}),第③步会一小段一小段吐:'我家''猫把我当'……

👶 小白:我不写 prompt 模板,直接 model.invoke("讲个笑话") 传字符串,为什么也能跑?

👨‍🏫 老师:因为 BaseChatModel.invoke 收到输入后第一件事是 self._convert_input(input)(D05 会看到,chat_models.py:463 的 invoke 里就有)——字符串会被自动包成 HumanMessage("讲个笑话")。方便是方便,但真实应用里提示词几乎总有"固定部分 + 变量部分",所以模板还是标配。另外注意:invoke 传的是一份输入;想一次发多份用 batch([...]),想逐字出用 stream(...)——这三兄弟明天正式介绍。

🧠 今天你应该能回答

  • 装 langchain + langchain-openai 后环境里有哪四个家族包?(主包/core/text-splitters/openai 客房)
  • init_chat_model("openai:gpt-5.5") 内部三步?(拆字符串 → 按厂商找创建函数(延迟 import)→ 造出 BaseChatModel 子类)
  • 没写厂商前缀会怎样?(按模型名前缀猜,gpt-→openai、claude→anthropic;建议写全)
  • chain = prompt | model | parser 这行调模型了吗?(没有!只是拧水管,invoke 才放水)
  • StrOutputParser.parse 的核心逻辑?(return text 原样返回,抠 content 的活在基类)
  • 三节管道为什么能用 | 串起来?(都实现了 Runnable 协议——明天的主角)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/langchain

# 1. init_chat_model 的身体与帮手
sed -n '475,500p' libs/langchain_v1/langchain/chat_models/base.py   # 主体
sed -n '510,545p' libs/langchain_v1/langchain/chat_models/base.py   # helper + 前缀推断

# 2. 第一节和第三节管道
sed -n '1124,1175p' libs/core/langchain_core/prompts/chat.py        # from_messages
sed -n '1,65p'     libs/core/langchain_core/output_parsers/string.py # StrOutputParser 全文

# 3. 跑真链(装好环境后)
python first_chain.py
# 没 API key 就把模型行换成 init_chat_model("ollama:qwen3")(需本地 ollama)
明日预告 · Day 03:今天那根竖线 | 是全框架最大的魔法。明天打开 6713 行的 runnables/base.py:Runnable 基类的三件套(invoke/stream/batch)、__or__ 怎么把竖线变成 RunnableSequence、以及 dict/函数为什么也能混进管道(coerce_to_runnable)。
← Day 01 项目全景与分包 Day 03 · Runnable 与 LCEL 管道 →