LLM输出倾向使用\(...\)格式,但主流Markdown插件只支持$...$;作者通过两个npm包(micromark-extension-math-extended、remark-math-extended)解决格式转换问题。
TL;DR——LLM 偏爱 \(...\) 和 \[...\],而大多数 Markdown 数学插件只支持 $...$ 和 $$...$$。我试图用正则表达式弥合两者之间的差异,结果以一些颇有意思的方式失败了,最终决定直接教 tokenizer 识别这些语法。由此诞生了两个 package:micromark-extension-math-extended 和 remark-math-extended。
有时候,渲染数学公式最难的部分并不是公式本身,而是就公式从哪里开始、到哪里结束达成一致。
我把一些 OpenAI 模型生成的 Markdown 输入渲染流水线,结果输出总是长这样:
The lift coefficient is \(C_L\).
\[
L = \frac{1}{2} \rho v^2 S C_L
\]
这本身没有任何问题,完全是教科书式的 TeX:
\(...\) → 行内数学公式
\[...\] → 块级数学公式
但我工具链里的每一个 Markdown 数学 package 都要求使用美元符号:
The lift coefficient is $C_L$.
$$
L = \frac{1}{2} \rho v^2 S C_L
$$
只差两个字符,却耗掉了我几周的人生。开始吧。
最显而易见的做法,是在模型输出进入 parser 之前先做预处理:
\(x\) -> $x$
\[x\] -> $$x$$
正则表达式可以处理一切顺利的理想情况。但真实世界里的 Markdown,从来都不是理想情况。
你的转换器必须避免修改出现在以下位置的分隔符:
最后一种尤其棘手:
\begin{cases}
x \\[1em]
y
\end{cases}
\\[1em] 是一个带可选间距的 TeX 换行命令。这里的 [ 并不是块级公式的起始位置。你的正则表达式并不知道这一点。事实上,你的正则表达式从来就什么都不知道。
然后还有真正危险的情况:
\[
not closed
# Everything after this
如果模型忘记输出闭合的 \]——在流式传输时这种情况经常发生——一个简单粗暴的转换器会把文档剩余的所有内容都吞进一个巨大的公式里。
等你把这些情况全都处理好之后,恭喜:你写的已经不是预处理器了,而是第二个 Markdown parser,而且现在你得同时维护两个 parser。
所以,我没有选择在解析前重写输入,而是直接把 TeX 风格的分隔符加入了 micromark tokenizer。只要一次正确解析,前面提到的所有问题就都不再需要你操心。
最终,这件事变成了两个 package。
这是底层版本,适合直接使用 micromark 的项目。
npm install micromark-extension-math-extended
import {micromark} from 'micromark'
import {math, mathHtml} from 'micromark-extension-math-extended'
const markdown = String.raw`
The lift coefficient is \(C_L\).
\[
L = \frac{1}{2} \rho v^2 S C_L
\]
`
const html = micromark(markdown, {
extensions: [math()],
htmlExtensions: [mathHtml()]
})
console.log(html)
三种形式都可以正常工作,并且会保留符合预期的语义:
这是更高层的版本,适用于 remark / unified,可以直接替代 remark-math:
npm install remark-math-extended
import rehypeKatex from 'rehype-katex'
import rehypeStringify from 'rehype-stringify'
import remarkMath from 'remark-math-extended'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
const markdown = String.raw`
The lift coefficient is \(C_L\).
\[
L = \frac{1}{2} \rho v^2 S C_L
\]
`
const file = await unified()
.use(remarkParse)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify)
.process(markdown)
console.log(String(file))
这是我最在意的部分,所以值得单独列一个小标题。
规则是:\[ 必须存在与之匹配的 \]。如果不存在,parser 会回退到普通 Markdown,而不是吞掉文档剩余的所有内容。
如果你正在逐 token 渲染 LLM 的输出,那么严格来说,每一帧都是格式不完整的输入。一段只接收了一半的响应,不应该让你的 UI 突然膨胀成一个巨大的 KaTeX 块,然后一秒后又恢复正常。
tokenizer 还能区分真正的嵌套起始分隔符和 \\[1em] 这类合法的 TeX 语法。
正是因为这两种行为,我才选择在 parser 层解决问题,而不是使用正则表达式。
这里有一个刻意引入的不兼容行为:在标准 CommonMark 中,反斜杠用于转义标点,因此 \( 和 \[ 分别表示字面量 ( 和 [。启用 TeX 风格的分隔符后,这一行为会发生变化。
如果你需要恢复原来的行为:
math({backslashDelimiters: false})
unified().use(remarkMath, {
backslashDelimiters: false
})
无论是否启用这一选项,使用美元符号分隔的数学公式都能继续正常工作。
remark-math-extended 复用了现有的 mdast-util-math 语法树和 serializer。数学公式的值及其行内或块级语义可以在往返转换中保留下来,但序列化时会把分隔符统一规范为美元符号:
\(x\) -> $x$
\[x\] -> $$x$$
对于大多数渲染流水线来说,这没有问题。但如果你需要逐字节保留原始分隔符,那就不适用了。如果你有这种需求,请提一个 issue——我很想知道这种需求到底有多普遍。
📦 micromark-extension-math-extended
📦 remark-math-extended
如果你正在处理由 LLM 生成的 Markdown、科学写作内容,或者任何混合使用 Markdown 和 TeX 的场景:你遇到过哪些边界情况?我正在收集这些案例。\\[1em] 这个问题花了我长得有些难为情的时间才找到,而且我敢肯定,它绝不会是最后一个。
如果这些 package 让你免于再维护一个分隔符转换 parser,你可以请我喝杯咖啡 ☕
若要采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。