客服工单经常混着产品型号、故障表现、购买日期和补充说明。直接让模型“整理成表格”,可能得到列名不断变化、日期被猜出来的结果。要把输出交给后续程序,首先需要固定字段,再检查内容是否真的来自原文。

Ollama 千问结构化输出教程:本地提取售后工单并校验 JSON方法示意图
把 AI 用到实际任务 · 原创方法示意图

本文使用 Ollama 本地服务与千问 Qwen3 模型,把一条虚构售后留言转成可校验的 JSON。最终产物是提取脚本、固定字段、人工核对清单和问题记录。本文已核对官方接口并运行自编校验器的离线测试,未在本文制作环境下载模型或完成真实推理,不将预期输出当作实测结果。

一、先分清三个环节

模型负责从文字中提出候选结果,JSON Schema 负责约束字段结构,程序与人工负责核实事实。这三层各有作用。结构正确不代表购买日期正确;只输出合法 JSON,也不代表没有漏掉重要信息。

Ollama 官方支持在本地生成接口的 format 字段传入 JSON Schema。本文使用 /api/generate,回复文字在外层响应的 response 字段中。不要把另一个 /api/chat 接口的 message.content 路径混过来,两者的响应结构不同。

本文查阅的结构化输出文档注明云端服务当前不支持这项能力,因此练习明确走本地地址,不把本地参数直接套到云端。将来产品行为变化时,以相应接口文档为准,不凭服务名称相同就假定功能一致。

二、准备一个最小环境

从 Ollama 官方站点选择与你的操作系统匹配的安装方式。安装后在终端执行 ollama --version,确认命令可用;Python 需要能够执行 python --version。本文提取脚本只使用 Python 标准库,不需要安装第三方 Python 包。

模型采用官方模型库中存在的 qwen3:4b 标签。首次拉取会下载模型文件,应先检查可用磁盘、网络及设备资源。标签中的参数规模不是运行内存承诺,不在这里给所有电脑统一保证速度与内存占用。

ollama pull qwen3:4b
ollama list

确认列表里出现对应模型,再继续接口练习。服务通常由桌面应用启动;若没有运行,按官方说明启动服务。需要手动使用 ollama serve 时,先确认没有另一个实例占用端口,不要把“端口已占用”一律当作安装失败。

保持接口仅供本机访问。 本文使用 http://localhost:11434,不要求开放公网端口,也不要求修改防火墙或绑定全部网卡。本地运行同样需要管理输入资料、日志和文件权限,不能因此把真实客户资料随意复制到测试目录。

三、固定工单字段与未知值

在空目录建立 ticket_extract.py。练习只处理一条工单,字段限定为产品、故障、购买日期、证据句和待确认项,不连接订单库、不自动判断保修资格,也不替客户作退款决定。

样本留言如下:

我用的是 Luma K2 键盘,连接电脑后偶尔断开。
重新插拔可以恢复。购买日期记不清了,需要找订单。

其中产品可以提取为 Luma K2 键盘,故障可以概括为连接后偶尔断开,购买日期必须未知。不能把今天日期填进购买日期,也不能因为“键盘”就补造接口类型、订单号和保修时间。

本例约定未知日期填 null,日期已知时使用完整年月日字符串。选择空字符串或“未知”也并非永远错误,但必须统一规格。只要同一列混用多个未知值,后续筛选和统计就容易失真。

四、写出完整的请求脚本

下面代码先声明结构,再给出提取要求。未知字段禁止出现,所有规定字段必须存在。stream: false 让接口返回一个完整响应,避免把多段流式数据当作单个 JSON 直接解析。

import json
import urllib.request
from datetime import date

SOURCE = "我用的是 Luma K2 键盘,连接电脑后偶尔断开。重新插拔可以恢复。购买日期记不清了,需要找订单。"
SCHEMA = {
    "type": "object",
    "properties": {
        "product": {"type": "string"},
        "issue": {"type": "string"},
        "purchase_date": {"type": ["string", "null"]},
        "evidence": {"type": "string"},
        "needs_confirmation": {
            "type": "array", "items": {"type": "string"}
        }
    },
    "required": ["product", "issue", "purchase_date", "evidence", "needs_confirmation"],
    "additionalProperties": False
}

def validate(record, source):
    fields = set(SCHEMA["required"])
    if type(record) is not dict or set(record) != fields:
        raise ValueError("字段缺失或出现额外字段")
    for key in ["product", "issue", "evidence"]:
        if type(record[key]) is not str or not record[key].strip():
            raise ValueError("文字字段必须是非空字符串")
    if record["evidence"] not in source:
        raise ValueError("证据必须逐字出现在原文中")
    value = record["purchase_date"]
    if value is not None:
        if type(value) is not str:
            raise ValueError("日期类型不正确")
        parsed = date.fromisoformat(value)
        if parsed.isoformat() != value:
            raise ValueError("日期必须使用完整年月日")
    items = record["needs_confirmation"]
    if type(items) is not list or any(
        type(item) is not str or not item.strip() for item in items
    ):
        raise ValueError("待确认项必须是非空文字组成的列表")

def extract(source):
    payload = {
        "model": "qwen3:4b",
        "prompt": (
            "从以下工单提取指定字段。只依据原文,不补造日期或订单信息。"
            "购买日期未知必须为null。evidence逐字引用支持故障判断的原句。"
            "needs_confirmation列出仍需询问的资料。只返回符合Schema的JSON。"
            "\nSchema:" + json.dumps(SCHEMA, ensure_ascii=False) +
            "\n工单:" + source
        ),
        "format": SCHEMA,
        "stream": False,
        "options": {"temperature": 0}
    }
    request = urllib.request.Request(
        "http://localhost:11434/api/generate",
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Content-Type": "application/json"}
    )
    with urllib.request.urlopen(request, timeout=180) as response:
        outer = json.load(response)
    record = json.loads(outer["response"])
    validate(record, source)
    return record

if __name__ == "__main__":
    result = extract(SOURCE)
    print(json.dumps(result, ensure_ascii=False, indent=2))

运行 python ticket_extract.py。首次加载模型可能比后续请求慢;出现超时应先检查服务与设备状态,不盲目连续重发很多请求。示例超时是练习参数,不是对所有硬件的性能保证。

五、看懂输出,不急着写入正式系统

可接受的预期结果应包含五个字段:产品与故障来自原文,购买日期为 null,证据是原文中的连续片段,待确认项包含购买日期或订单信息。故障摘要的具体措辞可以不同,不要求每次逐字相同。

校验器检查字段、类型、日期格式与证据是否存在,但仍有明显边界:存在于原文的某句话不一定能支持当前结论,合法日期也可能是模型猜的。产品名称与日期的事实对应关系,仍要逐条回查,不能因为程序通过就省掉人工。

先保存输入、原始回复、解析结果和审核状态到测试记录。正式接入工单系统前增加唯一工单编号、错误隔离与写入确认。不要把失败记录丢弃后再说“全部识别成功”,也不要用字符串切片强行截出一段看似正确的 JSON。

六、用反例测试这条流程

先在本地复制一个候选字典,删除产品字段,应当拒绝;增加订单号字段,也应拒绝。把证据改成原文里没有的话,应当拒绝。这些断言验证的是自编校验器,不是对模型理解能力的完整评测。

再准备三种文本:没有购买日期、明确写了完整日期、只写“上个月买的”。第一种与第三种都不能直接猜成某一天;第二种需要核对提取日期是否与原文一致。若业务需要处理相对日期,应另加可信的留言时间与日期解析规则,不偷偷扩展本例。

样本还可以加入无关广告、重复描述和互相矛盾的日期。让模型列待确认项,而不是替客户选一个“最可能”的答案。记录哪些错误会在程序层拒绝、哪些需要人工判断,这比只收藏一份成功输出更有用。

七、常见报错按层排查

连接被拒绝时,先确认 Ollama 服务是否启动、地址与端口是否正确。模型不存在时,核对 ollama list 中的实际标签,不把下载中断误判为提示词问题。接口返回错误时,保留状态与错误文字,不把错误响应当成正常结果解析。

出现 JSONDecodeError 时,先区分外层 HTTP 响应与内层模型文字。还要检查是否遗漏关闭流式输出、误用了另一个接口的字段路径。不要给解析器添加“失败就返回空对象”,这会把故障变成看不见的数据丢失。

结构合规但事实错误时,缩小输入、重写字段说明并增加反例验收。温度设低只能减少某些随机变化,不保证事实正确。对于未知信息,坚持保留未知,通常比生成一个整齐但错误的日期更适合后续工作。

八、怎样形成可用的办公成果

第一次落地建议先处理少量虚构或已获准的工单,由人工逐条核对,再统计缺字段、日期错误、证据不支持等问题。只有错误类型和处理方式清楚,才考虑批量化、并发和自动写入,不先追求处理数量。

交付时保留字段规格、样本文本、脚本版本、运行环境、失败样本与审核清单。安装完成、接口能连、结构通过和业务可用,是不同层次的验收。本文完成的是可复现的入门路径和离线校验示例,真实模型结果需要在你的设备上运行并记录。

九、把异常隔离到单条记录

后续处理多条留言时,不要在一条失败后丢掉整批结果,也不要把失败条目自动记为成功。为每条输入保存稳定编号,分别记录提取状态、校验错误与人工审核状态;再次执行时先确认哪些记录已经确认,避免重复建立工单。

原文有完整日期而模型返回未知,属于遗漏;原文没有日期而模型给出合法日期,属于补造。两者都可能通过部分结构检查,但修正方向不同。把错误分类记录下来,才能判断需要改字段说明、增加上下文,还是调整人工审核步骤。

修改字段规格后,用同一组固定样本重新执行并比较,不只检查新加的例子。若上次已经正确的未知值处理反而退步,应保留旧版本和差异记录。这样的版本管理才便于日后接入真实办公流程,而不是每次换一套提示词就丢掉之前的依据。

官方资料