约束解码写诗:两条走不通的路
用字符级 Transformer 和 BERT Diffusion 分别做了一轮约束解码实验,试图让小模型写出格律正确的古诗。格律确实能控住,但语义散了。这篇是过程复盘。
这篇文章讲什么
2025 年秋天,我在 AI 作诗项目(天权)的早期阶段做了两组实验,探索一个很直觉的想法:能不能在解码阶段直接把格律规则硬编码进去,让一个小模型也能写出平仄工整、押韵正确的格律诗?
两条技术路线:一条是自回归的(字符级 Transformer + FSM logit masking),一条是非自回归的(BERT masked diffusion + 后处理约束修复)。两个实验都跑通了,格律准确率都能拉到很高——但生成出来的诗,读起来像是把一堆符合平仄的字拼在一起,不像是有人在说话。
这两个实验后来都没有继续。它们的价值不在于产出了什么,而在于帮我排除了一条路:约束解码写诗,最符合直觉,但本质上是个玩具。
路线一:自回归 + FSM 约束
第一个实验叫 BabyGPT / NanoPoet。思路很简单:从零训练一个字符级的 GPT,用全量古诗词语料(poems.json 里的几万首诗词全部喂进去),跑了大概 4000 步,然后在推理时用有限状态机(FSM)逐字控制生成过程。
模型架构没什么花的——8 层 TransformerEncoder,128 维 embedding,4 头注意力,字符级 tokenization。训练数据做了增强:每首诗前面加一个元数据头,像 <体裁:七律> <韵部:一东> <作者:苏轼>,让模型在生成时能 condition on 体裁和韵部。
核心在推理阶段的 NeuroSymbolicGenerator。它预先把格律规则编译成一条约束链——每个位置该填汉字还是标点、平声还是仄声、是否韵脚——然后在模型每一步输出 logits 之后,用 mask 把不合规的 token 概率压到负无穷。比如当前位置要求仄声字,就只放行仄声字和两可字的 logits,其他全部屏蔽。
效果怎么说呢——格律是真的对了。你指定七律平起首句入韵、一东韵,出来的每个字都在格律框架里。但读起来有一种很诡异的感觉:每个字都对,但连在一起不通。就像你用「只能用仄声字」这个约束过滤了整个词表,剩下的候选里模型尽力挑了一个概率最高的——但那个概率最高的字,在语义上可能根本不该出现在这里。
模型太小了,它本身对语义的建模就很有限;你再用 mask 砍掉它一半的候选空间,剩下的就没什么好选的了。 不加约束的时候,生成质量大概是”梦到哪句说哪句”的水平——每句单看勉强有点意思,连起来完全没有逻辑。加了约束之后,连”梦话”的流畅度都没了。
不过这个小模型也带来了一个意外的发现:它认为词是更”冷”的诗。 在 temperature 1.0~1.2 的时候,模型倾向于输出七言诗(整齐的句式);把温度降到 0.5~0.7,它开始输出长短句——也就是词的形式。温度到 2.0 左右就是纯胡言乱语,低于 0.5 则陷入复读机循环。
这说明一件有趣的事:即使是这么小的模型,它也从语料分布里隐式地学到了诗和词的结构差异。词的句式变化更多、用字更讲究,对应的概率分布更”尖锐”,需要更低的温度才能采样出来。温度这个连续旋钮,意外地映射出了诗词之间的形式边界。
但那天最后我写下的结论是:“其实做到目前的效果都不如大一做的狗屁不通宋词生成器。” 那个生成器没有任何神经网络,做的事情非常朴素——把单字和双音节词按平仄、韵脚分组,然后随机抽取高频词往词谱模板里填。纯随机,纯规则,零学习。但它填出来的东西,读感居然不比 BabyGPT 差——因为它至少保证了每个位置填的都是高频的、“像诗词”的词,而 BabyGPT 在约束之下选出来的字,往往既不高频也不像话。
这大概是整个实验里最让人清醒的时刻:你搭了 8 层 Transformer,训了 4000 步,写了上百行约束解码逻辑,最后的效果和一个大一写的随机填词脚本差不多。如果这都不能说明”小模型 + 约束解码”是条死路,我不知道什么能。
路线二:BERT Diffusion + 约束修复
第二条路线换了个思路:用非自回归的方式生成。
具体来说,是在 BERT-base-Chinese 上加 LoRA 微调,做 masked diffusion language modeling。生成过程不是从左到右逐字写,而是一开始把整首诗的位置全部 mask 掉,然后迭代去噪——每一步预测所有 mask 位置的候选字,保留置信度高的,把置信度低的重新 mask 回去,反复迭代直到收敛。
约束的施加方式也不同。自回归那边是在每一步 decode 时做 logit mask,这边是在生成完成后做后处理修复:用格律检查器扫描全诗,找到平仄或韵脚有错的位置,把那些位置的 token 重新 mask 掉,让模型重新预测,如此循环最多 25 轮。
这个方案在理论上有一个优势:因为 BERT 是双向的,每个位置的预测能看到上下文,所以修复某个位置的时候不会像自回归那样只能看到前面的字。而且 diffusion 的迭代去噪过程天然适合做全局优化——你不用一步到位,可以慢慢调。
实际跑出来的效果比 BabyGPT 好一些——至少偶尔能蹦出几句读得通的诗句。但模型没训好的时候,会严重过拟合到 prompt 里给的标题和作者名上。有一个 test case 用了带我发论文的抱木学长作为参考风格的作者,模型直接蹦出一句”不知抱木有多悲”——把作者名嵌进了诗里。后来这首诗被拿去做 diffusion language model 的演示 demo,于是”不知抱木有多悲”就成了一个在朋友间流传的梗。
说实话这个 bug 反而让我对 diffusion LM 多了一点信心:它至少在尝试理解 prompt 里的语义信息,虽然理解的方式很离谱。BabyGPT 可不会这么做——它根本不管 prompt 里写了谁。
但问题也很明显:
- 语义连贯性依然不够。后处理修复每改一个字,都可能破坏周围的语境,然后下一轮修复又要改别的字,陷入一种”按下葫芦浮起瓢”的循环。
- BERT 的生成能力天花板太低。它毕竟是个理解模型不是生成模型,硬拿来做生成,上限就在那里。
- 没有明确的 scaling 路径。BERT 太小,但换一个更大的 encoder 能不能 work?我没想好怎么论证这件事,也没办法说服自己”只要模型大一点就一定行”。
所以这条路也搁置了。
两个实验教会我什么
回头看,这两个实验最大的收获是一个关于约束解码的认知:
约束解码是一种作弊式的正确。 你确实可以通过 logit mask 或后处理修复,让输出 100% 符合格律规则。但格律正确和诗写得好是两件事。当你在 logit 层强行限制候选空间的时候,你其实是在跟模型的语义建模抢方向盘——模型想往语义通顺的方向走,你非得让它拐进一个平仄正确的死胡同。模型越小,这个冲突越剧烈。
这个认知直接影响了后来天权的架构设计。天权没有用约束解码,而是走了另一条路:用大量数据微调一个足够大的模型,让它在训练阶段就把格律规则内化成直觉,然后用一个 Agent 架构(生成 → 检查 → 修推敲)做后处理修复。区别在于,修推敲模型看到的是完整的诗和具体的错误描述,它是在”理解了这首诗在说什么”的基础上换一个字,而不是在 logit 空间里盲选。
还有一个更大的感悟:约束解码写诗,虽然最符合直觉——格律不就是规则吗,规则不就应该硬编码吗——但它终究是个玩具级的方案,不是通往真正好用的 AI 作诗系统的正确姿势。真正的路径是让模型自己学会规则,而不是从外面给它戴枷锁。
当然,这两个实验也给了我很实在的东西:第一次从零搭一个 Transformer、第一次自己写训练循环、第一次处理字符级 tokenization——这些手感,后来做天权的时候全用上了。
Q&A
Q: 为什么要用字符级 tokenization 而不是 subword?
因为格律约束是字级别的——平仄、押韵都是逐字判定的。如果用 subword tokenization,一个 token 可能对应半个字或者两个字,约束就没法精确施加了。字符级 tokenization 虽然序列更长、训练更慢,但对于这个任务来说是唯一自然的选择。
Q: 自回归和 diffusion 两条路线,哪个效果更好?
各有千秋但都不够好。自回归那边格律控制更精确,因为你是逐字生成、逐字约束,每一步都保证合规。Diffusion 那边全局连贯性稍微好一点,因为 BERT 双向注意力能看到上下文,但后处理修复循环容易把语义改散。总体来说,两个方案在”小模型 + 约束解码”这个框架下都撞到了同一个天花板:格律对了,语义不行。
Q: 如果给你无限算力,约束解码这条路能走通吗?
我觉得不能。问题不在算力,在思路。约束解码的本质是在模型外部施加硬约束,这会和模型内部的语义建模产生冲突。模型越大、语义建模越强,这个冲突反而可能越尖锐——因为你要否决的候选质量更高了。天权后来的实验也验证了这一点:用 GLM-5.1 这种大模型做约束解码,语义反而比小模型被破坏得更严重,因为大模型对每个位置的预测已经很有信心了,你强行改掉它的选择,代价更大。正确的方向是让模型在训练阶段就内化这些规则,而不是在推理阶段从外部强加。
Q: “词是更冷的诗”是怎么发现的?
调 temperature 的时候偶然发现的。本来只是想看不同温度下生成质量的变化,结果发现温度高的时候(1.0~1.2)模型更容易输出七言齐言诗,温度降到 0.5~0.7 的时候反而开始输出长短句——词的形式。说明模型从语料分布里学到了一个隐式的结构:词的用字分布比诗更”尖锐”,需要更低的采样温度才能浮现出来。这大概是整个 minigpt 实验里最有意思的一个副产品了——你从一个”失败”的实验里也能看到模型确实在学东西,只是学到的层次还不够深。
Q: 这两个实验花了多长时间?
前后大概一个月。BabyGPT 是先做的,从写模型到跑出第一首”格律正确但语义很烂”的诗大概两周。然后觉得自回归的路有瓶颈,又花了两周做 BERT diffusion 的实验——效果比 BabyGPT 好一点,还贡献了”不知抱木有多悲”这个名场面。剩下的时间在调参和写数据处理 pipeline。说实话,写代码的时间远少于等训练跑完和盯着生成结果发呆的时间。