核心思想:从"灵光一闪"到"确定防线"
不能让模型靠运气输出,要用软件工程防线 包裹非确定性的生成模型,最终实现条件反射式的正确输出 。对于极致场景,可通过 SFT 监督微调让模型形成"肌肉记忆"。
三层防线详解
第一道防线:提示词控制(软约束)
-
Schema 注入 + Few-Shot:提供 JSON Schema 或 TypeScript 接口定义,并给 2-3 个范例,把字段类型约束写死。
-
利用近因效应 :指令末尾强调"禁绝废话,仅输出 JSON",并用定界符
json ...框定区域。
第二道防线:生成控制层(物理硬手段)
-
闭源 API :使用 Structured Outputs 功能,预编译 Schema 进解码引擎,遵循率接近 100%。
-
开源模型 :采用 Logit Masking(概率掩码),通过有限状态机强制将不符合语法的 token 概率设为负无穷,杜绝格式错误。
第三道防线:工程校验与自修复(闭环逻辑)
-
强校验框架 :用 Pydantic 等工具检查字段完整性和类型正确性,杜绝脏数据。
-
流式解析优化:对超长 JSON 边生成边检查,发现错误立即中断,节省算力。
-
闭环自修复:将校验报错信息(如"字段缺失")反馈给模型,指导其利用 LLM 能力自我修正。
第一部分:第三道防线 ------ Pydantic 强校验与自修复
这里展示一个完整的闭环逻辑:定义结构 -> 尝试解析 -> 失败则把报错喂给模型重试。
import json import openai from pydantic import BaseModel, Field, ValidationError from typing import List, Optional # 1. 定义期望的数据结构 (Schema) class UserProfile(BaseModel): name: str = Field(description="用户姓名") age: int = Field(description="年龄") skills: List[str] = Field(description="技能列表") email: Optional[str] = Field(description="邮箱,可选") # 2. 模拟大模型返回的"不干净"数据 (JSON字符串) bad_response_from_llm = ''' { "name": "张三", "age": "二十八", "skills": ["Python", "Java"], "extra_field": "这是多余字段会被忽略" } ''' def validate_and_repair(llm_output: str, max_retries: int = 2): """校验失败时,将报错反馈给模型进行自修复""" for attempt in range(max_retries): try: # 解析 JSON 字符串 data = json.loads(llm_output) # === 第三道防线核心:Pydantic 校验 === # 这里会自动检查 age 是否为 int,缺少字段会报错,多余字段默认忽略 validated_data = UserProfile(**data) print(f"校验通过!数据: {validated_data.model_dump()}") return validated_data except (json.JSONDecodeError, ValidationError) as e: print(f"第 {attempt + 1} 次校验失败: {e}") if attempt == max_retries - 1: raise e # === 闭环自修复机制 === # 把具体的报错信息 (如 'age: Input should be a valid integer') # 反馈给模型,让模型自己重写 JSON repair_prompt = f""" 你之前输出的 JSON 格式有误,报错信息如下: {str(e)} 请根据以下 Schema 定义修正错误,仅输出纯 JSON,不要解释: {UserProfile.model_json_schema()} 原始错误输出: {llm_output} """ # 这里调用 LLM (以 OpenAI 为例) # 注意:实际生产中这里需要调用你的模型 API print("正在请求模型自我修复...") # response = openai.ChatCompletion.create(...) # llm_output = response.choices[0].message.content # 模拟修复后的输出 (实际运行时会替换为模型返回) llm_output = '{"name": "张三", "age": 28, "skills": ["Python", "Java"], "email": null}' return None # 执行测试 validate_and_repair(bad_response_from_llm)第二部分:第二道防线 ------ OpenAI Structured Outputs 写法
这是 物理硬手段,在 API 请求阶段就直接约束模型生成符合 Schema 的 Token。
from openai import OpenAI from pydantic import BaseModel from typing import List client = OpenAI(api_key="your-api-key") # 1. 定义结构 (使用 Pydantic 定义即可,OpenAI SDK 直接兼容) class Step(BaseModel): explanation: str output: str class MathResponse(BaseModel): steps: List[Step] final_answer: str # 2. 调用 API,使用 parse 方法 (Structured Outputs 模式) completion = client.beta.chat.completions.parse( model="gpt-4o-2024-08-06", # 注意:需使用支持 Structured Outputs 的模型版本 messages=[ {"role": "system", "content": "你是一个数学老师,回答问题要分步骤。"}, {"role": "user", "content": "解方程 2x + 5 = 15"}, ], response_format=MathResponse, # 直接传入 Pydantic 类 ) # 3. 获取结果 ------ 已经是实例化好的 Pydantic 对象,100% 符合结构 math_result = completion.msg.parsed # 4. 直接使用属性,无需担心格式错误 print(f"最终答案: {math_result.final_answer}") for i, step in enumerate(math_result.steps): print(f"步骤 {i+1}: {step.explanation} -> {step.output}") # === 如果是开源模型,这里本应是 Logit Masking 的代码 === # 开源侧通常使用 outlines、guidance 或 llama.cpp grammars 库 # 例如 outlines 示例 (伪代码逻辑): # import outlines # model = outlines.models.transformers("microsoft/Phi-3-mini-4k-instruct") # --- 构建 Logit Masking 生成器 (第二道防线物理硬手段) --- # 这一行代码背后:Outlines 将 Pydantic Schema 编译成了有限状态机 (FSM), # 并在推理时自动挂载 LogitsProcessor 进行概率掩码。 # generator = outlines.generate.json(model, UserProfile.model_json_schema()) # result = generator("给我一个张三的用户档案")总结两者的区别
维度 Pydantic 校验 (第三道防线) OpenAI Structured Outputs (第二道防线) 执行时机 模型生成之后 模型生成过程之中 类比 质检员 带卡槽的模具 适用场景 所有模型、老版本 API、极端自定义校验 闭源商业 API (OpenAI, 火山等) 优点 灵活,能捕获业务逻辑错误,能自修复 零格式错误,速度快,Token 省
一个综合运用三道防线的完整生产级示例。
场景设定为:AI 辅助医疗问诊,需要模型输出包含诊断和处方的严格 JSON。
将代码分为四个逻辑块:第一道防线(Prompt构建) 、第二道防线(API约束尝试) 、第三道防线(Pydantic校验与自修复)。
import json
import openai
from pydantic import BaseModel, Field, ValidationError, field_validator
from typing import List, Optional, Literal
from openai import OpenAI
# ==================== 0. 初始化与Schema定义 ====================
client = OpenAI(api_key="your-api-key") # 请替换为真实Key
# 定义处方的药物结构
class PrescriptionDrug(BaseModel):
name: str = Field(..., description="药物通用名")
dosage: str = Field(..., description="单次剂量,如 '10mg'")
frequency: str = Field(..., description="用药频率,如 '每日一次'")
duration: str = Field(..., description="疗程,如 '7天'")
# 定义完整的诊断报告结构(Pydantic Schema,用于第一道防线的注入和第三道防线的校验)
class DiagnosisReport(BaseModel):
patient_complaint: str = Field(..., description="主诉,总结患者问题")
# 使用 Literal 限制枚举值,防止模型瞎编严重程度
severity: Literal["轻度", "中度", "重度", "危急"] = Field(..., description="严重程度分级")
diagnosis: str = Field(..., description="初步诊断名称")
reasoning: str = Field(..., description="诊断依据,逻辑推理过程")
prescriptions: List[PrescriptionDrug] = Field(..., min_length=1, description="处方列表,至少包含一种药物或处理方式")
notes: Optional[str] = Field(None, description="医嘱备注,如饮食禁忌")
# 第三道防线补充:自定义逻辑校验(Pydantic 的 field_validator)
@field_validator('reasoning')
def reasoning_must_be_meaningful(cls, v):
if len(v) < 10:
raise ValueError('诊断依据过于简短,需详细说明')
return v
# ==================== 1. 第一道防线:Prompt 工程 ====================
def build_defense_prompt(user_input: str) -> str:
"""
应用高手策略:
1. Schema注入 + Few-Shot (给出一个范例)
2. 近因效应 (末尾强调格式)
"""
# 将 Pydantic Schema 转换为 JSON 结构描述字符串,注入给模型看
schema_desc = DiagnosisReport.model_json_schema()
system_prompt = f"""
你是一名专业的全科医生AI助手。你必须严格按照以下 JSON Schema 定义输出诊断结果。
Schema 定义如下:
{schema_desc}
【Few-Shot 范例】:
用户输入:"我头疼流鼻涕,有点发烧,38度"
正确输出:
{{
"patient_complaint": "头痛、流涕伴发热1天",
"severity": "中度",
"diagnosis": "急性上呼吸道感染(感冒)",
"reasoning": "患者有明确的鼻部卡他症状(流涕)及发热体征,符合病毒性上呼吸道感染典型表现。",
"prescriptions": [
{{
"name": "对乙酰氨基酚片",
"dosage": "500mg",
"frequency": "必要时服用",
"duration": "3天"
}},
{{
"name": "生理性海水鼻腔喷雾",
"dosage": "每侧2喷",
"frequency": "每日3次",
"duration": "5天"
}}
],
"notes": "多喝温水,注意休息,若高热不退或出现呼吸困难请及时就医。"
}}
"""
user_prompt = f"""
患者描述:{user_input}
请给出诊断和处方。
"""
# === 第一道防线核心:近因效应 ===
# 将最严格的格式约束指令放在 Prompt 的最末尾
final_instruction = """
【最高优先级指令】
1. 仅输出符合上述 JSON Schema 的纯 JSON 字符串。
2. 严禁在 JSON 之外添加任何解释、问候语、Markdown标记(如 ```json)或换行字符。
3. 确保所有字段齐全,prescriptions 必须为数组。
"""
# 拼接最终消息
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt + final_instruction}
]
return messages
# ==================== 2. 第二道防线:生成控制层 (可选硬手段) ====================
def call_llm_with_structured_output(messages):
"""
尝试使用 OpenAI Structured Outputs 作为第二道防线(物理硬手段)。
如果模型或API不支持,则降级为普通JSON模式调用。
"""
try:
# 使用 beta.chat.completions.parse 进行强约束生成
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06", # 确保是支持此功能的模型
messages=messages,
response_format=DiagnosisReport,
temperature=0.1 # 降低随机性,提高格式遵循度
)
# 如果成功,这里返回的已经是校验通过的 Pydantic 对象
return completion.choices[0].message.parsed, None
except Exception as e:
# 降级逻辑:如果不支持 Structured Outputs 或网络出错,走普通 Chat 接口
print(f"第二道防线未启用或失败 (降级至普通生成): {e}")
response = client.chat.completions.create(
model="gpt-3.5-turbo", # 普通模型
messages=messages,
temperature=0.1
)
raw_content = response.choices[0].message.content
return None, raw_content
# ==================== 3. 第三道防线:Pydantic 校验 + 闭环自修复 ====================
def robust_diagnosis_parser(raw_json_str: str, original_messages: list) -> DiagnosisReport:
"""
尝试解析 JSON,失败则进入重试循环(自修复)。
"""
MAX_RETRY = 2
current_output = raw_json_str
for attempt in range(MAX_RETRY + 1):
try:
# 1. 基础清洗:移除可能的 Markdown 代码块标记 (第一道防线失效时的补救)
clean_str = current_output.strip()
if clean_str.startswith("```json"):
clean_str = clean_str[7:]
if clean_str.startswith("```"):
clean_str = clean_str[3:]
if clean_str.endswith("```"):
clean_str = clean_str[:-3]
# 2. 解析 JSON
data_dict = json.loads(clean_str)
# 3. Pydantic 强校验 (字段缺失、类型错误、自定义校验器都会在此报错)
validated_report = DiagnosisReport(**data_dict)
print(f"第三道防线校验通过 (尝试次数: {attempt + 1})")
return validated_report
except (json.JSONDecodeError, ValidationError) as e:
print(f"解析/校验失败 (Attempt {attempt + 1}): {e}")
if attempt == MAX_RETRY:
raise ValueError(f"模型经过 {MAX_RETRY} 次修复仍未通过校验,最后错误: {e}")
# === 闭环自修复逻辑 ===
# 构建修复用的消息,直接追加报错信息和错误输出
repair_messages = original_messages.copy()
# 模拟 Assistant 的错误回答
repair_messages.append({"role": "assistant", "content": current_output})
# 用户反馈错误详情 (这是最关键的反馈信号)
user_feedback = f"""
你的上一轮回答 JSON 格式或内容校验失败,详细错误如下:
{str(e)}
请根据报错信息修正 JSON。确保:
1. severity 只能是 '轻度'、'中度'、'重度'、'危急' 之一。
2. reasoning 长度需大于10个字符。
3. 字段必须完整。
请直接输出修正后的纯 JSON,不要加任何多余字符。
"""
repair_messages.append({"role": "user", "content": user_feedback})
print("进入自修复模式,重新请求模型...")
# 重新调用模型 (这里为了演示,直接走普通接口,实际可复用 call_llm_with_structured_output)
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=repair_messages,
temperature=0.0
)
current_output = response.choices[0].message.content
# ==================== 4. 主流程整合 (三道防线协同) ====================
def get_medical_diagnosis(user_query: str):
print(f"用户输入: {user_query}")
# Step 1: 构建第一道防线 Prompt
messages = build_defense_prompt(user_query)
# Step 2: 尝试第二道防线 (物理硬约束)
parsed_obj, raw_text = call_llm_with_structured_output(messages)
# Step 3: 如果第二道防线直接成功了 (返回了 Pydantic 对象)
if parsed_obj is not None:
print("第二道防线直接命中,无需第三道防线介入。")
return parsed_obj
# Step 4: 否则进入第三道防线 (软件校验 + 自修复)
print("启动第三道防线:工程校验与自修复...")
final_report = robust_diagnosis_parser(raw_text, messages)
return final_report
# ==================== 运行示例 ====================
if __name__ == "__main__":
# 模拟一个可能让模型出错的模糊输入
test_input = "嗓子疼,浑身没劲,感觉发冷,量了下38度5"
try:
result = get_medical_diagnosis(test_input)
print("\n========== 最终处方单 (JSON) ==========")
# 直接打印格式化的 JSON,完全符合应用层调用要求
print(result.model_dump_json(indent=2, ensure_ascii=False))
except Exception as e:
print(f"系统处理异常: {e}")
三道防线在这个例子中的具体体现
| 防线层级 | 代码位置 | 具体作用 | 对应策略 |
|---|---|---|---|
| 第一道 | build_defense_prompt |
1. 注入 model_json_schema() 2. 提供 Few-Shot 范例 3. 末尾强调"仅输出 JSON" |
Schema注入 + 近因效应 |
| 第二道 | call_llm_with_structured_output |
使用 client.beta.chat.completions.parse |
Structured Outputs (物理硬手段) |
| 第三道 | robust_diagnosis_parser |
1. Pydantic 校验字段类型、枚举值 2. field_validator 校验逻辑 3. 失败时将报错反馈给模型 |
强校验 + 闭环自修复 |
运行预期输出
当模型第一次尝试输出 "severity": "高烧" (不在枚举范围)或缺失字段时,系统会打印:
解析/校验失败 (Attempt 1): 1 validation error for DiagnosisReport severity ...
进入自修复模式,重新请求模型...
第三道防线校验通过 (尝试次数: 2)
最终输出一定是符合 DiagnosisReport 定义的干净 JSON,可以直接被下游的业务代码(如电子病历系统、药房系统)使用。