做 LLM 应用有一类问题是上线之后才暴露出来的:正文内容看起来很顺,但用户第一眼看到的主题行、标题、通知文案,却总是透着一种“模型凑字数”的感觉。要么空泛,要么超长,要么把敏感词、表情符号、夸张宣传词一起带出来。这个问题有一个很形象的总结:Never let the LLM write the subject line。
这个标题不是某个需要克隆到本地跑起来的仓库名,而是一条 LLM 应用工程经验的高度浓缩:正文可以让大模型自由发挥,但主题行这种高度结构化的短字段,不应该由模型直接“自由写”。更稳的方案是走“规则模板 + LLM 候选生成 + 校验兜底”的组合流程。
这篇文章会把这个经验拆开讲清楚:为什么主题行不能直接交给 LLM,适用于哪些业务场景,怎么用 Python 和 API 服务实现一套可控的主题行生成流程,以及上线之后怎么验证、怎么排查问题。如果你正在做邮件系统、工单系统、内容发布平台,或者正在把大模型接入 Agent、RAG、MCP 这类自动化流程,这篇文章建议先收藏。
1. 核心结论:主题行质量决定 LLM 应用的第一印象
先说结论:LLM 适合生成正文,规则引擎适合生成主题行;如果一定需要 LLM 参与,也必须把它限制在“候选生成器”的位置,而不是最终决策者。
为什么这个位置这么重要?因为主题行是用户接触系统的第一个信息点。邮件列表里第一眼看到的是主题,工单列表里决定先处理哪张单子靠的是标题,通知推送里用户是否点开也取决于这一行字。模型生成的正文可以允许有风格波动,但主题行一旦出现低质量结果,直接影响的是整个系统的可信度。
从工程实现上看,主题行生成和管理可以抽象为几个能力项:
| 能力项 | 推荐做法 | 说明 |
|---|---|---|
| 输出字段类型 | 短文本,建议限制长度 | 主题行通常是被截断展示的,超长和换行都是问题 |
| 生成策略 | 规则模板优先 | 格式固定的场景直接用模板填充 |
| LLM 参与方式 | 只生成候选文本 | 由校验器打分和选择,不让模型直接输出最终值 |
| 质量校验 | 独立校验器 | 检查长度、敏感词、前缀、编号、首尾空格 |
| 兜底策略 | 固定兜底文案 | 所有候选不合格时使用“待处理”等占位 |
| 审计能力 | 记录生成日志 | 主题行是业务可见字段,日志必须保留 |
| 批量任务 | 支持按批次生成 | 邮件批次、工单批次需要独立任务 ID 和重试机制 |
| API 服务 | 独立接口暴露 | 便于和现有业务系统、Agent 编排工具对接 |
| 合规边界 | 禁止误导和夸大 | 营销场景尤其要控制宣传词和敏感词 |
这套设计看起来比“直接把提示词发给 LLM”多了一步,但正是这一步避免了大量的线上问题。
2. 为什么不要让 LLM 直接写主题行
2.1 短文本是最难稳定的输出
大模型的优势在于长文本生成、语义理解和多轮对话,但主题行通常只有 10 到 30 个字。输出越短,模型的“自由发挥空间”越小,反而越难稳定命中结构要求。常见的情况是:
- 模型在主题里加戏,把正文里不存在的信息写进去。
- 同一个模板,这一批生成和上一批生成风格完全不一致。
- 中英文混排、标点符号混乱、首字母大小写随机。
这些问题在单次测试里很难暴露,因为人工看到一两句“看起来还行”的结果会降低警惕。但批量任务一跑,几百个主题行放到一起对比,风格不一致的问题会非常刺眼。
2.2 主题行有强烈的结构约束
业务系统里的主题行通常不是一句话那么简单,它有明确的结构要求:
- 必须包含前缀,例如“工单”“告警”“日报”。
- 必须包含关键业务信息,例如用户 ID、订单号、设备编号。
- 必须按长度限制截断,避免在列表页换行。
- 禁止包含特定敏感词、夸张宣传词、表情符号。
- 可能需要编号规则,例如按日期的
20250101-001。
这些约束用自然语言写进提示词,模型不一定每次都遵守。但用规则引擎去校验,每一条都是确定性判断,准确率是 100%。
2.3 主题行是审计和日志里的高频字段
与生成后被丢弃的中间文本不同,主题行会长时间出现在业务系统的各个位置:邮件列表、工单详情、审批流、通知记录。它不仅是给人看的,也是后续检索、分类、统计的重要字段。
如果主题行由 LLM 自由生成,出问题时的排查链路会很长:是提示词的问题,还是模型抽风,还是某条正文内容触发的?更麻烦的是,模型生成的结果不可复现,同一条输入换一个时间调用可能得到完全不同的主题。对需要审计的业务来说,这是不可接受的。
2.4 成本和延迟不划算
为主题行单独调用一次完整 LLM 推理,在大多数场景里都是浪费。主题行只有几十个字,但从构造提示词到模型推理,再到流式返回或单次返回,整个链路消耗的 token、时间和算力与生成一篇正文相比并没有数量级的差别。如果用本地模型部署,还会占用 GPU 显存和推理并发。
更合理的方式是:正文这样的长内容走 LLM,主题行先用规则生成一个“保底版本”,再把“是否需要用 LLM 优化”作为可选开关留给业务方。
2.5 主题行可能成为下游 Agent 的输入
在 LLM Agent 场景里,模型调用工具、生成中间结果后,经常会生产出 summary、title、subject 这类字段。这些字段如果质量不稳定,会直接污染下游流程。例如:
- 一个 Agent 把工单摘要作为下一个 Agent 的输入,主题行里的无关信息会被当成事实。
- 一个自动化发布流程把 LLM 生成的标题当作文档名,包含特殊符号时可能导致文件系统异常。
- 一个邮件通知 Agent 把夸张宣传词写进主题行,直接触发垃圾邮件过滤或合规审查。
所以从架构上看,主题行不只是“展示字段”,它是系统里的结构化数据。结构化数据就应该有明确的校验和兜底机制。
3. 适用场景:邮件、工单、通知、内容发布
这套“不要让 LLM 写主题行”的原则,最实用的场景集中在这几类:
3.1 邮件通知与营销触达
邮件主题决定打开率,但也是敏感词和合规风险集中的区域。营销邮件如果使用“免费”“秒杀”“不会还有人不知道吧”这类词,平台拦截和外发失败的概率很高。规则模板可以先把通用主题固定下来,LLM 只负责在给定前缀下生成候选,再由校验器过滤掉风险词。
另外,邮件主题行通常需要 A/B 测试,规则化生成的多个候选版本更容易做对照实验。
3.2 工单与客服系统
工单标题需要一眼看懂优先级、业务类型、关联对象。如果让 LLM 自由写,很可能出现“客户反馈了一个问题”“关于上周的事情”这类无法检索的废标题。更好的做法是:前缀用规则生成,例如[投诉] - 订单20250101-用户退款;LLM 只负责将摘要压缩补充进副标题。
3.3 内容发布与 SEO 标题
内容平台的标题同时影响点击率、SEO 收录和人工审核。这里不是完全不要 LLM,而是不能让 LLM 直接输出最终标题。正确的流程是:LLM 先生成 5 个候选,规则校验器过滤长度和关键词,运营人员从候选里选择或修改。没有人工审核的标题,不应该直接发布。
3.4 内部审批与自动化流程
审批流里的标题需要包含明确的业务编号和操作类型。这类场景几乎不需要 LLM,纯模板就能解决。如果你考虑引入 LLM,那只是为了让标题更接近自然语言,属于可选项,而不是必选项。
3.5 RAG 与 Agent 编排中的摘要标题
RAG 应用把检索到的片段拼进提示词后,经常需要生成一段“检索摘要”作为用户可见输出。这里的摘要标题同样不能直接交给模型。因为检索片段可能包含不同来源的版权内容、机密信息或相互矛盾的表述,直接生成标题有泄露和误导风险。需要在标题生成前加一层来源过滤和脱敏。
4. 推荐设计:规则模板 + LLM 候选 + 校验兜底
4.1 整体流程
更稳妥的主题行生成流程分五步:
- 确定前缀和编号:从业务参数读取,例如
prefix="工单"、seq_no=1001。 - 规则模板生成保底主题:直接用字符串拼接生成一个符合结构要求的主题。
- 调用 LLM 生成候选:把正文摘要、前缀、长度限制、禁用词列表写入提示词,让模型返回 3 到 5 个候选。
- 校验器过滤和打分:所有候选经过长度、敏感词、前缀存在性、字符规范校验,通过的按得分排序。
- 结果选择和兜底:优先使用规则主题,如果调用方明确要 LLM 优化,就选择得分最高的候选;如果没有候选通过,则使用规则主题或固定兜底文案。
4.2 策略代码示例
下面的 Python 代码演示了校验器和决策逻辑。接口路径、模型服务地址、禁用词列表需要按实际项目调整。
# -*- coding: utf-8 -*- """ 主题行生成策略示例: 规则模板 + LLM 候选 + 校验兜底 """ import re from dataclasses import dataclass from typing import List SUBJECT_MAX_LEN = 60 FORBIDDEN_WORDS = ["免费", "秒杀", "暴富", "点击领取"] # 示例,按业务维护 @dataclass class SubjectCandidate: text: str score: float source: str # rule / llm / fallback def rule_subject(prefix: str, key_info: str, seq_no: int) -> str: """规则模板:适合结构固定的业务主题行""" safe_key = key_info.strip()[:20] return f"{prefix}-{safe_key}-{seq_no:04d}" def validate_subject(text: str): """校验器:不依赖模型,保证主题行基础质量""" if not text or text != text.strip(): return False, "空内容或首尾空格" if len(text) > SUBJECT_MAX_LEN: return False, f"超长:{len(text)}" if any(w in text for w in FORBIDDEN_WORDS): return False, "命中敏感词" if re.search(r"[\u2014\u2013\u2026]{3,}", text): return False, "存在连续破折号或省略号" return True, "ok" def pick_subject(candidates: List[SubjectCandidate]) -> SubjectCandidate: """选择通过校验的候选,否则走兜底""" valid = [c for c in candidates if validate_subject(c.text)[0]] if not valid: return SubjectCandidate(text="待处理", score=0.0, source="fallback") return max(valid, key=lambda c: c.score) def llm_candidates(summary_text: str, prefix: str, n: int = 3) -> List[SubjectCandidate]: """ 调用 LLM API 生成候选。这里用一个占位函数代替实际请求, 你需要替换为你自己的模型服务地址和鉴权方式。 """ prompt = ( f"请为下面内容生成 {n} 个邮件主题行候选," f"每个不超过 {SUBJECT_MAX_LEN} 字,必须包含前缀 {prefix!r}," "不要使用夸张宣传词,不要使用表情符号,直接输出 JSON 数组。\n\n" f"内容:{summary_text[:500]}" ) # resp = your_llm_api_call(prompt) # items = json.loads(resp) # return [SubjectCandidate(text=i["text"], score=i.get("score", 0.5), source="llm") for i in items] return []这里的关键点在于validate_subject不感知模型类型,不依赖提示词是否“听话”。即使是本地部署的模型,或者不同版本的模型,校验逻辑都保持一致。
4.3 策略配置示例
主题行策略应该配置化,而不是把规则硬编码在代码里。下面是一个 JSON 配置示例:
{ "policy": { "max_length": 60, "forbidden_words": ["免费", "秒杀", "暴富", "点击领取"], "require_prefix": true, "prefix_pool": ["工作日报", "工单", "告警"] }, "llm": { "provider": "your-llm-provider", "model": "your-model-name", "temperature": 0.3, "max_tokens": 128, "timeout_seconds": 30 } }max_tokens不需要太大,主题行本身只有几十个字。把max_tokens控制在 128 以内,可以减少模型生成多余解释的成本。
5. 工程化落地:API 服务与批量任务
5.1 独立 API 服务
主题行生成逻辑应该独立成一个服务或模块,而不是散落在业务代码里。这样做的原因有三个:
- 邮件、工单、内容发布等不同系统都可以复用同一套校验逻辑。
- 策略配置可以独立更新,不需要重新发版。
- 生成日志、调用量、失败率可以统一监控。
假设服务端口是8000,接口路径是/api/subject-generate,一个请求示例如下:
curl -X POST "http://127.0.0.1:8000/api/subject-generate" \ -H "Content-Type: application/json" \ -d '{ "prefix": "工作日报", "content": "今天完成用户模块重构,修复三个线上问题,新增批量导出功能。", "seq_no": 42, "source": "ticket" }'返回结果:
{ "subject": "工作日报-用户模块重构-0042", "fallback": false, "validated": true, "llm_used": false, "candidates": [ { "text": "工作日报-用户模块重构-0042", "score": 1.0, "source": "rule" } ] }如果业务方希望 LLM 优化主题,可以在请求里增加"optimize": true开关。注意这个开关默认应该是关闭的,只有明确需要时才打开。
5.2 Python 批量调用示例
批量任务是主题行生成最常见的场景。一次性处理几百封邮件、几百张工单时,可以使用下面的 Python 示例:
import time import json import requests API_URL = "http://127.0.0.1:8000/api/subject-generate" tasks = [ {"prefix": "工单", "content": "客户反馈登录超时,需要排查网关延迟", "seq_no": 1001}, {"prefix": "工单", "content": "订单导出功能报错,需要定位文件生成服务", "seq_no": 1002}, {"prefix": "告警", "content": "磁盘使用率超过 90%,需扩容", "seq_no": 1003}, ] def log_warning(task, result): print(f"[WARN] task={task.get('seq_no')} result={result}") def retry_with_backoff(task, max_retries=3): for attempt in range(max_retries): try: resp = requests.post(API_URL, json=task, timeout=30) return resp.json() except requests.exceptions.Timeout: wait = 2 ** attempt print(f"[RETRY] task={task.get('seq_no')} attempt={attempt} wait={wait}s") time.sleep(wait) return None for task in tasks: result = retry_with_backoff(task) if result is None: log_warning(task, {"error": "timeout after retries"}) elif not result.get("validated"): log_warning(task, result) else: print(f"[OK] seq_no={task['seq_no']} subject={result['subject']}")批量任务建议加三个能力:
- 任务 ID:每批任务生成一个任务 ID,日志里要能关联到具体请求。
- 失败重试:超时和 5xx 错误需要指数退避重试,但不要无限制重试。
- 人工审核通道:凡是
fallback=true或validated=false的结果,一律进入待审核列表。
5.3 与 MCP、Agent 编排的对接
主题行生成服务完全可以作为 MCP 工具或 Agent 内部工具暴露。调用方式有两种:
- 外挂工具:Agent 在需要发送通知、创建工单时,调用主题行生成工具,拿到校验后的主题再执行下一步。
- 内部编排:在业务系统里使用 SpringAI、LangChain 这类编排框架时,把主题行生成节点放在 LLM 输出节点之后,做一个强制校验节点。
关键点是一个:不要允许 Agent 直接把模型输出的第一个字符串当作主题行使用。所有 Agent 生成的标题类字段,都应该经过同一个校验接口。
6. 功能验证与效果测试
6.1 测试用例设计
主题行生成策略的测试不能只测“能不能跑通”,要围绕控制变量设计用例:
| 测试维度 | 输入示例 | 预期表现 | 判断标准 |
|---|---|---|---|
| 基础规则 | prefix=工单, seq_no=1 | 生成符合模板主题 | 包含前缀和编号 |
| 长文本压缩 | 正文大于 500 字 | 主题不超过 60 字 | 不截断业务关键信息 |
| 敏感词过滤 | 正文包含“免费” | 候选被过滤或走兜底 | 返回 fallback=true |
| 空内容 | content 为空 | 返回兜底主题 | 不报异常 |
| 超长主题 | 候选超过 60 字 | 校验失败并过滤 | 最终主题符合长度 |
| 特殊字符 | 正文包含 emoji | 校验失败或清洗 | 主题中没有 emoji |
| 批量一致性 | 50 个相似任务 | 同一批次主题风格一致 | 视觉抽查无明显差异 |
6.2 验证步骤
- 先只启用规则模板,不开启 LLM 候选,跑一批真实业务数据。
- 检查所有主题行是否包含前缀、编号、关键业务信息,以及长度是否符合预期。
- 开启 LLM 候选,但保留规则主题为保底,对比模型候选与规则主题的差异。
- 统计 LLM 候选的通过率:如果通过率过低,优先调整提示词和温度参数,而不是直接放宽校验条件。
- 加入人工审核抽样,每周随机抽 20 条主题行,记录风格、准确率、敏感词遗漏情况。
6.3 成功标准
一套合格的主题行生成策略,至少应该满足:
- 100% 的主题行通过长度和敏感词校验。
- 100% 的主题行包含业务前缀。
- 兜底触发率低于 5%。
- 人工审核抽样中,主题行与正文内容的相关性评分高于 90%。
这些标准不是拍脑袋定的,而是建议你在测试环境里先跑两周真实数据后,根据自己的业务调整。
7. 资源占用与性能观察
7.1 短字段走规则能显著减少 token 消耗
如果让 LLM 直接写主题行,每次请求至少消耗几百 token:提示词模板、正文摘要、模型输出。批量任务跑一万条时,这部分消耗会非常可观。
使用规则模板 + 校验器的方案后,只有“确实需要优化”的请求才会调用 LLM。合理配置下,可以减少 70% 以上的主题行生成 LLM 调用量。具体数字依赖业务场景,建议自己在日志里加 token 统计和成本统计。
7.2 本地模型部署时的观察点
如果你在本地部署 LLM,核心观察指标有这几个:
- 单次请求延迟:主题行生成服务应该是低延迟的,建议在 2 秒以内。
- 并发请求下的显存占用:本地部署模型时,显存占用取决于模型参数量、量化精度、并发数。主题行生成任务通常不需要大模型,优先选择小模型或量化版本。
- 请求排队情况:如果主题行生成服务和正文生成服务共用同一个模型推理进程,要给主题行请求配置更高的优先级,或者直接拆成独立进程。
7.3 如何观察
建议在日志里记录:
llm_called:本次请求是否调用了 LLM。llm_latency_ms:LLM 调用耗时。rule_latency_ms:规则生成耗时。validation_latency_ms:校验耗时。final_source:最终结果的来源,是 rule、llm 还是 fallback。total_tokens:LLM 请求消耗的 token 总数。
把这些指标接入 Prometheus 或任意监控大盘后,你就能看到每天的主题行生成成本、失败率、兜底率,而不是凭感觉优化。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 主题行出现敏感词 | 校验器未命中或词表不完整 | 检查日志里的校验结果和词表 | 扩充禁用词表,加入近义词匹配 |
| 主题行超长 | 校验器长度限制未生效 | 检查配置文件和代码路径 | 确认max_length被读取 |
| LLM 候选全部不通过 | 提示词约束不够或用词不匹配 | 检查 LLM 返回的原始结果 | 调整提示词,降低温度,增加示例 |
| 主题行风格乱跳 | 温度过高或模型版本不固定 | 查看候选来源分布 | 温度调到 0.3 以下,固定模型版本 |
| 批量任务卡住 | 单条请求超时或并发过高 | 查看任务日志和接口监控 | 增加超时控制、指数退避重试、并发限流 |
| API 调用返回 404 | 接口路径或服务未启动 | 检查服务日志和路由配置 | 统一服务入口,使用健康检查 |
| 兜底触发率过高 | LLM 质量差或校验器过严 | 统计不通过原因分类 | 按原因逐个调整,不要一刀切放宽校验 |
| 主题行与正文不相关 | 传入的正文摘要过长或缺失关键信息 | 检查构造请求时的正文截断逻辑 | 优先传入结构化关键字段,而非整段正文 |
8.1 一个容易忽略的问题:模型缓存
LLM 服务如果开启了缓存,相同提示词可能返回相同结果。这在主题行生成里不一定是好事。如果你的业务要求主题行有细微变化(例如 A/B 测试),需要关闭缓存或给提示词加随机化参数。反之,如果业务希望同一条工单重试时得到稳定主题,则要保持提示词一致。
8.2 主题行与正文不一致问题
模型生成主题时,可能从正文里抽取了一个次要信息作为主题,导致用户点开后发现内容对不上。解决方式有两种:
- 在提示词里要求模型只使用
summary字段,不要依赖正文细节。 - 在校验器里增加“关键词覆盖”检查:把正文里的核心关键词提取后,检查主题是否包含至少一个。
关键词覆盖检查可以使用简单的 TF 权重或直接从结构化字段读取,不需要再调用一次 LLM。
9. 最佳实践与使用建议
9.1 默认禁用 LLM 优化
在所有业务系统里,把 LLM 优化主题行的开关默认设为关闭。只有在以下情况开启:
- 规则模板确实无法覆盖。
- 业务方明确要求标题更接近自然语言。
- 有专门的人工审核兜底。
默认禁用不是保守,而是避免 model 的随机性渗透进所有流程。
9.2 合规与隐私边界
主题行出现在邮件、短信、通知推送等用户可见的场景里,必须遵守以下边界:
- 不生成误导性文案,尤其是“退款失败”“账号异常”这类容易引发恐慌的主题行,虚假信息必须避免。
- 不生成夸大宣传词,营销场景下要符合平台规范。
- 不把用户隐私数据直接放在主题里,例如身份证号、银行卡号、合同金额。
- 使用第三方 LLM API 时,如果正文摘要包含客户信息,需要确认数据出境和合规策略。
- 涉及声音、人脸、品牌素材时,必须先确认授权;不要用主题行生成绕过审核。
9.3 数据与目录管理
主题行生成的配置文件、禁用词表、日志目录、审核队列要分目录管理:
subject-gen/ ├── config/ │ ├── policy.json │ └── words/ │ ├── forbidden.txt │ └── prefixes.txt ├── logs/ │ ├── access.log │ └── audit.log ├── output/ │ ├── approved/ │ └── pending_review/ └── src/ ├── generator.py ├── validator.py └── api.py禁用词表和前缀词表建议使用文本文件维护,这样运营或审核人员不需要改代码。
9.4 持续评估
把主题行生成效果纳入稳定性评估,而不是只在上线前测一次。每周抽样的基础上,每月统计一次兜底率、LLM 通过率、人工审核通过率。如果某个指标连续两周下降,优先检查是不是正文输入发生了变化,而不是直接调大模型。
10. 总结与下一步
这个原则最值得先验证的一点:在现有邮件或工单系统里,把主题行改成“规则模板 + 校验器”之后,线上质量问题会减少多少。不需要大规模改造,先把规则模板部分上线,观察一周,再决定是否把 LLM 候选加进来。
最容易踩的坑不是校验器漏了敏感词,而是你高估了 LLM 在短文本上的稳定性。第一次接入时,优先验证批量场景,一次跑几十条真实业务数据,看过候选分布后再放宽策略。
后续可以继续扩展的方向:
- 把主题行生成服务封装成 MCP 工具,让 Agent 在通知、建单流程里自动调用。
- 在 SpringAI、LangChain 这类编排框架里加入强制校验节点,拦截模型原始输出。
- 结合 RAG 场景做主题行来源过滤,避免把检索片段里的版权内容或敏感信息拼进标题。
- 为主题行 A/B 测试设计自动实验平台,用真实点击数据反推模板和 LLM 候选哪个更有效。
先做最小闭环:一条规则模板,一个校验器,一个待审核队列。跑通了再谈模型优化。