前面的系列已经覆盖了模型调用、消息历史、结构化输出、错误处理、工具边界、评估和可观测性。本篇把这些能力串成一个小项目:用户输入一段内容,模型判断它属于哪一类、给出理由和建议动作,程序负责校验、记录,并在高风险动作前要求人工确认。重点不是做一个“自动决策者”,而是学习如何把模型放进一条可检查、可追踪的程序流水线。

先定义项目边界

分流器只处理三个类别:question(问题)、feedback(反馈)和 other(其他)。模型必须返回固定 JSON:类别、置信度、简短理由和建议动作。建议动作只能是 reply、review 或 ignore。模型不能发送消息、修改数据库或执行命令;它只产生候选结果,真正的后续动作由普通 Python 代码决定。

这条边界很重要。自然语言模型适合提取和归纳,不适合直接拥有业务系统权限。把“理解”与“执行”分开后,即使模型返回了未知类别,程序也可以拒绝它,而不是把任意字符串传给下游系统。

准备环境和密钥

在独立虚拟环境中安装官方 Python SDK:

1
2
3
python -m venv .venv
source .venv/bin/activate
python -m pip install openai

密钥和模型名只从环境变量读取:

1
2
export OPENAI_API_KEY="替换为你的真实密钥"
export MODEL_NAME="替换为你可用的模型名称"

不要把真实密钥写入源码、测试样例或 Git。下面的程序使用 Responses API 的文本输出接口;如果你使用其他服务,应先对照该服务的官方文档确认客户端地址、模型名称和响应字段。

编写最小分流器

把下面内容保存为 content_router.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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

from openai import OpenAI

ALLOWED_CATEGORIES = {"question", "feedback", "other"}
ALLOWED_ACTIONS = {"reply", "review", "ignore"}
AUDIT_FILE = Path("router-audit.jsonl")

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
model = os.environ["MODEL_NAME"]

INSTRUCTIONS = """
你是内容分流助手。只返回一个 JSON 对象,不要使用 Markdown 代码围栏。
字段必须是:category、confidence、reason、action。
category 只能是 question、feedback、other;action 只能是 reply、review、ignore。
confidence 是 0 到 1 之间的数字。无法确定时使用 other、较低置信度和 review。
涉及投诉升级、退款、隐私或安全事件时,action 必须是 review。
""".strip()


def ask_model(text: str) -> dict:
last_error = None
for attempt in range(3):
try:
response = client.responses.create(
model=model,
instructions=INSTRUCTIONS,
input=text,
)
return json.loads(response.output_text)
except (json.JSONDecodeError, OSError, Exception) as exc:
last_error = exc
if attempt < 2:
time.sleep(2 ** attempt)
raise RuntimeError(f"模型调用失败:{last_error}") from last_error


def validate(result: dict) -> dict:
if not isinstance(result, dict):
raise ValueError("模型结果必须是 JSON 对象")
if result.get("category") not in ALLOWED_CATEGORIES:
raise ValueError("category 不在允许集合中")
if result.get("action") not in ALLOWED_ACTIONS:
raise ValueError("action 不在允许集合中")
confidence = result.get("confidence")
if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
raise ValueError("confidence 必须在 0 到 1 之间")
if not isinstance(result.get("reason"), str) or not result["reason"].strip():
raise ValueError("reason 必须是非空字符串")
return {
"category": result["category"],
"confidence": float(confidence),
"reason": result["reason"].strip(),
"action": result["action"],
}


def audit(text: str, result: dict | None, error: str | None) -> None:
record = {
"time": datetime.now(timezone.utc).isoformat(),
"input_length": len(text),
"result": result,
"error": error,
}
with AUDIT_FILE.open("a", encoding="utf-8") as file:
file.write(json.dumps(record, ensure_ascii=False) + "\n")


def main() -> int:
text = " ".join(sys.argv[1:]).strip()
if not text:
print("用法:python content_router.py '待分流内容'")
return 2
try:
result = validate(ask_model(text))
except Exception as exc:
audit(text, None, str(exc))
print(f"处理失败:{exc}")
return 1

audit(text, result, None)
print(json.dumps(result, ensure_ascii=False, indent=2))
if result["action"] == "review":
print("提示:该结果需要人工复核,程序不会自动执行后续动作。")
return 0


if __name__ == "__main__":
raise SystemExit(main())

这里的 responses.create 只负责请求模型,validate 才是程序信任结果的入口。即使提示词要求了固定字段,也不能省略校验,因为模型输出仍可能缺字段、类型错误或违反业务规则。audit 使用 JSON Lines 保存每次输入长度、结果和错误,后续可以按行统计失败率;为了减少敏感信息泄露,示例没有保存原文。

代码中的 except 写法是为了让示例保持短小,但在正式项目中不应把所有异常都混在一起。应根据 SDK 文档分别处理超时、认证失败、限流和服务端错误,并为不可重试的错误立即停止。重试也要有上限,且不能因为重试而重复执行真实业务动作。

增加人工确认和测试边界

当前程序只打印结果,因此没有真正的外部副作用。若下一步要把 reply 交给消息系统,应先加一道明确的审批函数:当动作是 review、置信度低于阈值,或输入命中隐私与安全关键词时,只写入待审核队列,不调用发送接口。审批记录还应包含操作者、时间和原始模型结果,便于追责和回滚。

网络调用不适合直接写进单元测试。可以把 ask_model 改成接收一个调用函数,并在测试中注入固定返回值,至少覆盖四类情况:合法结果、缺少字段、未知类别,以及无效置信度。这样测试验证的是业务边界,而不是某一次模型恰好返回了什么文字。

运行程序时,命令行参数会作为一段完整文本发送给模型:

1
python content_router.py "我想了解退款进度,但订单已经超过承诺时间"

不要把这里可能出现的输出当作固定结果。模型版本、提示词和服务端策略变化都会影响分类;真正可靠的验证方式是查看退出码、审计文件和测试集统计,而不是复制某次回答作为断言。

常见问题

为什么已经要求 JSON,还要 json.loads 和字段校验? 因为提示词是约束,不是类型系统。解析保证它至少是 JSON,业务校验保证它符合程序允许的集合和类型,两者缺一不可。

为什么不让模型直接发送回复? 因为发送消息是有副作用的动作。模型可以提出建议,但权限、审批和执行应该由确定性的代码控制,尤其要对投诉、退款、隐私和安全事件设置人工边界。

审计日志是否会泄露隐私? 会有风险。示例只保存长度和结果;真实项目还要做字段脱敏、访问控制、保留期限和加密,并评估是否有必要保存模型原始输出。

低置信度是不是一定代表错误? 不是。它只是一个需要复核的信号,不能代替真实评估。应准备带人工标签的测试集,按类别统计准确率、漏分流和误分流,再调整阈值。

小结

这个小项目的核心不是某个 SDK 调用,而是四层职责分离:模型负责理解,校验负责守住数据边界,审计负责留下证据,人工确认负责拦截高风险动作。沿着这条链路继续扩展时,可以替换检索器、增加评估集或接入真实队列,但不要跳过输入校验和副作用隔离。只要每一步都有明确输入、输出和失败路径,AI 功能就更容易测试、排错和逐步上线。