LangChain系列之模型 I/O:让 AI 稳定输出 JSON
模型 I/O:让 AI 稳定吐出 JSON
LangChain 系列 · 第 2 篇。上一篇讲了 1.0 之后的新架构,这一篇把 Agent 的地基打牢:怎么把话塞给模型,怎么让模型吐回结构化的数据。
一、先看一段跑不通的代码
网上搜"LangChain 结构化输出",八成会看到这样一段:
import json
prompt = "请以 JSON 格式返回以下配料表的分析结果,不要带任何解释。 eg:{// 此处省略....}"
resp = model.invoke(prompt + "小麦粉、白砂糖、植物油、山梨酸钾")
data = json.loads(resp.content)
然后 json.loads 炸了:
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
原因看一眼就懂。模型返回的是"好的,我来分析一下……以下是结果:"开头,后面才跟着一段带中文引号的 JSON。
你写提示词说"不要带任何解释",它偏要解释。你用英文逗号换分隔符,它给你回中文逗号。这不是模型笨,是"请返回 JSON"这句话本身就不算契约。
二、为什么 AI 输出总不听话
大模型的本质是字符串接龙。它预测下一个 token,不保证语法,更不保证字段名和你的代码对得上。
不用框架手写解析,链路是这样的:
# 1. 拼提示词,要求返回 JSON
# 2. 模型返回,可能带解释文字
# 3. 正则剥掉多余内容
# 4. json.loads,可能又失败
# 5. try / except,失败了让用户再试一次
每一步都是脆断点。提示词改一个字,格式就飘,字段名手滑打错,程序就崩。
生产环境里,这五个环节任何一个出问题,用户看到的就是"服务开小差"。
LangChain 的思路很简单:输入输出都用程序化的方式管理,提示词只负责讲人话,格式交给代码。
三、输入侧:Message 是唯一语言
3.1 四种消息类型
模型对话不是字符串拼接,是一组消息。LangChain 把它们抽象成四种类型:
| 消息类型 | 角色 | 用途 |
|---|---|---|
| SystemMessage | 系统 | 设定人设、规则、输出要求 |
| HumanMessage | 用户 | 用户说的话 |
| AIMessage | 助手 | 模型的历史回复 |
| ToolMessage | 工具 | 工具执行后返回的结果 |
多轮对话在底层就是一组消息列表:
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
messages = [
SystemMessage(content="你是一位食品配料分析专家,语气简洁,直接给结论。"),
HumanMessage(content="山梨酸钾是什么?"),
AIMessage(content="防腐剂,能抑制霉菌和酵母,国家标准允许使用。"),
HumanMessage(content="那搭配膨松剂一起吃安全吗?"),
]
resp = model.invoke(messages)
把历史消息原样传给模型,模型就有上下文。这就是"对话"的全部秘密。
3.2 ChatPromptTemplate:把提示词当模板管
生产环境里提示词要反复调,直接写死在代码里很难维护。ChatPromptTemplate 把提示词拆成模板和变量:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "你是一位食品配料分析专家。请分析用户提供的配料表,重点关注防腐剂、甜味剂和人工色素。"),
("human", "配料表内容:{food_text}"),
])
chain = prompt | model
resp = chain.invoke({"food_text": "小麦粉、白砂糖、植物油、山梨酸钾"})
模板变化只动一个文件,代码逻辑不用碰。变量用花括号占位,传参用字典。
3.3 MessagesPlaceholder:给对话历史留插槽
写 Agent 时,模型要能"看到"前面聊过什么。MessagesPlaceholder 就是预留的位置:
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([
("system", "你是一位食品配料分析专家。"),
MessagesPlaceholder("history"),
("human", "{food_text}"),
])
resp = prompt.invoke({
"history": [
AIMessage(content="上一轮我已经分析过山梨酸钾了。"),
HumanMessage(content="那再帮我看看麦芽糊精。"),
],
"food_text": "麦芽糊精",
})
这一节先记着,后面讲 Agent 循环时,这个插槽就是记忆的入口。
四、输出侧:让 AI 稳定吐 JSON
4.1 先定义契约,再绑定模型
结构化输出的核心动作是两行:定义 Pydantic 模型,然后 with_structured_output 绑定。
from typing import Optional
from pydantic import BaseModel, Field
class Ingredient(BaseModel):
name: str = Field(description="成分名称")
category: str = Field(description="成分类别:防腐剂/甜味剂/色素/香精/天然成分")
risk: str = Field(description="风险等级:low/medium/high")
note: str = Field(description="一句话说明风险原因")
class FoodAnalysis(BaseModel):
food_name: Optional[str] = Field(description="食品名称,识别不出就填 null")
ingredients: list[Ingredient] = Field(description="配料表中的所有成分")
score: int = Field(description="综合评分,0 到 100")
suggestion: str = Field(description="给用户的购买建议")
analyzer = model.with_structured_output(FoodAnalysis)
result = analyzer.invoke("配料:小麦粉、白砂糖、植物油、食用盐、膨松剂(碳酸氢钠)、食用香精、山梨酸钾")
Field 里的 description 不是摆设,它是传给模型的指令。字段名是代码,说明是提示词,两边都对上,输出才稳。
4.2 四种模式,一次搞懂
with_structured_output 底层有四种实现,接口相同,原理不同:
| 模式 | 指定方式 | 原理 | 适用场景 |
|---|---|---|---|
| JSON mode | method="json_mode" | 提示词强制 + 输出清洗 | 兼容最广,国产模型都支持 |
| Function calling | method="function_calling" | 模型原生函数定义 | 老式 API,已被 tool calling 取代 |
| Tool calling | method="tool_calling" | 工具定义 + 强制调用 | 主流方案,Agent 的底层机制 |
| 自动 | 不指定 | 按模型能力自动选 | 日常开发推荐 |
tool calling 模式值得多说一句。它强制模型"调用一个返回指定结构的工具",模型不能自由发挥,只能填字段。这就是后面 Agent 里工具调用的雏形。
4.3 include_raw:给解析兜个底
线上运行总有意外。include_raw=True 让结果带上原始输出,解析失败也不会抛异常:
raw_analyzer = model.with_structured_output(FoodAnalysis, include_raw=True)
result = raw_analyzer.invoke("配料:白砂糖、山梨酸钾")
parsed = result["parsed"] # 解析成功的对象
raw = result["raw"] # 模型原始输出
error = result["parsing_error"] # 解析失败时非空
拿到 error 就走降级分支,给用户一个友好提示。生产环境务必开这个选项。
五、实战:配料说
我做过一个小程序叫「配料说」,拍下食品包装上的配料表,帮你把成分翻译成人话。核心就是上面的代码,串起来是这样:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
model = init_chat_model("qwen-plus", model_provider="dashscope", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "你是食品配料分析专家。分析配料表,标注每种成分的风险等级,给食品整体打分并给出建议。"),
("human", "配料表:{food_text}"),
])
chain = prompt | model.with_structured_output(FoodAnalysis)
result = chain.invoke({"food_text": "配料:小麦粉、白砂糖、植物油、山梨酸钾、食用香精"})
print(result.model_dump())
一次调用,拿到的是可以直接渲染的对象:
{
"food_name": null,
"ingredients": [
{"name": "小麦粉", "category": "天然成分", "risk": "low", "note": "常见主食原料"},
{"name": "白砂糖", "category": "甜味剂", "risk": "medium", "note": "高糖摄入需控制"},
{"name": "山梨酸钾", "category": "防腐剂", "risk": "medium", "note": "国家标准允许,敏感人群留意"}
],
"score": 78,
"suggestion": "配料简单,风险成分少,可以适量食用,注意控糖。"
}
小程序拿到这个对象,前端直接渲染卡片,不需要任何字符串处理。温度设为 0,让输出尽量稳定可复现。
六、结构化输出是 Agent 的地基
回头看,模型 I/O 其实就两个原则:输入用消息组织,输出用契约约束。
这两条会贯穿整个系列。
下一章讲 RAG,问题从"怎么问"变成"喂什么",检索出来的内容要拼进 prompt,同样是输入侧的工程。再往后讲 Agent 循环,tool calling 模式就是工具的底层调用机制。
下篇预告:RAG 实战:Milvus + bge-m3 产线选型。检索出来的内容,才是回答质量的上限。