前面的系列已经覆盖了 API 调用、结构化输出、错误处理、工具边界、日志与人工审批。本篇把这些知识串成一个小项目:输入一段待处理事项,让模型生成一封邮件草稿,程序负责校验格式并保存,最后由人明确确认后才输出“可发送”的文本。工具不会连接邮箱,也不会自动发送消息,因此适合学习 AI 功能中的权限边界与可审计流程。

先设计不可越过的边界

这个工具只做三件事:生成草稿、检查草稿、等待审批。模型可以提出收件人、主题和正文,但这些字段都只是候选值;程序不能把模型返回的地址当成授权,也不能因为模型说“已发送”就执行发送动作。

流程可以画成四步:读取任务 → 调用模型 → 校验并保存草稿 → 人工确认。只有第四步输入明确的 approve,程序才把正文打印到终端。真实项目还应把发送动作放到独立服务中,使用用户身份、权限检查和发送前再次确认。

准备输入和环境变量

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

新建 task.json,只放业务输入,不放密钥:

1
2
3
4
5
6
7
8
9
10
{
"recipient": "项目小组",
"subject_hint": "本周进度同步",
"facts": [
"接口联调已完成",
"请周五前反馈剩余问题",
"下次会议时间待确认"
],
"tone": "专业、简洁"
}

示例中的事实会作为上下文交给模型。程序仍然要把模型输出视为不可信输入,不能因为提示词要求了 JSON,就省略解析和字段检查。

定义输出协议

先约定程序真正需要的最小结构:

1
2
3
4
5
{
"to": "项目小组",
"subject": "本周进度同步",
"body": "各位好……"
}

to、subject 和 body 都必须是非空字符串;正文长度也应设置上限,避免模型异常输出大段内容。若业务需要实际邮箱地址,应使用独立的地址簿或域名白名单校验,而不是让模型自由生成地址。

编写最小可运行程序

创建 draft_mail.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
import json
import os
import sys
from pathlib import Path

from openai import OpenAI


SCHEMA = {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"],
"additionalProperties": False
}


def load_task(path: Path) -> dict:
task = json.loads(path.read_text(encoding="utf-8"))
required = ("recipient", "subject_hint", "facts", "tone")
if any(not task.get(key) for key in required):
raise ValueError("task 缺少必填字段")
if not isinstance(task["facts"], list):
raise ValueError("facts 必须是列表")
return task


def create_draft(task: dict) -> dict:
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.responses.create(
model=os.environ["MODEL_NAME"],
instructions=(
"你负责起草邮件。只能使用用户提供的事实,不得补造日期、承诺或联系人。"
"返回符合 JSON Schema 的对象,不要输出额外字段。"
),
input=json.dumps(task, ensure_ascii=False),
text={"format": {
"type": "json_schema",
"name": "email_draft",
"schema": SCHEMA,
"strict": True
}}
)
draft = json.loads(response.output_text)
for key in ("to", "subject", "body"):
if not isinstance(draft.get(key), str) or not draft[key].strip():
raise ValueError(f"输出字段无效:{key}")
if len(draft["body"]) > 4000:
raise ValueError("正文超过长度限制")
return draft


def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("用法:python draft_mail.py task.json")
task = load_task(Path(sys.argv[1]))
draft = create_draft(task)
print("--- 邮件草稿 ---")
print(f"收件人:{draft['to']}")
print(f"主题:{draft['subject']}")
print(draft["body"])
print("\n输入 approve 才算通过审批:")
if input().strip().lower() != "approve":
print("已取消,未执行发送动作。")
return
print("审批通过。这里只输出已批准草稿;发送功能未连接。")


if __name__ == "__main__":
main()

运行:

1
python draft_mail.py task.json

这里的 responses.create 负责请求模型,text.format 要求结构化 JSON,json.loads 和字段检查则是程序自己的第二道防线。response.output_text 是 SDK 汇总后的文本结果;真正的内容取决于模型、输入和服务状态,不能在代码或文章中预先写死某个运行结果。

为什么还要人工审批

结构化输出只能改善格式,不能证明事实正确、收件人合适或语气符合组织规范。例如模型可能把“待确认”写成确定日期,也可能把项目小组扩展成不应联系的个人。审批步骤把不可逆动作前移为可见的候选内容,让人有机会检查事实、范围和敏感信息。

在生产环境中,不应只依赖终端输入。可以保存一条审批记录,至少包含草稿哈希、操作者、时间、审批结果和最终版本。审批人修改正文后,要重新计算版本,不能把“审阅过旧版本”误记成“批准新版本”。

常见问题

为什么提示词要求 JSON 还要自己校验? 结构化输出约束的是接口格式,不保证业务规则。例如字段可能存在,但收件人不在允许范围,正文也可能包含敏感数据。协议校验、业务校验和人工审批解决的是不同问题。

能不能把发送邮件的代码直接加在审批后? 学习示例不应该这样做。发送涉及身份、权限、重试、重复发送和审计。若未来接入邮件服务,应使用最小权限凭证,设置幂等键,并在服务端再次检查审批状态和内容版本。

请求失败时怎么办? 当前最小示例让异常终止,避免在不确定状态下继续。实际项目应区分认证失败、超时、限流和无效输出,设置有限次数的指数退避,并记录错误类型;不要把 API 密钥、完整敏感输入写入日志。

如何测试而不消耗 API 额度? 把 create_draft 改为接收一个客户端参数,测试时注入返回固定 output_text 的假客户端。这样可以独立测试 JSON 解析、字段校验、超长拒绝和取消审批;真实 API 只保留少量集成测试。

小结

这个小项目的重点不是自动发邮件,而是把模型放在“提出候选内容”的位置,把 Python 放在“校验、记录和控制副作用”的位置。结构化协议减少了解析歧义,业务校验拦截了明显异常,人工审批阻止了未经确认的外部动作。面对任何会代表用户发言、修改数据或产生现实影响的 AI 功能,都可以先按这个顺序设计:最小权限、可见草稿、明确确认、可追溯记录。