重复工单会增加客服和研发的处理成本。本篇不让模型直接关闭工单,而是让它生成候选组,再由程序校验、人工确认,演示结构化输出如何落到一个安全的小工具中。

设计边界

输入是 JSONL,每行有 id、title、body;输出是候选组、原因和 0 到 1 的置信度。编号必须来自输入,组内至少两个工单,程序发现格式错误就停止。模型做语义判断,程序做契约校验,系统不自动修改业务数据。

最小实现

代码只用标准库调用 Responses API。密钥来自环境变量;--demo 不访问网络,可先验证本地流程。

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
import json, os, sys
from pathlib import Path
from urllib.request import Request, urlopen

try:
path = Path(sys.argv[1]); demo = "--demo" in sys.argv
rows = [json.loads(x) for x in path.read_text(encoding="utf-8").splitlines() if x.strip()]
if len(rows) < 2 or any(not isinstance(x.get("id"), str) or not x.get("title") for x in rows):
raise ValueError("至少需要两个且都含 id、title 的工单")
if demo:
result = {"groups": [{"ticket_ids": [rows[0]["id"], rows[1]["id"]],
"reason": "演示结果,请人工复核", "confidence": 0.5}]}
else:
key = os.getenv("OPENAI_API_KEY")
if not key: raise RuntimeError("请设置 OPENAI_API_KEY,或使用 --demo")
prompt = ('只返回 JSON:{"groups":[{"ticket_ids":["..."],"reason":"...",'
'"confidence":0.0}]}。只分组描述同一根因的工单,编号必须来自输入。\n'
+ json.dumps(rows, ensure_ascii=False))
req = Request("https://api.openai.com/v1/responses",
data=json.dumps({"model": os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
"input": prompt}).encode(),
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
with urlopen(req, timeout=30) as r: data = json.load(r)
text = "".join(p.get("text", "") for i in data.get("output", [])
for p in i.get("content", []) if p.get("type") == "output_text")
if not text: raise ValueError("响应没有 output_text")
result = json.loads(text)
allowed, used = {x["id"] for x in rows}, set()
if not isinstance(result.get("groups"), list): raise ValueError("groups 必须是数组")
for group in result["groups"]:
ids, score = group.get("ticket_ids"), group.get("confidence")
if not isinstance(ids, list) or len(ids) < 2 or not set(ids) <= allowed:
raise ValueError("候选组的编号无效")
if used.intersection(ids): raise ValueError("一个工单不能属于多个组")
if not isinstance(score, (int, float)) or not 0 <= score <= 1:
raise ValueError("confidence 必须在 0 到 1 之间")
used.update(ids)
print(json.dumps(result, ensure_ascii=False, indent=2))
except (IndexError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as e:
print("处理失败:" + str(e), file=sys.stderr); raise SystemExit(1)

准备 tickets.jsonl:

1
2
{"id":"T-101","title":"导出报表失败","body":"点击导出后一直转圈"}
{"id":"T-102","title":"报表下载没有反应","body":"点击下载后没有文件"}

先离线运行:

1
python ticket_deduplicator.py tickets.jsonl --demo

确认流程后再使用占位密钥请求模型:

1
2
export OPENAI_API_KEY="your-api-key"
python ticket_deduplicator.py tickets.jsonl

代码按 output 结构提取文本,再用 json.loads 解析;代码围栏、缺字段、未知编号和非法分数都会失败,不会流入下游。

为什么保留人工复核

关键词相似不等于同一根因。“无法登录后台”和“无法登录移动端”可能需要不同团队;措辞不同的工单也可能来自同一次故障。因此工具只产生候选,保留 reason 与 confidence,由审核者决定是否合并。生产环境还应脱敏,并记录模型名、时间和失败原因,但不要记录密钥。

常见问题与小结

校验通过是否代表正确? 不是,它只证明格式和引用关系合法,语义仍需抽查。为什么只返回编号? 编号便于和原始输入核对,避免模型改写内容造成追踪困难。

可靠的 AI 工具应把模型放在受控环节:输入有格式、输出有契约、结果可追溯,最终动作由人批准。后续可加入审核状态和离线回归样本,但不应取消校验边界。