LangChain 实践笔记:模型、提示词与输出解析

从一次模型调用出发,梳理 Model I/O 的四段职责,讲清三类 Prompt 模板与部分格式化的用法,再把消息、模型包装器与输出解析串成一条从变量到结构化结果的调用链。

刚开始接大模型接口时,代码通常很简单。准备一段提示词,调用模型,再从返回结果里取出需要的内容,一次调用就结束了。

真正开始做项目以后,问题才会慢慢出现。提示词可能要反复调整,模型要切换,返回结果也不一定永远是字符串。如果这些逻辑都写在一起,每改一次需求,都可能牵动大面积的代码改动。

LangChain 在模型调用这一层做的第一件事,就是把这些职责拆开:Prompt 负责组织输入,Model 负责调用模型,Output 负责处理返回结果。

#一、Model I/O:先看完整调用链

不依赖框架、直接调用厂商 API 时,通常需要构造提示词、发起模型调用、从响应中取出目标数据。

这三个步骤在 LangChain 里各自对应一类组件,我们统称 Model I/O。

数据在这条链上换了三次形态:

变量 → Prompt → Message → Model → AIMessage → Output

Prompt 把变量渲染成一组消息,Model 把消息发出去、取回一条 AIMessage,Output 把这条消息转成程序下一步要用的结构。

如果把一次调用手动拆开看,可以理解成:

format Prompt
→ invoke Model
→ 得到 AIMessage
→ Parser 转换结果

#二、Prompt

提示(Prompt)是发给模型的一组指令和输入,用来指导模型的响应。 一段提示词写死在代码里,最初没什么问题;条件一变,问题就出来了。

# 要翻译的语言、要翻译的内容,都写死在提示词里
msg = [
    ("system", "请将以下的内容翻译成英文"),
    ("human", "你好,你今天过得好吗?"),
]
result = model.invoke(msg)

这段代码能跑。想把目标语言换成意大利文,或者换一段话翻译,都得回来改这两行。

#2.1 PromptTemplate:LLM提示模板

PromptTemplate 接收原始输入,返回一段准备好发给模型的提示。 它做的是把这两部分分开:不变的措辞留在模板里,每次调用才会变的部分用 {} 占位。

from langchain_core.prompts import PromptTemplate

# 固定的是措辞,{} 里是每次调用才会变的变量
prompt = PromptTemplate(template="你是一个翻译助手,请把以下内容翻译成{language}:{text}")
prompt.format(language="中文", text="I am a programmer")
# prompt.format(language="意大利文", text="今天天气真好!")

措辞要调,动模板;参数要换,动调用。调用方只负责传变量,不需要知道提示词长什么样。

模板还知道自己需要哪些变量。少传一个,format 阶段就会抛错,而不是把一段缺了参数的提示词照常发给模型。

#2.2 ChatPromptTemplate:聊天提示模板

PromptTemplate 产出的是一段字符串。聊天模型收的不是字符串,而是一组带角色的消息。 ChatPromptTemplate 组装的就是这组消息:

from langchain_core.prompts import ChatPromptTemplate,SystemMessagePromptTemplate, HumanMessagePromptTemplate

prompt = ChatPromptTemplate.from_messages([
	# 系统提示词:角色与约束
    ("system", "你是一个翻译助手,请把以下内容翻译成{language}"),
	# 每次传入的内容
    ("human", "{text}"),
])
# 等价
prompt = ChatPromptTemplate.from_messages([
	SystemMessagePromptTemplate.from_template("你是一个翻译助手,请把以下内容翻译成{language}"),
	HumanMessagePromptTemplate.from_template("{text}")
])

两个模板的差别不在写法,在产出物:一个拼出一段话,一个拼出一组带角色的消息。ChatPromptTemplate 不只是把变量填进字符串,还会把角色一起保留下来。

from_messages 的每一项都要标明角色。这一层常用三种:system 放角色、规则与约束,human 放用户输入,ai 放模型的历史回复。

#2.3 FewShotPromptTemplate:少样本提示模板

规则越写越长,还是说不清“我要的结果到底长什么样”。这时不妨考虑给模型提供几组正确的示例样本。

from langchain_core.prompts import PromptTemplate, FewShotPromptTemplate

examples = [
    {"sinput": "2+2", "soutput": "4", "sdescription": "加法运算"},
    {"sinput": "5-2", "soutput": "3", "sdescription": "减法运算"},
]
prompt_sample = PromptTemplate(template="算式: {sinput} 值: {soutput} 类型: {sdescription}")

prompt = FewShotPromptTemplate(
    examples=examples,             # 示例列表
    example_prompt=prompt_sample,  # 单个示例怎么排版
    prefix="你是一个数学专家,能够准确说出算式的类型,",
    suffix="现在给你算式: {input},值: {output},告诉我类型:",
    input_variables=["input", "output"],
)

示例不是简单堆在提示词里:prefix 与 suffix 一个在前一个在后,把示例列表夹在中间,example_prompt 决定每一组样例怎么排版。

#2.4 部分格式化

模板里有些值长期不变,每次调用却要重复传一遍。比如一个学习助手,学科在启动时就定了,之后每次提问只换问题本身。

prompt = PromptTemplate(template="讲一个关于{date}的小故事:{text}", input_variables=["date", "text"])

half_prompt = prompt.partial(date="2008-08-08") # 先填掉不变的
half_prompt.format(text="一个幸福的爱情故事")  # 剩下的每次调用再传

partial 返回的是一个新模板,比原来少一个待填参数。 上述代码中我们提前把 date 传进去,所以调用方手上只剩 text。先把固定的部分填进去,只把真正变化的参数留给调用方。

#三、Message:聊天模型到底收什么

Prompt 组织好之后,交给模型的并不是一段字符串。在聊天模型这一层,Message 是最核心的数据结构之一。

它在这条链上这样流动:

ChatPromptTemplate → 形成一组消息
Chat Model         → 返回 AIMessage
后续组件            → 再从消息中提取或转换结果

角色由消息类型决定,不需要额外传 role 参数:

消息类型产出方承载内容
SystemMessage系统角色设定、任务、约束
HumanMessage用户这一轮的输入
AIMessage模型上一轮的回复
ToolMessage工具工具执行的结果,用到工具调用时才会出现
from langchain.messages import SystemMessage, HumanMessage

messages = [
    SystemMessage(content="你是一个翻译助手"), # 角色与约束,写一次
    HumanMessage(content="把这句话翻译成英文:今天天气不错"), # 这一轮的输入,每轮都换
]
result = model.invoke(messages)  # 传进去一组消息,拿回来一条消息
print(type(result))              # AIMessage
print(result.content)            # 回复的正文

模型不会自动记住上一轮对话。 所谓多轮聊天,本质上就是把之前的消息重新放进列表,再连同这一轮输入一起发给模型。一个消息列表,就是一段完整的对话上下文。

单轮问答直接传字符串同样可以调用,model.invoke("你是谁?") 就是这种形式。只有当需要固定角色、或者需要模型看到前面的对话时,才必须显式构造消息列表。

AIMessage 的内容不止 content。 初学阶段只看 result.content 就够了,它对应的就是回复正文。

result.content          # 正文,最常用
result.content_blocks   # 按类型拆好的内容块
result.tool_calls       # 模型请求调用的工具

只接一家模型、只拿正文时,content 已经够用。真正遇到多模型、Tool Calling 或 Reasoning 以后,才会发现返回值不一定只是一段文本。

这时候 content_blocks 的价值才体现出来:上层代码可以按 block 类型处理,而不是针对每家厂商写一套字符串判断。

for block in result.content_blocks:
    if block["type"] == "text":            # 文本块
        print(block["text"])
    elif block["type"] == "tool_call":     # 模型请求调用工具
        print(block["name"], block["args"])

某些模型会返回 provider 暴露的 reasoning 或 reasoning summary 类内容,它们同样被拆成独立的块,而不是混在正文里。

#四、Model:怎么统一调用不同模型

模型这一层要解决的问题是:各家厂商的参数名、消息格式、返回结构都不一致,上层代码不该跟着一起变。LangChain 的做法是用一层通用接口把它们收拢成同一组方法。

invoke 发起单次请求,batch 发起批量请求,stream 获取流式返回:

model.invoke("你是谁")      # 单次调用
model.batch([...])         # 批量调用
model.stream("讲个笑话")    # 流式返回

LangChain 里仍然能看到传统 LLM / completion 接口,但现在的新项目里,直接以 Chat Model 为主即可。

接入一个聊天模型,有两条路径:

# 一、厂商提供 OpenAI 兼容端点:修改地址、密钥与模型名即可接入
model = ChatOpenAI(
	api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
    model="qwen-max"
)

# 二、接口不兼容的:按厂商名创建
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")

如果厂商提供 OpenAI-compatible endpoint,基础文本调用通常可以通过调整 base_url、API Key 和模型名完成切换。

但不同厂商在 Tool Calling、Structured Output、Reasoning、Streaming 等能力上仍可能存在差异,切换之后这些能力需要逐项验证。

在上层调用方式保持一致的前提下,模型切换通常可以尽量限制在初始化这一层。做法是把模型初始化集中到一处,业务代码只依赖接口,不依赖具体厂商的类。

#五、Output:怎么拿到程序真正需要的数据

模型返回的是一条 AIMessage,content 里是给人看的文本。程序要的可能是一段话、一组固定字段,或者一个日期。按“下一步代码需要什么”分成三层。

#5.1 只要文本

最常见的一层,用 StrOutputParser 把消息取成字符串:

from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()
parser.invoke(result)       # AIMessage → str

翻译、摘要、普通问答、文案生成,都属于只要一段文本的场景,这一类用它即可。

#5.2 要固定结构

如果下一步代码要的是固定字段,比如从一段描述里取出姓名和分数,用结构化输出更直接:

from pydantic import BaseModel

# 用 Pydantic 定义字段与类型
class Answer(BaseModel):
    name: str
    score: int
# 让模型直接按这个结构返回
structured_model = model.with_structured_output(Answer)
# 拿到的是 Answer 实例,不是字符串
answer = structured_model.invoke("李明这次考了 92 分")

如果下一步代码需要的本来就是结构化数据,优先考虑 Structured Output,而不是先让模型吐一段 JSON 字符串,再自己解析。

#5.3 需要额外转换或校验

传统 OutputParser 仍然有用,只是不必当主角。 当模型返回的是文本,但业务还需要额外校验、转换,或者要兼容旧逻辑时,再让它做后处理:

解析器产出用在什么时候
JsonOutputParser字典要多个字段,但走的是文本返回
DatetimeOutputParser日期时间要把文本转成时间点或时间区间
自定义 Parser自定目标格式库里没有,或者要额外校验
output_parser = JsonOutputParser()
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个专业的程序员"),
    ("human", "{input}"),
])
chain = prompt | model | output_parser

这类解析器不只是负责解析,还会提供 get_format_instructions(),用于提前告诉模型应该按什么格式输出:

prompt = PromptTemplate.from_template(
    template,
    # 把格式要求提前填进提示词里的 {format_instructions}
    partial_variables={"format_instructions": output_parser.get_format_instructions()},
)

这类方式本质上是“提示约束 + 返回解析”两头配合:发送前把格式要求写进提示词,返回后再校验结果。

但如果模型本身支持 Structured Output,通常可以直接使用结构化输出能力,不必再经过这一层。

DatetimeOutputParser 这类组件的输出格式写死在库里,不能自定义。要按自己的格式取日期,就自己写一个解析器,实现两个方法:

class DateStringParser(BaseOutputParser[str]):
    target_format: str = "%Y-%m-%d"
	# 返回后:按目标格式解析并校验
    def parse(self, text: str) -> str:
        ...
	# 发送前:告诉模型该输出成什么样
    def get_format_instructions(self) -> str:
        ...

#六、总结

这一篇拆的是模型调用这一层。拆开之后,三类改动被隔在了三段之内:

  • 改措辞,不动模型:模板把固定措辞与每次变化的变量分开,调用方只传变量,提示词集中维护;
  • 换厂商,不动业务代码:调用统一成 invoke / batch / stream,差异收敛在初始化那一处,但 Tool Calling、Structured Output 这类能力,换模型后仍要逐项验证;
  • 调输出,不动前两段:只要文本用 StrOutputParser,要固定字段用 with_structured_output,只有需要额外校验或兼容旧逻辑时,才轮到 OutputParser。

拆开的意义不在于分成了几段,而在于每一段都能单独替换、单独测试。