一个 LLM 输入法的实现
一个 LLM 输入法的实现
其实输入法用多了很容易发现自己几乎不用完整敲完每个词,只需要不断从候选词里挑一个继续,句子就自然地成型了。预测下一个 token 作为大模型的基础能力,天然适配于输入法这种天天都在预测下一个字的场景。现在简单记录一些相关的想法和思路:
一、为什么是 LLM
大语言模型训练时其实只在做一件事:给定前面一段 token 序列 ,预测下一个 token 的条件概率分布:
整段文本的联合概率就是把每一步条件概率乘起来:
这就是所谓的自回归语言建模,训练目标是最大化训练语料上的对数似然(等价于最小化交叉熵):
输入法在做什么?给定用户已经输入的内容 ,预测他接下来最可能想输入的字词 。这两件事的建模目标完全一致。
差别在哪?传统输入法用的是 n-gram 统计语言模型,做了马尔可夫假设,只看前面固定的 个词:
上下文窗口非常短,一般 取 3 或 4。而 LLM 没有这个限制,理论上可以建模整段甚至整篇的长距离依赖,语义、语法、风格都能捕捉到。所以从原理层面,LLM 就是输入法智能联想天然的最优解。
二、已经在发生的事
其实市面上一些输入法已经在往这个方向走了,比如豆包输入法和微信输入法。用过之后你会发现一个很有意思的现象:有时候你只要一路点击最靠前的候选词,输入法就能自动帮你把整句话补出来。
这其实非常接近 LLM 的连续文本生成过程。标准的自回归生成是这样的:
模型从条件分布里采样出下一个 token,追加到序列尾部,再进入下一步。输入法的差别在于采样这一步交给了人——模型给出候选分布 top-k,用户从中挑一个作为 ,本质上是一个”人机协同解码器”。
三、方法与实现
整个项目建立在 Rime 输入法框架之上。Rime 提供了一套非常成熟的输入法基础设施——键盘事件处理、拼音解析、输入状态管理、候选窗口 UI,这些都不用重新造轮子。我用的是它的 Windows 发行版小狼毫。
在这套架构之上,我扩展了一层 LLM 候选生成模块,让输入法在原本的候选词之外,多出一路来自语言模型的智能补全。
推理后端上做了统一封装,支持三种切换:
- llama.cpp:本地推理,CPU / CUDA / Vulkan 都能跑,性能好,是主力方案;
- Transformers:方便实验和快速验证新模型;
- OpenAI 兼容接口:可以接入 Ollama 或各种云端模型,延迟高一些,但灵活。
一个统一接口封起来之后,切换模型和推理环境都很轻松。
模型选型:为什么用 Base 而不是 Instruct
一个反直觉的结论:用 Base 模型比 Instruct 模型更适合输入法。
Base 模型的输出分布就是在自然语料上拟合出来的自回归分布 。而 Instruct 模型经过了 SFT + RLHF,把分布”扭”向了对话式回答,通常可以形式化为在原始分布上加一个奖励偏置:
其中 是对齐阶段学到的奖励函数, 是温度。这个分布本身就已经偏离了”自然语言续写”的目标,即使你在 prompt 里写”请补全下面的句子""你是一个智能输入法”,也只是在条件端做微小扰动,很难把 拉回 。
实际测试下来,Base 模型在联想预测这个任务上表现更自然、也更稳定。它没有被”对话”这件事污染,看到什么就接着写什么,这正是输入法需要的行为。
解码策略:Group Beam Search
输入法有两个硬指标:一次要出多个候选词,同时延迟要足够低。
理想目标是找到条件概率最高的整条序列:
在整个词表上做精确搜索是指数级复杂度,实际用 Beam Search 做近似——每一步只保留得分(对数概率之和)最高的 条候选序列:
问题是,标准 Beam Search 的这 条候选往往长得非常像——因为它们都倾向于沿着同一条高概率主干走,最终列表看起来只是同一个词的几个变体,对输入法没意义。
Group Beam Search 的做法是把 个 beam 拆成 组,每组 条。第 组在打分时,会对已经被前 组选过的 token 加一个 diversity penalty:
其中 是第 组已经选中的 token 集合, 是惩罚强度。这样后面的组会被”推开”,去探索不同的生成路径,一次推理就能拿到一批真正有差异的候选补全。
拼音约束解码
输入法场景还有一个绕不开的约束:候选词必须匹配用户当前输入的拼音。
模型不知道你在按什么键,它只会自顾自地往下写。所以做法是在每一步 token 生成时,根据当前拼音输入 构造一个允许集合 ,只保留其对应汉字与拼音匹配的 token,其他全部屏蔽掉。
形式上就是在 logits 层面做掩码:
其中 是模型原始输出的 logit。等价于在原始条件分布上乘一个指示函数再归一化:
这一层过滤看起来简单,实际上是让”通用语言模型”能真正跑在”拼音输入”这个特定场景下的关键。
上下文压缩
随着用户不断输入,上下文长度 会越滚越长。Transformer 自回归推理时每一步的计算量正比于 (因为要和历史所有 KV 做 attention),一段完整生成的总代价大约是 量级,其中 是模型维度。
更麻烦的是,这些历史里还夹杂了噪声——光标跳转、删除重打、切换到新输入框重新开始——这些行为让上下文的语义连续性被打破,模型看到的”上文”其实早就不是当前该续写的那段话了。
我加了一个自动上下文压缩策略:让 LLM 自己把较久远的历史整理压缩,形式上可以写成一个压缩算子 :
远端历史被 压缩成更短的摘要,近端 步保留原始 token。这样一方面推理开销降下来,另一方面噪声被过滤,预测质量反而更稳。
有一个副作用要处理:压缩之后前缀变了,原本缓存好的 KV Cache 就作废了。如果等下一次真实输入触发再重建,第一次响应会显著变慢。所以压缩完之后会立刻异步跑一次空推理,把新的 KV Cache 预热好,用户感知不到延迟毛刺。
四、实验:用深度学习去打统计模型的新手村
做了一个简单的对比实验,让 LLM 和传统 n-gram 模型正面比一下。对照组是一个 3-gram 统计模型,语料规模约 460 万词条。
评估的核心指标是模型在测试语料上的困惑度(Perplexity):
它衡量的是模型在预测下一个 token 时”平均要在多少个候选里犹豫”,越低越好。除此之外还看了 top- 命中率(用户真实输入的下一个词是否落在模型给出的前 个候选里):
结果不出意料,LLM 在两个指标上都有明显提升。但比数字更值得说的是体验层面的差异:传统输入法是逐词预测,你敲一个词它出一批候选;而 LLM 可以一次就把好几个词、甚至一整句话一起给你,这带来的手感是根本不同的。
性能方面,用千问 3 Base 0.6B Q8 模型、llama.cpp 后端并启用 Beam Search,单批候选词生成大约 200ms 左右,完全够输入法的实时响应。如果设备有独立显卡,可以直接上 4B 之类更大的模型,预测质量还能再上一个台阶。
五、未来工作
目前最核心的遗留问题是拼音预测本身。
理想情况下,我们希望模型学到的是”给定上文和拼音,输出汉字”的条件分布:
但当前模型学到的只是无拼音条件的语言分布 ,拼音信息完全没进到模型内部。当你输入 pingguo,模型只会把 “苹果” 当作普通文本继续往下补,而不是执行”给定拼音、输出对应汉字”这个任务。真实输入里还大量存在 pg 这种简拼,但预训练语料里几乎不存在这种形式,训练分布和推理分布之间就出现了明显的错位。
现在的拼音约束解码是一种事后补救——用一个指示函数 硬性过滤,模型在预测时并没有真正”看到”拼音。而 LLM 通常用 BPE 分词,一个 token 可能横跨多个汉字,让拼音级别的约束实现起来又多了一层复杂度:需要在 subword 边界和拼音边界之间做对齐。
更理想的方案是在训练阶段就把拼音映射教给模型,让训练目标直接对齐到有拼音条件的分布:
同时在训练数据里加入简拼、模糊音、常见输入错误等模式,让模型原生理解”拼音是它输入的一部分”,而不是靠外部规则去掰。这条路走通之后,整个系统的一致性和上限也许会有一些质变。
分享到社交平台
将本文分享给你的朋友们
Zhongye