提示词模板与消息
跟大模型打交道,第一件事是把话说清楚。这一章讲 LangChain 的「话术工厂」:模板负责把要说的变成填空题,消息(Message) 负责把填好的话按角色打包发给模型。学完你能独立搭出带历史记录的多轮对话提示词,还能看…
跟大模型打交道,第一件事是把话说清楚。这一章讲 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 / textFewShotChatMessagePromptTemplate少样本提示词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} 的格式回答这条书评该打几分
常见坑:
- 漏传变量直接炸:
tpl.invoke({"member": "老周"})抛KeyError,但报错写得相当贴心——Expected: ['member', 'mood'] Received: ['member'],缺谁补谁就行。 - 单花括号 = 变量:模板里出现任何
{xxx}都会被当变量解析;不是变量的必须写成{{xxx}}。让模型输出 JSON 的提示词是重灾区。 - 不想跟花括号搏斗:改用 mustache 风格——
ChatPromptTemplate.from_messages([...], template_format="mustache"),变量写成{{name}},字面花括号反而不用转义(名字相同纯属历史巧合,别背混)。 - 老 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 跑一次就取一次值,凌晨十二点跑的定时任务拿到的日期是对的。
常见坑:
- 函数必须是零参数的:
lambda: ...可以,lambda x: ...不行。要带参数就先算好结果,再把结果作为值传进来。 - partial 不改原模板:它返回新对象,
tpl.partial(...)之后接着用tpl调用,欠的变量一个没少。 - 别传模板对象进去搞嵌套:
partial(notice=另一个模板)不会自动递归渲染,填进去的是它的「名片」(repr)。想组合两个模板:先invoke内层拿到文本,再把文本传给外层(或者等下一章用 LCEL 的管道符,那才是正路)。 - 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']
常见坑:
- 角色名的两套叫法:LangChain 本地叫
human/ai(元组里推荐用这个);OpenAI 风格的user/assistant也能识别,是别名。但tool角色不能用元组表达——ToolMessage必须带tool_call_id(工具调用的回执单号),所以只能用对象形式。 - 想自定义角色:用
ChatMessage(content="……", role="narrator"),它是一种「万能消息」,type为chat。但注意多数模型只认四种标准角色,自定义角色主要是本地排版/剧情脚本用。 - system 放最前:多数模型对 system 的位置敏感——放中间可能被当成普通对话忽略掉。
("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: 书评:通勤路上读完了大半
常见坑:
- 两套变量别撞车:
examples里的 dict 喂给example_prompt,真问题喂给外层模板。上例中示范用{review}、外层也用{review}——撞名没问题,因为渲染时机不同;但如果你给示范卡的变量起名{question}、外层也叫{question}又想填不同的值,就会打架。稳妥做法:示范卡变量加前缀,如{example_review}。 - 示例里的花括号同样要转义:示范卡的模板也走 f-string 规则,
{{才能输出{。 - 数量 2~5 张足够:示范不是越多越好,token 是真金白银,而且太多示例反而稀释模型的注意力。
- 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
常见坑:
- JS 版的
getType()是方法,Python 1.x 的type是属性:写msg.type,别加括号。 - 守卫函数没了:JS 版有
isHumanMessage(msg)这类守卫,Python 1.x 的langchain_core.messages里没有is_human_message一族——直接用isinstance(msg, HumanMessage),或者比对msg.type == "human"。 content类型有两幅面孔:str或内容块list。拿去切片、拼接前先isinstance(m.content, str)摸一下底,别假设它永远是字符串。要纯文本,统一走.text。additional_kwargs不是你的储物柜:它是留给各家模型 API 的兼容抽屉(比如早期的function_call字段)。业务数据硬塞进去,可能被某个集成原样透传给模型,也可能被覆盖丢掉。想存自定义元信息,response_metadata稍微安全些,但更稳妥的是放在消息外部的业务对象里。ToolMessage缺tool_call_id直接构造失败:这个字段是跟AIMessage.tool_calls里的id对账用的,少了它模型不知道这条结果回的是哪次请求。
2.6 消息占位:剧本里留一页「回忆插页」
作用:多轮对话里,历史记录是动态的——写模板时根本不知道会聊几轮、聊了什么。总不能每来一轮就手写一份新模板吧。MessagesPlaceholder 就是在剧本里预留的一页活页:演出前把整段「历史对话」插进去,页数不限。
两个问题一次说清:
- 怎么创建:
MessagesPlaceholder("history")——"history"是这个坑位的名字(变量名); - 放哪里:塞进
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}"),
])
常见坑:
history必须喂「消息列表」:list里装消息对象(或元组、dict 等消息形态)。直接喂字符串"你好"会在渲染时报错——活页夹里插的是一页页卡片,不是一段裸文字。- 忘喂照样
KeyError:占位变量跟普通变量一样查账,optional=True是唯一的免死金牌。 - 位置即语义:插在
system前面,历史会盖过人设;插在当前问题后面,模型会把历史当新问题聊。惯例:system → history → human。 - 占位符 ≠ 变量:
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
作业
- 同一句「你是拾光书屋的店员」,放在
("system", ...)里和放在("human", ...)里,模型看到的区别是什么?为什么说 system 消息「权重高」? MessagesPlaceholder("history")和("human", "{history}")都能在invoke时喂history,两者渲染结果差在哪?- 写一个模板让模型输出
{"score": 5}这样的 JSON,提示词里那个 JSON 示例该怎么写才不会被当成变量解析掉? partial(date=lambda: date.today().isoformat())和partial(date=date.today().isoformat()),差在哪一秒?什么时候必须用前者?pv.to_string()和pv.messages分别适合什么场合?把to_string()的结果直接喂给ChatOllama.invoke会发生什么?