三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

LLM结构化输出实战:从提示工程到函数调用,打造可靠Agent数据管道

LLM结构化输出实战:从提示工程到函数调用,打造可靠Agent数据管道

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_object=ContractSummary) # 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(temperature=0) # 低温度使输出更确定 chain = prompt | model | parser result = chain.invoke({"query": "甲方宇宙科技与乙方银河集团于2024-05-01签署协议,金额100万人民币。"}) print(result) # 输出:ContractSummary(parties=['宇宙科技', '银河集团'], effective_date='2024-05-01', total_value=1000000.0, currency='CNY')

这个方案的优势是灵活、快速,无需训练。但缺点也很明显:它依赖于模型的理解和遵从能力,对于极其复杂的结构或长文本,成功率会下降。解析器虽然能捕获格式错误,但无法纠正模型对内容理解的偏差。

2.2 方案二:函数调用(Function Calling)或工具调用(Tool Calling)

这是目前各大主流API(如OpenAI GPT, Anthropic Claude)原生支持的最强大特性。其核心思想是:你不直接让模型输出JSON,而是让它思考后,“决定”去调用一个你预先定义好的“函数”。这个函数的参数,就是你想要的结构化数据。

OpenAI将其称为“Function Calling”,Claude称为“Tool Use”。以OpenAI为例:

  1. 你在API调用中,除了消息列表,还提供一个tools参数,里面描述了你希望模型可以调用的函数(工具),包括函数名、描述、以及严格的参数JSON Schema。
  2. 模型分析用户请求后,如果认为需要调用某个函数,它就不会生成常规的聊天内容,而是返回一个特殊的响应,表明它“想”调用哪个函数,以及调用这个函数时传入的参数(一个完全符合你定义的Schema的JSON对象)
  3. 你的程序收到这个响应后,解析出函数名和参数,然后去真正执行这个函数(或模拟执行),最后将执行结果再返回给模型,让模型生成最终面向用户的回答。

在这个过程中,我们真正需要的数据——那个结构化的参数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( model="gpt-4-turbo", messages=[{"role": "user", "content": "甲乙双方,张三和李四,于2024-06-01签订合作协议,约定付款方式为分期付款,并包含保密条款。"}], tools=tools, tool_choice="auto", # 让模型自己决定是否调用工具 ) # 解析响应 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 Schema

Schema是你和模型之间的契约。一份好的Schema能极大提升输出质量。

  1. 字段描述(description)是关键:不要只写字段名。为每个字段提供清晰、无歧义的自然语言描述。模型是根据描述来理解该字段期望填入什么内容的。例如,“amount”这个字段,描述写成“合同金额,以数字表示”就比空着好,写成“合同的总金额,是一个浮点数,不包含货币符号”则更佳。
  2. 善用枚举(enum)和常量(const):如果某个字段只能是几个特定值之一,一定要用enum限定。这能几乎100%保证输出正确。例如“currency”: {“type”: “string”, “enum”: [“CNY”, “USD”, “EUR”]}
  3. 明确必填(required)与选填:在required数组中列出所有必须返回的字段。对于可能不存在的信息,设置为非必填,避免模型因无法找到信息而“胡编乱造”。
  4. 利用additionalProperties: false:这是保证输出纯净度的利器。设置后,模型绝不会生成Schema中未定义的字段,避免了垃圾数据。
  5. 嵌套结构的复杂性管理:对于深层嵌套的复杂JSON,考虑将其拆分为多个步骤或多个工具调用。让模型一次生成一个简单的结构,比让它一次生成一个极其复杂的结构成功率更高。

3.2 温度(Temperature)与采样策略的设定

生成结构化数据时,我们需要的是确定性,而不是创造性。

  • temperature设置为0或接近0(如0.1)。这会使模型选择概率最高的token,输出结果保持最大程度的一致。
  • 对于OpenAI API,还可以考虑设置top_p=1(默认值)或一个较高的值,同时使用seed参数来确保完全的可复现性。例如seed=123,这样相同的输入每次都会得到相同的输出,这对调试和测试至关重要。

3.3 实现带重试机制的解析流程

即使有了上述所有措施,网络波动、模型偶尔的“分神”仍可能导致输出格式错误。因此,一个具备自动重试(Retry)和回退(Fallback)机制的调用流程是生产环境必备的。

import tenacity import json from openai import OpenAI client = OpenAI() @tenacity.retry( stop=tenacity.stop_after_attempt(3), # 最多重试3次 retry=tenacity.retry_if_exception_type((json.JSONDecodeError, KeyError, ValueError)), # 捕获解析异常 wait=tenacity.wait_exponential(multiplier=1, min=2, max=10) # 指数退避等待 ) def get_structured_output_with_retry(user_query: str, schema: dict): """一个带重试的结构化输出获取函数""" try: response = client.chat.completions.create( model="gpt-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"}}, # 强制调用 temperature=0, seed=42 ) 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输出,或字段缺失/错误

可能原因及排查:

  1. Schema描述不清:这是最常见的原因。回头检查你的字段描述(description)是否足够清晰、无歧义?是否用自然语言说明了字段要提取什么?尝试用更具体、更示例化的语言重写描述。
  2. 提示词(系统消息)冲突:如果你同时使用了系统消息(System Message)来指导模型角色,并且系统消息中的指令与工具Schema的指令不一致,模型可能会困惑。确保系统消息是宏观角色定义(如“你是一个合同分析助手”),而具体格式要求交给工具Schema。
  3. 任务过于复杂:模型可能无法从单次输入中提取所有信息。解决方案是任务分解(Task Decomposition)。不要用一个工具提取所有信息,而是设计多个工具,让Agent通过多次对话来逐步收集。例如,先调用identify_parties工具,再调用extract_dates工具。
  4. 模型能力不足:对于非常复杂或专业的领域,即使是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的工作流:

  1. 信息提取Agent:使用工具调用,从上传的合同PDF(经OCR识别为文本)中,提取出parties,dates,payment_terms等结构化信息。
  2. 条款分析Agent:将提取出的payment_terms字段文本,传递给另一个专门分析条款的LLM调用,该调用返回一个结构化的风险评估JSON,包含risk_level(高/中/低)、unusual_clauses(异常条款列表)。
  3. 数据库操作:将前两步得到的结构化数据(ContractSummaryRiskAssessment)直接映射到数据库模型,存入数据库。
  4. 报告生成Agent:从数据库中读取结构化数据,传递给报告生成LLM,并指令其按照固定的{summary, risk_analysis, recommendations}的JSON格式生成最终报告。

在整个流程中,数据在Agent之间、Agent与系统之间都以严格的JSON格式流动。这消除了解析不确定性,使得每个环节都可以独立开发、测试和替换,整个系统变得可靠且可维护。

最后的实操心得:开始一个新项目时,不要一上来就追求最复杂的Agent编排。先从核心的“输入-输出”环节做起,花时间打磨好第一个工具调用的Schema和提示词,实现一个稳定、可重复的结构化数据提取功能。把这个基础打牢,后续构建复杂工作流就会水到渠成。记住,可靠的智能体,始于可靠的结构化输出。

← 返回列表