
1. 这行代码引发的教训不要把邮件主题行直接交给 LLM先还原一个非常常见的线上场景。客服工单系统每天会产生几百封通知邮件每封邮件都需要一个主题行。背景信息很清晰客户姓名、订单号、工单分类、问题摘要。看起来这个需求再简单不过于是很多开发者的第一反应是这种“一句话总结”的活交给大语言模型LLM正合适。于是代码里出现了类似这样的调用prompt 请根据以下信息写一封客服邮件的主题行... subject llm_client.complete(prompt)上线之后问题开始陆续暴露有的主题超过 50 个字符被邮件网关截断有的主题里带着 emoji 在部分客户端显示乱码有的邮件主题和正文表达的重点完全不一致还有更隐蔽的——模型“脑补”了一个不存在的订单号。排查到最后问题不是模型能力不够而是把不该交给 LLM 的任务交给了 LLM。本文想聊一个容易被忽视但非常重要的工程原则Never let the LLM write the subject line。更准确地说越是短小、越是关键、越需要稳定格式的业务字段越不应该让大语言模型直接生成。即便要使用 LLM也应该是把它放在“信息理解”和“信息抽取”的位置而不是“最终文案决策”的位置。1.1 到底什么是“subject line”subject line 最直白的解释是邮件主题行也就是用户在收件箱里看到的那一行文字。它往往很短可能只有 20 到 60 个字符但承担的作用非常大用户根据它决定是否点开邮件。客服系统根据它做自动化归档和分类。邮件服务商根据它做垃圾邮件过滤。CRM 系统可能根据它关联工单编号。在程序员视角里subject line 本质上是一个“强业务约束的短文本字段”。它不但要可读还要稳定、可预期、符合下游系统的限制。这种字段和一篇开放的文案不同它更像一个接口参数而不是一篇小作文。很多 AI 应用刚落地时最容易踩的坑就是误把“文本生成能力”等同于“文本决策能力”。模型擅长从语义上理解输入并产出内容但不太擅长精确遵守长度、格式、字符集、敏感词、唯一性这类硬规则。1.2 标题里的“Never”是不是太绝对了有同学会问直接用 LLM 写主题行也会成功呀为什么不加一句“质量不行”而要说“Never”这里其实是工程上的“反脆弱”思路。在可控的业务链路上我们要默认 LLM 是一个“不太稳定的组件”而不是默认它“大部分时间可靠”。主题行这种字段一旦出错直接影响用户信任、搜索归档、甚至有法律风险例如重要通知被误判为垃圾邮件。为了 20% 的“创意提升”去承担 80% 的不可控风险对线上业务来说不划算。所以标题里的 Never 强调的是不要让 LLM 成为主题行的唯一决策者。它可以是建议者、抽取者、候选生成者但最终的决定权必须回到代码、模板和校验规则里。2. 核心根因生成式模型并不擅长“短字段”输出想要真正理解这个原则不能只背结论要看清楚大语言模型在生成 subject line 这类文本时为什么容易出现系统性偏差。2.1 主题行本质上是“精炼”不是“创作”邮件主题行和正文不同。正文可以展开讲背景、原因、解决方案主题行却必须在极短的空间里完成信息压缩。对 LLM 来说生成式训练目标决定它更擅长“续写”和“补全”。当你要求它“写一个主题行”它其实是在海量语料中寻找一句最自然的“主题样式文本”。这个过程中它不会被数据库字段长度约束也不会被邮件服务商规则约束它只会追求语义上的自然。举个例子你输入客户张三关于订单 20240501 的退款申请请生成主题行。模型可能输出关于您的订单 20240501 退款申请我们已经收到正在为您加急处理请耐心等待我们会尽快回复您这句话作为回复文案没问题作为邮件主题却太长了。它把“主题行”写成了“短信内容”。原因就在于模型在“创作”而不是在“压缩”。2.2 非确定性输出带来稳定性风险LLM 的输出带有采样随机性。即便把 temperature 设置为 0模型在不同版本、不同时间、不同服务实例上也可能有细微差异。对于客服工单的归档体系来说同一个工单编号如果生成了两种不同风格的主题后续检索、匹配、去重都可能被影响。更重要的是业务测试很难对这种非确定性做断言。你没法在单元测试里写assert subject expected_subject因为你不知道模型下一秒会生成什么。当“主题行内容”成为可变的工程产物时它的可测试性会大幅下降回归测试只能退化为“人工看一眼”这在现代研发流程里是不能接受的。2.3 业务规则无法靠 Prompt 保障有人说“那我把所有规则都写进 prompt 里不就行了”例如指定长度、禁止 emoji、必须包含订单号。问题是 prompt 约束是软约束不是硬约束。模型在训练时并没有真正理解“必须”“禁止”这些词的逻辑含义它只是把这些词当作文本上下文的一部分。你写了“必须在 30 字以内”它仍然可能输出 40 字而且看起来非常自然。硬规则必须由代码来执行。规则引擎、正则表达式、schema 校验、长度检查这些组件是确定性的是可测试的出现问题时能快速定位。把业务规则放进 prompt等于把确定性责任交给一个概率系统这本身就是风险。3. 环境准备与实验设计为了把“错误示范”和“正确改造”讲清楚我用一个最小化但完整的示例环境来做演示。这样你可以直接复制代码在自己的环境里体验差别。3.1 环境说明本文示例使用 Python 编写主要依赖如下Python 3.10 或以上版本。OpenAI SDK或其他兼容 OpenAI Chat Completions 接口的 SDK。一个可调用的大模型 API或者本地部署的兼容接口。操作系统不限Windows、macOS、Linux 都可以。考虑到大语言模型 SDK 迭代比较快下面的代码只演示核心调用方式。SDK 版本不同时参数名可能有细微差异比如openai新版本推荐OpenAI()客户端方式而不是旧版openai.ChatCompletion.create()。你在实际运行时按自己项目依赖调整即可。如果你不想在本地配置真实 API也可以用 Mock 类模拟 LLM 返回结果后面我会给出一个简单的抽象思路方便你先跑通流程。3.2 项目结构llm-subject-demo/ ├── bad_approach.py # 错误示范让 LLM 直接写主题行 ├── good_approach.py # 正确方案模板 抽取 校验 ├── hybrid_approach.py # 进阶方案LLM 输出 JSON代码拼接主题 └── requirements.txt # 依赖声明requirements.txt内容如下openai1.0.0 python-dotenv1.0.0python-dotenv主要用于读取环境变量不是必须的如果你已经把 API Key 配置到系统环境变量里可以省略。4. 实战错误示范与正确工程化改造4.1 需求定义我们模拟一个客服邮件通知模块。输入信息来自工单系统包含以下字段ticket_info { customer_name: 张三, order_id: ORD-20240501-001, category: refund, summary: 用户反馈收到的商品有破损要求退款并希望尽快处理。, }业务要求主题行长度不超过 50 个字符。不包含 emoji 和换行符。必须包含订单号方便客服检索。邮件主题风格统一以“【分类】”开头。这是一个非常典型的“短字段输出”需求。下面先看错误做法。4.2 错误示范让 LLM 直接生成主题行新建bad_approach.pyimport os from openai import OpenAI client OpenAI(api_keyos.getenv(OPENAI_API_KEY)) def generate_subject_llm_direct(ticket_info: dict) - str: prompt f 客户姓名{ticket_info[customer_name]} 订单号{ticket_info[order_id]} 问题分类{ticket_info[category]} 问题摘要{ticket_info[summary]} 请根据以上信息写一封客服邮件回复的主题行。 要求简洁、礼貌、吸引用户注意。 response client.chat.completions.create( modelgpt-4o-mini, messages[ {role: user, content: prompt}, ], temperature0.7, ) return response.choices[0].message.content.strip() if __name__ __main__: ticket_info { customer_name: 张三, order_id: ORD-20240501-001, category: refund, summary: 用户反馈收到的商品有破损要求退款并希望尽快处理。, } subject generate_subject_llm_direct(ticket_info) print(生成的邮件主题, subject) print(长度, len(subject))这段代码的核心问题在于它把“生成主题行”的完整任务交给 LLM而且没有做任何后置校验。从调用方式看prompt 里确实写了“简洁、礼貌”但这只是软约束。4.3 错误示范的问题复现在真实运行中模型可能输出这样的结果关于您的订单 ORD-20240501-001 的退款申请我们已经收到并正在加急处理感谢您的耐心等待你拿这个字符串做校验时会发现长度已经超过 50 个字符。内容风格完全不像邮件主题更像短信通知。如果模型“发挥”一下还可能写出“您的包裹已破损请放心我们会尽快为您退款期待您的再次光临❤️”附带一个 emoji。更严重的情况下模型可能生成一个并不存在的订单号比如把ORD-20240501-001写成ORD-20240502-999这就是幻觉。这些结果不是小概率事件而是在 daily 使用中反复出现的。每出现一次都是真实用户看到的一次错误体验。4.4 正确方案一模板 LLM 分类抽取正确改造的第一步是调整 LLM 的角色定位。主题行的最终拼接工作交给 Python 代码LLM 只负责从用户问题中抽取“分类”和“核心关键词”。这两个任务属于模型比较擅长的信息理解任务即使偶尔理解偏差也更容易通过枚举值校验来兜底。新建good_approach.pyimport os import re from openai import OpenAI client OpenAI(api_keyos.getenv(OPENAI_API_KEY)) MAX_SUBJECT_LENGTH 50 DEFAULT_SUBJECT 【客服】您的工单已收到 SUBJECT_TEMPLATES { refund: 【退款】{order_id} 退款进度说明, delivery: 【物流】{order_id} 物流异常提醒, general: 【客服】订单 {order_id} 问题说明, } def classify_issue_with_llm(summary: str) - str: prompt f 你是一个工单分类助手。 请判断用户问题属于以下哪个分类只输出一个分类词 - refund退款、退货、质量问题 - delivery物流、快递、收货问题 - general其他问题 用户问题 {summary} 只输出分类词不要输出其他内容。 response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0, ) category response.choices[0].message.content.strip().lower() if category not in SUBJECT_TEMPLATES: category general return category def validate_and_fix_subject(subject: str) - str: if not subject or len(subject) MAX_SUBJECT_LENGTH: return DEFAULT_SUBJECT subject subject.replace(\n, ).strip() # 去除常见 emoji emoji_pattern re.compile( [\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F] ) subject emoji_pattern.sub(, subject).strip() return subject or DEFAULT_SUBJECT def build_subject_by_template(ticket_info: dict) - str: category classify_issue_with_llm(ticket_info[summary]) template SUBJECT_TEMPLATES[category] subject template.format(order_idticket_info[order_id]) return validate_and_fix_subject(subject) if __name__ __main__: ticket_info { customer_name: 张三, order_id: ORD-20240501-001, category: refund, summary: 用户反馈收到的商品有破损要求退款并希望尽快处理。, } subject build_subject_by_template(ticket_info) print(生成的邮件主题, subject) print(长度, len(subject))这个方案的关键变化是什么第一模板里预置了风格统一的句子结构订单号由代码插入不会产生幻觉。第二LLM 只负责判断问题属于哪个分类。分类结果是确定的枚举值不在枚举内就回落为general安全边界很清晰。第三validate_and_fix_subject做了长度、换行、emoji 三层兜底。即使模板未来被改动最坏情况也只是返回默认主题不会把一段超长垃圾文本发出去。4.5 正确方案二让 LLM 输出 JSON 再由代码拼接模板方案适合分类固定、句式固定的场景。如果业务变化很快模板数量会膨胀维护成本变高这时可以把方案升级为“LLM 输出结构化 JSON代码负责最终拼接”。新建hybrid_approach.pyimport json import os import re from openai import OpenAI client OpenAI(api_keyos.getenv(OPENAI_API_KEY)) MAX_SUBJECT_LENGTH 50 DEFAULT_SUBJECT 【客服】您的工单已收到 def extract_subject_fields_with_llm(ticket_info: dict) - dict: prompt f 你是客服工单摘要助手。 请从工单信息中提取以下字段并严格按照 JSON 格式输出不要输出多余文字 {{ category: refund 或 delivery 或 general, order_id: 订单号原样输出, keyword: 不超过10个字的核心问题词例如商品破损 }} 工单信息 客户姓名{ticket_info[customer_name]} 订单号{ticket_info[order_id]} 问题分类{ticket_info[category]} 问题摘要{ticket_info[summary]} response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0, ) text response.choices[0].message.content.strip() try: data json.loads(text) except json.JSONDecodeError: return { category: general, order_id: ticket_info[order_id], keyword: , } return { category: data.get(category, general), order_id: data.get(order_id, ticket_info[order_id]), keyword: data.get(keyword, ), } def validate_and_fix_subject(subject: str) - str: if not subject or len(subject) MAX_SUBJECT_LENGTH: return DEFAULT_SUBJECT subject subject.replace(\n, ).strip() emoji_pattern re.compile( [\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F] ) subject emoji_pattern.sub(, subject).strip() return subject or DEFAULT_SUBJECT def build_subject_hybrid(ticket_info: dict) - str: fields extract_subject_fields_with_llm(ticket_info) category fields[category] order_id fields[order_id] keyword fields[keyword] or 问题处理 subject_map { refund: f【退款】订单 {order_id} {keyword}说明, delivery: f【物流】订单 {order_id} {keyword}提醒, general: f【客服】订单 {order_id} {keyword}说明, } subject subject_map.get(category, subject_map[general]) return validate_and_fix_subject(subject) if __name__ __main__: ticket_info { customer_name: 张三, order_id: ORD-20240501-001, category: refund, summary: 用户反馈收到的商品有破损要求退款并希望尽快处理。, } subject build_subject_hybrid(ticket_info) print(生成的邮件主题, subject) print(长度, len(subject))在这个方案里LLM 的输出被限制为 JSON 结构化数据。category字段再经过一次映射order_id字段只取原文并原样输出keyword虽然由模型生成但它不是决定性内容最多影响主题的具体措辞。如果模型返回的 JSON 解析失败兜底逻辑会回退到general分类并用原始订单号拼一条安全主题。整个链路不会因为 LLM 一次异常输出而中断。4.6 运行与对比在同样的输入下三种方案的表现对比大概是这样的方案标题示例长度是否含订单号是否可控直接生成关于您的订单 ORD-20240501-001 的退款申请我们已经收到并正在加急处理感谢您的耐心等待超长是较弱模板分类【退款】ORD-20240501-001 退款进度说明可控是强JSON混合方案【退款】订单 ORD-20240501-001 商品破损说明可控是强从对比可见模板方案和混合方案在主题长度、格式、内容稳定性上都显著优于直接生成方案。付出的代价仅仅是多写了一点校验逻辑和模板映射这个成本完全可以接受。5. 常见问题与排查清单在实际项目中即使你采用了正确的设计思路也可能会遇到一些问题。下面整理了一份高频问题排查表。5.1 高频问题排查表问题现象常见原因解决思路LLM 直接生成了超长主题模型输出没有经过长度校验增加最大长度检查超出后回退默认主题主题中包含 emoji模型在文案里“自由发挥”用正则统一过滤 emoji 字符订单号错误或不存在模型产生幻觉重写了数字订单号一律由代码从业务数据中取出不允许模型生成分类识别错误用户问题描述模糊增加分类枚举白名单不在白名单内回退 general输出 JSON 解析失败模型返回了多余文字或 Markdown 代码块尝试用正则提取 JSON 片段失败则回退兜底同一工单两次生成主题不同temperature 0 导致的采样差异将 temperature 设置为 0并对候选做规则排序模板数量越来越多难以维护分类粒度太细调整分类粒度把个性化内容全部交给字段拼接上线后仍然出现异常主题校验器只处理了部分情况增加日志与人工抽检积累 badcase 后回归测试5.2 排查步骤建议当线上出现一封不正常的主题邮件时可以按以下顺序排查第一步先看这封邮件是哪条代码链路生成的。是模板链路还是 LLM 直出链路如果系统里同时存在新旧方案多半是旧逻辑没清理干净。第二步把输入数据原样复制到本地跑一次相同的 LLM 调用。如果本地可以稳定复现说明问题出在模型输出侧如果不能复现考虑是不是线上 prompt 被动态改过。第三步检查是否有后置校验器。如果没有先补上校验器再讨论模型是不是需要换更强的版本。大多数情况下问题不在模型而在工程链路缺少硬约束。第四步把异常样本加入回归测试集。后续每次升级模型版本或调整 prompt 时都用这批样本跑一遍确保不会回归。6. 工程化最佳实践LLM 短字段输出控制的几条准则6.1 模板优先LLM 兜底对于结构相对固定的短字段先问自己能不能用模板直接拼能拼就用模板。公共前缀、分类前缀、固定结尾这些都应该存在于代码中而不是存在于模型脑中。模板确实会带来一定程度的“死板”但它换来的是确定性、可测试性和可审查性。业务追求风格统一的程度越高模板的优先级就越高。6.2 让 LLM 做信息抽取而不是文案创作信息抽取是从一段文本中提取结构化字段例如分类、实体、关键词文案创作则是生成一段新的自然语言。前者有比较明确的评价标准后者更多靠主观感受。如果你必须使用 LLM 来辅助生成主题请优先把它定位为“抽取器”。比如输入用户问题输出{category: refund, order_id: xxx}。拿到这些字段后再用代码拼出主题。6.3 所有 LLM 输出必须经过后置校验器后置校验器是最后一道防线。无论如何设计 prompt默认模型可能输出任意内容因此必须在业务代码中加一层强制校验。校验器至少包含这些检查空值检查。最大长度检查。禁止字符检查例如 emoji、换行、控制字符。枚举值白名单检查。关键字段存在性检查例如必须包含订单号。不通过时的兜底策略。6.4 关键业务字段不要依赖模型生成订单号、手机号、金额、日期、用户 ID这些字段必须来自业务侧的数据源永远不要交给模型生成。模型生成的数字看起来可能很像真的但它没有查询数据库本质上是在猜测概率分布。如果模型需要引用这些字段正确做法是把字段值注入到 prompt 中然后要求模型“原样引用”。但更稳妥的方案是模型输出 JSON 后用代码从原始业务数据中二次覆盖字段值。6.5 设置合理的 temperature对于分类、抽取、结构化输出这类任务temperature 应设置为 0甚至使用 greedy decoding以尽量降低随机性。对于创意内容生成任务temperature 可以升高但在短字段场景里随机性带来的收益远小于风险。6.6 建立回归测试集选择 20 到 50 条典型工单数据作为固定测试集包含退款、物流、账号问题、恶意输入、超长摘要等边界情况。每次调整 prompt 或更换模型后都跑一遍测试集确认主题在长度、格式、分类准确性上没有回归。如果团队有 CI/CD 流程把这个回归测试加到流水线里比任何文档都有效。6.7 关注 LLM 的“过度自主性”风险“不要让 LLM 写主题行”背后的思想和 AI Agent 工程中的“最小权限”原则是一致的。LLM 的输出如果直接驱动下游动作——比如发送邮件、修改工单、触发退款——一旦输出失控就不只是文案问题而是资损或安全事件。在 Agent 场景里推荐做法是让 LLM 产出“意图参数”由代码执行真实动作并在执行前经过审批或规则拦截。主题行生成也是一样模型只提供内容和建议审批、校验、发送都由代码掌握。7. 适用范围与延伸思考把这次讨论放大看不只是邮件主题行下面这些字段同样适用“不要让 LLM 直接生成”的原则工单标题。报警通知标题。告警邮件主题。短信模板变量。导出文件的文件名前缀。消息通知的标题栏。数据库记录中的短文本字段。这些字段的共同点是短、影响面大、需要稳定格式、可能被下游系统消费。对于这些字段LLM 可以当“参谋”但不要让它当“最终签发人”。反过来对于公众号文章标题、营销邮件的预览文本、活动页的 banner 文案这类真正需要“创意发散”的场景LLM 直接生成不仅没问题反而是很合适的用法。这也是为什么“Never let the LLM write the subject line”不能机械理解成“任何标题都不能用 LLM”而是说在业务链路中凡是需要有确定性约束的字段都要用代码把决策权拿回来。如果你现在准备在项目里接入大模型生成功能建议先列一个清单哪些字段是“创意型”哪些字段是“业务型”。业务型字段默认走模板创意型字段才交给模型。然后在模型输出后面补一层校验器再配一批回归测试集。这套方法做下来既能享受大语言模型的理解能力又不会让它成为线上事故的来源。如果这篇文章对你有帮助可以收藏备用。后续遇到 LLM 输出失控的问题时回头看看这个“短字段控制”的思路应该能少走不少弯路。