前面的系列已经分别介绍了提示词、消息历史、结构化输出、错误处理、评估和安全边界。现在把这些知识组合成一个小而完整的项目:输入一封邮件,判断它属于 sales、support 或 other,同时给出简短理由;如果模型返回的类别不合法,程序不猜测,而是把任务交给人工复核。重点不是让模型“看起来聪明”,而是让每个结果都能解释、校验和处理。

先定义边界和结果

邮件分拣是一个适合入门的任务,因为类别有限,且业务动作清楚:sales 可以进入销售队列,support 可以进入支持队列,无法确定的内容进入人工队列。模型只负责提出候选结果,Python 负责检查边界。这样可以把概率性的生成和确定性的业务规则分开。

我们规定模型必须返回三个字段:category、reason 和 needs_review。category 只能是三个枚举值,reason 不能是空字符串;如果类别是 other,或模型明确表示不确定,就必须复核。不要把“置信度”直接当作事实,除非你已经用固定测试集验证过它具有可用的校准意义。

准备环境和密钥

创建虚拟环境并安装官方 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="替换为你可用的模型名称"

示例使用 Responses API 的 input 和 output_text。不同模型对结构化输出参数的支持可能不同,运行前应查看所选服务的官方文档;本文把最关键的 JSON 解析和业务校验保留在本地,即使服务端没有提供严格 JSON 模式,也不会直接把未经检查的文本交给下游系统。

编写模型调用和本地校验

新建 email_triage.py:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import json
import os
import sys
from typing import Any

from openai import OpenAI

ALLOWED = {"sales", "support", "other"}


def ask_model(client: OpenAI, email: str) -> dict[str, Any]:
response = client.responses.create(
model=os.environ["MODEL_NAME"],
input=[
{
"role": "system",
"content": (
"你是邮件分拣器。只返回 JSON,不要 Markdown。"
'格式为 {"category":"sales|support|other",'
'"reason":"不超过30字的理由",'
'"needs_review":true或false}。'
"无法确定时使用 other,并将 needs_review 设为 true。"
),
},
{"role": "user", "content": email},
],
max_output_tokens=120,
)
try:
value = json.loads(response.output_text)
except json.JSONDecodeError as exc:
raise ValueError("模型没有返回合法 JSON") from exc
if not isinstance(value, dict):
raise ValueError("JSON 顶层必须是对象")
return value


def validate(value: dict[str, Any]) -> dict[str, Any]:
category = value.get("category")
reason = value.get("reason")
review = value.get("needs_review")
if category not in ALLOWED:
raise ValueError(f"未知类别:{category!r}")
if not isinstance(reason, str) or not reason.strip():
raise ValueError("reason 必须是非空字符串")
if not isinstance(review, bool):
raise ValueError("needs_review 必须是布尔值")
result = {
"category": category,
"reason": reason.strip(),
"needs_review": review or category == "other",
}
return result


def main() -> None:
if len(sys.argv) != 2 or not sys.argv[1].strip():
raise SystemExit("用法:python email_triage.py '邮件正文'")
if not os.environ.get("OPENAI_API_KEY"):
raise SystemExit("请先设置 OPENAI_API_KEY")
if not os.environ.get("MODEL_NAME"):
raise SystemExit("请先设置 MODEL_NAME")

client = OpenAI()
try:
result = validate(ask_model(client, sys.argv[1]))
except (ValueError, KeyError) as exc:
result = {
"category": "other",
"reason": f"自动结果未通过校验:{exc}",
"needs_review": True,
}
print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
main()

关键点有三个。第一,提示词限制了输出,但限制不是校验,json.loads 和字段检查才是程序的边界。第二,异常分支没有重试到“总会成功”,而是生成一个明确的人工复核结果;真实系统还可以把原邮件和错误代码写入安全的任务队列。第三,other 强制复核,体现了宁可降低自动化率,也不要把不确定邮件错误分发的策略。

如何验证而不伪造结果

先做不需要网络的语法检查:

1
python -m py_compile email_triage.py

然后准备三条固定样例,分别覆盖销售咨询、售后问题和无法判断的内容。每次修改提示词或模型,只改变一个变量,并把程序输出保存下来,与预先写好的期望类别比较。不要把一次成功调用当成测试;至少要包含同义表达、缺少上下文和混合意图等容易出错的样例。

还可以把 ask_model 替换成一个返回字典的假实现,专门测试 validate:合法类别应通过,未知类别、空理由、字符串形式的 "true" 都应进入复核。这类单元测试不消耗 API 额度,也能验证最重要的安全边界。文章不展示虚构的模型输出,实际结果取决于你配置的服务和测试邮件。

常见问题

为什么不直接相信 JSON? JSON 只说明语法正确,不说明类别合法。{"category":"finance"} 仍然可能让下游路由失败,所以必须做枚举和类型校验。

为什么不让模型直接调用队列或发邮件? 分拣结果会影响后续业务,入门项目应先输出建议,再由确定性的程序或人工审批执行动作。工具调用和权限控制应在后续主题中单独设计。

出现 JSON 解析失败怎么办? 先记录错误类型而不是原样记录可能包含隐私的邮件内容;可以有限次数重试,仍失败就人工复核。重试不能替代兜底,也要设置超时和预算。

理由是否可信? 理由是模型生成的解释,不是决策证据。若业务需要审计,应同时保存输入摘要、提示词版本、模型名、时间和校验结果,并按隐私要求控制保存范围。

小结

这个项目只有一个核心能力:把一封邮件转换成经过本地规则校验的分拣建议。模型负责理解开放文本,Python 负责枚举、类型、异常和人工兜底。今后增加类别、接入数据库或加入队列时,仍应保持这条边界:先定义可验证的结果,再让模型参与其中,而不是把未经检查的生成文本直接当作业务事实。