Prompt Template 体系:从字符串拼接到结构化消息
在前几天的学习中,我们已经反复使用了 ChatPromptTemplate,但始终把它当作一种"带有花括号占位符的字符串模板"来用。实际上,LangChain 中的 Prompt 模板体系远比简单的字符串替换要丰富,它的设计核心在于将对话的结构从动态内容中分离出来,让你可以定义一套固定的消息骨架,每次调用时只注入变化的部分。
ChatPromptTemplate.from_messages 是这个体系的总入口。它接收一个列表,列表中每个元素描述一条消息,目前最常用的消息类型有三种。第一种是 ("system", "你是{role},擅长{skill}") 这种元组形式,第一个位置写上消息的角色(system、user 或 assistant),第二个位置写消息内容,内容中可以使用花括号 {} 标记变量占位符,每次调用时传入对应的值即可替换。第二种是 ("user", "{input}") 这种用户消息模板,用法与系统消息完全一致。第三种是 MessagesPlaceholder,这也是今天要重点讲解的概念,它不是一个普通的变量占位符,而是一个消息列表的插槽。
理解 MessagesPlaceholder 的关键在于区分两种不同性质的"变量"。{role} 和 {input} 这种普通占位符代表的是单个字符串值 ,LangChain 在渲染模板时把它们替换为对应的字符串后,消息内容就固定下来了。而 MessagesPlaceholder(variable_name="history") 接受的是一个消息对象的列表 ,比如 [HumanMessage("你好"), AIMessage("你好!有什么可以帮助你的?")],渲染时 LangChain 会把这个列表中的每一条消息按先后顺序展开,直接插入到模板消息序列的对应位置。这意味着你可以把任意轮次的对话历史动态地注入 prompt,而不需要关心这段历史到底有几条消息------这正是多轮对话的基石。
下面的代码构建了一个完整的支持对话历史的聊天链,system 消息定义了角色,MessagesPlaceholder 插入历史,user 消息承载本轮输入,三者的顺序决定了 LLM 看到的上下文结构:
python
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage
load_dotenv()
prompt = ChatPromptTemplate.from_messages([
("system", "你是{role},擅长{skill}。请用简洁的语言回答问题。"),
MessagesPlaceholder(variable_name="history"),
("user", "{input}")
])
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
chain = prompt | llm | parser
# 模拟多轮对话:第一轮
history_round1 = []
result1 = chain.invoke({
"role": "Python 专家",
"skill": "代码调试",
"history": history_round1,
"input": "装饰器是什么?"
})
print(f"第一轮: {result1}")
# 第二轮:把上一轮的问答塞进 history
history_round2 = [
HumanMessage(content="装饰器是什么?"),
AIMessage(content=result1)
]
result2 = chain.invoke({
"role": "Python 专家",
"skill": "代码调试",
"history": history_round2,
"input": "给我一个实际例子"
})
print(f"第二轮: {result2}")
你会注意到第一轮调用时 history 传的是一个空列表,模型基于系统角色和用户问题独立作答。第二轮调用时,我们把第一轮的 HumanMessage 和 AIMessage 放进 history 列表,模型在回答第二个问题之前会"看到"上一轮的问答,因此能够自然地延续上下文,比如第二此问的"给我一个实际例子"不需要再重复说"装饰器的例子",模型已经知道你在接着刚才的话题继续问。这就是 MessagesPlaceholder 的威力,它让你用最直观的方式管理对话上下文,不需要手动拼接历史文本,也不需要对不同轮次的消息做任何格式转换。
除了 from_messages 这种消息列表式的构造方式,ChatPromptTemplate 还提供了 from_template 这个快捷入口,它等价于一条单一的 user 消息,适合那些最简单的单轮问答场景。前几天的示例中大量使用了 from_template,用法不再赘述。需要牢记的是一旦你的场景涉及系统角色定义、对话历史或多种消息类型的组合,就应该切换到 from_messages,它提供的结构化表达能力是从简单模板到复杂对话的关键升级。
Few-Shot 提示:用示例教会模型
大语言模型虽然具备强大的通用能力,但在面对特定领域或特定输出格式要求时,仅靠一段系统提示的文字描述往往不够精确。同样的格式指令,不同模型的遵守程度可能截然不同。Few-Shot 提示的思路很朴素:与其用一千字描述你想要的输出长什么样,不如直接给出几个具体的输入输出示例让模型照猫画虎。LangChain 提供了 FewShotChatMessagePromptTemplate 来系统化地管理这些示例。
它的用法可以拆成两步来理解。第一步准备一个示例列表,每个示例是一个包含输入输出键值对的字典。第二步用 FewShotChatMessagePromptTemplate 把这些示例包装成一组消息,它内部会为每个示例生成一条 user 消息(放输入)和一条 assistant 消息(放输出),然后将它们整体插入到你的主 prompt 的指定位置。
下面的代码实现了一个情感分类的 Few-Shot 链,先给出三个标准示例让模型学习分类规则,再让它对一条新的输入做判断:
python
from dotenv import load_dotenv
from langchain_core.prompts import (
ChatPromptTemplate,
FewShotChatMessagePromptTemplate
)
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
load_dotenv()
# 第一步:定义示例
examples = [
{"input": "这家餐厅的菜太好吃了,服务也很棒", "output": "正面"},
{"input": "等了两个小时才上菜,态度还特别差", "output": "负面"},
{"input": "今天天气还可以,不冷不热的", "output": "中性"}
]
# 第二步:用 FewShotChatMessagePromptTemplate 包装示例
example_prompt = ChatPromptTemplate.from_messages([
("user", "{input}"),
("assistant", "{output}")
])
few_shot_prompt = FewShotChatMessagePromptTemplate(
example_prompt=example_prompt,
examples=examples
)
# 第三步:把示例嵌入主 prompt
final_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个情感分析助手,请将用户输入的文本分类为'正面'、'负面'或'中性',只输出分类结果。"),
few_shot_prompt,
("user", "{input}")
])
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
chain = final_prompt | llm | parser
result = chain.invoke({"input": "快递三天还没到,客服电话根本打不通"})
print(f"分类结果: {result}")
这段代码中三个示例清晰地展示了正面、负面和中性三种分类标准,模型参考这些示例后,对"快递三天还没到"这条新输入得出了"负面"的判断。你可以把 examples 列表替换成任何你需要引导模型的示例组合,翻译格式、代码风格、问答范式,只要是"输入→输出"的映射关系,Few-Shot 都能帮你建立。示例数量不宜过多,通常 3 到 5 个就能在大多数场景下起到明显效果,再多不仅增加 token 消耗,还可能让模型过度拟合示例中的表达方式而丧失灵活性。
输出解析器:从自由文本到结构化数据
LLM 的原始输出本质上是无结构的纯文本流,但真实应用几乎总是需要结构化的数据,一个 JSON 对象、一组带类型约束的字段、或某种自定义的格式。LangChain 通过输出解析器(Output Parser)把"从无结构文本中提取结构化信息"这件事标准化了。解析器位于 chain 的最末端,接收模型原生的 AIMessage 对象,返回你最终需要的格式。
StrOutputParser 我们已经用过很多次,它的工作最简单,从 AIMessage 中取出 .content 属性返回纯字符串。对于只需要文本回答的场景,这条解析器就足够了。但当你的下游系统需要解析机器可读的数据结构时,就必须引入更强大的解析器。
JsonOutputParser 是结构化的入门级方案。它不会改变模型的行为,只是告诉你"请输出 JSON 格式",然后尝试把模型的文本输出解析为 Python 字典。它的 get_format_instructions() 方法会返回一段文字,描述了期望的 JSON schema,你需要把这段指令嵌入到 prompt 中提醒模型按格式作答。下面是一个提取用户信息的例子:
python
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
load_dotenv()
# 定义期望的数据结构
class UserInfo(BaseModel):
name: str = Field(description="用户姓名")
age: int = Field(description="年龄")
city: str = Field(description="所在城市")
parser = JsonOutputParser(pydantic_object=UserInfo)
prompt = ChatPromptTemplate.from_messages([
("system", "从用户输入中提取信息。\n{format_instructions}"),
("user", "{input}")
])
# 把 format_instructions 注入 prompt
prompt = prompt.partial(format_instructions=parser.get_format_instructions())
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
chain = prompt | llm | parser
result = chain.invoke({"input": "我叫李明,今年28岁,住在杭州"})
print(f"解析结果: {result}")
print(f"姓名: {result['name']}, 年龄: {result['age']}, 城市: {result['city']}")
这里的 JsonOutputParser(pydantic_object=UserInfo) 会根据 Pydantic 模型的字段定义自动生成 JSON schema 格式说明,get_format_instructions() 返回的文字会告诉 LLM 应该输出什么样的 JSON 结构。prompt.partial() 是一种在模板中预先绑定部分变量的技巧,它把格式说明固化到模板里,调用时只需传入 input 即可。
PydanticOutputParser 比 JsonOutputParser 更进一步,它不仅解析 JSON,还会用 Pydantic 的校验机制对解析结果做类型强制和字段验证。如果模型输出的 age 字段是字符串 "28",Pydantic 会自动把它转换为整数 28,如果某个必需字段缺失,则会抛出校验错误。用法上与 JsonOutputParser 几乎一样,只需要把解析器的类换成 PydanticOutputParser:
python
from langchain_core.output_parsers import PydanticOutputParser
parser = PydanticOutputParser(pydantic_object=UserInfo)
chain = prompt | llm | parser
result = chain.invoke({"input": "我叫李明,今年28岁,住在杭州"})
print(f"类型安全的解析结果: {result}")
# 可以直接访问属性
print(f"姓名: {result.name}, 年龄: {result.age}, 城市: {result.city}")
PydanticOutputParser 返回的 result 是 UserInfo 的实例而不是普通字典,你可以在 IDE 中获得完整的类型提示和自动补全,这是它相对 JsonOutputParser 最大的工程收益。
然而所有解析器都面临一个共同的现实问题:大语言模型并非总是听话的。即使你在 prompt 中明确要求输出 JSON 格式,模型有时也会在 JSON 前后附加额外的文字说明,或者输出格式本身就不合法。LangChain 用 OutputParserException 统一表示这类解析失败。正确的工程实践是在调用链时捕获这个异常并设计降级策略,而不是假设解析永远成功。下面是一个带错误处理的完整示例:
python
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.exceptions import OutputParserException
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
load_dotenv()
class MovieReview(BaseModel):
title: str = Field(description="电影名称")
rating: float = Field(description="评分,1-10分")
summary: str = Field(description="一句话总结")
parser = PydanticOutputParser(pydantic_object=MovieReview)
prompt = ChatPromptTemplate.from_messages([
("system", "从用户的影评中提取电影名称、评分和一句话总结。\n{format_instructions}"),
("user", "{input}")
])
prompt = prompt.partial(format_instructions=parser.get_format_instructions())
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
chain = prompt | llm | parser
user_input = "昨晚看了《星际穿越》,太震撼了,我给9分,诺兰把科幻和亲情融合得太好了"
try:
result = chain.invoke({"input": user_input})
print(f"电影: {result.title}, 评分: {result.rating}, 总结: {result.summary}")
except OutputParserException as e:
print(f"解析失败: {e}")
# 降级策略:回退到纯文本解析,让下游自行处理
fallback_chain = prompt | llm
raw_response = fallback_chain.invoke({"input": user_input})
print(f"降级获取原始输出: {raw_response.content}")
这段代码先用 try/except 包裹解析过程,一旦捕获到 OutputParserException,就回退到不经过解析器的基础链,获取模型的原始文本输出。在生产环境中,你还可以在降级时记录告警日志、触发重试、或者把原始输出发给人工审核。关键是不要让一次解析失败中断整个业务流程,优雅降级永远是结构化解耦式架构的正确态度。
练习任务
今天的三项练习构成一条从结构设计到格式约束的递进路径。首先用 from_messages 和 MessagesPlaceholder 设计一个用于客服场景的多轮对话 Prompt 模板,要求包含 system 角色定义、对话历史占位和用户提问入口三个部分,然后用一段模拟的两轮对话验证它能否正确理解上下文中的指代关系。接着选一个你熟悉的领域(比如食谱提取、新闻分类、订单信息录入),用 JsonOutputParser 解析一段自然语言文本,观察 get_format_instructions() 生成的格式说明是什么样的,以及模型能否稳定输出合法 JSON。最后用 FewShotChatMessagePromptTemplate 构建一个包含至少 3 个示例的提示模板,并对比"有 Few-Shot"和"没有 Few-Shot"两种情况下模型的输出质量差异,用一句话写下你的发现。
考核点 ✅
模板设计方面,提交一个包含 MessagesPlaceholder 的多轮对话 Prompt 模板代码,并附上一段至少两轮对话的调用日志,清楚展示历史消息如何传递以及模型如何利用历史理解上下文。结构化解析方面,提交使用 JsonOutputParser 或 PydanticOutputParser 的完整 chain,要求 Pydantic 模型至少包含 3 个不同数据类型的字段(如 str、int、list),并附上至少一条成功解析的调用结果截图。Few-Shot 应用方面,提交包含至少 3 个示例的 FewShotChatMessagePromptTemplate 代码,要求示例的输入输出有清晰的对应关系,并在代码注释中说明这些示例如何帮助模型理解任务意图。错误处理方面,演示当 LLM 输出不符合 JSON 格式时解析器抛出的异常类型,并写一段不超过 10 行的异常处理代码实现回退到纯文本的降级方案,附上异常发生和被捕获时的控制台输出截图。