本次实践以一个真实的推荐系统数据工程场景为背景:把 210 段风格自由、毫无 schema 可言的餐厅描述文本,批量转换成机器可查询的结构化 JSON 知识库。完整走一遍 One-shot 提示抽取 → Pydantic 模式验证 → 错误回喂自动修复 的三段式管道,最终 210 条输入产出 209 条合法 JSON------看看 LLM 结构化输出的工程化闭环到底怎么做,以及"让 LLM 自己修自己"这个看似偷懒的设计,为什么在工程上反而说得通。
一、问题:非结构化文本是搜索的天敌
场景不复杂:一家 AI 公司要做美食推荐产品 Connoisseur Companion,不满足于传统的 1--5 星评分,想从评论文本里挖出餐厅的氛围、饮食限制和"招牌菜"。手里的原料是一份 73KB 的 TXT 文件------210 段餐厅描述,每段长这样:
The Gilded Artichoke brings a bohemian chic energy to the hills of Silver Lake , operating as an upscale bistro that prioritizes Farm-to-Table Californian ingredients... Price range: $$$$
人读起来信息密度很高,但对程序来说这是灾难:
- 没有统一 schema:菜系、评分、价格散落在句子的任何位置,正则写不完;
- 类型不统一 :评分是
4.5/5,价格是$$$$,招牌菜是一句话里的短语; - 无法索引:想查"洛杉矶 300 元以内、有素食选项的融合菜",关键词匹配基本靠缘分。
这正是 LLM 结构化抽取的典型战场:用自然语言理解替代正则规则,用模式验证保证输出可控。整个管道的架构如下:
#mermaid-svg-sdbMfqIm4ZMAkAau{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sdbMfqIm4ZMAkAau .error-icon{fill:#552222;}#mermaid-svg-sdbMfqIm4ZMAkAau .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sdbMfqIm4ZMAkAau .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sdbMfqIm4ZMAkAau .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sdbMfqIm4ZMAkAau .marker.cross{stroke:#333333;}#mermaid-svg-sdbMfqIm4ZMAkAau svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sdbMfqIm4ZMAkAau p{margin:0;}#mermaid-svg-sdbMfqIm4ZMAkAau .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster-label text{fill:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster-label span{color:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster-label span p{background-color:transparent;}#mermaid-svg-sdbMfqIm4ZMAkAau .label text,#mermaid-svg-sdbMfqIm4ZMAkAau span{fill:#333;color:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau .node rect,#mermaid-svg-sdbMfqIm4ZMAkAau .node circle,#mermaid-svg-sdbMfqIm4ZMAkAau .node ellipse,#mermaid-svg-sdbMfqIm4ZMAkAau .node polygon,#mermaid-svg-sdbMfqIm4ZMAkAau .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sdbMfqIm4ZMAkAau .rough-node .label text,#mermaid-svg-sdbMfqIm4ZMAkAau .node .label text,#mermaid-svg-sdbMfqIm4ZMAkAau .image-shape .label,#mermaid-svg-sdbMfqIm4ZMAkAau .icon-shape .label{text-anchor:middle;}#mermaid-svg-sdbMfqIm4ZMAkAau .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sdbMfqIm4ZMAkAau .rough-node .label,#mermaid-svg-sdbMfqIm4ZMAkAau .node .label,#mermaid-svg-sdbMfqIm4ZMAkAau .image-shape .label,#mermaid-svg-sdbMfqIm4ZMAkAau .icon-shape .label{text-align:center;}#mermaid-svg-sdbMfqIm4ZMAkAau .node.clickable{cursor:pointer;}#mermaid-svg-sdbMfqIm4ZMAkAau .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sdbMfqIm4ZMAkAau .arrowheadPath{fill:#333333;}#mermaid-svg-sdbMfqIm4ZMAkAau .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sdbMfqIm4ZMAkAau .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sdbMfqIm4ZMAkAau .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sdbMfqIm4ZMAkAau .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sdbMfqIm4ZMAkAau .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sdbMfqIm4ZMAkAau .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster text{fill:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau .cluster span{color:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sdbMfqIm4ZMAkAau .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sdbMfqIm4ZMAkAau rect.text{fill:none;stroke-width:0;}#mermaid-svg-sdbMfqIm4ZMAkAau .icon-shape,#mermaid-svg-sdbMfqIm4ZMAkAau .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sdbMfqIm4ZMAkAau .icon-shape p,#mermaid-svg-sdbMfqIm4ZMAkAau .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sdbMfqIm4ZMAkAau .icon-shape .label rect,#mermaid-svg-sdbMfqIm4ZMAkAau .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sdbMfqIm4ZMAkAau .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sdbMfqIm4ZMAkAau .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sdbMfqIm4ZMAkAau :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} split('
')
通过
失败
超过重试上限
TXT 原文
210 段餐厅描述
段落列表
每段一家餐厅
LLM 抽取
One-shot 提示模板
输出清洗
剥离 ```json 标记
Pydantic
Schema 验证
结构化 JSON
入库
修复 LLM
错误信息回喂
跳过并告警
注意最后那条回边------验证失败不是终点,而是修复循环的起点。这是全文最重要的一处设计,后面细讲。
二、数据切分
文本按空行分段,加载和切分只需要三行:
python
file_path = "California-Culinary-Map.txt"
with open(file_path, 'r') as file:
data = file.read()
restaurant_list = data.split('\n\n') # 按空行切段
restaurant_list = restaurant_list[1:] # 第一段是标题,去掉
len(restaurant_list) 得到 210。这里有个不起眼但值得强调的点:切段就是建立处理单元的边界契约。后续所有环节(抽取、验证、修复、入库)都以"段"为原子单位,任何一段失败都不应污染其他段。边界划得干净,后面的容错设计才有立足点------如果一段里混进两家餐厅,LLM 抽取和验证都会陷入混沌。
三、封装 LLM:面向 OpenAI 兼容协议编程
模型调用层用 LangChain 的 ChatOpenAI 封装,好处是换模型只改三个参数:
python
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
def llm_model(system_msg, prompt_txt):
model = ChatOpenAI(
model="mimo-v2.6-flash",
base_url="https://token-plan-cn.xiaomimimo.com/v1",
api_key="...",
)
messages = [
SystemMessage(content=system_msg),
HumanMessage(content=prompt_txt)
]
response = model.invoke(input=messages)
return response.content
base_url + model + api_key 三件套一换,实验里从 CSDN 的 deepseek-v4-flash 自由切换到小米的 mimo-v2.6-flash 。
在 OpenAI 兼容协议已经成为事实标准的今天,业务代码不应该绑定任何一家模型供应商------这对生产环境尤其重要。
另外套了一层带重试的包装,网络抖动时不至于让 210 次循环中途夭折:
python
def safe_llm_call(system_msg, prompt_txt, retries=3):
for i in range(retries):
try:
return llm_model(system_msg, prompt_txt)
except Exception:
time.sleep(2)
return "Failed after retries"
四、One-shot 提示:把 schema 塞进示例里
1. 先明确要抽什么
从每段描述中提取 9 个字段:
| 字段 | 含义 | 原文形态 | 目标类型 |
|---|---|---|---|
name |
餐厅名 | **The Gilded Artichoke** |
str |
location |
位置 | Silver Lake |
str |
type |
餐厅类型 | upscale bistro |
str |
food_style |
餐饮风格 | Farm-to-Table Californian |
str |
rating |
评分 | 4.5/5 |
float |
price_range |
价格区间 | $$$$ |
int(美元符号个数) |
signatures |
招牌菜 | 句中短语 | Liststr |
vibe |
氛围 | bohemian chic |
str |
environment |
环境描述 | 一句话 | str |
shortcomings |
不足 | 可能缺失 | Liststr |
其中 price_range: $$$$ → 4 是典型的隐式类型转换 需求------人一眼看懂,但如果不明确指示,LLM 十有八九原样输出 "$$$$" 字符串。
2. One-shot 模板的设计
不用空口描述格式,直接给一对完整的输入-输出示例:
python
EXAMPLE_OUTPUT = """
{
"name": "Mar de Cortez",
"location": "Santa Monica",
"type": "casual taqueria",
"food_style": "Baja-style seafood",
"rating": 4.2,
"price_range": 1,
"signatures": ["beer-battered snapper tacos", "zesty octopus ceviche"],
"vibe": "salt-air energy",
"environment": "a premier sun-drenched spot for open-air dining near the pier.",
"shortcomings": []
}
"""
def restaurant_data_structure_prompt_generation(restaurant_paragraph):
base_system_msg = "你是一个专业的餐厅数据结构化处理助手。"
base_user_prompt = f"""
Task:
将餐厅描述结构化处理, 输出JSON格式。
Restaurant description:
{restaurant_paragraph}
Example:
Input Restaurant Description: {EXAMPLE_RESTAURANT_PARAGRAPH}
Output:
{EXAMPLE_OUTPUT}
"""
return base_system_msg, base_user_prompt
实测单条抽取的输出:
json
{
"name": "The Gilded Artichoke",
"location": "Silver Lake",
"type": "upscale bistro",
"food_style": "Farm-to-Table Californian",
"rating": 4.5,
"price_range": 4,
"signatures": ["lavender-rubbed roasted chicken", "delicate heirloom tomato tarts"],
"vibe": "bohemian chic",
"environment": "a high-end greenhouse feel with reclaimed wood and floor-to-ceiling windows.",
"shortcomings": []
}
字段一个不少,$$$$ 正确转成了 4,招牌菜准确抓出了两个。One-shot 的本质是把"格式规范"从抽象的文字描述变成具体的对齐目标------模型对示例的模仿能力远强于对指令的遵循能力,这也是 few-shot 提示在结构化输出场景几乎必用的原因。
五、Pydantic 验证:schema 即契约
LLM 输出"看起来像 JSON"不等于"是合法且符合业务的 JSON"。用 Pydantic 把数据契约正式化:
python
from pydantic import BaseModel, Field, ValidationError
from typing import List, Optional
class Restaurant(BaseModel):
name: str
location: str
type: str
food_style: str
rating: Optional[float] = None
price_range: Optional[int] = None
signatures: List[str] = Field(default_factory=list)
vibe: Optional[str] = None
environment: str
shortcomings: List[str] = Field(default_factory=list)
restaurant_data = Restaurant.model_validate_json(json_data=test_response)

这个 schema 有两层设计考量:
- 核心字段非空 (
name/location/type/environment是 str 必填):缺了就没法做检索,宁缺毋滥; - 易缺失字段 Optional + 默认值 (
rating、shortcomings等):原文里可能压根没提,硬性要求反而制造大量验证失败。
model_validate_json 一句话同时完成 JSON 语法校验 + 字段类型校验 + 缺失字段校验 。更重要的是,它抛出的 ValidationError 自带结构化的错误详情(哪个字段、什么原因)------这份错误信息,正是下一环节自动修复的燃料。
六、自动修复:让 LLM 当自己的"格式审查员"
1. 为什么需要修复环节
批处理 210 条时,单条 95% 的成功率意味着期望失败 10 条左右。失败原因五花八门:字段名拼错、价格输出了字符串、signatures 里混进了整句话。靠人工盯着改,210 条能把人改疯。
实验给出的方案是引入第二个 LLM 角色------修复专家,输入是"错误输出 + 验证错误详情",输出是修好的 JSON:
python
def JSON_auto_repair_prompts(candidate_json_output, error_message):
auto_repair_system_msg = f"""
这是原始的错误输出: {candidate_json_output} ;
这是错误信息: {error_message}
"""
auto_repair_prompt = f"""
根据错误信息{error_message}和原始非结构化脚本,修复{candidate_json_output}文件。
"""
return auto_repair_system_msg, auto_repair_prompt
注意 error_message 用的是 Pydantic 的 str(e.errors())------把结构化的错误详情(字段路径 + 违反原因)喂给修复模型,而不是只说"格式错了",修复的命中率完全不在一个量级。
2. 生成-验证-修复闭环
#mermaid-svg-UmHWdEkrBhK572f3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UmHWdEkrBhK572f3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UmHWdEkrBhK572f3 .error-icon{fill:#552222;}#mermaid-svg-UmHWdEkrBhK572f3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UmHWdEkrBhK572f3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UmHWdEkrBhK572f3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UmHWdEkrBhK572f3 .marker.cross{stroke:#333333;}#mermaid-svg-UmHWdEkrBhK572f3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UmHWdEkrBhK572f3 p{margin:0;}#mermaid-svg-UmHWdEkrBhK572f3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UmHWdEkrBhK572f3 .cluster-label text{fill:#333;}#mermaid-svg-UmHWdEkrBhK572f3 .cluster-label span{color:#333;}#mermaid-svg-UmHWdEkrBhK572f3 .cluster-label span p{background-color:transparent;}#mermaid-svg-UmHWdEkrBhK572f3 .label text,#mermaid-svg-UmHWdEkrBhK572f3 span{fill:#333;color:#333;}#mermaid-svg-UmHWdEkrBhK572f3 .node rect,#mermaid-svg-UmHWdEkrBhK572f3 .node circle,#mermaid-svg-UmHWdEkrBhK572f3 .node ellipse,#mermaid-svg-UmHWdEkrBhK572f3 .node polygon,#mermaid-svg-UmHWdEkrBhK572f3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UmHWdEkrBhK572f3 .rough-node .label text,#mermaid-svg-UmHWdEkrBhK572f3 .node .label text,#mermaid-svg-UmHWdEkrBhK572f3 .image-shape .label,#mermaid-svg-UmHWdEkrBhK572f3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UmHWdEkrBhK572f3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UmHWdEkrBhK572f3 .rough-node .label,#mermaid-svg-UmHWdEkrBhK572f3 .node .label,#mermaid-svg-UmHWdEkrBhK572f3 .image-shape .label,#mermaid-svg-UmHWdEkrBhK572f3 .icon-shape .label{text-align:center;}#mermaid-svg-UmHWdEkrBhK572f3 .node.clickable{cursor:pointer;}#mermaid-svg-UmHWdEkrBhK572f3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UmHWdEkrBhK572f3 .arrowheadPath{fill:#333333;}#mermaid-svg-UmHWdEkrBhK572f3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UmHWdEkrBhK572f3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UmHWdEkrBhK572f3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UmHWdEkrBhK572f3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UmHWdEkrBhK572f3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UmHWdEkrBhK572f3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UmHWdEkrBhK572f3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UmHWdEkrBhK572f3 .cluster text{fill:#333;}#mermaid-svg-UmHWdEkrBhK572f3 .cluster span{color:#333;}#mermaid-svg-UmHWdEkrBhK572f3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UmHWdEkrBhK572f3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UmHWdEkrBhK572f3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UmHWdEkrBhK572f3 .icon-shape,#mermaid-svg-UmHWdEkrBhK572f3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UmHWdEkrBhK572f3 .icon-shape p,#mermaid-svg-UmHWdEkrBhK572f3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UmHWdEkrBhK572f3 .icon-shape .label rect,#mermaid-svg-UmHWdEkrBhK572f3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UmHWdEkrBhK572f3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UmHWdEkrBhK572f3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UmHWdEkrBhK572f3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ✓ 通过
✗ ValidationError
超出 max_attempts
LLM 抽取
生成候选 JSON
清洗 markdown 标记
Pydantic 验证
restaurant_data
加入结果集
提取 e.errors()
构造修复提示
修复 LLM
⚠️ 打印告警
跳过该条
主循环把三个环节串起来:
python
structured_restaurant_lists = []
for i, restaurant_paragraph in enumerate(restaurant_list):
# 1. 生成候选 JSON
base_system_msg, base_user_prompt = restaurant_data_structure_prompt_generation(
restaurant_paragraph=restaurant_paragraph)
raw_response = llm_model(system_msg=base_system_msg, prompt_txt=base_user_prompt)
candidate_json_output = raw_response.replace("```json", "").replace("```", "").strip()
# 2. 验证 + 自动修复(限次)
max_attempts = 1 # 防止无限循环
current_attempt = 0
restaurant_data = None
while current_attempt < max_attempts:
try:
restaurant_data = Restaurant.model_validate_json(candidate_json_output)
break
except ValidationError as e:
current_attempt += 1
auto_repair_system_msg, auto_repair_prompt = JSON_auto_repair_prompts(
candidate_json_output,
error_message=f"{str(e.errors())}"
)
candidate_json_output = llm_model(
system_msg=auto_repair_system_msg,
prompt_txt=auto_repair_prompt
)
# 3. 成功才入库,失败告警跳过
if restaurant_data is not None:
structured_restaurant_lists.append(restaurant_data)
else:
print(f"⚠️ 第{i+1}/{len(restaurant_list)}条餐厅数据修复失败,已跳过")
if (i+1)%20 == 0:
print(f'{i+1} out of {len(restaurant_list)} is done')
print('ALL DONE!!')
跑完的实际结果:210 条输入,209 条入库,第 1 条修复失败被跳过 。

3. 这个设计里藏着三个工程决策
① 为什么要限次(max_attempts=1)? 修复 LLM 也是 LLM,不是确定性程序------修复失败后再喂回去重试,可能陷入"生成错 → 修复 → 还是错"的死循环,而每次循环都是真金白银的 token。限次 + 跳过,用完成率换确定性。
② 为什么跳过而不是中断? 批处理的第一原则是单点失败不拖垮全局。209 条质量合格的数据,价值远大于 210 条里夹着一条手工兜底的脏数据。跳过的记录留下了告警日志,可以事后人工补。
③ "让 LLM 修 LLM"是不是最佳实践? 说实话不是。生产级方案有更严格的约束手段:JSON mode / function calling 强制输出格式、解码层约束(如 outlines、guidance)、独立的校验重试框架。但在本场景中,抽取和修复对模型来说都是简单任务,成功率极高,引入重型框架的收益抵不上复杂度成本。工程选型的标准从来不是"理论最优",而是"够用且最简"。
七、入库:两个容易被忽略的收尾细节
python
structured_restaurant_lists_json = [r.__dict__ for r in structured_restaurant_lists]
for i, response in enumerate(structured_restaurant_lists_json):
response['itemId'] = 1000001 + i
filename = 'structured_restaurant_data.json'
with open(filename, 'w', encoding='utf-8') as f:
json.dump(structured_restaurant_lists_json, f, indent=4)
__dict__转换 :Pydantic 对象不能直接json.dump,先转成字典;itemId对齐 :分配1000001 + i的连续 ID,与后续用户评论数据集里的itemId对齐------这一步是在为多表关联预埋主键。结构化的最终目的不是存文件,而是让餐厅知识库和用户行为数据能够 JOIN 起来做推荐。schema 设计要往前看一步。
八、结语:这套管道的通用性
回头看,这个实验给出的不是"餐厅数据处理方案",而是一个可迁移的 LLM 结构化抽取模式:
#mermaid-svg-RwPQrVr277vgBBvB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RwPQrVr277vgBBvB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RwPQrVr277vgBBvB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RwPQrVr277vgBBvB .error-icon{fill:#552222;}#mermaid-svg-RwPQrVr277vgBBvB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RwPQrVr277vgBBvB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RwPQrVr277vgBBvB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RwPQrVr277vgBBvB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RwPQrVr277vgBBvB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RwPQrVr277vgBBvB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RwPQrVr277vgBBvB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RwPQrVr277vgBBvB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RwPQrVr277vgBBvB .marker.cross{stroke:#333333;}#mermaid-svg-RwPQrVr277vgBBvB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RwPQrVr277vgBBvB p{margin:0;}#mermaid-svg-RwPQrVr277vgBBvB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RwPQrVr277vgBBvB .cluster-label text{fill:#333;}#mermaid-svg-RwPQrVr277vgBBvB .cluster-label span{color:#333;}#mermaid-svg-RwPQrVr277vgBBvB .cluster-label span p{background-color:transparent;}#mermaid-svg-RwPQrVr277vgBBvB .label text,#mermaid-svg-RwPQrVr277vgBBvB span{fill:#333;color:#333;}#mermaid-svg-RwPQrVr277vgBBvB .node rect,#mermaid-svg-RwPQrVr277vgBBvB .node circle,#mermaid-svg-RwPQrVr277vgBBvB .node ellipse,#mermaid-svg-RwPQrVr277vgBBvB .node polygon,#mermaid-svg-RwPQrVr277vgBBvB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RwPQrVr277vgBBvB .rough-node .label text,#mermaid-svg-RwPQrVr277vgBBvB .node .label text,#mermaid-svg-RwPQrVr277vgBBvB .image-shape .label,#mermaid-svg-RwPQrVr277vgBBvB .icon-shape .label{text-anchor:middle;}#mermaid-svg-RwPQrVr277vgBBvB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RwPQrVr277vgBBvB .rough-node .label,#mermaid-svg-RwPQrVr277vgBBvB .node .label,#mermaid-svg-RwPQrVr277vgBBvB .image-shape .label,#mermaid-svg-RwPQrVr277vgBBvB .icon-shape .label{text-align:center;}#mermaid-svg-RwPQrVr277vgBBvB .node.clickable{cursor:pointer;}#mermaid-svg-RwPQrVr277vgBBvB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RwPQrVr277vgBBvB .arrowheadPath{fill:#333333;}#mermaid-svg-RwPQrVr277vgBBvB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RwPQrVr277vgBBvB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RwPQrVr277vgBBvB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RwPQrVr277vgBBvB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RwPQrVr277vgBBvB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RwPQrVr277vgBBvB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RwPQrVr277vgBBvB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RwPQrVr277vgBBvB .cluster text{fill:#333;}#mermaid-svg-RwPQrVr277vgBBvB .cluster span{color:#333;}#mermaid-svg-RwPQrVr277vgBBvB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RwPQrVr277vgBBvB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RwPQrVr277vgBBvB rect.text{fill:none;stroke-width:0;}#mermaid-svg-RwPQrVr277vgBBvB .icon-shape,#mermaid-svg-RwPQrVr277vgBBvB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RwPQrVr277vgBBvB .icon-shape p,#mermaid-svg-RwPQrVr277vgBBvB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RwPQrVr277vgBBvB .icon-shape .label rect,#mermaid-svg-RwPQrVr277vgBBvB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RwPQrVr277vgBBvB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RwPQrVr277vgBBvB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RwPQrVr277vgBBvB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 任意非结构化文本
合同 / 简历 / 工单 / 日志
定义目标 Schema
Pydantic 模型
One-shot 模板
一对精选示例
批量生成 + 清洗
模式验证
错误回喂修复
限次 + 跳过
带主键的知识库
可索引 / 可关联
变的是数据和 schema,不变的是四条原则:
- 示例优于指令------想稳定拿到结构,就给它看一个结构;
- 验证先于信任------LLM 的输出在通过 schema 校验前,一律视为候选;
- 错误是资源------结构化的验证错误详情,是修复环节最精准的提示词;
- 提示词非常关键------好的提示词会大大提高执行效率,同时减少Token消耗;
- 批处理要有退路------限次、跳过、告警,用局部损失换全局确定。
这套管道的下一步,是把图片和 URL 也纳入知识库------用多模态模型给菜谱图片生成描述,让"视觉信息"变成可检索的文本。