AI / LLM

用大模型改写邮件时,如何保留用户原有的排版格式

1807 字约 7 分钟

摘要邮件改写看似只是普通的润色,但用户从 Word 粘贴进来的字体、字号、下划线和表格,经常在改写后丢失。通过三个反例,我放弃了一个即将合并的 PR。

在 Auto Email Sender 中,有一个常用功能是“AI 润色”:用户先用模板写好一封联系导师或客户的邮件初稿,然后让大模型在保留原意的前提下,把措辞改得更得体、更自然。

用户输入的邮件通常不是纯文本,大多是从 Word 或网页复制过来的富文本:中英文字体混排、重点内容标黄加粗、段落首行缩进、超链接,甚至还有复杂的排版表格。

用户的预期很简单:我只是让你修改错别字和语病,不应该顺便把我调整好的格式全部改掉。

我最初的方案是:先去掉样式,把纯文本交给 LLM,拿到改写后的文本,再根据字符偏移量,把原始 HTML 中的字体和加粗标签重新放回去。

用几个简单句子测试时,效果很好,PR 也准备提交给团队合并了。

但是,三个边界情况让这个方案无法继续。

三个反例

消失的两个空格

原文中,Hello 和 world 之间有两个不换行空格(  )。

经过“提取文本、LLM 润色、回填样式”的流程后,这两个空格被浏览器的规范化处理合并成了一个普通空格。

视觉上的变化很小,但它说明:在富文本中,空格、空段落和换行本身就是结构的一部分,不只是样式。 一旦在提取纯文本时丢失,之后的回填就无法恢复。

同名不同样式的“AI”

模板正文中出现了两次 AI:

  • 第一处是一个小标题,使用的是无衬线字体 A;
  • 第二处在正文末尾,使用的是衬线字体 B。

大模型改写之后,句子中仍然有两个 AI。但由于文字变化导致字符偏移量全部错位,回填算法在整篇文档中搜索时,把第二处 AI 错误地匹配到了第一处的样式规则上。

原文位置原始字体回填匹配到的样式最终渲染结果
第一处 AI字体 A第 1 处字体 A(正确)
第二处 AI字体 B误匹配到第 1 处字体 A(被改错)
通俗比喻:拆掉毛衣重织,花纹也就找不回来了

为什么“提取纯文本,改写后再贴回原样式”一定会失败? 想象你有一件有花纹的毛衣(带排版的富文本):

  • 去掉样式:相当于把毛衣拆成一团纯色毛线(纯文本),交给织工(大模型)修整得更柔顺(改写润色);
  • 贴回样式:你希望照着原来的照片,把新毛线原样织回去。但织工在修整时增加了几针、减少了几针(句子长度和词序都变了),原来的花纹位置就对不上了。

文本一旦被 AI 重写,原来的字符位置也就失去了对应关系。

为什么不能用模糊匹配(Fuzzy Matching)来定位?

大模型改写的本质是重新组织语言:它可能把两个短句合并成一个长句,删掉多余的修饰词,甚至调换主谓宾的顺序。 用改写后的新文本反推改写前的位置,在逻辑上没有确定的解。文本一旦改变,依靠字符位置和字符比对建立的映射就不可靠了。

Word 私有属性中的中文字体

测试时,有用户从 Microsoft Word 复制了一段包含“微软雅黑”和“宋体”的内容。Word 导出的 HTML 包含大量无关的标签,例如 class="MsoNormal" 和 mso-bidi-font-family。

为了防止这些私有属性污染编辑器,清洗流程把所有带有 mso-* 前缀的内联样式都过滤掉了。英文部分看起来没有受影响,但中文字体信息恰好就存放在这些 Word 私有属性中。样式被删除之后,整封邮件的中文字体都退回到了系统默认的宋体。

什么是 mso 前缀样式?

从 Office Word 复制文本时,剪贴板中的 HTML 会包含几十种以 mso- 开头的专有样式。其中有些(如 mso-pagination)对网页没有意义,但有些(如 mso-ascii-font-family 和 mso-fareast-font-family)记录了西文字体和东亚文字字体的对应关系。一刀切地删除这些属性,容易误伤跨语言的字体设置。

放弃这个 PR

遇到这些问题后,最直接的反应是逐个打补丁:

  • 空格被合并?写一个正则保护  ;
  • 重复词错位?加入最长公共子序列(LCS)动态规划算法来计算相似度;
  • Word 样式丢失?写一个白名单,挑出需要保留的 mso 字段。

但在写这些代码时,我意识到这种做法只会让系统越来越复杂。

每次输入的内容稍有变化,这套依赖启发式猜测的算法就可能出现新的错位。我不能把一个依靠“碰运气猜位置”的系统部署到生产环境。

于是,我关闭了这个包含几十个 commit 的 PR。

重新设计:结构与内容分离

既然不能事后拼接,就应该让大模型从一开始就接触不到样式。

新方案的核心是:结构(HTML DOM、CSS、位置)由程序负责,文字内容由大模型替换。

【旧方案:事后猜测位置】
原始富文本 ──> 提取纯文本 ──> LLM 自由改写 ──> 正则 / LCS 回填(容易出错)
 
【新方案:锚点占位,结构与内容分离】
原始富文本 ──> 解析为 AST ──> 标记可编辑的锚点 ──> LLM 只改写锚点内容 ──> 按原位置写回
  1. 冻结 AST 结构: 富文本进入系统后,先被解析为抽象语法树(AST)。段落、样式标签、表格和空行节点都被完整保留,不参与文本序列化;
  2. 为文本设置锚点(Slotting): 只有允许编辑的纯文本叶子节点,才会被分配唯一的锚点 ID,例如 {{slot_1}}、{{slot_2}};
  3. 模型只负责填空: 发送给大模型的不再是可能被改写结构的富文本,而是一张键值表:
    • 输入:{"slot_1": "尊敬的王教授", "slot_2": "我拜读了您的论文..."}
    • 输出:{"slot_1": "尊敬的王老师", "slot_2": "最近研读了您近期的成果..."}
  4. 按原位置写回: 后端拿到新的文本后,沿着原始 AST 把内容写回对应的位置。字体、加粗标签和 Word 的核心样式都保持不变。
让模型只做它擅长的事

大模型擅长生成文本,但对闭合标签、十六进制颜色码(如 #1f2328)和 CSS 层叠规则并不敏感。如果把 HTML 直接交给模型改写,相当于要求它一边写文章,一边手写汇编代码。结构由本地程序保留,语义交给模型处理,系统才会稳定可靠。

总结

富文本保真的问题,本质上是系统架构的边界问题,而不只是前端排版问题。

当为了维护一个功能,需要在流程中加入大量正则、模糊匹配和偏移量补偿算法时,通常说明这个组件承担了不属于它的职责。

放弃那个问题很多的回填 PR,把大模型限定在“只填空、不改结构”的角色中,代码量减少了约三分之二。之后,无论用户从多复杂的 Word 文档中粘贴内容,AI 改写后的邮件都没有再丢失加粗,也没有出现字体错位。

评论