最近继续使用 SlyTranslate + GLM-5.2 翻译 WordPress 中文博客时,遇到了一个之前偶尔出现过的问题。
中文文章已经正常保存,在 Gutenberg 编辑器里也看不出什么异常,但点击 SlyTranslate 的“Translate now”以后,翻译直接失败:
A paragraph run did not contain a top-level paragraph.
以前我也遇到过类似情况。当时曾经通过一个比较奇怪的方法绕过去:在中文文章末尾增加一个空段落,保存以后再删除这个空段落,然后重新保存。
那一次重新翻译就成功了。
所以这次最开始我也重复尝试了几次,但已经没有任何作用。
既然文章内容本身看不出异常,继续反复修改 Gutenberg 区块也没有太大意义,于是决定直接查找错误到底来自哪里。
一、先定位错误来自哪里
我在 WordPress 生产目录中搜索完整的报错文本:
grep -RniF \
--include='*.php' \
--include='*.js' \
'A paragraph run did not contain a top-level paragraph.' \
wp-content/plugins \
wp-content/mu-plugins结果只有一处:
wp-content/mu-plugins/swq-glm52-translation-tuning.php:2193:
return new WP_Error( 'swq_full_article_paragraph_run_empty', 'A paragraph run did not contain a top-level paragraph.' );
这一步把排查范围缩小了很多。
错误并不是 SlyTranslate 核心插件直接产生的,而是来自我此前为 GLM-5.2 整篇 Gutenberg 翻译增加的 MU Plugin:
swq-glm52-translation-tuning.php
因此,问题更可能出现在我自己实现的 Paragraph Run 解析与恢复逻辑。
二、Paragraph Run 的作用
为了让长篇 Gutenberg 文章能够整篇交给 AI 翻译,同时尽量保护 WordPress 区块结构,我之前增加了一套 Paragraph Run 机制。
简单来说,连续的普通顶层段落可以组成一个 Paragraph Run。
例如:
<p>第一段。</p>
<p>第二段。</p>
<p>第三段。</p>这些连续段落可以作为一个整体交给模型翻译。
这里并不要求翻译前后的 paragraph 数量完全一致。
自然语言翻译过程中,模型可能认为两个很短的中文段落合并成一个英文段落更加自然,也可能把一个很长的中文段落拆分成两个英文段落。
因此 Paragraph Run 本身允许:
3 个 paragraph → 2 个 paragraph
也允许:
1 个 paragraph → 2 个 paragraph
真正需要严格保持的是 Paragraph Run 外面的 Gutenberg 结构,例如标题、列表、引用、图片、代码块、表格以及其他受保护区块。
这些结构不能因为翻译而被删除、跨越或者随意重排。
三、真正的问题出在 <p> 解析
继续检查错误附近的代码以后,很快发现了真正值得怀疑的位置:
preg_match_all( '~<p>(.*?)</p>~su', $html, $matches, PREG_OFFSET_CAPTURE );
if ( empty( $matches[0] ) ) {
return new WP_Error(
'swq_full_article_paragraph_run_empty',
'A paragraph run did not contain a top-level paragraph.'
);
}原来的正则只认识严格的:
<p>Text</p>但是不能识别:
<p class="model">Text</p>或者:
<p style="color:red">Text</p>从 HTML 本身来看,这些仍然是 <p> 段落。
但是对于原来的 Paragraph Run parser 来说,只要 opening tag 上出现 attribute,就无法匹配。
如果一个 Paragraph Run 中没有匹配到任何符合 ~<p>(.*?)</p>~ 的内容,就会返回:
swq_full_article_paragraph_run_empty
最后在 SlyTranslate 中显示:
A paragraph run did not contain a top-level paragraph.
所以这个错误提示其实并不是说程序经过完整 DOM 分析以后确认“没有顶层 paragraph”。
真正发生的是:
模型返回的 paragraph wrapper 不再严格等于裸 <p>,而旧 parser 又只认识裸 <p>。
四、问题来自模型返回的 wrapper 漂移
源文章本身并不是这次问题的重点。
当前 Paragraph Run 逻辑只会把符合条件的普通 Gutenberg paragraph 放入 run;带特殊 Gutenberg attributes 的 paragraph 仍然走更严格的结构保护路径。
真正可能发生变化的是 AI 返回结果。
例如发送给模型的是:
<p>第一段。</p>
<p>第二段。</p>正常情况下应该返回:
<p>First paragraph.</p>
<p>Second paragraph.</p>但模型偶尔可能自行增加 HTML attribute:
<p class="something">First paragraph.</p>
<p>Second paragraph.</p>对于浏览器来说,第一个仍然是完全可以理解的 <p>。
但对原来的正则:
~<p>(.*?)</p>~来说,它已经不是符合要求的 paragraph。
这也解释了为什么文章在 Gutenberg 编辑器中肉眼看不出问题。
真正发生变化的地方并不是中文源文章,而是翻译模型返回的 HTML。
五、最终只做一个很小的兼容修复
由于现有翻译流程已经运行得比较稳定,所以这次没有重新设计 Paragraph Run,也没有改变它原来的 merge/split 行为。
最终只修改两个文件:
swq-glm52-translation-tuning.php
swq-paragraph-run-test.php
整个提交只有:
2 files changed, 12 insertions(+), 6 deletions(-)真正的核心修改,是把原来的:
preg_match_all( '~<p>(.*?)</p>~su', $html, $matches, PREG_OFFSET_CAPTURE );调整为:
preg_match_all( '~<p(?:\s+[^<>]*)?>(.*?)</p\s*>~isu', $html, $matches, PREG_OFFSET_CAPTURE );这样除了原来的:
<p>...</p>也能够识别模型偶发返回的:
<p class="model">...</p>或者:
<p style="color:red">...</p>不过,仅仅能够识别还不够。
模型自己生成的 paragraph attributes 并不应该因此进入最终的 Gutenberg 文章。
所以恢复时不再直接使用模型返回的完整 wrapper,而是只取其中的 inner HTML:
$paragraphs[] = '<p>' . $inner . '</p>';例如模型返回:
<p class="model">English A.</p>本地最终只会保留:
<p>English A.</p>然后重新生成标准 Gutenberg paragraph:
<!-- wp:paragraph -->
<p>English A.</p>
<!-- /wp:paragraph -->也就是说,这个修复的处理原则是:
可以容忍模型偶尔给 <p> wrapper 增加 attribute,但不会信任和保存这些 attribute。

<p> wrapper 解析,并在恢复时丢弃模型生成的 attributes六、原有结构保护没有放宽
支持 <p class> 和 <p style>,并不意味着 Paragraph Run 开始接受任意 HTML。
原来的结构保护规则仍然存在。
例如 Gutenberg comments、heading、list、image、blockquote、pre、nested paragraph、非 paragraph 根节点内容等仍然会被拒绝。
空 paragraph 和只有标点符号的 paragraph 也仍然无法通过验证。
原有的 protected token 和 inline HTML balance 等检查也没有因为这次修改而放宽。
因此,这次并不是把 Paragraph Run 的验证规则整体变宽。
真正放宽的只有一个很具体的兼容边界:
原来只能识别裸
<p>,现在可以识别模型偶发返回的带 attributes<p>,然后在恢复时重新归一化成裸<p>。
七、确认原来的 merge/split 能力没有受到影响
代码修改以后重新执行 Paragraph Run 测试:
php swq-paragraph-run-test.php全部测试通过。
其中我比较关注的是原有段落重组能力:
PASS: post 25425 pattern: consecutive source paragraphs may merge without paragraph STRUCT tokens
PASS: merged run restores one standard Gutenberg paragraph
PASS: one source paragraph may split into two target paragraphs
PASS: split run restores two standard Gutenberg paragraphs
PASS: three source paragraphs may become two target paragraphs说明原来的 Paragraph Run 行为没有因为这个 Bug 修复发生变化。
这次新增的两类情况同样通过:
PASS: model class paragraph wrapper is accepted as a non-structural wrapper
PASS: model class paragraph wrapper attributes are discarded during standard restoration
PASS: model style paragraph wrapper is accepted as a non-structural wrapper
PASS: model style paragraph wrapper attributes are discarded during standard restoration最终:
ALL TESTS PASSED与此同时,原来的各种非法情况仍然继续被拒绝。
这比单纯让当前这一篇文章翻译成功更加重要,因为一个局部 Bug 修复不应该破坏已经长期正常工作的其他行为。
八、用原来失败的文章进行生产验证
修改完成以后,我先备份生产环境原来的 swq-glm52-translation-tuning.php,再将修复后的版本部署到 WordPress。
部署时使用 SHA-256 核对文件,并再次执行 PHP 语法检查。
完成以后,没有再修改那篇出错的中文文章。
没有增加空段落,也没有删除空段落,没有调整 Gutenberg 区块,更没有通过重新保存文章尝试改变它的 serialization。
我直接在原文章上再次点击:
Translate now
这一次翻译成功。
这个结果也基本完成了整个问题的验证闭环:
同一篇文章、同样的中文正文,只修改 Paragraph Run parser 后,原来的错误消失。
因此,可以排除之前通过增删空段落偶然成功所带来的干扰。
这次真正需要修复的是模型输出与本地 parser 之间的兼容问题。
九、提交修复
生产环境真实翻译成功以后,才正式提交代码:
3e2d43c
fix: 修复 paragraph run 带属性 p 标签解析最终修改规模仍然只有:
2 files changed, 12 insertions(+), 6 deletions(-)对于一个已经运行比较稳定的自动翻译流程,我更希望看到这样的修改规模。
找到明确 Bug,然后只补上这个兼容边界,而不是为了一个低概率异常重新调整整个翻译协议。
十、总结
这次最开始看到:
A paragraph run did not contain a top-level paragraph.很容易怀疑是 Gutenberg 中文文章本身的结构存在问题。
尤其是以前通过“增加一个空段落、删除以后重新保存”曾经偶尔解决过类似问题,更容易把排查方向带到文章 serialization 上。
但最终真正的问题非常小:
模型偶发返回:
<p class="...">...</p>而旧 Paragraph Run parser 只认识:
<p>...</p>于是一个实际上仍然合法的 paragraph,被误判成了“没有 top-level paragraph”。
最终的解决方式也比较简单:
扩大 <p> wrapper 的识别范围,但只提取 inner HTML;模型自行添加的 attributes 不进入最终 Gutenberg 内容。
这样既解决了当前翻译失败,也没有改变 Paragraph Run 原来允许 merge/split 的行为,更没有放宽其他 Gutenberg 结构保护。
对于已经经过大量文章验证、整体运行比较稳定的翻译流程,我现在越来越倾向于这种处理方式:
定位具体问题,做最小修复,用回归测试保护原有行为,再拿真实失败文章完成最终验证。
这一次,问题解决了,原来的翻译能力也完整保留下来。
需要长期技术维护或远程问题排查?
我是拥有 15+ 年经验的 PHP / Go 后端工程师,长期关注已有系统维护、Bug 修复、性能优化、服务器排查、WordPress 网站维护和小功能迭代。
如果你的项目遇到以下情况,可以先从一次小问题排查开始合作:
- ✅ PHP / Laravel / Yii2 老项目无人维护
- ✅ Go / Gin 后端接口需要排查或优化
- ✅ WordPress 网站访问慢、报错或插件冲突
- ✅ Nginx / MySQL / Redis / Linux 服务器异常
- ✅ CDN / Cloudflare / DNS / HTTPS 配置问题
- ✅ 需要长期远程技术支持或兼职维护
更多介绍请查看:关于我 & 合作
微信:13980074657
邮箱:shuijingwanwq@gmail.com
Telegram:@shuijingwan
GitHub:https://github.com/shuijingwan
