
1. 项目概述从自由文本到结构化输出的范式转变如果你最近在折腾大语言模型LLM的应用开发尤其是想把LLM的输出结果喂给下游系统比如数据库、API、工作流引擎那你一定遇到过这个让人头疼的问题你满怀期待地向模型提问它却回给你一段看似正确、实则“自由奔放”的文本。你需要费劲地从这段文本里“人肉”提取信息写一堆脆弱的正则表达式或者祈祷模型每次的表述都一模一样。这种不确定性是LLM从“玩具”走向“生产工具”的最大障碍之一。“Agent 结构化输出”要解决的正是这个核心痛点。它的目标非常明确强制或引导LLM按照我们预先定义好的、严格的格式比如JSON Schema来输出结果而不是一段自由文本。这听起来简单但背后涉及提示工程、模型微调、输出后处理等一系列技术的组合拳。想象一下你问模型“总结一下这份合同的关键条款。” 你希望得到的是一个结构化的JSON包含parties合同方、effective_date生效日期、payment_terms付款条款等字段每个字段都有明确的类型字符串、日期、数字。这样你的程序就能直接解析这个JSON无缝地存入数据库或触发后续流程整个过程可靠、自动化。这个需求在智能客服自动生成工单、数据分析从报告中提取指标、RPA理解邮件内容并生成操作指令等场景下是刚需。没有结构化输出所谓的“智能体”Agent就只是一个聊天窗口无法真正融入企业系统。今天我们就来彻底拆解如何实现LLM的结构化输出从核心思路到实操细节再到避坑指南让你能真正把LLM的输出“管”起来。2. 核心思路与方案选型不止于“在提示词里写Please output JSON”很多人第一步会想到我在提示词Prompt里加一句“请以JSON格式回复”不就行了实测下来这招时灵时不灵。模型可能会输出JSON但字段名可能是中文的结构可能多一层或少一层值里可能包含多余的说明文字。这种“软约束”在简单任务中或许够用但对于生产环境我们需要的是“硬约束”。目前主流且可靠的实现路径主要有三条各有优劣适用于不同场景2.1 方案一提示工程Prompt Engineering 输出解析Output Parsing这是最常用、门槛最低的方法。核心思想是通过精心设计的提示词明确告诉模型输出的格式并在模型输出后用代码进行解析和校验。1. 结构化提示词设计这不仅仅是加一句话。一个有效的结构化提示通常包含角色与任务定义明确告诉模型它现在是一个“数据提取专家”或“JSON生成器”。格式范例Few-shot提供1-3个清晰的输入-输出示例。这是最有效的手段之一。例如用户输入“苹果公司于2023年9月发布iPhone 15起售价799美元。” 你应输出{entity: 苹果公司, product: iPhone 15, release_year: 2023, release_month: 9, price: 799, currency: USD}格式规范描述用自然语言清晰描述JSON的每个字段、类型和含义。甚至可以附上一段JSON Schema的描述。严格指令使用“必须”、“只能”、“严格遵循”等强动词并指示模型不要添加任何解释性文字。实操心得在提供范例时最好使用与真实数据分布相似的例子。如果任务复杂可以分步骤指示例如“第一步识别文本中的公司名第二步提取产品名第三步找到价格和货币单位...”2. 输出解析与后处理即使提示词写得再好也需要一个“安全网”。这就是输出解析库的作用。以LangChain的PydanticOutputParser为例它允许你用一个Pydantic模型一个用于数据验证的Python库来定义你期望的结构。from pydantic import BaseModel, Field from langchain.output_parsers import PydanticOutputParser # 1. 定义你期望的数据结构 class ContractSummary(BaseModel): parties: list[str] Field(description合同涉及的主体名称列表) effective_date: str Field(description合同生效日期YYYY-MM-DD格式) total_value: float Field(description合同总金额) currency: str Field(description货币代码如USD, CNY) # 2. 创建解析器 parser PydanticOutputParser(pydantic_objectContractSummary) # 3. 将格式指令融入提示词 from langchain.prompts import PromptTemplate prompt PromptTemplate( template请从以下文本中提取信息。\n{format_instructions}\n文本{query}\n, input_variables[query], partial_variables{format_instructions: parser.get_format_instructions()} ) # 4. 调用模型并解析 model ChatOpenAI(temperature0) # 低温度使输出更确定 chain prompt | model | parser result chain.invoke({query: 甲方宇宙科技与乙方银河集团于2024-05-01签署协议金额100万人民币。}) print(result) # 输出ContractSummary(parties[宇宙科技, 银河集团], effective_date2024-05-01, total_value1000000.0, currencyCNY)这个方案的优势是灵活、快速无需训练。但缺点也很明显它依赖于模型的理解和遵从能力对于极其复杂的结构或长文本成功率会下降。解析器虽然能捕获格式错误但无法纠正模型对内容理解的偏差。2.2 方案二函数调用Function Calling或工具调用Tool Calling这是目前各大主流API如OpenAI GPT, Anthropic Claude原生支持的最强大特性。其核心思想是你不直接让模型输出JSON而是让它思考后“决定”去调用一个你预先定义好的“函数”。这个函数的参数就是你想要的结构化数据。OpenAI将其称为“Function Calling”Claude称为“Tool Use”。以OpenAI为例你在API调用中除了消息列表还提供一个tools参数里面描述了你希望模型可以调用的函数工具包括函数名、描述、以及严格的参数JSON Schema。模型分析用户请求后如果认为需要调用某个函数它就不会生成常规的聊天内容而是返回一个特殊的响应表明它“想”调用哪个函数以及调用这个函数时传入的参数一个完全符合你定义的Schema的JSON对象。你的程序收到这个响应后解析出函数名和参数然后去真正执行这个函数或模拟执行最后将执行结果再返回给模型让模型生成最终面向用户的回答。在这个过程中我们真正需要的数据——那个结构化的参数JSON——已经完美地、可校验地拿到了。模型在生成这个参数JSON时受到了严格的Schema约束。import openai from typing import List client openai.OpenAI() # 定义工具函数的Schema tools [{ type: function, function: { name: extract_contract_info, description: 从合同文本中提取关键结构化信息, parameters: { type: object, properties: { parties: {type: array, items: {type: string}, description: 合同双方名称}, effective_date: {type: string, description: 生效日期ISO 8601格式}, key_terms: {type: array, items: {type: string}, description: 关键条款摘要} }, required: [parties, effective_date], additionalProperties: False # 禁止输出Schema未定义的字段 } } }] response client.chat.completions.create( modelgpt-4-turbo, messages[{role: user, content: 甲乙双方张三和李四于2024-06-01签订合作协议约定付款方式为分期付款并包含保密条款。}], toolstools, tool_choiceauto, # 让模型自己决定是否调用工具 ) # 解析响应 tool_calls response.choices[0].message.tool_calls if tool_calls: for tool_call in tool_calls: if tool_call.function.name extract_contract_info: import json arguments json.loads(tool_call.function.arguments) print(arguments) # 输出{parties: [张三, 李四], effective_date: 2024-06-01, key_terms: [分期付款, 保密条款]}这个方案是目前生产环境的首选。它结构化输出的成功率极高因为这是模型被专门优化过的能力。additionalProperties: False这样的设置能强制模型输出完全合规的JSON。缺点是它绑定于特定模型的API且流程稍显复杂需要处理工具调用循环。2.3 方案三微调Fine-tuning或提示词微调Prompt Tuning当你对输出格式有极其固定、独特的要求且上述方法在成本或性能上不满足时可以考虑微调。通过在有特定格式标签的数据集上对模型进行微调你可以“教会”模型一种新的、稳定的输出模式。例如你可以准备成千上万条(合同文本, 目标JSON)的数据对然后用这些数据对开源模型如Llama 3、Qwen进行全参数微调或更高效的LoRA微调。训练完成后模型就会“习惯性”地输出这种JSON格式。注意事项这条路成本高、周期长需要数据准备、训练和评估。它适用于输出格式是产品核心能力、且调用量巨大的场景。对于大多数应用方案一和方案二已经足够。一个折中的方法是“提示词微调”如OpenAI的Fine-tuning for function calling它用较少的数据调整模型更好地使用工具比全模型微调更轻量。方案选型速查表特性提示工程解析函数/工具调用模型微调实现难度低中高输出可靠性中依赖提示词和模型能力高模型原生优化高但可能过拟合灵活性高随时改提示词中需修改Schema并可能影响历史对话低改格式需重新训练成本低仅API调用低仅API调用高训练成本数据准备适用场景简单结构、原型验证、快速迭代复杂结构、生产部署、高可靠性要求固定格式、超高频率调用、私有化部署对于大多数Agent应用我个人的建议是优先采用“函数调用”方案。它提供了最佳的可控性和可靠性平衡。提示工程方案可以作为快速原型或对不支持函数调用的模型的备选。3. 核心细节解析与实操要点打造健壮的结构化输出管道选定了方案只是第一步。要把结构化输出真正用稳我们需要在细节上下功夫构建一个从提示、调用到校验的健壮管道。3.1 设计一个“抗揍”的JSON SchemaSchema是你和模型之间的契约。一份好的Schema能极大提升输出质量。字段描述description是关键不要只写字段名。为每个字段提供清晰、无歧义的自然语言描述。模型是根据描述来理解该字段期望填入什么内容的。例如“amount”这个字段描述写成“合同金额以数字表示”就比空着好写成“合同的总金额是一个浮点数不包含货币符号”则更佳。善用枚举enum和常量const如果某个字段只能是几个特定值之一一定要用enum限定。这能几乎100%保证输出正确。例如“currency”: {“type”: “string”, “enum”: [“CNY”, “USD”, “EUR”]}。明确必填required与选填在required数组中列出所有必须返回的字段。对于可能不存在的信息设置为非必填避免模型因无法找到信息而“胡编乱造”。利用additionalProperties: false这是保证输出纯净度的利器。设置后模型绝不会生成Schema中未定义的字段避免了垃圾数据。嵌套结构的复杂性管理对于深层嵌套的复杂JSON考虑将其拆分为多个步骤或多个工具调用。让模型一次生成一个简单的结构比让它一次生成一个极其复杂的结构成功率更高。3.2 温度Temperature与采样策略的设定生成结构化数据时我们需要的是确定性而不是创造性。将temperature设置为0或接近0如0.1。这会使模型选择概率最高的token输出结果保持最大程度的一致。对于OpenAI API还可以考虑设置top_p1默认值或一个较高的值同时使用seed参数来确保完全的可复现性。例如seed123这样相同的输入每次都会得到相同的输出这对调试和测试至关重要。3.3 实现带重试机制的解析流程即使有了上述所有措施网络波动、模型偶尔的“分神”仍可能导致输出格式错误。因此一个具备自动重试Retry和回退Fallback机制的调用流程是生产环境必备的。import tenacity import json from openai import OpenAI client OpenAI() tenacity.retry( stoptenacity.stop_after_attempt(3), # 最多重试3次 retrytenacity.retry_if_exception_type((json.JSONDecodeError, KeyError, ValueError)), # 捕获解析异常 waittenacity.wait_exponential(multiplier1, min2, max10) # 指数退避等待 ) def get_structured_output_with_retry(user_query: str, schema: dict): 一个带重试的结构化输出获取函数 try: response client.chat.completions.create( modelgpt-4-turbo, messages[{role: user, content: user_query}], tools[{ type: function, function: { name: extract_data, parameters: schema } }], tool_choice{type: function, function: {name: extract_data}}, # 强制调用 temperature0, seed42 ) tool_call response.choices[0].message.tool_calls[0] args json.loads(tool_call.function.arguments) # 可以在此处添加额外的业务逻辑校验 if not args.get(parties): raise ValueError(关键字段 parties 缺失或为空) return args except (json.JSONDecodeError, IndexError, KeyError, ValueError) as e: # 记录日志然后触发重试 print(f解析失败进行重试。错误: {e}) raise # 抛出异常让tenacity捕获并重试 # 使用示例 schema {...} # 你的JSON Schema try: result get_structured_output_with_retry(一段合同文本..., schema) print(成功:, result) except tenacity.RetryError: print(重试多次后仍失败执行降级方案例如使用更简单的提示词解析或返回错误信息给用户。)这个流程确保了单次调用失败不会导致整个服务中断大大提升了系统的鲁棒性。4. 常见问题与排查技巧实录在实际操作中你会遇到各种各样的问题。下面是我踩过坑后总结的一些典型问题及其解决方法。4.1 模型不按Schema输出或字段缺失/错误可能原因及排查Schema描述不清这是最常见的原因。回头检查你的字段描述description是否足够清晰、无歧义是否用自然语言说明了字段要提取什么尝试用更具体、更示例化的语言重写描述。提示词系统消息冲突如果你同时使用了系统消息System Message来指导模型角色并且系统消息中的指令与工具Schema的指令不一致模型可能会困惑。确保系统消息是宏观角色定义如“你是一个合同分析助手”而具体格式要求交给工具Schema。任务过于复杂模型可能无法从单次输入中提取所有信息。解决方案是任务分解Task Decomposition。不要用一个工具提取所有信息而是设计多个工具让Agent通过多次对话来逐步收集。例如先调用identify_parties工具再调用extract_dates工具。模型能力不足对于非常复杂或专业的领域即使是GPT-4也可能出错。可以尝试提供更详细的示例Few-shot在用户消息或系统消息中直接包含一个格式范例。使用思维链Chain-of-Thought在工具描述中引导模型“先思考再输出”。例如在描述中加入“请先识别文本中所有涉及的公司和个人然后再填入parties字段。”4.2 输出格式正确但内容“胡编乱造”Hallucination这是LLM的固有问题在结构化输出中依然存在。增加校验与约束在Schema中尽可能使用enum。对于数字字段可以设置minimum/maximum范围。对于日期可以要求特定格式pattern: ^\\d{4}-\\d{2}-\\d{2}$这能在一定程度上限制模型乱写。后处理校验解析出数据后增加一道业务逻辑校验。例如检查提取的金额是否在合理范围内检查日期是否在未来。如果校验失败触发重试或人工审核流程。提供更充分的上下文确保提供给模型的源文本包含了生成答案所需的全部信息。信息缺失是导致幻觉的主要原因之一。4.3 性能与延迟问题函数调用和复杂的提示词可能会增加API调用的延迟和Token消耗。精简Schema和描述在保证清晰的前提下尽量使用简短的字段名和描述。不必要的描述会消耗Token。缓存结果对于相同或相似的输入可以考虑缓存结构化输出结果避免重复调用。评估使用更小/更快的模型对于格式简单、内容明确的任务可以尝试使用gpt-3.5-turbo或专门微调过的小模型它们通常更快、更便宜。但需要充分测试其输出稳定性。4.4 如何处理模型拒绝调用工具的情况有时即使用tool_choice强制调用模型也可能返回一个普通的聊天消息而不是工具调用这在tool_choice“auto”时更常见内容可能是“我无法从文本中找到相关信息”。设计默认值或空值在你的Schema中为可能不存在的字段设置合理的默认值在应用层处理或允许返回null。在你的代码中要能处理这些空值。设计两级流程先让模型判断“能否处理”如果能再调用工具。这可以通过设计两个工具来实现can_process返回布尔值和extract_data。或者在系统消息中明确指示“如果你认为文本中不包含所需信息请直接说明‘信息不足’不要调用工具。”5. 进阶应用结构化输出作为智能体的“关节”当你熟练掌握了让单个LLM调用返回结构化数据后就可以构建更强大的多步骤智能体Agent。结构化输出在这里扮演了“标准化关节”的角色。想象一个智能合同审查Agent的工作流信息提取Agent使用工具调用从上传的合同PDF经OCR识别为文本中提取出parties,dates,payment_terms等结构化信息。条款分析Agent将提取出的payment_terms字段文本传递给另一个专门分析条款的LLM调用该调用返回一个结构化的风险评估JSON包含risk_level高/中/低、unusual_clauses异常条款列表。数据库操作将前两步得到的结构化数据ContractSummary和RiskAssessment直接映射到数据库模型存入数据库。报告生成Agent从数据库中读取结构化数据传递给报告生成LLM并指令其按照固定的{summary, risk_analysis, recommendations}的JSON格式生成最终报告。在整个流程中数据在Agent之间、Agent与系统之间都以严格的JSON格式流动。这消除了解析不确定性使得每个环节都可以独立开发、测试和替换整个系统变得可靠且可维护。最后的实操心得开始一个新项目时不要一上来就追求最复杂的Agent编排。先从核心的“输入-输出”环节做起花时间打磨好第一个工具调用的Schema和提示词实现一个稳定、可重复的结构化数据提取功能。把这个基础打牢后续构建复杂工作流就会水到渠成。记住可靠的智能体始于可靠的结构化输出。