经过前面的数据准备、索引构建和检索优化,我们终于来到了RAG流程的最后一站:生成(Generation)。检索系统已经找回了最相关的上下文,现在轮到LLM来阅读这些资料并写出最终答案了。
但现实往往比理想更复杂——应用中需要的不是一段“美文”,而是一个可以直接被程序消费的数据结构:JSON对象、API调用参数、数据库记录……本章将深入探讨如何让LLM的输出从“自然语言”进化为“结构化数据”,让AI真正成为应用程序的有机组成部分。
格式化生成
从大语言模型(LLM)那里获得一段非结构化的文本,在应用中常常不满足实际需求。为了实现更复杂的逻辑、与外部工具交互或以用户友好的方式展示数据,我们需要模型能够输出具有特定结构的数据,例如JSON或XML。
本章将讨论实现格式化生成的几种主流方法,包括LangChain、LlamaIndex等框架内置的解决方案,不依赖框架的实现思路,以及一种更强大的技术——Function Calling。
📝关于提示词工程:在生成阶段,提示词工程是一个重要的部分。但因为在前面的章节中已经有了较多介绍,本章不再赘述,而是聚焦于“如何让输出变得结构化”这一核心主题。
一、为什么需要格式化生成?
先来看几个具体的应用场景:
| 场景 | 用户输入 | 期望的结构化输出 |
|---|---|---|
| RAG驱动的电商客服 | “推荐几款适合程序员的键盘” | [{"name": "...", "price": 99, "features": [...], "url": "..."}] |
| 自然语言转API调用 | “帮我查一下明天从上海到北京的航班” | {"departure": "上海", "destination": "北京", "date": "2025-07-18"} |
| 数据自动提取 | 一篇新闻文章 | {"event": "...", "time": "...", "location": "...", "people": [...]} |
| 智能表单填写 | “我叫张三,25岁,想申请软件开发岗位” | {"name": "张三", "age": 25, "position": "软件开发"} |
在这些场景中,格式化生成是连接LLM的自然语言理解能力和下游应用程序的程序化逻辑之间的关键桥梁。
二、格式化生成的实现方法
2.1 LangChain的Output Parsers
LangChain提供了一个强大的组件——OutputParsers(输出解析器),专门用于处理LLM的输出。其核心思想是:在发送给LLM的提示中自动注入一段关于如何格式化输出的指令,并在得到结果后将LLM返回的纯文本字符串解析成预期的结构化数据(如Python对象)。
LangChain提供了多种开箱即用的解析器:
| 解析器 | 用途 | 特点 |
|---|---|---|
| StrOutputParser | 基础字符串输出 | 最简单,直接返回LLM输出 |
| JsonOutputParser | JSON解析 | 支持嵌套结构和列表的复杂JSON |
| PydanticOutputParser | 强类型数据验证 | 与Pydantic模型结合,自动验证类型和结构 |
| CommaSeparatedListOutputParser | 逗号分隔列表 | 将输出解析为字符串列表 |
| DatetimeOutputParser | 日期时间解析 | 将输出解析为日期时间对象 |
深度案例:PydanticOutputParser的工作原理
以下通过一个具体代码示例,分析PydanticOutputParser的内部机制——它将用户定义的Pydantic数据模型转换为详细的格式指令并注入提示词,再将模型返回的JSON字符串安全解析为Pydantic对象实例。
# (此处省略导入和LLM初始化代码) # ========================================== # 1. 定义期望的数据结构 # ========================================== from pydantic import BaseModel, Field from typing import List class PersonInfo(BaseModel): """用于存储个人信息的数据结构。""" name: str = Field(description="人物姓名") age: int = Field(description="人物年龄") skills: List[str] = Field(description="技能列表") # ========================================== # 2. 基于Pydantic模型创建解析器 # ========================================== parser = PydanticOutputParser(pydantic_object=PersonInfo) # ========================================== # 3. 创建提示模板,注入格式指令 # ========================================== prompt = PromptTemplate( template="请根据以下文本提取信息。\n{format_instructions}\n{text}\n", input_variables=["text"], partial_variables={ "format_instructions": parser.get_format_instructions() }, ) # ========================================== # 4. 创建处理链(LCEL方式) # ========================================== chain = prompt | llm | parser # ========================================== # 5. 执行调用 # ========================================== text = "张三今年30岁,他擅长Python和Go语言。" result = chain.invoke({"text": text}) print(result) # 输出: name='张三' age=30 skills=['Python', 'Go语言']深度拆解:解析器内部做了什么?
| 步骤 | 操作 | 详细说明 |
|---|---|---|
| 步骤1 | 调用.model_json_schema() | PydanticOutputParser内部调用Pydantic模型的.model_json_schema()方法,提取完整的JSON Schema定义,包括字段名、类型、描述、是否必填等元数据 |
| 步骤2 | 生成格式指令 | 将JSON Schema嵌入预设的提示模板中,生成的format_instructions会明确要求LLM输出符合该Schema的JSON对象,例如:“You must format your output as a JSON object that conforms to the following schema...” |
| 步骤3 | 注入并发送 | prompt将用户输入和格式指令组合成完整提示,发送给LLM |
| 步骤4 | 解析JSON字符串 | LLM返回JSON字符串后,解析器先使用json.loads()将其解析为Python字典 |
| 步骤5 | Pydantic验证 | 使用PersonInfo.model_validate()验证字典——检查所有必填字段是否存在、类型是否正确(如age是否为int) |
| 步骤6 | 返回对象实例 | 验证通过后返回PersonInfo实例;验证失败则抛出OutputParserException异常 |
💡最佳实践:
Field(description=...)中的描述文本会直接出现在format_instructions中,因此需要清晰准确地描述每个字段的含义和格式要求,直接影响LLM生成的质量。
2.2 LlamaIndex的输出解析
LlamaIndex的输出解析与生成过程紧密结合,主要体现在两大核心组件中:响应合成(Response Synthesis)和结构化输出(Structured Output)。
响应合成的四种模式
在RAG流程中,检索器召回相关文本块后,响应合成器(Response Synthesizer)负责以不同策略将它们呈现给LLM:
| 模式 | 工作方式 | 适用场景 |
|---|---|---|
| compact | 尽可能多地将文本块压缩进单次LLM调用 | 默认推荐,效率高 |
| refine | 逐块处理,迭代优化答案 | 需要多轮推理的复杂问题 |
| tree_summarize | 构建摘要树,递归总结 | 需要整体把握的长文档 |
| simple_summarize | 简单拼接所有文本块 | 文本块少且短的场景 |
Pydantic Programs
当需要LLM返回结构化数据而非纯文本时,LlamaIndex使用Pydantic Programs,思路与LangChain的PydanticOutputParser一致:
from llama_index.core.program import LLMTextCompletionProgram from pydantic import BaseModel # 1. 定义数据模型 class FlightInfo(BaseModel): departure: str destination: str date: str # 2. 创建结构化输出程序 program = LLMTextCompletionProgram.from_defaults( output_cls=FlightInfo, prompt_template_str="从以下文本中提取航班信息:{text}", llm=llm, verbose=True ) # 3. 执行提取 result = program(text="明天从上海飞往北京的航班有哪些?") print(result.departure) # "上海" print(result.destination) # "北京"📝关键差异:如果底层LLM支持Function Calling,LlamaIndex会优先使用该功能以获得更可靠的结构化输出;如果不支持,则会回退到将JSON Schema注入提示词的方法。
2.3 不依赖框架的简单实现
如果不想依赖特定框架,纯粹通过提示工程也能实现格式化生成。以下是一些实用技巧:
| 技巧 | 说明 | 示例 |
|---|---|---|
| 明确要求JSON格式 | 直接、强硬地要求模型返回JSON | “你必须返回一个JSON对象,不要包含任何解释性文字” |
| 提供JSON Schema | 在提示中给出想要的JSON模式 | 描述每个键的含义和数据类型 |
| Few-shot示例 | 给出1-2个完整的“用户输入→期望JSON输出”示例 | 让模型学习输出格式和风格 |
| 强调字段完整性 | 明确要求所有字段都要有值 | “如果信息不存在,请使用null值” |
| 使用语法约束 | 对于本地部署的开源模型(如llama.cpp),使用GBNF语法强制约束输出 | 确保生成的每个token都严格符合预定义的JSON语法 |
# 纯提示工程实现示例 prompt = f""" 请从以下文本中提取人物信息,并以JSON格式返回。 要求: 1. 只返回JSON对象,不要包含任何解释 2. JSON必须包含以下字段:name(字符串)、age(整数)、skills(字符串数组) 3. 如果某个字段信息缺失,使用null值 文本:{text} JSON输出: """ response = llm.invoke(prompt) result = json.loads(response.content) # 解析为Python字典三、Function Calling
Function Calling(或称Tool Calling)是近年来LLM领域的一个重要进展,它极大地提升了模型与外部世界交互和生成结构化数据的能力。
3.1 概念与工作流程
Function Calling的本质是一个多轮对话流程,让模型、代码和外部工具(如API)协同工作。
🎯通俗理解:Function Calling就是让LLM从“写作文”变成“填写工单”——它不直接给你答案,而是告诉你“该调用什么工具、传什么参数”,由你的代码去执行并拿到结果。
完整工作流程:
text
┌─────────────────────────────────────────────────────────────────┐ │ ① 定义工具(代码中提前定义好可用的函数,包括名称、描述、参数) │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ ② 用户提问 → LLM分析意图 → 匹配最合适的工具 │ │ 返回特殊响应:{"tool_calls": [{"name": "get_weather", │ │ "arguments": {"city": "杭州"}}]} │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ ③ 代码执行:解析tool_calls → 调用实际API → 获取真实数据 │ │ 例如:调用天气API,得到 "24℃,晴朗" │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ ④ 结果反馈:将工具执行结果包装为tool消息,再次发送给模型 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ ⑤ 最终生成:模型结合工具返回的真实数据,生成自然语言答案 │ │ "杭州今天天气晴朗,气温24℃。" │ └─────────────────────────────────────────────────────────────────┘3.2 Function Calling实践
以下使用OpenAI风格API展示完整流程:
# ========================================== # 1. 定义工具(JSON Schema格式) # ========================================== tools = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如:杭州、北京" }, "date": { "type": "string", "description": "日期,格式为YYYY-MM-DD,默认为今天" } }, "required": ["city"] } } } ] # ========================================== # 2. 用户提问 → 第一次调用(User → Model) # ========================================== messages = [{"role": "user", "content": "杭州今天天气怎么样?"}] response = client.chat.completions.create( model="gpt-4", messages=messages, tools=tools, tool_choice="auto" # 让模型自动决定是否调用工具 ) message = response.choices[0].message # ========================================== # 3. 处理tool_calls → 代码执行 # ========================================== if message.tool_calls: tool_call = message.tool_calls[0] function_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) print(f"模型决定调用: {function_name}") print(f"参数: {arguments}") # 输出: 模型决定调用: get_weather # 参数: {"city": "杭州"} # 模拟API调用(实际场景中为真实HTTP请求) def get_weather(city: str) -> str: weather_data = {"杭州": "24℃,晴朗", "北京": "32℃,多云"} return weather_data.get(city, "未知天气") tool_result = get_weather(arguments["city"]) print(f"工具返回: {tool_result}") # 输出: 工具返回: 24℃,晴朗 # ========================================== # 4. 结果反馈 → 第二次调用(Tool → Model) # ========================================== messages.append(message) # 添加模型的tool_calls响应 messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": tool_result }) # 第二次发送给模型,让它基于工具结果生成最终答案 final_response = client.chat.completions.create( model="gpt-4", messages=messages, ) print("最终答案:", final_response.choices[0].message.content) # 输出: 杭州今天天气晴朗,气温24℃。3.3 Function Calling的三大优势
| 优势 | 说明 | 与纯提示工程的对比 |
|---|---|---|
| 可靠性更高 | 模型原生支持,输出结构稳定、精确 | 纯提示工程依赖模型“自觉遵守”,格式不稳定的概率更高 |
| 意图识别 | 不仅格式化输出,更包含“意图到函数的映射” | 模型能主动判断“这个问题需要调用工具”,而非被动等待指令 |
| 与外部世界交互 | 构建能执行实际任务的AI代理(Agent)的基础 | LLM可以查询数据库、调用API、控制智能家居等 |
3.4 Function Calling的底层原理(理解更透)
Function Calling的本质是让LLM学会“选函数”而非“写文本”。具体来说:
训练阶段:在模型预训练时,使用了大量包含“函数调用”格式的数据,让模型学会了识别工具调用的模式。
推理阶段:模型在输出时,不是直接生成文本,而是生成一个特殊的
tool_calls结构。这个结构的格式是固定的(如JSON),保证了一致性。系统层面:工具的描述(名称、参数、描述)被包含在系统提示中,模型在生成时会“看到”这些信息,根据用户问题匹配最合适的工具。
从概率角度看:
纯提示工程:模型生成文本 → 我们解析JSON → 如果模型输出格式不对,解析失败
Function Calling:模型生成
tool_calls→ 系统直接解析 → 几乎不会格式错误
这也是为什么Function Calling被认为比纯提示工程更可靠的原因——它走的是模型内部的“专用通道”,而非“通用文本生成通道”。
四、扩展:现代结构化生成的进阶技术
4.1 Instructor —— 结构化输出的“瑞士军刀”
Instructor是一个开源Python库,专门为简化LLM结构化输出而设计。它在Pydantic的基础上提供了一层优雅的封装,支持OpenAI、Anthropic、Cohere等多种模型。
核心特点:
| 特点 | 说明 |
|---|---|
| 简化代码 | 几行代码即可实现结构化输出,无需手动编写解析逻辑 |
| 自动重试 | 验证失败时自动重试,并包含错误信息,提高成功率 |
| 流式支持 | 支持流式结构化输出,逐步获取验证后的对象 |
| 多模型兼容 | 同一套代码可切换不同LLM提供商 |
import instructor from openai import OpenAI from pydantic import BaseModel, Field # 1. 定义数据模型 class UserInfo(BaseModel): name: str = Field(description="用户姓名") age: int = Field(description="用户年龄") skills: list[str] = Field(description="技能列表") # 2. 创建带instructor的客户端 client = instructor.from_openai(OpenAI()) # 3. 直接返回Pydantic对象 user_info: UserInfo = client.chat.completions.create( model="gpt-4", messages=[ {"role": "user", "content": "张三今年30岁,擅长Python和Go语言"} ], response_model=UserInfo, # 只需指定响应模型! ) print(user_info.name) # "张三" print(user_info.age) # 30 print(user_info.skills) # ["Python", "Go语言"]Instructor vs LangChain PydanticOutputParser:两者核心目标一致,但Instructor的API更简洁,且支持更灵活的验证器和重试策略,在现代LLM开发中被广泛采用。
4.2 Outlines —— 本地模型的“格式锁”
对于本地部署的开源模型,Outlines提供了基于正则表达式和上下文无关语法的结构化生成能力,通过“格式锁”(logit bias)技术,在Token生成层面强制约束输出格式。
工作原理:Outlines定义了JSON Schema对应的正则表达式,然后在每个Token生成时,只允许生成那些“能使输出继续匹配该正则表达式”的Token,从而实现100%的格式保证。
import outlines # 定义数据模型 @outlines.pydantic_model class PersonInfo: name: str age: int # 生成符合模型的结构化输出 model = outlines.models.transformers("microsoft/Phi-3-mini-4k-instruct") generator = outlines.generate.json(model, PersonInfo) result = generator("Extract name and age: John is 25 years old.") print(result) # PersonInfo(name="John", age=25)适用场景:
本地部署的LLM(不依赖外部API)
对成本敏感的应用(无需为每次调用付费)
需要严格格式保证的生产环境
4.3 Guidance —— 控制生成过程的“高级提示”
Guidance是Microsoft开源的库,允许开发者通过约束解码(Constraint Decoding)来控制LLM的生成过程,确保输出符合特定格式。
与Outlines的对比:
| 维度 | Outlines | Guidance |
|---|---|---|
| 核心原理 | 基于JSON Schema的正则表达式约束 | 基于模板的交互式约束 |
| 适用模型 | Transformers兼容的本地模型 | 支持本地模型和部分API |
| 学习曲线 | 相对平缓 | 较高(需要学习特定语法) |
📝一句话总结:对于在线API调用,使用Instructor最方便;对于本地模型部署,使用Outlines或Guidance实现100%格式保证;对于快速原型开发,使用LangChain的PydanticOutputParser足够。
4.4 流式结构化输出:实时处理JSON
在实时应用中,LLM的推理时间可能成为瓶颈。流式结构化输出允许在模型生成完整JSON之前,就开始逐块解析和展示数据。
实现方案:
LangChain的
stream():通过chain.stream()获取流式输出,但需要配合特殊解析器。Instructor的流式支持:
create_partial()方法支持流式获取Pydantic对象的渐进式构建。手动增量解析:使用
ijson等库增量解析JSON流。
# Instructor流式示例 import instructor from openai import OpenAI client = instructor.from_openai(OpenAI()) # 流式获取结构化数据 stream = client.chat.completions.create_partial( model="gpt-4", messages=[ {"role": "user", "content": "列出5个Python库及其用途"} ], response_model=list[LibraryInfo], ) for partial_object in stream: print(f"已接收 {len(partial_object)} 个项目") # 可以实时展示/处理部分结果五、结构化生成的常见挑战与最佳实践
5.1 常见挑战及解决方案
| 挑战 | 表现 | 解决方案 |
|---|---|---|
| 格式不一致 | 有时返回纯文本,有时返回JSON | 使用Function Calling或结构化生成库(Instructor、Outlines) |
| 字段缺失 | LLM漏掉了某些字段 | 设置默认值;使用验证器确保字段存在;在提示中强调“必须包含所有字段” |
| 类型错误 | 数字字段返回了字符串 | 使用Pydantic自动类型转换;使用Field(..., coerce=True) |
| 中文JSON解析问题 | JSON中包含中文导致解析失败 | 确保使用json.loads(..., ensure_ascii=False);使用Pydantic自动处理 |
| 复杂嵌套结构 | LLM难以生成深层嵌套的JSON | 拆分为多个简单的子结构;使用Few-shot示例引导 |
| API调用失败 | Function Calling的网络请求失败 | 实现重试机制;使用tenacity库处理重试逻辑 |
5.2 最佳实践总结
| 实践 | 说明 | 优先级 |
|---|---|---|
| 使用Pydantic定义Schema | 统一的模型定义 → 自动生成JSON Schema → 自动验证 | ⭐⭐⭐ 必做 |
| Field描述要清晰 | Field(description="用户姓名,例如:张三") | ⭐⭐⭐ 必做 |
| 提供Few-shot示例 | 让LLM学习输出格式,特别是复杂结构 | ⭐⭐ 推荐 |
| 设置temperature=0 | 降低随机性,确保格式稳定性 | ⭐⭐ 推荐 |
| 实现验证和重试 | 格式错误时自动重试,附带错误信息 | ⭐⭐ 推荐 |
| 使用系统性提示 | 在系统提示中统一说明格式要求,而非每次重复 | ⭐ 可选 |
| 监控输出质量 | 记录格式错误率,持续优化 | ⭐ 可选 |
5.3 格式化生成的完整流程模板
from pydantic import BaseModel, Field, ValidationError from typing import List import json # 1. 定义数据模型 class OutputSchema(BaseModel): field1: str = Field(description="...") field2: int = Field(description="...") # 2. 构建提示 def build_prompt(input_text: str, schema: dict) -> str: return f""" 请从以下文本中提取信息,按照JSON Schema返回。 Schema: {json.dumps(schema, ensure_ascii=False, indent=2)} 文本: {input_text} 只返回JSON,不要包含其他内容。 """ # 3. 调用LLM + 解析 + 验证(含重试) def extract_with_validation(text: str, max_retries: int = 3): schema = OutputSchema.model_json_schema() for attempt in range(max_retries): response = llm.invoke(build_prompt(text, schema)) try: data = json.loads(response.content) return OutputSchema.model_validate(data) except (json.JSONDecodeError, ValidationError) as e: print(f"尝试 {attempt+1} 失败: {e}") continue raise Exception("所有尝试均失败,无法提取有效信息")六、总结
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| LangChain Output Parsers | 快速原型、框架统一 | 开箱即用、LCEL集成 | 依赖框架 |
| LlamaIndex Pydantic Programs | LlamaIndex生态 | 与RAG流程紧密集成 | 依赖框架 |
| 纯提示工程 | 轻量场景、无框架依赖 | 简单直接、无依赖 | 格式不稳定 |
| Function Calling | 工具调用、API交互 | 最稳定、原生支持 | 需要LLM支持 |
| Instructor | 现代Python LLM开发 | 简洁优雅、自动重试 | 需要额外库 |
| Outlines/Guidance | 本地模型部署 | 100%格式保证 | 仅支持本地模型 |
如果本文对你有帮助,欢迎点赞、收藏、转发!有任何疑问,欢迎在评论区交流讨论~