前面的系列已经介绍了模型调用、结构化输出、错误处理、模型适配和可观测性。本篇把这些知识组合成一个小项目:用户输入一段问题描述,模型给出分类、优先级和下一步建议,程序再负责校验、记录和输出。它不是一个自动执行工单的系统,而是一个“生成候选分诊结果、由程序守住边界”的最小实现。

先定义分诊结果

先把问题分成四个队列:billing(账单)、technical(技术)、account(账户)和 other(其他)。每条结果包含 category、priority、summary、next_action 四个字段。优先级只能是 low、medium 或 high,摘要和行动建议必须是非空字符串。

为什么不直接保存模型返回的文本?因为自然语言的格式和含义都可能漂移。比如“无法登录且今天要提交报表”既可能被分到技术,也可能被分到账户;程序至少要保证它最终落在允许的枚举值中。分类本身仍然需要人工抽查,校验只能保证形状和边界,不能证明判断一定正确。

本项目的处理链是:读取输入、调用模型、解析 JSON、校验字段、写入 JSONL 日志。每一步都可以单独替换,后续要接入数据库或工单系统时,也不必让业务代码依赖某一家模型服务。

准备环境与密钥

在项目目录创建虚拟环境,并安装官方 Python SDK:

1
2
3
4
5
python -m venv .venv
source .venv/bin/activate
python -m pip install openai
export OPENAI_API_KEY="替换为你的真实密钥"
export MODEL_NAME="替换为你可用的模型名称"

密钥只从环境变量读取,不能写进源码、配置文件或日志。模型名称也通过环境变量传入,避免把某个具体模型写死。不同模型服务对结构化输出的支持和参数命名可能不同,实际使用前应查看所选服务的官方 Python SDK 文档;下面只依赖客户端返回文本的通用部分。

编写最小分诊器

新建 issue_triage.py。请求层使用 Responses API 的 Python SDK 写法,response.output_text 作为模型生成的文本。如果你使用的服务不是该 SDK,只需要替换 call_model,校验和日志部分可以保持不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

from openai import OpenAI

CATEGORIES = {"billing", "technical", "account", "other"}
PRIORITIES = {"low", "medium", "high"}
SYSTEM = """你是问题分诊助手。只返回 JSON 对象,不要 Markdown 代码围栏。
格式必须是:
{"category":"billing|technical|account|other",
"priority":"low|medium|high",
"summary":"一句中文摘要",
"next_action":"一句可执行的下一步建议"}
不确定时使用 other 和 medium,不要添加额外字段。"""


def call_model(client: OpenAI, text: str) -> str:
response = client.responses.create(
model=os.environ["MODEL_NAME"],
instructions=SYSTEM,
input=text,
)
return response.output_text


def validate(value: object) -> dict[str, str]:
fields = {"category", "priority", "summary", "next_action"}
if not isinstance(value, dict) or set(value) != fields:
raise ValueError("返回结果的字段不符合约定")
if value["category"] not in CATEGORIES:
raise ValueError("category 不是允许的枚举值")
if value["priority"] not in PRIORITIES:
raise ValueError("priority 不是允许的枚举值")
for name in ("summary", "next_action"):
if not isinstance(value[name], str) or not value[name].strip():
raise ValueError(f"{name} 必须是非空字符串")
return value


def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("用法:python issue_triage.py '问题描述'")
client = OpenAI()
raw = call_model(client, sys.argv[1])
result = validate(json.loads(raw))
record = {
"created_at": datetime.now(timezone.utc).isoformat(),
"input": sys.argv[1],
"result": result,
}
with Path("triage.jsonl").open("a", encoding="utf-8") as file:
file.write(json.dumps(record, ensure_ascii=False) + "\n")
print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
main()

validate 是这个例子的核心。json.loads 只负责把字符串变成 Python 对象,并不代表内容可信;随后检查顶层类型、字段集合、枚举值和字符串非空,才能把模型输出转换成程序认可的数据。使用 set(value) != fields 还可以拒绝模型悄悄增加的字段,避免下游误把未经设计的数据当成正式字段。

执行示例:

1
python issue_triage.py "昨晚扣款成功,但订阅页面仍显示未付款"

这里不预先写死输出,因为模型结果会受模型、提示词和服务配置影响。实际运行后,终端会打印通过校验的 JSON,triage.jsonl 会追加一条包含输入、结果和 UTC 时间的记录。JSONL 比覆盖一个大 JSON 文件更适合逐条追加,也方便之后按行回放和排查。

让失败变得可诊断

当前代码在 JSON 格式错误、网络异常或校验失败时会直接退出。对于最小示例这是有意的:先让失败暴露出来,再决定恢复策略。生产代码可以在边界处分别捕获异常,并记录错误类型,而不是无条件重试所有错误:

1
2
3
4
5
6
7
8
9
try:
raw = call_model(client, text)
result = validate(json.loads(raw))
except json.JSONDecodeError as exc:
print(f"模型没有返回合法 JSON:{exc}", file=sys.stderr)
raise SystemExit(2)
except ValueError as exc:
print(f"分诊结果未通过校验:{exc}", file=sys.stderr)
raise SystemExit(3)

网络超时、限流和临时服务错误可以采用有限次数的指数退避;JSON 格式错误则更适合携带校验错误再次请求,或转人工处理。不要把所有失败都重试,否则一个稳定的业务错误会变成更多请求和更高成本。日志中也应避免保存不必要的个人信息,必要时先脱敏。

常见问题

为什么不让模型直接创建工单? 因为模型输出属于不完全可信的候选结果。创建工单、发送通知等有副作用的动作应放在独立函数中,并增加权限、人工确认或幂等检查;本例只写本地日志,故意把风险限制在可回滚范围内。

分类错了,字段校验有什么用? 校验不能解决语义错误,但可以阻止非法队列、缺失字段和空建议进入下游。要改进分类质量,需要准备带人工标签的测试集,定期检查准确率和高优先级样本,而不是只看一次成功运行。

为什么不用正则表达式解析 JSON? JSON 是有嵌套和转义规则的数据格式,正则容易遗漏边界情况。先使用 json.loads,再做业务校验,职责更清楚。若服务支持严格的结构化输出,也应结合本地校验,而不是完全依赖服务端约束。

小结

问题分诊器展示了一条可复用的 AI 应用边界:模型负责从自然语言生成候选判断,Python 负责约束数据、记录过程和控制副作用。最小项目已经具备结构化输出、适配层、校验、日志和错误分类几个关键部件。下一步可以加入人工确认界面、离线测试集和有限重试,但每次扩展都应先保留输入、原始响应摘要与最终结果之间的可追踪关系。