Python + Ollama 实现本地 LLM 结构化输出:Pydantic Schema、校验和失败处理
很多人试用本地大模型时,第一步是把 Ollama 装起来,然后在命令行里问几句话。这个阶段主要验证模型能不能跑、速度能不能接受、显存或内存够不够。
但一旦把本地 LLM 接进真实程序,问题就变了:程序不想读一段自然语言,它更希望拿到稳定的字段。
比如你要从一段家庭设备记录里提取调度信息,后面的调度器需要的是:
1.当前时间
2.哪些设备还没完成
3.每个设备的最早开始时间、耗电量、运行时长
4.电价时段
5.同时运行设备数量限制
如果模型每次返回一段不同风格的解释,后面的代码就只能靠正则、字符串切割或二次提示词补救。结构化输出的价值就在这里:先定义字段,再让模型按这个结构返回,最后由程序做解析和校验。
本文用 Python + Ollama + Pydantic 做一个最小可复现示例,并重点讨论一个容易被忽略的问题:结构正确,不代表内容一定正确。
环境和版本说明
本文涉及的动态信息已在 2026-08-11 核实:
Ollama 官方文档支持通过 format 字段传入 JSON Schema,用于生成结构化输出。
Ollama Python 客户端示例支持用 model_json_schema() 生成 Schema,并用 model_validate_json() 解析模型返回内容。
Ollama 模型库中存在 gemma4:e4b 这个模型标签。
安装 Ollama 后,可以拉取模型:
ollama pull gemma4:e4b
Python 依赖:
pip install ollama pydantic
如果你的机器内存或显存不足,可以换更小的模型标签,或者先用同类小模型验证流程。结构化输出的代码路径不依赖某一个具体模型,但不同模型的字段准确率会有差异。
用 Pydantic 定义业务需要的结构
先不要急着写提示词。结构化输出的第一步,是把后续程序真正需要的字段列清楚。
下面这个例子模拟一个家庭能源调度场景:用户问“洗碗机应该现在运行还是稍后运行”,本地 LLM 负责从家庭记录中提取干净的调度数据,不负责最终决策。
from typing import Annotated
from pydantic import BaseModel, Field
ClockTime = Annotated[
str,
Field(
min_length=5,
max_length=5,
description="Clock time in HH:MM format.",
),
]
class DeviceToSchedule(BaseModel):
device_name: str
duration_minutes: int
energy_kwh: float
earliest_start: ClockTime
finish_by: ClockTime | None
class SchedulingContext(BaseModel):
current_time: ClockTime
focus_device: str
max_concurrent_devices: int
current_price_per_kwh: float
off_peak_start: ClockTime
off_peak_end: ClockTime
off_peak_price_per_kwh: float
devices_to_schedule: list[DeviceToSchedule] = Field(
description="Devices that have not completed their work and still need to be scheduled."
)
这里有两个设计点:
第一,ClockTime 只限制长度为 5,并说明格式是 HH:MM。这还不是完整时间校验,如果要严格拒绝 29:99,可以继续加正则或自定义 validator。
第二,devices_to_schedule 是一个嵌套列表。对业务程序来说,这种结构很舒服;但对小模型来说,嵌套结构也意味着一次生成里要同时完成筛选、抽取和组装。
把 Pydantic Schema 传给 Ollama
Ollama 的关键参数是 format。它可以接收 "json",也可以接收 JSON Schema。为了让 Schema 和 Python 解析逻辑保持一致,建议直接从 Pydantic 模型生成。
import ollama
def call_local_llm(schema, instructions: str, prompt: str):
response = ollama.chat(
model="gemma4:e4b",
messages=[
{"role": "system", "content": instructions},
{"role": "user", "content": prompt},
],
format=schema.model_json_schema(),
options={"temperature": 0},
)
return schema.model_validate_json(response.message.content)
这段代码做了三件事:
schema.model_json_schema() 把 Pydantic 模型转换成 JSON Schema。
format=... 告诉 Ollama 按这个结构生成。
schema.model_validate_json(...) 把模型返回的 JSON 再交给 Pydantic 校验。
temperature 设为 0 是为了降低随机性。它不能保证内容一定正确,但通常会让结构化抽取任务更稳定。
如果使用支持 thinking 的模型,也可以根据模型文档开启 think。但结构化抽取任务里,不建议把思维内容混进后续解析流程;程序应该只解析最终 JSON 字段。
准备一段输入材料
为了让问题更接近实际应用,输入材料里故意包含已经完成的设备、还需要调度的设备,以及不应该泄露给后续云端模型的个人细节。
USER_QUESTION = "Should the dishwasher run now or later?"
SMART_HOME_CONTEXT = """
It is currently 18:30.
The activity log records that the robot vacuum completed today's kitchen pass
at 16:10 and returned to its dock. No more vacuuming is needed today.
The dishwasher's earliest start is 18:30. A cycle takes 90 minutes and uses about 1.2 kWh.
It must be complete before breakfast at 06:30. Because the dishwasher is beside
the bedrooms, it must stop running by 22:30.
The EV charger's earliest start is 18:30. Charging will take 120 minutes and use about
14 kWh. The car must be charged before its driver leaves at 07:00.
The dryer's earliest start is 19:00. Its cycle takes 75 minutes and uses about
3.2 kWh. It contains the football kit, which must be dry by 23:00. The dryer is
too loud later in the evening, so it must stop running by 21:30.
The washing machine's earliest start is 20:00. Its cycle takes 60 minutes and
uses about 0.9 kWh. It contains tomorrow's work clothes and must finish by 05:30.
A kitchen pass with the robot vacuum takes 45 minutes and uses about 0.2 kWh.
The vacuum's earliest start was 15:00.
The home energy controller permits only one flexible load to run at a time.
Electricity costs 0.45 per kWh from 17:00 to 20:00, 0.22 from 20:00 to 00:00,
0.12 from 00:00 to 06:00, and 0.25 from 06:00 to 17:00.
""".strip()
本地 LLM 在这里扮演的是“数据清洗层”,不是最终调度器。它只应该保留调度需要的事实,把“早餐”“卧室旁边”“球衣”“上班衣服”等个人化信息排除在最终结构之外。
一步提取:结构有效,但可能混入错误内容
最直接的做法,是一次性让模型填完整个 SchedulingContext。
STRUCTURING_INSTRUCTIONS = """
Convert the supplied source material into the structured scheduling context.
Do not decide or propose a schedule.
""".strip()
def build_prompt(source_material: str) -> str:
return f"""
User question:
{USER_QUESTION}
Source material:
{source_material}
""".strip()
context = call_local_llm(
SchedulingContext,
STRUCTURING_INSTRUCTIONS,
build_prompt(SMART_HOME_CONTEXT),
)
如果只看程序层面,这个调用很可能是成功的:Ollama 返回了 JSON,Pydantic 也解析成了 SchedulingContext。
但业务层面还要继续检查:devices_to_schedule 里面到底有没有只包含未完成设备?
小模型在这类任务里容易犯一个典型错误:把扫地机器人也放进待调度列表。原因不是 Schema 失效了,而是 Schema 只管字段形状,不负责判断事实是否正确。
这就是结构化输出最容易被误解的地方:
> JSON 合法,只说明程序能解析;字段内容正确,才说明业务能使用。
更稳的做法:先确定范围,再抽取详情
当 Schema 有嵌套字段,而且输入材料里存在“已完成”和“未完成”的混合信息时,可以把任务拆开。
第一步只问一个小问题:哪些设备还需要调度?
class SchedulingScope(BaseModel):
focus_device: str
device_names_to_schedule: list[str]
SCOPE_INSTRUCTIONS = """
Identify the focus device and the household devices that still need scheduling.
Do not decide or propose a schedule.
""".strip()
scope = call_local_llm(
SchedulingScope,
SCOPE_INSTRUCTIONS,
build_prompt(SMART_HOME_CONTEXT),
)
这一步的 Schema 很小,模型只需要做筛选,不需要同时填运行时长、电价和嵌套对象。理想结果应该类似:
{
"focus_device": "Dishwasher",
"device_names_to_schedule": [
"Dishwasher",
"EV charger",
"Washing machine"
]
}
第二步再把设备名单传回去,让模型只为这些设备填详情。
import json
DETAILS_INSTRUCTIONS = """
Convert the supplied source material into the structured scheduling context
for the supplied devices. Do not decide or propose a schedule.
""".strip()
details_prompt = f"""
Selected devices:
{json.dumps(scope.device_names_to_schedule)}
User question:
{USER_QUESTION}
Source material:
{SMART_HOME_CONTEXT}
""".strip()
context = call_local_llm(
SchedulingContext,
DETAILS_INSTRUCTIONS,
details_prompt,
)
这样做的好处是,每一步的模型任务更单一:
第一步负责“筛选对象”。
第二步负责“为已筛选对象抽取字段”。
Pydantic 负责“结构和类型校验”。
业务代码负责“结果是否可用”的最终判断。
对本地小模型来说,拆成两步通常比一次填完整个复杂 Schema 更稳。
失败时不要只重试,要区分三类问题
结构化输出失败后,很多代码会直接重试一次。但重试只能解决偶发问题,不能解决设计问题。更实用的做法是先分类。
1. JSON 解析失败
表现:model_validate_json() 抛错,或者返回内容不是合法 JSON。
处理建议:
降低 temperature。
在 prompt 里再次说明“只返回 JSON,不要解释”。
把 Schema 作为文本也放进 prompt,增强模型对字段的感知。
缩短输入材料,减少无关上下文。
2. Schema 合法,但字段内容错误
表现:Pydantic 能解析,但业务检查发现设备漏了、多了,或者字段值不符合原文。
处理建议:
拆分任务,先做范围判断,再做详情抽取。
给关键字段增加更明确的描述。
对枚举类字段使用 Literal 或白名单。
增加业务校验,例如“已完成设备不得进入待调度列表”。
3. 字段看似正确,但业务规则不完整
表现:模型提取了 finish_by=06:30,但忽略了“洗碗机必须在 22:30 前停止运行”这类额外约束。
处理建议:
不要把复杂业务规则藏在自然语言里。
Schema 里明确增加 latest_end、must_finish_by、noise_cutoff 等字段。
如果规则会参与调度计算,应该由程序或专门调度器执行,不要完全交给 LLM。
本地测试和长期运行不是一回事
在笔记本上跑通这段代码,只能说明流程可行。要把它变成一个长期运行的服务,还要补上几件事:
输入和输出日志,便于追查模型到底返回了什么。
失败样本保存,方便后续调整 Schema 和提示词。
超时控制,避免单次请求卡死。
重试策略,但要限制次数。
模型版本记录,避免换模型后结果分布变化却不知道。
隐私边界,确认哪些字段可以传给后续云端模型。
如果只是个人实验,本地机器足够;如果要让它长期运行、定时处理任务或对外提供 API,可以迁移到远程 Linux 环境。服务器可以选择 Hostease 或其他合适资源;涉及 CPU、内存、GPU、带宽和价格时,应以当前官方产品页和下单页为准。
小结:结构化输出是接口,不是事实校验器
用本地 LLM 做结构化输出,核心路径并不复杂:
用 Pydantic 定义后续程序真正需要的结构。
把 model_json_schema() 传给 Ollama 的 format 参数。
用 model_validate_json() 解析并校验返回内容。
对业务正确性再做一层检查。
真正需要注意的是边界:结构化输出解决的是“程序能不能稳定解析”,不是“模型有没有理解对”。当输入材料复杂、Schema 嵌套较深、模型规模较小时,拆分任务通常比一次性生成更可靠。
发布或上线前,建议至少做三类样本测试:正常样本、已完成设备样本、缺字段或冲突样本。只有这些失败路径都能被识别和处理,结构化输出才算真正接进了业务流程。
更多推荐



所有评论(0)