首页/LangChain 指南/提示词模板与消息
LangChain 指南 · 第 02 章

提示词模板与消息

跟大模型打交道,第一件事是把话说清楚。这一章讲 LangChain 的「话术工厂」:模板负责把要说的变成填空题,消息(Message) 负责把填好的话按角色打包发给模型。学完你能独立搭出带历史记录的多轮对话提示词,还能看…

约 34 分钟13,433 字23 个代码示例

跟大模型打交道,第一件事是把话说清楚。这一章讲 LangChain 的「话术工厂」:模板负责把要说的变成填空题,消息(Message) 负责把填好的话按角色打包发给模型。学完你能独立搭出带历史记录的多轮对话提示词,还能看懂模型吐回来的每一个字段。

示例场景统一为拾光书屋阅读社区——一家开在老街角的线下书店 + 线上社区,会员爱聊天、爱写书评、爱让店员荐书。

环境:Python 3.10+,pip install langchain langchain-ollama,本地 Ollama 跑 qwen3:4b。

本章你会用到的东西

  • ChatPromptTemplate.from_messages / from_template:两扇进模板世界的门
  • 变量插值 {name} 与 invoke 渲染
  • .partial() 部分填充:值可以后到,甚至可以是个函数
  • 角色元组 ("system" / "human" / "ai", "...")
  • SystemMessage / HumanMessage / AIMessage / ToolMessage 四种消息对象
  • AIMessage 的 content / additional_kwargs / tool_calls / content_blocks / text
  • FewShotChatMessagePromptTemplate 少样本提示词
  • MessagesPlaceholder 消息占位

2.1 提示词模板:把写死的话变成填空题

作用:直接把提示词写成一坨字符串,等于把每位会员的姓名都印刷在菜单上——来一位新会员就得重印一份。模板干的事正好相反:固定的话印好,变化的话挖空,用的时候再填。顺手还能统一管理店里的「话术」,改一处、处处生效。

签名(两扇门 + 一个渲染动作):

# 门一:按「多条消息」建模板(本章主角,接受元组/对象/占位符的混合列表)
ChatPromptTemplate.from_messages(
    messages: Sequence[MessageLikeRepresentation],
    template_format: str = "f-string",   # 也支持 "mustache" 等
) -> ChatPromptTemplate

# 门二:按「单条消息」建模板(人类那一方,省事写法)
ChatPromptTemplate.from_template(template: str) -> ChatPromptTemplate

# 渲染:填空 → 得到一个 ChatPromptValue
tpl.invoke(values: Mapping[str, Any]) -> ChatPromptValue

ChatPromptValue 是「渲染成品」,两个取法:

  • .messages:list[BaseMessage]——真正发给模型的东西(2.3 节展开)
  • .to_string():把所有消息拼成一段带 System: / Human: 前缀的文本——给自己调试看的,别发给模型

最小示例——会员来找店员荐书:

from langchain_core.prompts import ChatPromptTemplate

tpl = ChatPromptTemplate.from_messages([
    ("human", "我是{member},请用{mood}的口吻给我推荐一本书"),
])

# 变量是自动识别的,不用像声明函数参数那样先登记
print(tpl.input_variables)   # ['member', 'mood']

pv = tpl.invoke({"member": "老周", "mood": "温暖"})
print(type(pv).__name__)     # ChatPromptValue
print(pv.messages)
# [HumanMessage(content='我是老周,请用温暖的口吻给我推荐一本书')]
print(pv.to_string())
# Human: 我是老周,请用温暖的口吻给我推荐一本书

单条消息嫌写列表啰嗦,可以走第二扇门:

tpl = ChatPromptTemplate.from_template("给我推荐一本{genre}书")
print(tpl.invoke({"genre": "科幻"}).to_string())
# Human: 给我推荐一本科幻书

想在成品里保留一对真花括号(比如让模型输出 JSON 示例),写两层花括号转义:

tpl = ChatPromptTemplate.from_messages([
    ("human", "{{answer}} 字段示例:请按 {{\"score\": 5}} 的格式回答{question}"),
])
print(tpl.invoke({"question": "这条书评该打几分"}).to_string())
# Human: {answer} 字段示例:请按 {"score": 5} 的格式回答这条书评该打几分

常见坑:

  1. 漏传变量直接炸:tpl.invoke({"member": "老周"}) 抛 KeyError,但报错写得相当贴心——Expected: ['member', 'mood'] Received: ['member'],缺谁补谁就行。
  2. 单花括号 = 变量:模板里出现任何 {xxx} 都会被当变量解析;不是变量的必须写成 {{xxx}}。让模型输出 JSON 的提示词是重灾区。
  3. 不想跟花括号搏斗:改用 mustache 风格——ChatPromptTemplate.from_messages([...], template_format="mustache"),变量写成 {{name}},字面花括号反而不用转义(名字相同纯属历史巧合,别背混)。
  4. 老 JS 写法翻译:JS 版要 inputVariables: ["user"] 手动登记变量,Python 1.x 全自动扫描,数组登记这步可以直接扔掉。

2.2 partial:变量分批到货,先到的先腌着

作用:现实中变量很少齐齐整整一起到。会员名要等用户输入,但「店名」「今天的日期」在程序启动时就知道了。partial 允许先填一部分,剩下的留给以后——像腌肉:酱油先抹上,肉到了再下锅。

签名:

tpl.partial(**kwargs: str | Callable[[], str]) -> ChatPromptTemplate
# 返回一个新模板(原模板不动);
# 值可以是字符串,也可以是「零参数的可调用对象」——渲染时才现取

最小示例——固定值先腌上:

from langchain_core.prompts import ChatPromptTemplate

tpl = ChatPromptTemplate.from_messages([
    ("system", "你是{shop}的资深店员,用{lang}回答"),
    ("human", "{question}"),
])

half = tpl.partial(shop="拾光书屋", lang="中文")
print(half.input_variables)   # ['question'] —— 腌进去的两个不再算欠的
print(half.invoke({"question": "有什么新书"}).to_string())
# System: 你是拾光书屋的资深店员,用中文回答
# Human: 有什么新书

再腌一个「每次渲染时现做」的动态值——日期函数:

from datetime import date

tpl = ChatPromptTemplate.from_messages([
    ("system", "今天是{date},你是拾光书屋的当值店员"),
    ("human", "{question}"),
])

half = tpl.partial(date=lambda: date.today().isoformat())
print(half.invoke({"question": "今天有读书会吗"}).to_string())
# System: 今天是2026-08-28,你是拾光书屋的当值店员
# Human: 今天有读书会吗

函数版的妙处:invoke 跑一次就取一次值,凌晨十二点跑的定时任务拿到的日期是对的。

常见坑:

  1. 函数必须是零参数的:lambda: ... 可以,lambda x: ... 不行。要带参数就先算好结果,再把结果作为值传进来。
  2. partial 不改原模板:它返回新对象,tpl.partial(...) 之后接着用 tpl 调用,欠的变量一个没少。
  3. 别传模板对象进去搞嵌套:partial(notice=另一个模板) 不会自动递归渲染,填进去的是它的「名片」(repr)。想组合两个模板:先 invoke 内层拿到文本,再把文本传给外层(或者等下一章用 LCEL 的管道符,那才是正路)。
  4. JS 版的 PipelinePromptTemplate 在 Python 1.x 里已删除:模板流水线的活儿交给 LCEL(第 03 章),本章记住「先 render 再填」就够用。

2.3 结构化提示词:给每句话安排角色

作用:聊天模型读的不是一整段文字,而是一叠标了发言人的卡片。同一句「你是拾光书屋的店员」,印在后台守则上和由顾客随口喊出来,模型眼里的分量天差地别。给每句话安排好角色,是结构化提示词的全部意义。

拿话剧后台打个比方:

  • system 是贴在后台的演出守则——观众看不见,但演员必须照办,权重最高;
  • human 是观众递上台的提问纸条——真实需求都在这;
  • ai 是演员(模型)之前说过的话——把它放回上下文,演员才知道自己演到哪了。

签名:还是 from_messages,列表里塞「角色元组」:

ChatPromptTemplate.from_messages([
    ("system", "模板字符串,可含 {变量}"),   # 或 "user",同一意思
    ("ai", "模板字符串"),                    # 或 "assistant"
    ("human", "模板字符串"),
])

最小示例——元组写法(最常用):

from langchain_core.prompts import ChatPromptTemplate

tpl = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的资深店员,用{tone}的口吻说话,荐书不超过三本"),
    ("human", "{question}"),
])

pv = tpl.invoke({"tone": "温和", "question": "想找一本睡前能读的散文"})
for m in pv.messages:
    print(f"{m.type:8} | {m.content}")
# system  | 你是拾光书屋的资深店员,用温和的口吻说话,荐书不超过三本
# human   | 想找一本睡前能读的散文

对象写法——一句话里没有变量时,直接放现成的消息对象更顺手:

from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.prompts import ChatPromptTemplate

tpl = ChatPromptTemplate.from_messages([
    SystemMessage(content="你是拾光书屋的资深店员"),
    AIMessage(content="欢迎光临!今天想看点什么?"),
    HumanMessage(content="有没有看一晚上能哭出来的书"),
])
print(tpl.invoke({}).messages)   # 没有变量,传空字典

混着写完全合法——固定台词用对象、要填空的用元组:

tpl = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的资深店员,用{tone}的口吻说话"),
    AIMessage(content="欢迎光临!"),
    ("human", "{question}"),
])

两种写法互转:元组渲染出来的本来就是消息对象(看上面的 pv.messages)。反过来,手头有一堆「元组 / OpenAI 风格 dict」想统一成消息对象,用 convert_to_messages:

from langchain_core.messages import convert_to_messages

msgs = convert_to_messages([
    ("system", "你是拾光书屋的店员"),
    {"role": "user", "content": "你好"},      # OpenAI 风格的 dict 也认识
    ("ai", "欢迎光临"),
])
print([m.type for m in msgs])   # ['system', 'human', 'ai']

常见坑:

  1. 角色名的两套叫法:LangChain 本地叫 human / ai(元组里推荐用这个);OpenAI 风格的 user / assistant 也能识别,是别名。但 tool 角色不能用元组表达——ToolMessage 必须带 tool_call_id(工具调用的回执单号),所以只能用对象形式。
  2. 想自定义角色:用 ChatMessage(content="……", role="narrator"),它是一种「万能消息」,type 为 chat。但注意多数模型只认四种标准角色,自定义角色主要是本地排版/剧情脚本用。
  3. system 放最前:多数模型对 system 的位置敏感——放中间可能被当成普通对话忽略掉。
  4. ("human", "{q}") 和 HumanMessage(content="{q}") 不一样:前者是模板(渲染时填 {q}),后者是字面量({q} 会被原样发出)。元组走模板解析,对象不走。

2.4 少样本提示词:先看示范,再照着做

作用:与其给新来的兼职店员写一万字《荐书服务规范》,不如直接给他看三张「会员问—店员答」的示范卡。模型的模仿能力一流,几个示范往往比一页规则更省 token、更不容易跑偏。这就是少样本(few-shot)提示词。

签名:

FewShotChatMessagePromptTemplate(
    examples: list[dict[str, Any]] | None,   # 示范数据:一个 dict 就是一张示范卡
    example_prompt: ChatPromptTemplate,      # 每张卡片渲染成哪几条消息
) -> FewShotChatMessagePromptTemplate

它本身是个「消息段」,直接嵌进 from_messages 的列表里,跟普通模板排排坐。

最小示例——拾光书屋的书评打分助手:

from langchain_core.prompts import (
    ChatPromptTemplate,
    FewShotChatMessagePromptTemplate,
)

# 一张示范卡 = 一问一答两条消息
card = ChatPromptTemplate.from_messages([
    ("human", "书评:{review}"),
    ("ai", "评分:{score}"),
])

few = FewShotChatMessagePromptTemplate(
    examples=[
        {"review": "一晚上看完,舍不得睡。", "score": "5"},
        {"review": "开头精彩,结尾仓促。", "score": "3"},
        {"review": "书脊开胶了,内容还行。", "score": "3"},
    ],
    example_prompt=card,
)

final = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的评分助手,先看示范再打分。"),
    few,
    ("human", "书评:{review}"),
])

print(final.invoke({"review": "通勤路上读完了大半"}).to_string())
# System: 你是拾光书屋的评分助手,先看示范再打分。
# Human: 书评:一晚上看完,舍不得睡。
# AI: 评分:5
# Human: 书评:开头精彩,结尾仓促。
# AI: 评分:3
# Human: 书评:书脊开胶了,内容还行。
# AI: 评分:3
# Human: 书评:通勤路上读完了大半

常见坑:

  1. 两套变量别撞车:examples 里的 dict 喂给 example_prompt,真问题喂给外层模板。上例中示范用 {review}、外层也用 {review}——撞名没问题,因为渲染时机不同;但如果你给示范卡的变量起名 {question}、外层也叫 {question} 又想填不同的值,就会打架。稳妥做法:示范卡变量加前缀,如 {example_review}。
  2. 示例里的花括号同样要转义:示范卡的模板也走 f-string 规则,{{ 才能输出 {。
  3. 数量 2~5 张足够:示范不是越多越好,token 是真金白银,而且太多示例反而稀释模型的注意力。
  4. examples 是数据不是函数:它就是普通的 dict 列表,1.x 里不接受传函数进去(0.x 时代的 Callable 写法已被收紧)。想运行时动态换示例集,用新数据重建一个 FewShotChatMessagePromptTemplate 即可——对象本身很轻,重建没有负担。

2.5 Message 消息对象:拆开包裹看看

作用:Message 是 LangChain 跟模型交互的基本单位——「谁说了什么」加上一包随箱附赠的元信息。前面所有模板的渲染终点,都是一叠 Message;模型的每一次回答,也是一条 Message。把它拆开看明白,后面章节的工具调用、对话记忆才有的聊。

拿快递包裹打比方:content 是箱子里主要的货,additional_kwargs 是商家塞的随箱赠品,response_metadata 是贴在箱上的物流单,id 是运单号,tool_calls 是「顺便帮我下一单」的请求。

四种标准角色:

类 type 值 谁在说话 干什么用
SystemMessage system 店长 定规矩、设定人设,用户看不见
HumanMessage human 会员 提问、下指令
AIMessage ai 店员(模型) 回复内容 + 工具调用请求
ToolMessage tool 收银台 回传工具执行结果,须带 tool_call_id

字段签名(以最复杂的 AIMessage 为例):

AIMessage(
    content: str | Sequence[ContentBlock] = "",   # 正文:纯文本或内容块列表(多模态)
    additional_kwargs: dict[str, Any] = {},       # 各家 API 的扩展抽屉
    response_metadata: dict[str, Any] = {},       # 模型返回时自动附的元信息
    tool_calls: list[ToolCall] = [],               # 模型请求的工具调用
    id: str | None = None,                        # 消息运单号
    name: str | None = None,                      # 发言人名字(多角色时有用)
)

最小示例——挨个字段看:

from langchain_core.messages import AIMessage, HumanMessage, ToolMessage

hm = HumanMessage(content=[
    {"type": "text", "text": "这张封面图里的书还有货吗"},
    # {"type": "image", ...}  图片块同理,qwen3:4b 是文本模型就先不放了
])
print(hm.content_blocks)   # [{'type': 'text', 'text': '这张封面图里的书还有货吗'}]
print(hm.text)            # 这张封面图里的书还有货吗 —— 从内容块里抽出纯文本

ai = AIMessage(
    content="还有最后一本,给你留到打烊。",
    additional_kwargs={"foo": "bar"},
    response_metadata={"model_name": "qwen3:4b"},
    id="ai-001",
)
print(ai.type)                 # ai
print(ai.additional_kwargs)    # {'foo': 'bar'}
print(ai.response_metadata)    # {'model_name': 'qwen3:4b'}
print(ai.tool_calls)           # [] 模型没请求工具时就是空列表
print(ai.text)                 # 还有最后一本,给你留到打烊。

tm = ToolMessage(content="库存查询:剩 1 本", tool_call_id="call-001")
print(tm.type)                  # tool

工具调用场景下的 AIMessage(先睹为快,第 10 章细讲):

ai = AIMessage(content="", tool_calls=[
    {"name": "search_book", "args": {"genre": "科幻"}, "id": "call-001"},
])
print(ai.tool_calls)
# [{'name': 'search_book', 'args': {'genre': '科幻'}, 'id': 'call-001', 'type': 'tool_call'}]

消息的等价形式——同一条消息有三种长相,说的事一模一样:

("human", "你好")                        # 元组:写模板时最顺手
HumanMessage(content="你好")             # 对象:构造、传递、类型判断时用
{"role": "user", "content": "你好"}       # dict:跟 OpenAI 系 API 打交道时用

三者用工具函数互转:

from langchain_core.messages import (
    HumanMessage, convert_to_messages, messages_from_dict, messages_to_dict,
)

msgs = convert_to_messages([("human", "你好"), {"role": "assistant", "content": "欢迎光临"}])
# [HumanMessage(content='你好'), AIMessage(content='欢迎光临')]

d = messages_to_dict(msgs)          # 转成可 JSON 序列化的 dict 列表(存库、走网络)
back = messages_from_dict(d)        # 再转回对象(读库、收网络)
print(type(back[0]).__name__)      # HumanMessage

常见坑:

  1. JS 版的 getType() 是方法,Python 1.x 的 type 是属性:写 msg.type,别加括号。
  2. 守卫函数没了:JS 版有 isHumanMessage(msg) 这类守卫,Python 1.x 的 langchain_core.messages 里没有 is_human_message 一族——直接用 isinstance(msg, HumanMessage),或者比对 msg.type == "human"。
  3. content 类型有两幅面孔:str 或内容块 list。拿去切片、拼接前先 isinstance(m.content, str) 摸一下底,别假设它永远是字符串。要纯文本,统一走 .text。
  4. additional_kwargs 不是你的储物柜:它是留给各家模型 API 的兼容抽屉(比如早期的 function_call 字段)。业务数据硬塞进去,可能被某个集成原样透传给模型,也可能被覆盖丢掉。想存自定义元信息,response_metadata 稍微安全些,但更稳妥的是放在消息外部的业务对象里。
  5. ToolMessage 缺 tool_call_id 直接构造失败:这个字段是跟 AIMessage.tool_calls 里的 id 对账用的,少了它模型不知道这条结果回的是哪次请求。

2.6 消息占位:剧本里留一页「回忆插页」

作用:多轮对话里,历史记录是动态的——写模板时根本不知道会聊几轮、聊了什么。总不能每来一轮就手写一份新模板吧。MessagesPlaceholder 就是在剧本里预留的一页活页:演出前把整段「历史对话」插进去,页数不限。

两个问题一次说清:

  1. 怎么创建:MessagesPlaceholder("history")——"history" 是这个坑位的名字(变量名);
  2. 放哪里:塞进 from_messages 的列表,想插哪儿插哪儿,通常在 system 之后、当前问题之前。

签名:

MessagesPlaceholder(
    variable_name: str,          # 坑位名,invoke 时按这个名字喂消息列表
    optional: bool = False,     # True 时允许不喂(新对话的第一轮就用得上)
)
# 等价简写(塞元组时):
("placeholder", "{history}")

最小示例——店员记得住会员聊过什么:

from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

tpl = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的店员,记得住会员聊过的话题,回答要接得上前文"),
    MessagesPlaceholder("history"),          # 「回忆插页」插在这儿
    ("human", "{question}"),
])

pv = tpl.invoke({
    "history": [
        HumanMessage(content="上次你推荐我读《小王子》"),
        AIMessage(content="对呀!读完感觉怎么样?"),
    ],
    "question": "类似风格的还有吗",
})
for m in pv.messages:
    print(f"{m.type:8} | {m.content}")
# system  | 你是拾光书屋的店员,记得住会员聊过的话题,回答要接得上前文
# human   | 上次你推荐我读《小王子》
# ai      | 对呀!读完感觉怎么样?
# human   | 类似风格的还有吗

新对话的第一轮,history 还是空的——用 optional=True,不喂也不报错:

tpl_first = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的店员"),
    MessagesPlaceholder("history", optional=True),   # 允许缺席
    ("human", "{question}"),
])
print([m.type for m in tpl_first.invoke({"question": "你好"}).messages])
# ['system', 'human'] —— history 悄悄略过,不炸

列表里写简写版也行,跟 MessagesPlaceholder 完全等价:

tpl2 = ChatPromptTemplate.from_messages([
    ("system", "你是拾光书屋的店员"),
    ("placeholder", "{history}"),     # 与 MessagesPlaceholder("history") 等价
    ("human", "{question}"),
])

常见坑:

  1. history 必须喂「消息列表」:list 里装消息对象(或元组、dict 等消息形态)。直接喂字符串 "你好" 会在渲染时报错——活页夹里插的是一页页卡片,不是一段裸文字。
  2. 忘喂照样 KeyError:占位变量跟普通变量一样查账,optional=True 是唯一的免死金牌。
  3. 位置即语义:插在 system 前面,历史会盖过人设;插在当前问题后面,模型会把历史当新问题聊。惯例:system → history → human。
  4. 占位符 ≠ 变量:MessagesPlaceholder("history") 的坑里放的是消息列表;("human", "{history}") 的坑里放的是字符串(会被塞进 human 消息的正文里)。前者长出好几条消息,后者只是一条消息里的文字。

2.7 小结

一张表带走本章:

你想做什么 用什么
把提示词变成可复用的填空题 ChatPromptTemplate.from_messages([...]) / from_template("...")
填空渲染 tpl.invoke({...}) → .messages 发模型,.to_string() 调试
输出字面花括号 {{ 转义,或 template_format="mustache"
变量分批填 / 动态取值 tpl.partial(shop=..., date=lambda: ...)
给每句话安排角色 元组 ("system"/"human"/"ai", "...") 或消息对象
给模型看几个示范 FewShotChatMessagePromptTemplate(examples=..., example_prompt=...)
看懂模型返回的消息 AIMessage 的 content / text / tool_calls / response_metadata
消息三种长相互转 convert_to_messages / messages_to_dict / messages_from_dict
往模板里插历史对话 MessagesPlaceholder("history", optional=True)

配套代码:

  • demo/demo_prompt.py:单文件演示「模板 + 变量 + 多角色 + MessagesPlaceholder」四连;探测到本地 Ollama 就真调 qwen3:4b,探测不到就只打印渲染好的 messages

作业

  1. 同一句「你是拾光书屋的店员」,放在 ("system", ...) 里和放在 ("human", ...) 里,模型看到的区别是什么?为什么说 system 消息「权重高」?
  2. MessagesPlaceholder("history") 和 ("human", "{history}") 都能在 invoke 时喂 history,两者渲染结果差在哪?
  3. 写一个模板让模型输出 {"score": 5} 这样的 JSON,提示词里那个 JSON 示例该怎么写才不会被当成变量解析掉?
  4. partial(date=lambda: date.today().isoformat()) 和 partial(date=date.today().isoformat()),差在哪一秒?什么时候必须用前者?
  5. pv.to_string() 和 pv.messages 分别适合什么场合?把 to_string() 的结果直接喂给 ChatOllama.invoke 会发生什么?

下一章:03 · LCEL 与 Runnable