天空 Blond - LLM
type: channel
link: https://www.skyblond.info/tag/LLM/
items: 【歪门邪道】本地部署LLM的初尝试
items: 【歪门邪道】使用LLM翻译超时空辉夜姬轻小说

【歪门邪道】本地部署LLM的初尝试
type: item
date: 2026-05-01T10:54:00+08:00
description: 四月初谷歌发布了Gemma 4系列的开源模型。先前我对开源模型其实一直没什么太大兴趣，因为大部分都可以在OpenRouter上用到，价格远比自己买显卡便宜。但看到网上那么多人说Gemma 4这么好那么好，我寻思，这我得尝尝咸淡。于是陆续下单了两块AMD 7900XTX，尝试了在本地部署大模型。Gemma 4虽然已经过去了一个月，但我依旧记得当时清明假期看到Gemma 4发布的时候，大家对它的赞不绝口，同时模型的尺寸也从遥不可及变成了使使劲也能用。之前我对于开源模型一直没有太大的兴趣，主要是因为他们太大了。比如Qwen3.5 397B A17B，写代码很好用，但即便是4比特的量化，光是模型自己就要吃掉大约200GB的内存或者显存。MoE结构当然有优势，即便不能全部装进显卡也能取得比较快的计算速度，但就算这样，你仍然需要显存+内存的总量大于200GB，更别说还有用于上下文的kv cache。我这屋里所有设备的内存加起来还不到200GB，更别说插到一个主板上了，更何况现在内存价格那么贵，曾经600块钱的32GB内存我嫌贵，现在一条涨到3000块还买不到，我真的悔不当初。但Gemma 4不一样。谷歌给出的口号是“Byte for byte, the most capable open models”，这个系列中最大的模型是31B，是个稠密模型，其次是26B A4B，这个是混合专家（MoE）模型。就算是我那个64GB内存+8GB显存的3070笔记本也能挑战一下，虽然只有惨兮兮的1 token/s，但确实让我领略到了这个模型的能力，它和我几年前尝试过的llama 2 70b确实不一样，进步很大（废话）。于是想要更快更好地运行本地模型的想法在我心中不断酝酿着，更别说自己部署还能体验到各种uncensored模型，这种无（弱）对齐的体验简直是任何云服务都无法提供的。当然，最大的推手还是因为谷歌的AI Studio一直不提供付费的Gemma 4 31B访问，以至于我有钱没地方花；免费的一直有额度限制，同时谷歌也明确所有数据都会拿去训练。AMD Yes！在酝酿了一周后，我先下单了第一块7900XTX。为什么是AMD呢？因为隔壁英伟达买不起。比如说同样为24GB显存的RTX 5090D v2，阉割过不说，这一张卡竟然要两万！而没有阉割的5090，虽然有32GB显存，可是价格三万，我实在是家境贫寒。至于我这块蓝宝石的7900XTX白金版，24GB显存只要6000块，两块也只要1万2，这还要什么自行车？更别说Linux上面对AMD显卡的支持是开箱自用。虽然ROCm官方只支持一些商用Linux系统，但官方也提供了基于Ubuntu的docker容器，虽然我不喜欢Ubuntu，也不会装它作为我的主力系统，但有了docker，我可以无痛把显卡透穿给容器，完全不需要在主机上安装什么依赖啊闭源驱动什么的。尽管性能不比N卡，可是省心啊。至于一周之后为什么下单了第二块显卡，那主要还是因为显存不够。一开始我以为只要把模型塞进显卡就好了，但其实这只是一部分，另一个大头是kv cache。KV Cache现代大语言模型普遍以注意力机制为基础，而大语言模型的输出又是一个字一个字往外蹦。因此当预测第N个字符的概率（logits）时，你需要将前面所有字符进行计算。为了防止计算量爆炸，人们在实现时普遍采用了一种缓存机制，即预测第N-1个字符的时候，就把注意力机制的K和V矩阵保存下来，因为这两个矩阵在不超过上下文长度的情况下是只增不减的。 这里严重简化了注意力机制的原理，请不要把这里说的当成学术入门。 简单来说注意力机制有三个矩阵：Q、K和V。他们都是由同一个输入矩阵X根据不同权重计算出来的。这里说是矩阵，其实可以理解为向量，例如X=(x1, x2, x3, x4, ..., xn)，这里的xn可以理解为第N个token对应的embedding向量，为了计算过程能够发挥显卡的能力，我们不会挨个向量进行计算，而是将向量打包成矩阵，利用线性代数帮我们自动做for循环。其中Q矩阵代表了当前的输入要查询什么样的信息，是一种需求。例如当前token是“他”，那么这个q向量就包含了对这个人称代词解引用的需求。而K矩阵则代表了每个token包含的信息的标签，例如之前一个token看着像人名，那么这个token生成的k向量就会标明自己是个人物。而V矩阵代表这个token实际包含的信息，比如文字里这个人叫“踏三山游五岳，恨天无把恨地无环，代管天津及周边各县真正大英雄王斯文”，那么这个v向量就会包含王斯文这个信息。注意力在计算的时候先要计算Q和K的乘法，这相当于用当前token的需求Q去筛选已知信息的标签K，得到的结果，某一行越大，说明这一行对应的信息就越有可能解答Q的需求。在得到这个筛选之后，再去和V…

【歪门邪道】使用LLM翻译超时空辉夜姬轻小说
type: item
date: 2026-04-01T09:59:00+08:00
description: 上个月月初看了超时空辉夜姬，非常喜欢，喜欢到每天都在b站上看各种解析和二创。听闻官方还有轻小说，于是便想办法来看。可是我一句日本话也看不懂，这怎么办呢？没事，已经2026年了，我们有LLM。前言也许是受到电影的鼓舞，我3月6号周五晚上看完的电影，紧接着那个周末就把小说给翻译了。以前我从未想过，一句日文都看不懂的我甚至还能以一种比较有参与感的方式翻译一本小说。尽管一开始我想到了LLM会让翻译工作轻松不少，但我确实没有期待什么质量。但真正做完了才知道，虽然远不比彩叶，但我也能做到我曾经没想过或者不敢想的事情。我可太喜欢这部电影了。买书所谓万事开头难，由于小说是日文的，并且目前只在日本发行，我又等不及了，所以搜了全网售卖日文电子书的网站。找到了，但全都是日文，我看不懂。最后我想起来乐天kobo，于是就跑到上面搜了一下。好消息：能买坏消息：日区是独立管理买之前网页上信誓旦旦地说提供Adobe DRM保护的EPUB，这种通常是提供一个acsm文件，然后内容服务器会根据你阅读器的密钥生成一个加密的EPUB，理论上只能在你导入acsm文件的阅读器上使用，因为只有它有密钥。但实际上Calibre的插件就可以轻而易举地破解——Adobe只提供加密服务，但并不控制最终设备，因此只要能够通过API模拟出设备的注册过程，那用我们自己持有的密钥解密自己购买的电子书，那不是轻而易举？只可惜我买之后才发现，整个kobo日区都不提供下载服务，要么直接从电子书里下载，要么下载app到手机上看。好在天无绝人之路，Kobo PC版还可以下载。虽然下载的是kobo加密的格式（Kepub），但Calibre照样有破解的插件。第一次解密发现怎么解出来全是乱码，我还以为新书用了新的加密方法，插件落后了。后来搜索了一番才只要，要把Kobo PC关掉才行。总之，我们得到了心心念念的drm-free epub文件。I have a plan!一开始我打算在OpenCode中操作。简单来说OpenCode是一个利用LLM进行编程的工具，它为LLM提供了一个环境，其中包含各种与文件和终端互动的工具。最近我司在推行这个工具，也被迫用了一段时间，感觉还行，但还是离不开人的审核。我原本打算直接让它把文件中的日语内容翻译成中文。后来觉得有三个问题：编程用的Opus 4.6或者codex 5.3都是为了编写代码而优化的，他们也许能在OpenCode中如鱼得水，但或许并不擅长翻译这类具有文学性的工作我希望大部分更改是reproducible的，也就是说我搞砸了，可以重新运行代码恢复到某一个步骤。由于LLM的随机性，我不可能做到完全的可重复，但能做一点是一点嘛Opus和Codex太贵了，虽然有公司给的key，但这毕竟是个人项目，不能用公司的资源。我尝试了GLM-5，整体算是堪用，写不出kotlin native的代码风格，更像是用kotlin写Java，整体不是很糟糕，但也没有让我很想用基于以上理由，我选择在IDEA中新建项目，将大部分操作编写成可重复运行的代码。这样不光实现了可重复性，还变相增加了稳定性。本文的代码全都是我自己手写的，但写完之后有让GLM-5检查。由于版权原因，本文尽可能不提供对小说内容的直接引用。因为我没有处理DMCA投诉的经验，所以多一事不如少一事，我希望我的博客能长存八千年。拆EPUB首先是拆解EPUB。众所周知，EPUB其实一个具有特殊格式的zip压缩包。理论上来说，想要解包的话直接把epub的后缀改成zip就可以用随便什么压缩工具解压了。但我还是想用代码处理：fun unpackEpub(epubFile: File, outputFolder: File) { require(epubFile.exists()) { "EPUB file not found: ${epubFile.path}" } if (!outputFolder.exists()) { require(outputFolder.mkdirs()) { "Cannot create output folder: ${outputFolder.path}" } } ZipInputStream(epubFile.inputStream()).use { zis -> var entry = zis.nextEntry while (entry != null) { val newFile = File(outputFolder, entry.name) // zip slip detection, to prevent entry name contains something like ../../../etc/something val canonicalDestPath = newFile.…