一个 LLM 输入法的实现

2786 个字
14 分钟
一个 LLM 输入法的实现

一个 LLM 输入法的实现#

其实输入法用多了很容易发现自己几乎不用完整敲完每个词,只需要不断从候选词里挑一个继续,句子就自然地成型了。预测下一个 token 作为大模型的基础能力,天然适配于输入法这种天天都在预测下一个字的场景。现在简单记录一些相关的想法和思路:

一、为什么是 LLM#

大语言模型训练时其实只在做一件事:给定前面一段 token 序列 x1:t1=(x1,x2,,xt1)x_{1:t-1} = (x_1, x_2, \dots, x_{t-1}),预测下一个 token xtx_t 的条件概率分布:

Pθ(xtx1,x2,,xt1)P_\theta(x_t \mid x_1, x_2, \dots, x_{t-1})

整段文本的联合概率就是把每一步条件概率乘起来:

Pθ(x1:T)=t=1TPθ(xtx<t)P_\theta(x_{1:T}) = \prod_{t=1}^{T} P_\theta(x_t \mid x_{<t})

这就是所谓的自回归语言建模,训练目标是最大化训练语料上的对数似然(等价于最小化交叉熵):

L(θ)=t=1TlogPθ(xtx<t)\mathcal{L}(\theta) = -\sum_{t=1}^{T} \log P_\theta(x_t \mid x_{<t})

输入法在做什么?给定用户已经输入的内容 x<tx_{<t},预测他接下来最可能想输入的字词 xtx_t。这两件事的建模目标完全一致。

差别在哪?传统输入法用的是 n-gram 统计语言模型,做了马尔可夫假设,只看前面固定的 n1n-1 个词:

Pn-gram(xtx<t)P(xtxtn+1,,xt1)P_{\text{n-gram}}(x_t \mid x_{<t}) \approx P(x_t \mid x_{t-n+1}, \dots, x_{t-1})

上下文窗口非常短,一般 nn 取 3 或 4。而 LLM 没有这个限制,理论上可以建模整段甚至整篇的长距离依赖,语义、语法、风格都能捕捉到。所以从原理层面,LLM 就是输入法智能联想天然的最优解。

二、已经在发生的事#

其实市面上一些输入法已经在往这个方向走了,比如豆包输入法和微信输入法。用过之后你会发现一个很有意思的现象:有时候你只要一路点击最靠前的候选词,输入法就能自动帮你把整句话补出来。

这其实非常接近 LLM 的连续文本生成过程。标准的自回归生成是这样的:

x^tPθ(xtx^<t),t=1,2,\hat{x}_t \sim P_\theta(x_t \mid \hat{x}_{<t}), \quad t = 1, 2, \dots

模型从条件分布里采样出下一个 token,追加到序列尾部,再进入下一步。输入法的差别在于采样这一步交给了人——模型给出候选分布 top-k,用户从中挑一个作为 x^t\hat{x}_t,本质上是一个”人机协同解码器”。

三、方法与实现#

整个项目建立在 Rime 输入法框架之上。Rime 提供了一套非常成熟的输入法基础设施——键盘事件处理、拼音解析、输入状态管理、候选窗口 UI,这些都不用重新造轮子。我用的是它的 Windows 发行版小狼毫

在这套架构之上,我扩展了一层 LLM 候选生成模块,让输入法在原本的候选词之外,多出一路来自语言模型的智能补全。

推理后端上做了统一封装,支持三种切换:

  • llama.cpp:本地推理,CPU / CUDA / Vulkan 都能跑,性能好,是主力方案;
  • Transformers:方便实验和快速验证新模型;
  • OpenAI 兼容接口:可以接入 Ollama 或各种云端模型,延迟高一些,但灵活。

一个统一接口封起来之后,切换模型和推理环境都很轻松。

模型选型:为什么用 Base 而不是 Instruct#

一个反直觉的结论:用 Base 模型比 Instruct 模型更适合输入法。

Base 模型的输出分布就是在自然语料上拟合出来的自回归分布 Pθ(xtx<t)P_\theta(x_t \mid x_{<t})。而 Instruct 模型经过了 SFT + RLHF,把分布”扭”向了对话式回答,通常可以形式化为在原始分布上加一个奖励偏置:

Pinstruct(xtx<t)Pθ(xtx<t)exp ⁣(1βr(x<t,xt))P_{\text{instruct}}(x_t \mid x_{<t}) \propto P_\theta(x_t \mid x_{<t}) \cdot \exp\!\left(\frac{1}{\beta} r(x_{<t}, x_t)\right)

其中 r()r(\cdot) 是对齐阶段学到的奖励函数,β\beta 是温度。这个分布本身就已经偏离了”自然语言续写”的目标,即使你在 prompt 里写”请补全下面的句子""你是一个智能输入法”,也只是在条件端做微小扰动,很难把 PinstructP_{\text{instruct}} 拉回 PθP_\theta

实际测试下来,Base 模型在联想预测这个任务上表现更自然、也更稳定。它没有被”对话”这件事污染,看到什么就接着写什么,这正是输入法需要的行为。

输入法有两个硬指标:一次要出多个候选词,同时延迟要足够低

理想目标是找到条件概率最高的整条序列:

y^=argmaxyt=1TPθ(yty<t)\hat{y} = \arg\max_{y} \prod_{t=1}^{T} P_\theta(y_t \mid y_{<t})

在整个词表上做精确搜索是指数级复杂度,实际用 Beam Search 做近似——每一步只保留得分(对数概率之和)最高的 BB 条候选序列:

Bt=TopKB ⁣{i=1tlogPθ(yiy<i)  |  y<tBt1, ytV}\mathcal{B}_t = \operatorname{TopK}_{B}\!\left\{ \sum_{i=1}^{t} \log P_\theta(y_i \mid y_{<i}) \;\middle|\; y_{<t} \in \mathcal{B}_{t-1},\ y_t \in \mathcal{V} \right\}

问题是,标准 Beam Search 的这 BB 条候选往往长得非常像——因为它们都倾向于沿着同一条高概率主干走,最终列表看起来只是同一个词的几个变体,对输入法没意义。

Group Beam Search 的做法是把 BB 个 beam 拆成 GG 组,每组 B/GB/G 条。第 gg 组在打分时,会对已经被前 g1g-1 组选过的 token 加一个 diversity penalty:

scoreg(yt)=logPθ(yty<t)λg<g1 ⁣[ytSg]\text{score}_g(y_t) = \log P_\theta(y_t \mid y_{<t}) - \lambda \sum_{g' < g} \mathbb{1}\!\left[y_t \in \mathcal{S}_{g'}\right]

其中 Sg\mathcal{S}_{g'} 是第 gg' 组已经选中的 token 集合,λ>0\lambda > 0 是惩罚强度。这样后面的组会被”推开”,去探索不同的生成路径,一次推理就能拿到一批真正有差异的候选补全。

拼音约束解码#

输入法场景还有一个绕不开的约束:候选词必须匹配用户当前输入的拼音

模型不知道你在按什么键,它只会自顾自地往下写。所以做法是在每一步 token 生成时,根据当前拼音输入 π\pi 构造一个允许集合 VπV\mathcal{V}_\pi \subseteq \mathcal{V},只保留其对应汉字与拼音匹配的 token,其他全部屏蔽掉。

形式上就是在 logits 层面做掩码:

~t(v)={t(v),vVπ,vVπ\tilde{\ell}_t(v) = \begin{cases} \ell_t(v), & v \in \mathcal{V}_\pi \\ -\infty, & v \notin \mathcal{V}_\pi \end{cases}P~θ(xt=vx<t,π)=exp(~t(v))vVexp(~t(v))\tilde{P}_\theta(x_t = v \mid x_{<t}, \pi) = \frac{\exp\bigl(\tilde{\ell}_t(v)\bigr)}{\sum_{v' \in \mathcal{V}} \exp\bigl(\tilde{\ell}_t(v')\bigr)}

其中 t(v)\ell_t(v) 是模型原始输出的 logit。等价于在原始条件分布上乘一个指示函数再归一化:

P~θ(xtx<t,π)Pθ(xtx<t)1 ⁣[xtVπ]\tilde{P}_\theta(x_t \mid x_{<t}, \pi) \propto P_\theta(x_t \mid x_{<t}) \cdot \mathbb{1}\!\left[x_t \in \mathcal{V}_\pi\right]

这一层过滤看起来简单,实际上是让”通用语言模型”能真正跑在”拼音输入”这个特定场景下的关键。

上下文压缩#

随着用户不断输入,上下文长度 LL 会越滚越长。Transformer 自回归推理时每一步的计算量正比于 LL(因为要和历史所有 KV 做 attention),一段完整生成的总代价大约是 O(L2d)O(L^2 \cdot d) 量级,其中 dd 是模型维度。

更麻烦的是,这些历史里还夹杂了噪声——光标跳转、删除重打、切换到新输入框重新开始——这些行为让上下文的语义连续性被打破,模型看到的”上文”其实早就不是当前该续写的那段话了。

我加了一个自动上下文压缩策略:让 LLM 自己把较久远的历史整理压缩,形式上可以写成一个压缩算子 C()\mathcal{C}(\cdot)

x<t    [C(x<tk)  ;  xtk:t1]x_{<t} \;\longrightarrow\; \bigl[\, \mathcal{C}(x_{<t-k})\; ;\; x_{t-k:t-1} \,\bigr]

远端历史被 C\mathcal{C} 压缩成更短的摘要,近端 kk 步保留原始 token。这样一方面推理开销降下来,另一方面噪声被过滤,预测质量反而更稳。

有一个副作用要处理:压缩之后前缀变了,原本缓存好的 KV Cache {Ki,Vi}i<t\{K_i, V_i\}_{i<t} 就作废了。如果等下一次真实输入触发再重建,第一次响应会显著变慢。所以压缩完之后会立刻异步跑一次空推理,把新的 KV Cache 预热好,用户感知不到延迟毛刺。

四、实验:用深度学习去打统计模型的新手村#

做了一个简单的对比实验,让 LLM 和传统 n-gram 模型正面比一下。对照组是一个 3-gram 统计模型,语料规模约 460 万词条。

评估的核心指标是模型在测试语料上的困惑度(Perplexity):

PPL=exp ⁣(1Tt=1TlogP(xtx<t))\text{PPL} = \exp\!\left(-\frac{1}{T}\sum_{t=1}^{T} \log P(x_t \mid x_{<t})\right)

它衡量的是模型在预测下一个 token 时”平均要在多少个候选里犹豫”,越低越好。除此之外还看了 top-kk 命中率(用户真实输入的下一个词是否落在模型给出的前 kk 个候选里):

Acc@k=1Tt=1T1 ⁣[xtTopK(P(x<t))]\text{Acc@}k = \frac{1}{T}\sum_{t=1}^{T} \mathbb{1}\!\left[x_t \in \operatorname{TopK}\bigl(P(\cdot \mid x_{<t})\bigr)\right]

结果不出意料,LLM 在两个指标上都有明显提升。但比数字更值得说的是体验层面的差异:传统输入法是逐词预测,你敲一个词它出一批候选;而 LLM 可以一次就把好几个词、甚至一整句话一起给你,这带来的手感是根本不同的。

性能方面,用千问 3 Base 0.6B Q8 模型、llama.cpp 后端并启用 Beam Search,单批候选词生成大约 200ms 左右,完全够输入法的实时响应。如果设备有独立显卡,可以直接上 4B 之类更大的模型,预测质量还能再上一个台阶。

五、未来工作#

目前最核心的遗留问题是拼音预测本身

理想情况下,我们希望模型学到的是”给定上文和拼音,输出汉字”的条件分布:

Pθ(xtx<t,πt)P_\theta(x_t \mid x_{<t}, \pi_t)

但当前模型学到的只是无拼音条件的语言分布 Pθ(xtx<t)P_\theta(x_t \mid x_{<t}),拼音信息完全没进到模型内部。当你输入 pingguo,模型只会把 “苹果” 当作普通文本继续往下补,而不是执行”给定拼音、输出对应汉字”这个任务。真实输入里还大量存在 pg 这种简拼,但预训练语料里几乎不存在这种形式,训练分布和推理分布之间就出现了明显的错位。

现在的拼音约束解码是一种事后补救——用一个指示函数 1[xtVπ]\mathbb{1}[x_t \in \mathcal{V}_\pi] 硬性过滤,模型在预测时并没有真正”看到”拼音。而 LLM 通常用 BPE 分词,一个 token 可能横跨多个汉字,让拼音级别的约束实现起来又多了一层复杂度:需要在 subword 边界和拼音边界之间做对齐。

更理想的方案是在训练阶段就把拼音映射教给模型,让训练目标直接对齐到有拼音条件的分布:

Lpinyin(θ)=t=1TlogPθ(xtx<t,πt)\mathcal{L}_{\text{pinyin}}(\theta) = -\sum_{t=1}^{T} \log P_\theta(x_t \mid x_{<t}, \pi_t)

同时在训练数据里加入简拼、模糊音、常见输入错误等模式,让模型原生理解”拼音是它输入的一部分”,而不是靠外部规则去掰。这条路走通之后,整个系统的一致性和上限也许会有一些质变。

分享到社交平台

将本文分享给你的朋友们

评论

Profile Image of the Author
Zhongye
南漂中
公告
新的博客站!旧站点传送门 👇
音乐
专辑封面

音乐

暂无播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章数
144
分类数
14
标签数
203
总字数
421,371
运行天数
0
最后更新
0 天前
总访问量
42296
访客数
29212

目录