结构化输出是指让大模型(如GPT、LLama等)的输出结果符合预定义的格式(如 JSON、Pydantic 对象、表格等)而不是自由文本,这在需要程序化处理结果(如数据提取、后续自动化操作)时非常有用,一个常见的用例是从文本中提取数据以插入数据库或与其他下游系统一起使用。本文介绍一下如何使用 LangChain 框架让大模型输出结构化数据。
实现结构化输出的常见方法包括:
- 提示工程:通过系统提示指令LLM以特定格式进行响应。输出解析器:使用后处理技术从LLM响应中提取结构化数据。工具调用:利用某些LLM内置的工具调用功能生成结构化输出。
使用提示工程实现结构化输出
手动编写提示工程
使用提示工程实现结构化输出需要通过设计明确的系统提示(System Prompt),直接要求模型输出符合特定格式(如 JSON、表格等)的数据,而不是自由文本。这种方法依赖模型的理解能力,所以在模型输出数据后需要对数据进行后处理(可以使用langchain提供的输出解析器**BaseLLMOutputParser**
的子类,后面会详细介绍)。
以下是一些常见的方法和技巧:
1. 明确目标
在提问时明确要求模型输出结构化数据。例如:
- “请以JSON格式输出。”“请用XML格式回答,并包含以下字段:...”“请输出CSV格式,第一行是表头。”
2. 提供示例
给出一个输出示例,这样模型可以模仿格式。例如:
- “例如:{'name': 'John', 'age': 30}”在复杂结构中,提供示例尤为重要。
3. 指定字段
明确列出需要包含的字段,以及它们的数据类型。例如:
- “请输出一个JSON对象,包含以下字段:name(字符串)、age(整数)、hobbies(字符串列表)。”
4. 分步引导
对于复杂结构,可以分步引导模型。先让模型输出一部分,再逐步完善。
5. 强制约束(如果API支持)
在API调用中,可以通过系统消息(system message)来设定输出格式。例如:
{ "role": "system", "content": "不要解释,仅以JSON格式输出,包含name和age字段。"}
6. 后处理
如果模型输出不是完全结构化的,可以尝试用后处理(如正则表达式)来提取信息并转化为结构化的数据。
7. 调整温度参数
将温度(temperature)设置为0(或较低值),使输出更加确定,减少随机性,有助于稳定输出结构。
8. 使用模板
在提示词中提供模板,让模型填充内容。例如:
请按照以下模板输出:| 书名 | 作者 | 出版年份 ||------|------|----------|| <书名> | <作者> | <年份> |
示例:
假设我们想让模型输出一本书的信息,包括书名、作者和出版年份,要求是JSON格式。
提问:
请以JSON格式输出《三体》这本书的信息,包含字段:title(字符串)、author(字符串)、year(整数)。
期望输出:
{ "title": "三体", "author": "刘慈欣", "year": 2008}
注意:
- 模型有时可能会在结构化数据之外添加额外文本。如果要求严格的JSON,可以在指令中强调“只输出JSON,不要其他任何文字”。如果输出复杂嵌套结构,提供更详细的示例和说明。
总结:
手动编写提示工程实现结构化输出的一般步骤:
- 定义目标格式:在系统提示中明确输出格式(如 JSON、XML、表格等)。提供示例:在提示中加入示例,帮助模型理解期望的结构。强制约束:在提示中加入约束,确保模型输出符合格式要求,比如 "请严格按照以下格式输出"。后处理:在模型输出后,使用输出解析器(正则表达式)解析并验证数据。
下面是一个手动编写提示词实现结构化输出的示例:
system_prompt = """根据用户的输入生成转账或者红包的金额和备注,请严格按照以下JSON格式输出下面是一些例子:示例一:输入: 张三说给李四转账1000元输出: {"money": 1000, "unit":"元","remark": "转给李四"}示例二:输入: 转账100元输出: {"money": 100, "unit":"元","remark": ""}"""user_input = "李雷给韩梅梅转账2000元"messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}]
通过以上方法,可以大大提高模型输出结构化数据的概率和准确性。
手动编写提示工程的缺点总结
手动编写提示工程(Prompt Engineering)实现结构化输出虽然直观,但存在以下显著缺点:
问题类别 | 具体问题 | 详细说明 | 示例/备注 |
---|---|---|---|
1. 输出稳定性问题 | 格式漂移 | 模型可能在输出中添加解释性文字或前缀,破坏结构化格式 | "答案:{ \"name\": \"Alice\" }" |
随机性 | 即使设置 temperature=0 ,复杂任务仍可能生成非法格式 | 输出缺少引号、括号不匹配等 | |
边界案例处理差 | 对空值、特殊字符、极端输入处理不佳,易导致格式错误 | null 被替换为 "N/A" 或遗漏 | |
2. 开发维护成本高 | 脆弱提示 | 不同模型(如 GPT-4、Claude、Llama)对同一提示响应不一致,需单独调整 | 提示需为每个模型定制优化 |
版本敏感 | 模型更新后原有提示可能失效,需重新测试和修改 | OpenAI 模型微更新导致 JSON 失效 | |
长提示难优化 | 复杂结构提示常超 100 token,调试困难,影响可读性和维护性 | 提示过长导致注意力分散 | |
3. 类型控制薄弱 | 无强制类型校验 | 模型可能返回字符串而非数字、日期等预期类型 | "age": "二十八" 应为 28 |
格式错误无自愈 | 无法自动修复非法 JSON(如尾逗号、未转义字符) | ["a","b",] 是非法 JSON | |
复杂结构难实现 | 嵌套对象、联合类型、枚举等复杂 Schema 几乎无法稳定生成 | 难以保证 { user: { profile: { ... } } } 的完整性 | |
4. 错误处理缺失 | 无重试机制 | 解析失败后需外部逻辑触发重试,提示本身不具备容错能力 | 需手动编写重试循环 |
无错误反馈 | 模型不知道输出为何被拒绝,无法从错误中学习 | 无法告知模型“缺少字段 email ” | |
异常处理全手动 | 所有格式异常需用代码捕获并处理,增加开发负担 | try-except 块遍地开花 | |
5. 扩展性限制 | 多字段维护难 | 字段超过 10 个时,提示词变得冗长且难以管理 | 提示膨胀至数百 token |
动态结构不支持 | 无法根据输入内容动态调整输出结构(如条件字段、可选对象) | 输入为“公司”则输出产品列表,否则不输出 | |
多语言适配复杂 | 不同语言需重写整个提示规则以保持结构一致性 | 中文提示需重新设计格式约束 | |
6. 效率问题 | token 浪费 | 每次请求都需重复传输完整的 Schema 描述,占用大量上下文 | 每次都传 "输出严格JSON格式:..." |
响应延迟 | 模型需花费计算资源理解格式要求,而非专注于内容生成 | 影响推理速度 | |
解析开销 | 需先提取文本再解析 JSON,无法直接获取结构化数据,增加后处理成本 | 大响应体下解析耗时显著 | |
7. 与工程实践脱节 | 无版本控制 | 提示词通常以字符串形式存在,难以进行 git diff 、合并冲突管理等代码级操作 | 修改提示无法追溯变更历史 |
测试困难 | 缺乏对提示的有效单元测试机制,难以验证其在各种输入下的行为一致性 | 无法像函数一样进行自动化测试 | |
协作障碍 | 非技术人员(如产品经理)难以参与结构设计,因提示词混合了逻辑与格式指令 | 业务人员看不懂技术提示 |
何时仍需手动编写提示工程?
- 原型验证阶段:快速验证概念简单格式输出:单层CSV/简单列表无LangChain环境:受限环境下的临时方案非技术用户:需避免代码依赖的场景
建议:对于生产系统,优先采用
PydanticOutputParser
、OpenAI Function Calling
或专用输出解析库。手动提示仅作为临时方案,其维护成本随系统复杂度呈指数级增长。
使用langchain实现结构化输出
在 langchain 中使用内置的Pydanticoutputparser
实现从大模型的输出中解析出结构化的数据。
我们使用PydanticOutputParser
的主要好处在于:
- 结构化数据:
Pydantic
模型可以定义我们期望输出的数据结构,包括字段名称、类型和描述,使得输出更加规范。类型安全:Pydantic
会自动进行类型检查,如果模型输出不符合定义,会抛出错误,便于我们捕获和处理异常。自动解析:LangChain
的PydanticOutputParser
可以将语言模型的输出自动解析成我们定义的Pydantic
模型对象,简化了代码。提示词优化:Parser
可以根据Pydantic
模型自动生成格式说明,减少手动编写提示词的工作量,并降低出错概率。如何使用LangChain
的PydanticOutputParser
实现模型结构化输出?
- 定义Pydantic模型:描述期望输出的数据结构。初始化解析器:传入定义好的
Pydantic
模型到PydanticOutputParse(pydantic_object=模型)
。构建提示词:使用PydanticOutputParser
的get_format_instructions
方法获取格式指令,并加入到提示词中。调用模型:将提示词输入模型,得到输出。解析输出:使用PydanticOutputParser
的 parse
方法解析模型的输出,得到结构化的数据对象。下面是一个具体的示例,通过结构化输出一个主题的各个章节列表,包含复杂嵌套结构的处理:
1. 定义Pydantic模型
from pydantic import BaseModel, Fieldfrom langchain.output_parsers import PydanticOutputParser# 1. 定义数据结构模型 (Pydantic)class Section(BaseModel): name: str = Field( description="章节名称", ) description: str = Field( description="简要概述本章节涉及的主要主题和概念,不超过50字", )# Pydantic 支持嵌套模型,轻松处理多层数据class Sections(BaseModel): sections: List[Section] = Field( description="报告的章节列表", )
2. 初始化解析器
# 2. 初始化解析器parser = PydanticOutputParser(pydantic_object=Sections)# 查看生成的格式指令format_instructions = parser.get_format_instructions()print(format_instructions)
输出的promp内容如下(这个prompt是 PydanticOutputParser
根据提供的Pydantic
模型自动生成的):
The output should be formatted as a JSON instance that conforms to the JSON schema below.As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.Here is the output schema:```{"$defs": {"Section": {"properties": {"name": {"description": "章节名称", "title": "Name", "type": "string"}, "description": {"description": "简要概述本章节涉及的主要主题和概念", "title": "Description", "type": "string"}}, "required": ["name", "description"], "title": "Section", "type": "object"}}, "properties": {"sections": {"description": "报告的章节列表", "items": {"$ref": "#/$defs/Section"}, "title": "Sections", "type": "array"}}, "required": ["sections"]}```
可以看到该prompt按照前面手动编写提示工程的技巧:
- 明确要求输出格式为JSON,并且指定了具体的Schema。通过示例帮助模型理解要求(展示了正确的格式和错误的格式)。提供了详细的Schema,包括每个字段的类型、描述和是否必需。
在LangChain的PydanticOutputParser中,我们通过编程方式定义数据结构,然后自动生成格式指令,这样更简洁,且避免了手动编写复杂的Schema描述。
3.构建提示词(包含格式说明)
接下来需要将format_instructions
的格式指令放在system_prompt
中,如下所示:
topic = "创建关于 LLM 缩放定律的报告"system_prompt = f"""请生成报告结构,严格遵守以下要求:{format_instructions}不要添加任何额外文本,仅输出JSON。"""user_prompt = f"""这里是报告主题: {topic}"""messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}]
4.调用模型
将提示词输入模型,得到输出。这里使用 litellm 进行模型调用,关于 litellm 的使用可以查看我前面的文章,调用模型的代码如下:
import litellmfrom typing import Union, List, Dict, Optionalmodel_config_dict = { "deepseek-chat": { "model": "openai/deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": os.getenv("DEEPSEEK_API_KEY"), },}def llm_chat( messages: Union[str, List[Dict[str, str]]], model: str="deepseek-chat", tools: Optional[List] = None, stream=False): print(f'调用模型:{model}') model_config = model_config_dict.get(model) if isinstance(messages, str): messages = [{"role": "user", "content": messages}] response = litellm.completion( model=model_config.get('model'), base_url=model_config.get('base_url'), api_key=model_config.get('api_key'), messages=messages, tools=tools, stream=stream, ) return response.choices[0].messageresponse = llm_chat(messages)print(response.content)
调用模型输出结果如下:
```json{ "sections": [ { "name": "引言", "description": "介绍LLM(大型语言模型)的基本概念和缩放定律的重要性。" }, { "name": "LLM缩放定律的定义", "description": "详细解释LLM缩放定律的含义及其在模型性能预测中的作用。" }, { "name": "缩放定律的数学基础", "description": "探讨缩放定律背后的数学原理和关键公式。" }, { "name": "实证研究", "description": "总结关于LLM缩放定律的实证研究结果和主要发现。" }, { "name": "应用与影响", "description": "分析缩放定律在实际LLM开发中的应用及其对行业的影响。" }, { "name": "未来研究方向", "description": "提出关于LLM缩放定律未来可能的研究方向和挑战。" }, { "name": "结论", "description": "总结报告的主要观点和缩放定律在LLM发展中的重要性。" } ]}
#### 5.解析输出从前面可以看到模型输出的结果不是一个存粹的json字符串,包含特殊符号````json`,所以还需要使用`PydanticOutputParser`的 `parse`方法解析模型的输出,得到结构化的数据对象,代码如下:```python# 5. 使用PydanticOutputParser的 parse 方法解析模型的输出content = response.contentresult: Sections = parser.parse(content)print(result)
输出结果如下:
sections=[Section(name='引言', description='介绍LLM(大型语言模型)的基本概念和缩放定律的重要性。'), Section(name='LLM缩放定律的定义', description='详细解释LLM缩放定律的含义及其在模型性能预测中的作用。'), Section(name='缩放定律的数学基础', description='探讨缩放定律背后的数学原理和关键公式。'), Section(name='实证研究', description='总结关于LLM缩放定律的实证研究结果和主要发现。'), Section(name='应用与影响', description='分析缩放定律在实际LLM开发中的应用及其对行业的影响。'), Section(name='未来研究方向', description='提出关于LLM缩放定律未来可能的研究方向和挑战。'), Section(name='结论', description='总结报告的主要观点和缩放定律在LLM发展中的重要性。')]
使用 pydantic
的 model_dump_json()
查看json字符串
json_result = result.model_dump_json()print(json_result)
输出结果如下:
{ "sections": [ { "name": "引言", "description": "介绍LLM(大型语言模型)的基本概念和缩放定律的重要性。" }, { "name": "LLM缩放定律的定义", "description": "详细解释LLM缩放定律的含义及其在模型性能预测中的作用。" }, { "name": "缩放定律的数学基础", "description": "探讨缩放定律背后的数学原理和关键公式。" }, { "name": "实证研究", "description": "总结关于LLM缩放定律的实证研究结果和主要发现。" }, { "name": "应用与影响", "description": "分析缩放定律在实际LLM开发中的应用及其对行业的影响。" }, { "name": "未来研究方向", "description": "提出关于LLM缩放定律未来可能的研究方向和挑战。" }, { "name": "结论", "description": "总结报告的主要观点和缩放定律在LLM发展中的重要性。" } ]}
parse方法源码分析
查看 PydanticOutputParser.parse()
方法的源码,看看它到底帮我们做了什么,首先使用正则表达式提取json字符串:
_json_markdown_re = re.compile(r"```(json)?(.*)", re.DOTALL)# Try to find JSON string within triple backticksmatch = _json_markdown_re.search(json_string)# If no match found, assume the entire string is a JSON string# Else, use the content within the backticksjson_str = json_string if match is None else match.group(2)
然后使用下面的代码进行json字符串补全:
def parse_partial_json(s: str, *, strict: bool = False) -> Any: """Parse a JSON string that may be missing closing braces. Args: s: The JSON string to parse. strict: Whether to use strict parsing. Defaults to False. Returns: The parsed JSON object as a Python dictionary. """ # Attempt to parse the string as-is. try: return json.loads(s, strict=strict) except json.JSONDecodeError: pass # Initialize variables. new_chars = [] stack = [] is_inside_string = False escaped = False # Process each character in the string one at a time. for char in s: new_char = char if is_inside_string: if char == '"' and not escaped: is_inside_string = False elif char == "\n" and not escaped: new_char = ( "\\n" # Replace the newline character with the escape sequence. ) elif char == "\\": escaped = not escaped else: escaped = False elif char == '"': is_inside_string = True escaped = False elif char == "{": stack.append("}") elif char == "[": stack.append("]") elif char in {"}", "]"}: if stack and stack[-1] == char: stack.pop() else: # Mismatched closing character; the input is malformed. return None # Append the processed character to the new string. new_chars.append(new_char) # If we're still inside a string at the end of processing, # we need to close the string. if is_inside_string: if escaped: # Remoe unterminated escape character new_chars.pop() new_chars.append('"') # Reverse the stack to get the closing characters. stack.reverse() # Try to parse mods of string until we succeed or run out of characters. while new_chars: # Close any remaining open structures in the reverse # order that they were opened. # Attempt to parse the modified string as JSON. try: return json.loads("".join(new_chars + stack), strict=strict) except json.JSONDecodeError: # If we still can't parse the string as JSON, # try removing the last character new_chars.pop() # If we got here, we ran out of characters to remove # and still couldn't parse the string as JSON, so return the parse error # for the original string. return json.loads(s, strict=strict)
完整的结构化输出代码
from pydantic import BaseModel, Fieldfrom langchain.output_parsers import PydanticOutputParserimport litellmfrom typing import Union, List, Dict, Optionalmodel_config_dict = { "deepseek-chat": { "model": "openai/deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": os.getenv("DEEPSEEK_API_KEY"), },}def llm_chat( messages: Union[str, List[Dict[str, str]]], model: str="deepseek-chat", tools: Optional[List] = None, stream=False): print(f'调用模型:{model}') model_config = model_config_dict.get(model) if isinstance(messages, str): messages = [{"role": "user", "content": messages}] response = litellm.completion( model=model_config.get('model'), base_url=model_config.get('base_url'), api_key=model_config.get('api_key'), messages=messages, tools=tools, stream=stream, ) return response.choices[0].message# 1. 定义数据结构模型 (Pydantic)class Section(BaseModel): name: str = Field( description="章节名称", ) description: str = Field( description="简要概述本章节涉及的主要主题和概念", )# Pydantic 支持嵌套模型,轻松处理多层数据class Sections(BaseModel): sections: List[Section] = Field( description="报告的章节列表", )# 2. 初始化解析器parser = PydanticOutputParser(pydantic_object=Sections)# 查看生成的格式指令format_instructions = parser.get_format_instructions()print(format_instructions)# 3.构建提示词(包含格式说明)topic = "创建关于 LLM 缩放定律的报告"system_prompt = f"""请生成报告结构,严格遵守以下要求:{format_instructions}不要添加任何额外文本,仅输出JSON。"""user_prompt = f"""这里是报告主题: {topic}"""messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}]print(messages)# 4.调用模型response = llm_chat(messages)content = response.contentprint(content)# 5. 使用PydanticOutputParser的 parse 方法解析模型的输出content = response.contentresult: Sections = parser.parse(content)print(result)json_result = result.model_dump_json()print(json_result)
使用支持函数调用的模型实现结构化输出
我们使用 LangChain 的 .with_structured_output()
方法来实现结构化输出,这是 LangChain 提供的一个高级抽象,它简化了从模型中获取结构化输出的过程。这个方法通常与支持工具调用或函数调用的模型一起使用。
为什么模型具有函数调用功能?
函数调用(或工具调用)功能是指大模型(如 OpenAI 的 GPT-4 等)能够根据用户请求和预定义的函数描述,输出一个符合函数参数的调用请求。这种能力使得模型能够与外部工具或API进行交互,从而执行具体的操作(如查询天气、计算等)或者返回结构化的数据。
模型之所以具有函数调用功能,是因为在训练过程中,它们接触到了包含函数调用示例的数据,或者通过微调(fine-tuning)来学习如何根据用户指令和函数描述生成正确的函数调用参数。在推理时,我们通过提供函数的描述(名称、描述、参数及其类型)来引导模型生成相应的调用。
使用示例
下面我们以 OpenAI 的模型(支持函数调用)和 Pydantic 模型为例,展示如何使用 .with_structured_output()
。
步骤 1:定义 Pydantic 模型
from pydantic import BaseModel, Fieldclass Person(BaseModel): name: str = Field(description="The person's name") age: int = Field(description="The person's age") hobbies: list[str] = Field(description="The person's hobbies")
步骤 2:创建模型并绑定结构化输出
from langchain_openai import ChatOpenAI# 创建支持函数调用的模型实例model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)# 使用 with_structured_output 绑定输出结构structured_model = model.with_structured_output(Person)
步骤 3:调用模型获取结构化输出
# 直接调用,返回的是 Person 实例result = structured_model.invoke("John Doe is a 30 year old who likes hiking and reading.")print(result)# 输出:name='John Doe' age=30 hobbies=['hiking', 'reading']
底层过程解析
- 构造工具(函数)描述:在内部,
with_structured_output
方法会根据 Person
模型生成一个 JSON Schema,然后创建一个 LangChain 工具(Tool
)或函数(Function
),其参数就是这个 JSON Schema。例如,在OpenAI的API中,你可以这样定义:{ "name": "get_user_info", "description": "获取用户信息", "parameters": { "type": "object", "properties": { "name": { "type": "string", "description": "用户的姓名" }, "age": { "type": "integer", "description": "用户的年龄" } }, "required": ["name", "age"] }}
- 调用模型:当调用
invoke
时,模型会接收到:- 用户的消息(即输入的字符串)可用的工具列表(包含我们定义的这个虚拟工具)
Person
数据。解析工具调用:LangChain 运行时捕获这个工具调用,提取参数,并将其转换为 Person
对象。注意事项
- 这种方法依赖于模型对函数调用的支持。如果模型不支持函数调用,则可能无法使用此方法。使用结构化输出时,模型的温度(temperature)通常设置为0,以增加输出的确定性。
总结
with_structured_output()
方法通过利用模型的函数调用能力,将输出约束在预定义的结构中,从而简化了从大语言模型获取结构化数据的过程。其核心在于将输出解析器(如Pydantic模型)转换为函数描述,引导模型生成结构化的函数调用参数,然后将其解析为对象。这种方法比手动编写提示词和解析文本输出更可靠、更简洁。
对比解决方案的优势
问题维度 | 手动提示工程 | PydanticOutputParser/LangChain |
---|---|---|
类型安全 | ❌ 无保障 | ✅ 自动验证字段类型和范围 |
错误恢复 | ❌ 需完全手动处理 | ✅ 内置RetryOutputParser自动重试 |
嵌套结构 | ⚠️ 极难实现 | ✅ 原生支持嵌套模型/联合类型 |
提示词维护 | ❌ 完全手动 | ✅ 自动生成格式指令 |
多模型适配 | ❌ 需为每个模型定制 | ✅ 统一接口适配主流模型 |
工程化支持 | ❌ 无 | ✅ 集成测试/版本控制/CI/CD |
参考文档