前面的系列已经覆盖了 API 调用、结构化输出、错误处理、工具边界、日志与人工审批。本篇把这些知识串成一个小项目:输入一段待处理事项,让模型生成一封邮件草稿,程序负责校验格式并保存,最后由人明确确认后才输出“可发送”的文本。工具不会连接邮箱,也不会自动发送消息,因此适合学习 AI 功能中的权限边界与可审计流程。
先设计不可越过的边界 这个工具只做三件事:生成草稿、检查草稿、等待审批。模型可以提出收件人、主题和正文,但这些字段都只是候选值;程序不能把模型返回的地址当成授权,也不能因为模型说“已发送”就执行发送动作。
流程可以画成四步:读取任务 → 调用模型 → 校验并保存草稿 → 人工确认。只有第四步输入明确的 approve,程序才把正文打印到终端。真实项目还应把发送动作放到独立服务中,使用用户身份、权限检查和发送前再次确认。
准备输入和环境变量 使用独立虚拟环境安装官方 Python SDK:
1 2 3 python -m venv .venv source .venv/bin/activatepython -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 jsonimport osimport sysfrom pathlib import Pathfrom openai import OpenAISCHEMA = { "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 功能,都可以先按这个顺序设计:最小权限、可见草稿、明确确认、可追溯记录。