这次我们来看一个在AI应用开发中非常实际的问题:当大语言模型(LLM)被要求输出JSON格式时,它常常“自作主张”地添加一些解释性文字、前言、后语,导致下游程序解析时频繁报错。本文将系统性地拆解这个问题,并提供一套从提示词工程到后处理的四层解决方案,确保你拿到干净、可解析的JSON数据。
对于任何需要将LLM集成到自动化流程中的开发者来说,稳定的结构化输出是基石。模型输出的不可靠性会直接导致管道中断、任务失败。本文将重点介绍如何通过组合策略来约束模型行为,涵盖提示词设计、Few-shot示例、生成参数调优以及输出校验与清洗,并提供可直接复用的代码示例。无论你使用的是OpenAI API、本地部署的Llama系列,还是其他兼容接口的模型,这套方法都具有普适性。
1. 核心能力速览:四层防御体系
| 能力层 | 核心目标 | 关键手段 | 适用阶段 |
|---|---|---|---|
| 第一层:提示词约束 | 明确指令,限定输出格式 | 结构化指令、指定JSON Schema、使用特殊标记 | 请求前 |
| 第二层:Few-shot示例 | 提供范例,引导模型模仿 | 在上下文中提供输入-输出对,展示纯净JSON | 请求前 |
| 第三层:生成参数调优 | 控制模型“创造力”,减少废话 | 调整temperature,max_tokens, 停止序列 | 请求中 |
| 第四层:输出校验与清洗 | 兜底处理,提取有效JSON | 正则表达式匹配、尝试解析、容错处理 | 请求后 |
这套组合拳的核心思想是:前置引导为主,后置清洗兜底。前三层旨在从源头减少“杂质”的产生,第四层则确保即使有杂质,也能被有效过滤,保证下游解析的稳定性。
2. 问题场景与影响分析
在自动化任务中,我们经常需要模型根据指令生成结构化的数据,例如:
- 从产品描述中提取属性:生成
{"name": "...", "price": ..., "color": "..."} - 进行情感分析:输出
{"sentiment": "positive/negative/neutral", "confidence": 0.95} - 生成任务列表:输出
{"tasks": [{"id": 1, "title": "..."}, ...]}
然而,模型的原始输出可能是这样的:
好的,根据您的要求,我将分析这段文本。分析结果如下: { "sentiment": "positive", "confidence": 0.92 } 希望这个结果对您有帮助!或者更糟糕的,在JSON对象内部添加注释:
{ // 这是情感字段 "sentiment": "positive", "confidence": 0.92 // 置信度较高 }这种非纯净的JSON会导致标准的json.loads()解析失败,抛出JSONDecodeError,整个自动化流程随即中断。
3. 第一层解决方案:提示词工程
提示词是与模型沟通的第一道指令,清晰的指令能极大降低模型“自由发挥”的概率。
3.1 使用明确的结构化指令
在提示词中强烈要求模型只输出JSON,不要任何其他文字。
基础示例:
请严格仅输出一个JSON对象,不要有任何额外的解释、前言、后语或标记。 文本:“这个手机拍照效果很棒,电池也很耐用。” 请提取产品特征,JSON格式必须包含以下字段:name, positive_features (数组), negative_features (数组)。强化指令示例(加入“否则”后果):
你必须只输出一个有效的JSON对象,不能包含任何其他文本。如果你的输出不是纯粹的JSON,将导致系统错误。3.2 指定JSON Schema
对于复杂结构,直接提供Schema能更精确地约束输出格式。
示例提示词:
请将以下用户查询转换为一个结构化的任务对象。 用户查询:“提醒我明天下午三点开会,并记得买咖啡。” 请严格按照下面的JSON Schema输出,不要输出任何其他内容: { "type": "object", "properties": { "task_title": {"type": "string"}, "datetime": {"type": "string", "format": "iso8601"}, "sub_tasks": { "type": "array", "items": {"type": "string"} } }, "required": ["task_title", "datetime"] }3.3 使用特殊标记包裹
这是一种常见的技巧,在提示词中要求模型用特定标记(如 ````json或`) 包裹输出,便于后续用正则提取。
示例提示词:
请分析以下评论的情感。将结果用 <json> 和 </json> 标签包裹起来。 评论:“物流速度太慢了,但商品质量不错。” 输出格式: <json> { "sentiment": "mixed", "positive_aspects": ["商品质量"], "negative_aspects": ["物流速度"] } </json>4. 第二层解决方案:Few-shot示例
Few-shot(少样本)学习是引导模型输出的强大工具。通过提供几个输入和期望输出的例子,模型会更好地模仿你想要的格式和风格。
4.1 如何设计有效的Few-shot示例
- 示例必须纯净:每个示例的输出都应该是你期望的、无任何废话的完美JSON。
- 多样性:示例应覆盖不同的输入情况和输出结构,但格式保持一致。
- 明确性:在示例中也可以加入简单的指令。
示例提示词(包含Few-shot):
请根据用户输入,提取地点和活动信息,并只输出JSON。 示例1: 输入:“我打算周末去杭州西湖玩。” 输出:{"location": "杭州西湖", "activity": "游玩"} 示例2: 输入:“下周在上海有个技术峰会要参加。” 输出:{"location": "上海", "activity": "参加技术峰会"} 现在请处理新的输入: 输入:“下个月想去西安看兵马俑。” 输出:4.2 在编程中的实现
在实际调用API时,我们需要将Few-shot示例构建到消息列表(message list)中。
import openai def get_structured_output(user_input): messages = [ {"role": "system", "content": "你是一个信息提取助手,只输出JSON格式的结果。"}, {"role": "user", "content": "输入:“我打算周末去杭州西湖玩。”"}, {"role": "assistant", "content": '{"location": "杭州西湖", "activity": "游玩"}'}, {"role": "user", "content": "输入:“下周在上海有个技术峰会要参加。”"}, {"role": "assistant", "content": '{"location": "上海", "activity": "参加技术峰会"}'}, {"role": "user", "content": f"输入:“{user_input}”"} ] response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=messages, temperature=0.1, # 配合低温度值 max_tokens=150 ) return response.choices[0].message.content # 测试 result = get_structured_output(“下个月想去西安看兵马俑。”) print(result) # 期望输出: {"location": "西安", "activity": "看兵马俑"}5. 第三层解决方案:生成参数调优
模型API通常提供一系列参数来控制生成过程,合理设置可以抑制模型的随意性。
5.1 关键参数解析
temperature(温度):控制输出的随机性。值越低(如0.1-0.3),输出越确定、保守,更倾向于遵循指令和范例,适合需要稳定格式的任务。这是解决废话问题最关键参数之一,建议设为0.1或0.2。max_tokens(最大令牌数):限制生成文本的最大长度。设置一个刚好足够容纳你期望JSON的长度,可以防止模型生成过长的无关内容。stop(停止序列):指定一个或多个字符串,当模型生成这些字符串时立即停止。例如,如果你用<json>包裹,可以设置stop=["</json>"],确保模型生成闭合标签后立刻停止。top_p(核采样):与temperature类似,控制随机性。通常与temperature选一个使用即可。对于确定性输出,可以设为较低值(如0.1)。
5.2 参数配置示例
import openai response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ {"role": "user", "content": "请只输出JSON:提取‘苹果,价格5元’中的信息,格式为{\"item\": \"...\", \"price\": ...}"} ], temperature=0.1, # 低温度,减少随机性 max_tokens=100, # 限制输出长度 top_p=0.1, # 低核采样,进一步聚焦 # stop=["\n\n"] # 可选:如果发现模型常在空行后加话,可以用此停止 )6. 第四层解决方案:输出校验与清洗
无论前三层做得多好,都必须假设模型的输出可能包含非JSON内容。这一层是保证程序健壮性的安全网。
6.1 使用正则表达式提取JSON
这是最常用和直接的方法,从返回的文本中匹配出第一个类似JSON的结构。
import re import json def extract_json_from_text(text): """ 从可能包含额外文本的字符串中提取第一个有效的JSON对象或数组。 """ # 尝试匹配被 ```json ... ``` 或 ``` ... ``` 包裹的JSON code_block_pattern = r'```(?:json)?\s*([\s\S]*?)\s*```' # 尝试匹配被 <json> ... </json> 包裹的JSON tag_pattern = r'<json>\s*([\s\S]*?)\s*</json>' # 通用JSON对象/数组匹配(较宽松,可能匹配到不完整的) json_pattern = r'(\{[\s\S]*\}|\[[\s\S]*\])' cleaned_text = text.strip() # 优先级1:检查代码块或标签包裹 for pattern in [code_block_pattern, tag_pattern]: match = re.search(pattern, cleaned_text, re.IGNORECASE) if match: cleaned_text = match.group(1).strip() break # 优先级2:直接匹配最外层的花括号或方括号 # 这是一个更健壮的匹配,寻找成对的大括号 stack = [] start_index = -1 for i, char in enumerate(cleaned_text): if char == '{' or char == '[': if not stack: start_index = i stack.append(char) elif char == '}' and stack and stack[-1] == '{': stack.pop() elif char == ']' and stack and stack[-1] == '[': stack.pop() if start_index != -1 and not stack: # 找到了一个完整的JSON结构 potential_json = cleaned_text[start_index:i+1] try: # 尝试解析验证 json.loads(potential_json) return potential_json except json.JSONDecodeError: # 解析失败,继续寻找下一个可能的结构 start_index = -1 continue # 如果没有找到成对的结构,回退到简单的正则匹配(作为最后手段) match = re.search(json_pattern, cleaned_text) if match: return match.group(1) # 如果什么都找不到,返回原文本或空字符串,由上层处理 return cleaned_text # 测试函数 test_cases = [ "这是前言。{\"name\": \"test\"} 这是后语。", "```json\n{\"status\": \"ok\"}\n```", "<json>\n[\"item1\", \"item2\"]\n</json>", "输出:{\"a\": 1, }", # 无效JSON "没有任何JSON。" ] for txt in test_cases: result = extract_json_from_text(txt) print(f"输入: {txt[:50]}...") print(f"提取: {result}") print("-" * 30)6.2 安全解析与容错处理
提取出文本后,必须进行安全的JSON解析,并做好异常处理。
import json def safe_parse_json(json_string, default=None): """ 安全地解析JSON字符串,解析失败时返回默认值。 """ if json_string is None: return default cleaned_string = json_string.strip() if not cleaned_string: return default try: return json.loads(cleaned_string) except json.JSONDecodeError as e: # 可选:记录日志或进行更复杂的修复尝试(如处理尾随逗号) print(f"JSON解析错误: {e}. 原始字符串: {cleaned_string[:100]}...") # 简单修复尝试:移除JSON对象外的所有字符(更激进) # 此正则匹配从第一个'{'或'['开始,到最后一个'}'或']'结束 import re match = re.search(r'(\{[\s\S]*\}|\[[\s\S]*\])', cleaned_string) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass return default # 使用示例 raw_output = "好的,结果如下:{\"score\": 95}" # 假设这是模型返回 extracted = extract_json_from_text(raw_output) # 先提取 parsed_data = safe_parse_json(extracted, default={"error": "解析失败"}) print(parsed_data) # 输出: {'score': 95}6.3 结合使用:完整的处理管道
将以上所有层组合成一个健壮的处理器。
class LLMJsonProcessor: def __init__(self, llm_client, default_temperature=0.1): self.llm_client = llm_client self.default_temperature = default_temperature def build_prompt(self, instruction, few_shots=None, schema=None): """构建包含指令、few-shot和schema的提示词""" prompt_parts = [] prompt_parts.append("请严格只输出一个有效的JSON对象,不要有任何其他文本、解释或标记。") if schema: prompt_parts.append(f"\n请严格遵循以下JSON Schema格式:\n{json.dumps(schema, indent=2)}") if few_shots: prompt_parts.append("\n以下是一些示例:") for shot in few_shots: prompt_parts.append(f"输入:{shot['input']}") prompt_parts.append(f"输出:{json.dumps(shot['output'])}") prompt_parts.append(f"\n{instruction}") return "\n".join(prompt_parts) def generate_and_parse(self, instruction, **kwargs): """生成输出并解析为JSON""" # 1. 构建消息 prompt = self.build_prompt(instruction, few_shots=kwargs.get('few_shots'), schema=kwargs.get('schema')) messages = [{"role": "user", "content": prompt}] # 2. 调用LLM(参数可覆盖) temperature = kwargs.get('temperature', self.default_temperature) try: response = self.llm_client.chat.completions.create( model=kwargs.get('model', 'gpt-3.5-turbo'), messages=messages, temperature=temperature, max_tokens=kwargs.get('max_tokens', 500), stop=kwargs.get('stop', None) ) raw_content = response.choices[0].message.content except Exception as e: return {"error": f"LLM调用失败: {str(e)}"} # 3. 提取和清洗 extracted_json_str = extract_json_from_text(raw_content) # 4. 安全解析 parsed_result = safe_parse_json(extracted_json_str, default={"error": "无法解析为JSON", "raw": raw_content[:200]}) return { "raw_output": raw_content, "extracted": extracted_json_str, "parsed": parsed_result, "success": not isinstance(parsed_result, dict) or "error" not in parsed_result } # 模拟一个LLM客户端(实际替换为OpenAI, Anthropic等) class MockLLMClient: def chat(self): return self class completions: @staticmethod def create(**kwargs): # 模拟一个有时会加废话的模型 import random responses = [ '{\"city\": \"北京\", \"weather\": \"晴\"}', '答案:{\"city\": \"北京\", \"weather\": \"晴\"}', '好的,天气信息如下:\n{\"city\": \"北京\", \"weather\": \"晴\"}\n以上是结果。' ] from unittest.mock import Mock mock_choice = Mock() mock_choice.message.content = random.choice(responses) mock_response = Mock() mock_response.choices = [mock_choice] return mock_response # 使用示例 processor = LLMJsonProcessor(MockLLMClient()) result = processor.generate_and_parse( instruction="查询北京的天气,返回城市和天气状况。", few_shots=[ {"input": "查询上海天气", "output": {"city": "上海", "weather": "多云"}} ] ) print(json.dumps(result, indent=2, ensure_ascii=False))7. 针对特定模型与平台的优化
不同模型和平台可能有其特性,需要进行微调。
7.1 OpenAI GPT 系列
- 使用
response_format参数:OpenAI的Chat Completions API部分模型支持response_format参数,可以强制指定输出格式为JSON。这是最推荐的方式,如果可用,应优先使用。response = openai.ChatCompletion.create( model="gpt-3.5-turbo-1106", # 或更新版本 messages=[...], response_format={ "type": "json_object" }, # 关键参数 temperature=0.1 ) - 系统消息强化:在
system角色消息中明确指令,模型会给予更高权重。
7.2 本地部署模型(Llama, Qwen等)
- 注意提示词模板:本地模型通常有特定的提示词模板(如ChatML、Alpaca、Vicuna格式)。确保你的Few-shot示例和指令被正确包裹在模板中。
- 参数差异:
temperature和top_p的效果可能更敏感,需要更多测试。repeat_penalty等参数也可能影响格式稳定性。 - 后处理更重要:开源模型的指令遵循能力可能弱于商用API,因此第四层清洗逻辑需要更健壮。
7.3 其他商用API(Claude, DeepSeek等)
- 查阅官方文档,看是否有类似OpenAI
response_format的结构化输出参数。 - 关注其消息格式要求。
- 同样适用低
temperature和清晰的Few-shot。
8. 高级技巧与最佳实践
8.1 混合使用多种约束
不要只依赖单一方法。例如:
系统指令:你只输出JSON。 用户消息(包含Few-shot和Schema):[示例1] [示例2] 请按此Schema处理新输入:[Schema] [输入]同时,在API调用中设置temperature=0.1和response_format(如果支持)。
8.2 为复杂嵌套结构设计Schema
对于深度嵌套的JSON,在提示词中提供完整、清晰的Schema比单纯说“输出JSON”有效得多。可以使用JSON Schema描述,甚至用文字说明每个字段的含义和类型。
8.3 实施重试机制
即使有全套防护,解析仍可能失败。在生产系统中,应实现重试逻辑。
def get_structured_output_with_retry(processor, instruction, max_retries=2): for attempt in range(max_retries + 1): result = processor.generate_and_parse(instruction) if result["success"]: return result["parsed"] else: print(f"第{attempt+1}次尝试失败: {result.get('parsed', {}).get('error')}") if attempt < max_retries: # 可以稍微调整参数再试,例如提高一点temperature让输出有些变化 instruction_with_retry = instruction + "\n(请务必只输出JSON,不要任何其他文字)" # 继续循环 # 所有重试都失败 raise ValueError(f"在{max_retries+1}次尝试后仍无法获得有效JSON。最后原始输出: {result.get('raw_output')}")8.4 监控与日志记录
记录下模型原始的raw_output、清洗后的extracted字符串以及解析结果。这有助于:
- 分析哪种提示词或参数组合更有效。
- 发现模型新的“废话”模式,从而更新正则表达式。
- 在解析失败时进行问题排查。
8.5 单元测试
为你的JSON提取和解析函数编写全面的单元测试,覆盖各种边缘情况:
- 纯净JSON
- 前后有文本
- 被代码块包裹
- 被自定义标签包裹
- 包含注释的非法JSON
- 完全不包含JSON的文本
- 多个JSON对象嵌套在文本中
9. 常见问题排查清单
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
json.decoder.JSONDecodeError | 输出包含非JSON文本或格式错误 | 1. 打印raw_output查看原始内容。2. 检查是否被标记包裹。 3. 检查JSON内部是否有注释或尾随逗号。 | 1. 强化提示词指令。 2. 使用 extract_json_from_text函数。3. 尝试 safe_parse_json的修复逻辑。 |
输出为null或空对象 | 模型未理解任务或Schema | 1. 检查提示词是否清晰。 2. 检查Few-shot示例是否正确。 3. 模型能力是否不足。 | 1. 简化指令,提供更直接的示例。 2. 换用更强大的模型。 3. 在提示词中要求“如果无法提取,返回空对象 {}”。 |
| 输出缺失字段 | Schema约束力不足或模型忽略 | 1. 检查输出是否完全遵循了Schema。 2. 模型是否自行简化了结构。 | 1. 在提示词中强调“必须包含所有字段”。 2. 在Few-shot示例中展示完整结构。 3. 使用 response_format(如果支持)。 |
| 输出格式不稳定 | temperature过高或指令模糊 | 1. 检查temperature参数(应调低)。2. 检查不同次运行的输出差异。 | 1. 将temperature设为0.1或0。2. 使用相同的随机种子(如果API支持)。 3. 提供更精确的Few-shot。 |
| 提取函数匹配不到JSON | 正则表达式不覆盖新的“废话”模式 | 1. 检查新的raw_output格式。2. 测试提取函数。 | 1. 更新extract_json_from_text中的正则模式。2. 加入更通用的JSON括号匹配算法(如栈匹配)。 |
通过实施上述四层策略——精准的提示词、清晰的Few-shot示例、严格的生成参数以及健壮的后处理清洗——你可以将LLM输出不可靠JSON的问题发生率降到最低。这套方法的核心在于理解模型的行为模式,并通过工程化的手段对其进行约束和修正。建议从最简单的提示词约束开始,逐步叠加其他层,直到在你的具体应用场景下达到满意的稳定性。