刚开始接大模型接口时,代码通常很简单。准备一段提示词,调用模型,再从返回结果里取出需要的内容,一次调用就结束了。
真正开始做项目以后,问题才会慢慢出现。提示词可能要反复调整,模型要切换,返回结果也不一定永远是字符串。如果这些逻辑都写在一起,每改一次需求,都可能牵动大面积的代码改动。
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。
拆开的意义不在于分成了几段,而在于每一段都能单独替换、单独测试。