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

Prompt 模板:PromptTemplate / ChatPromptTemplate / MessagesPlaceholder / Few-Shot

Day06 认识了消息,但没人想每次手拼 [SystemMessage(...), HumanMessage(...)]。今天看 libs/core/langchain_core/prompts/——LCEL 链条 prompt | model | parser 里的第一节车厢。弄清四件事:PromptTemplate 怎么"填空"出字符串;②ChatPromptTemplate.from_messages 怎么把简写元组批量变成消息;③MessagesPlaceholder 怎么给"聊天历史"留座位;④few-shot 范例怎么被装订进最终提示词。

📍 你在 20 天里的位置(阶段2:模型·消息·提示·输出 · D05-08)
D05 ChatModel D06 消息体系 D07 Prompt 模板 D08 输出解析 S3 数据与RAG S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 类比一:Prompt 模板就是邮件合并(mail merge)——HR 写好一封"亲爱的 {name},恭喜你入职 {company}"的信纸模板,每来一个新员工就填一次空、发一封信。模板写一次、数据换着来。类比二:ChatPromptTemplate 是一份剧本:里面既有写死的台词(SystemMessage 人设)、带空的台词("{question}"),还有一行舞台指示"此处插入前情回顾"(MessagesPlaceholder)——开演(invoke)时把前情(聊天历史)原样插进那个位置。整天我们就在这两个世界观里打转。
L01

痛点:提示词不能硬编码

🤔 痛点直接用 f-string 拼提示词看似够用,但很快遇到:①提示词要复用和分发(存库、存文件、团队共享),f-string 是代码不是数据;②聊天模型要的是消息列表不是字符串,还要分角色;③多轮对话的历史长度不定,没法用固定数量的空位表达;④想给模型塞几个示范例子(few-shot),例子还想按输入动态挑选。这些 f-string 全都做不到。
💡 本质:把"提示词"从代码降格为数据,再升格为 RunnableLangChain 的解法分两步:第一步,模板是可序列化的数据对象(模板串 + 变量名清单),能存盘能校验;第二步,模板类都继承了 Runnable——所以它能直接用 | 接进 LCEL 链(D03 讲过的管道),invoke({"question": "..."}) 输出 PromptValue 喂给模型。一张信纸模板,既是数据、又是流水线的第一道工位。
位置产出
PromptTemplateprompts/prompt.py:24一根字符串(补全模型/单段文本)
ChatPromptTemplateprompts/chat.py:794一列消息(聊天模型)
MessagesPlaceholderprompts/chat.py:53把一个变量原样展开成 N 条消息
FewShotPromptTemplateprompts/few_shot.py:121前言 + N 个范例 + 收尾拼成的字符串
L02

PromptTemplate:一张填空卷

最基础的字符串模板 PromptTemplatelibs/core/langchain_core/prompts/prompt.py:24):

# libs/core/langchain_core/prompts/prompt.py:24
class PromptTemplate(StringPromptTemplate):
    """Prompt template for a language model.

    A prompt template consists of a string template. It accepts a set of
    parameters from the user that can be used to generate a prompt for a
    language model.

    The template can be formatted using either f-strings (default), jinja2,
    or mustache syntax.

    Example:
        # Instantiation using from_template (recommended)
        prompt = PromptTemplate.from_template("Say {foo}")
        prompt.format(foo="bar")
    """

填空动作在 formatlibs/core/langchain_core/prompts/prompt.py:191),短得可以背下来:

# libs/core/langchain_core/prompts/prompt.py:191
def format(self, **kwargs: Any) -> str:
    """Format the prompt with the inputs."""
    kwargs = self._merge_partial_and_user_variables(**kwargs)   # ① 预填值 + 现填值合并
    return DEFAULT_FORMATTER_MAPPING[self.template_format](self.template, **kwargs)  # ② 按格式渲染
_merge_partial_and_user_variables模板支持 partial_variables"预填空"——比如"今天日期"这种固定值先填上,剩下的空留给调用时。两拨值在这合并。
DEFAULT_FORMATTER_MAPPING[...]template_format 查表选渲染器:默认 f-string(就是 Python 的 str.format),也支持 jinja2 / mustache。渲染策略是查表可换的,模板类本身不关心语法。
⚠️ 坑:jinja2 模板别接受外部输入类 docstring 里有大段安全警告(prompt.py:33 附近):jinja2 模板可能导致任意代码执行,虽然默认用了沙箱(SandboxedEnvironment),但"沙箱是尽力而为不是保证"。结论:优先 f-string;绝不渲染来路不明的 jinja2 模板——比如"让用户自己粘贴模板"的功能就是高危场景。
L03

from_template:变量名怎么被自动侦测

为什么推荐 from_template 而不是直接构造?看源码(libs/core/langchain_core/prompts/prompt.py:257):

# libs/core/langchain_core/prompts/prompt.py:257
@classmethod
def from_template(cls, template, *, template_format="f-string",
                  partial_variables=None, **kwargs) -> PromptTemplate:
    input_variables = get_template_variables(template, template_format)  # ① ★扫描模板抠出变量名
    partial_variables_ = partial_variables or {}
    if partial_variables_:
        input_variables = [                                              # ② 预填过的不再算"待填空"
            var for var in input_variables if var not in partial_variables_
        ]
    return cls(input_variables=input_variables, template=template,
               template_format=template_format,
               partial_variables=partial_variables_, **kwargs)
get_template_variables★解析模板字符串,把 {city}{date} 这些空位名全抠出来,自动生成 input_variables 清单。你不用手写也不会写漏。
剔除 partial已经预填的变量从"待填清单"里去掉。之后 invoke 时框架会校验:给的变量和待填清单对不上就报错——填空卷交卷前先对答题卡
📝 真实值:填一次空 p = PromptTemplate.from_template("把下面的{lang}翻译成中文:{text}")
→ 自动侦测 input_variables=["lang", "text"]
p.format(lang="英文", text="Hello")"把下面的英文翻译成中文:Hello"
p.invoke({"lang": "英文", "text": "Hello"})StringPromptValue(text="把下面的英文翻译成中文:Hello")(invoke 走 Runnable 协议,产出可直接喂模型的 PromptValue)。
L04

ChatPromptTemplate:批量生产消息

聊天模型要消息列表,主角换成 ChatPromptTemplatelibs/core/langchain_core/prompts/chat.py:794)。入口 from_messageschat.py:1124)接受五花八门的写法:

# libs/core/langchain_core/prompts/chat.py:1124
@classmethod
def from_messages(cls, messages, template_format="f-string") -> ChatPromptTemplate:
    """Create a chat prompt template from a variety of message formats.

    A message can be represented using the following formats:
    1. BaseMessagePromptTemplate
    2. BaseMessage
    3. 2-tuple of (message type, template); e.g., ('human', '{user_input}')
    4. 2-tuple of (message class, template)
    5. A string which is shorthand for ('human', template)
    """
    return cls(messages, template_format=template_format)

真正干活的是 format_messageslibs/core/langchain_core/prompts/chat.py:1174)——逐条"过一遍剧本":

# libs/core/langchain_core/prompts/chat.py:1174
def format_messages(self, **kwargs: Any) -> list[BaseMessage]:
    kwargs = self._merge_partial_and_user_variables(**kwargs)
    result = []
    for message_template in self.messages:
        if isinstance(message_template, BaseMessage):
            result.extend([message_template])              # ① 写死的台词:原样上
        elif isinstance(message_template,
                        (BaseMessagePromptTemplate, BaseChatPromptTemplate)):
            message = message_template.format_messages(**kwargs)  # ② 带空的台词:填空后上
            result.extend(message)                                #    (可能展开成多条!)
        else:
            raise ValueError(msg)
    return result
BaseMessage 分支剧本里写死的消息(如 SystemMessage("你是客服"))不用填空,直接进结果。
模板分支 + extend★注意用的是 extend 不是 append——一个模板项可以展开成多条消息。普通的 ("human", "{q}") 展开成 1 条;而 MessagesPlaceholder 能展开成 N 条(下一节)。这个 extend 就是"留座位"机制的接口。
("human", "{q}") 怎么变的from_messages 里每个元组经 _convert_to_message 变成对应的 HumanMessagePromptTemplate 等——底层复用了 D06 提过的 convert_to_messages 家族。
L05

MessagesPlaceholder:给聊天历史留座位

🤔 痛点多轮对话时历史消息 5 条还是 50 条不确定,模板里没法写固定空位。怎么表达"这里插入不定长的一段历史"?

MessagesPlaceholderlibs/core/langchain_core/prompts/chat.py:53)就是那行"此处插入前情回顾"的舞台指示。它的 format_messageschat.py:164):

# libs/core/langchain_core/prompts/chat.py:164
def format_messages(self, **kwargs: Any) -> list[BaseMessage]:
    value = (
        kwargs.get(self.variable_name, [])      # optional=True:没给就空列表
        if self.optional
        else kwargs[self.variable_name]         # 必填:没给直接 KeyError
    )
    if not isinstance(value, list):
        raise ValueError(msg)                   # 必须是列表,防呆
    value = convert_to_messages(value)          # ★元组简写 → 真消息对象
    if self.n_messages:
        value = value[-self.n_messages :]       # ★只保留最近 n 条(截断历史)
    return value
variable_name座位名。MessagesPlaceholder("history") = "开演时从输入的 history 变量取一列消息,原样坐进这个位置"。
optional=True没有历史(第一轮对话)也不报错,返回空列表。源码 docstring 演示了这个对比:不带 optional 直接 KeyError
convert_to_messages允许历史用 ("human", "5+2等于几") 简写传入,这里统一转成 HumanMessage 等对象——和 D06 学的消息体系无缝衔接。
n_messages 截断★内置"只记最近 n 条"的滑动窗口——控制 token 成本最朴素的一招,模板层就给你做了。
ChatPromptTemplate:剧本 + 座位 → 最终消息列表 剧本(模板定义) ("system", "你是数学助教") MessagesPlaceholder("history") ("human", "{question}") invoke({history: […2条], question: "再乘4呢"}) 开演 产出:list[BaseMessage] SystemMessage("你是数学助教") HumanMessage("5+2等于几") AIMessage("等于7") HumanMessage("再乘4呢") 黄色两条 = 从"座位"展开的历史
图注:3 项剧本展开成 4 条消息——Placeholder 那一项按输入的 history 长度伸缩(0 条、2 条、50 条都行)。
L06

Few-Shot:把范例装订进卷子

🤔 痛点想让模型模仿格式,最有效的办法是给几个示范例子(few-shot)。手拼例子进提示词很啰嗦,而且例子多了还想"按输入挑最相关的几个"。

FewShotPromptTemplatelibs/core/langchain_core/prompts/few_shot.py:121)的 formatfew_shot.py:180)把装订过程写得清清楚楚:

# libs/core/langchain_core/prompts/few_shot.py:180
def format(self, **kwargs: Any) -> str:
    kwargs = self._merge_partial_and_user_variables(**kwargs)
    examples = self._get_examples(**kwargs)            # ① 拿例子:固定列表 或 selector 动态挑
    examples = [{k: e[k] for k in self.example_prompt.input_variables} for e in examples]
    example_strings = [
        self.example_prompt.format(**example) for example in examples   # ② 每个例子用小模板渲染
    ]
    pieces = [self.prefix, *example_strings, self.suffix]               # ③ 前言 + 例子们 + 收尾
    template = self.example_separator.join([piece for piece in pieces if piece])
    return DEFAULT_FORMATTER_MAPPING[self.template_format](template, **kwargs)  # ④ 最后整体填空
_get_examplesfew_shot.py:82)二选一:要么用写死的 examples 列表,要么用 example_selector 按本次输入动态挑(如语义最相似的 2 个)——例子库很大时特别有用。
example_prompt.format每个例子本身也是一次"小填空":用一个子 PromptTemplate(如 "问:{q}\n答:{a}")渲染。模板套模板,组合而非硬编码。
prefix + examples + suffix三明治装订:前言(任务说明)+ 若干范例 + 收尾(真正的问题),用 example_separator(默认 "\n\n")连接。
最后再 format 一次拼好的大模板里 prefix/suffix 的空位(如 {input})最后统一填。所以范例文本里若有花括号要小心转义。
💡 本质:给新员工看范例工单few-shot 就像培训新客服:与其写十页规章(长 system prompt),不如直接给他看 3 张写得标准的历史工单——"照这个样子写"。而 example_selector 相当于"来了退款问题就抽 3 张退款工单给他看",比固定 3 张更省注意力(token)。聊天版 FewShotChatMessagePromptTemplatefew_shot.py:397)则把每个例子渲染成 human/ai 消息对,原理相同。
L07

串起来 + 今日小结

📝 真实值:源码 docstring 里的完整多轮例子 这是 chat.py:81 docstring 的原例:
prompt = ChatPromptTemplate.from_messages([("system", "You are a helpful assistant."), MessagesPlaceholder("history"), ("human", "{question}")])
prompt.invoke({"history": [("human", "what's 5 + 2"), ("ai", "5 + 2 is 7")], "question": "now multiply that by 4"})
ChatPromptValue(messages=[SystemMessage("You are a helpful assistant."), HumanMessage("what's 5 + 2"), AIMessage("5 + 2 is 7"), HumanMessage("now multiply that by 4")])
再接上 D05/D06 的知识:这个 PromptValue 直接 | model 喂给 ChatModel,模型读完整"会议纪要"就知道 "that" 指的是 7。

👶 小白:("human", "{q}") 元组、HumanMessage、HumanMessagePromptTemplate 三个有啥区别?

👨‍🏫 老师:元组是简写、Message 是成品、MessagePromptTemplate 是带空的半成品。from_messages 收到元组后:模板串里空位(如 {q})就转成 HumanMessagePromptTemplate(invoke 时填空产消息);直接给 HumanMessage("你好") 则是写死的成品,原样进结果。记住流向:简写 → 半成品(存在模板里)→ invoke 填空 → 成品消息

🧠 今天你应该能回答

  • PromptTemplate.format 干了哪两步?(合并 partial 变量 + 按 template_format 查表渲染,prompt.py:191)
  • from_template 比手动构造好在哪?(自动侦测 input_variables,prompt.py:257)
  • ChatPromptTemplate.from_messages 支持哪些写法?(消息对象/元组/字符串等 5 种,chat.py:1124)
  • format_messages 为什么用 extend?(一个模板项可展开成 N 条消息,chat.py:1174)
  • MessagesPlaceholder 的三个开关?(variable_name 座位名 / optional 可空 / n_messages 截断,chat.py:164)
  • few-shot 的装订公式?(prefix + 渲染后的例子们 + suffix,例子可由 selector 动态挑,few_shot.py:180)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/langchain/libs/core/langchain_core

# 1. 字符串模板
sed -n '191,201p' prompts/prompt.py    # format:合并 + 查表渲染
sed -n '257,312p' prompts/prompt.py    # from_template:变量自动侦测

# 2. 聊天模板
sed -n '1124,1200p' prompts/chat.py    # from_messages + format_messages
sed -n '164,190p'   prompts/chat.py    # MessagesPlaceholder.format_messages

# 3. few-shot 装订
sed -n '180,207p' prompts/few_shot.py  # prefix + examples + suffix
grep -n "example_selector" prompts/few_shot.py | head
明日预告 · Day 08:链条 prompt | model 已经通了,但模型吐出来的是 AIMessage,业务代码要的是字符串、JSON、甚至强类型对象。明天看 output_parsers/:StrOutputParser / JsonOutputParser / PydanticOutputParser,以及大杀器 with_structured_output 是怎么用工具调用逼模型"填表"的。
← Day 06 消息体系 Day 08 · 输出解析与结构化输出 →