很多人试用本地大模型时,第一步是把 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 返回了 JSONPydantic 也解析成了 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_endmust_finish_bynoise_cutoff 等字段。

如果规则会参与调度计算,应该由程序或专门调度器执行,不要完全交给 LLM

本地测试和长期运行不是一回事

在笔记本上跑通这段代码,只能说明流程可行。要把它变成一个长期运行的服务,还要补上几件事:

输入和输出日志,便于追查模型到底返回了什么。

失败样本保存,方便后续调整 Schema 和提示词。

超时控制,避免单次请求卡死。

重试策略,但要限制次数。

模型版本记录,避免换模型后结果分布变化却不知道。

隐私边界,确认哪些字段可以传给后续云端模型。

如果只是个人实验,本地机器足够;如果要让它长期运行、定时处理任务或对外提供 API,可以迁移到远程 Linux 环境。服务器可以选择 Hostease 或其他合适资源;涉及 CPU、内存、GPU、带宽和价格时,应以当前官方产品页和下单页为准。

小结:结构化输出是接口,不是事实校验器

用本地 LLM 做结构化输出,核心路径并不复杂:

 Pydantic 定义后续程序真正需要的结构。

 model_json_schema() 传给 Ollama format 参数。

 model_validate_json() 解析并校验返回内容。

对业务正确性再做一层检查。

真正需要注意的是边界:结构化输出解决的是程序能不能稳定解析,不是模型有没有理解对。当输入材料复杂、Schema 嵌套较深、模型规模较小时,拆分任务通常比一次性生成更可靠。

发布或上线前,建议至少做三类样本测试:正常样本、已完成设备样本、缺字段或冲突样本。只有这些失败路径都能被识别和处理,结构化输出才算真正接进了业务流程。

更多推荐