【歪门邪道】使用LLM翻译超时空辉夜姬轻小说
上个月月初看了超时空辉夜姬，非常喜欢，喜欢到每天都在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.canonicalPath require(canonicalDestPath.startsWith(outputFolder.canonicalPath)) { "Zip Slip vulnerability detected: ${entry.name}" } if (entry.isDirectory) { newFile.mkdirs() } else { newFile.parentFile?.mkdirs() newFile.outputStream().use { fos -> zis.copyTo(fos) } } zis.closeEntry() entry = zis.nextEntry } } }这里是第一次觉得GLM-5这种开源模型还没有那么没用。一开始我只是写了一个解压缩的代码，然后问GLM-5怎么样。随后GLM-5指出我这样会有Zip slip漏洞。所谓zip slip，正如你在代码中看到的：解压后的文件路径大约就是输出文件夹的路径拼接上zip中记录的文件名（其实是路径+文件名）。这样一来，通过精心构造的文件名，我就可以利用解压缩的过程将压缩包内的数据覆盖到其他位置。例如我想把文件解压到/home/user/folder，如果压缩包有叫作../../../etc/sudoers的文件，那么当我解压这个压缩包的时候，我的sudo配置文件就会被悄悄覆盖掉。俗话说，听人劝吃饱饭，虽然我不认为官方出品的epub会利用这种漏洞，但还是加上为好。安全嘛。洗排版解包EPUB只是最简单的一部，清洗排版可能是第二难的事情。清洗Kobo余孽由于这个EPUB是通过Kobo PC破解出来的，因此源文件中难免沾上kobo相关的东西。具体表现是：epub中包含一个kobo.js文件所有xhtml中都包含一个koboSpanStyle所有xhtml的正文都被这样的标签打断这些是专门为kobo阅读器优化的机制。其中kobo.js经过我和GLM-5的验证，它所操纵的class在正文的xhtml中全都不存在，因此实际上在这本书中没有发挥任何效用，可以安心删掉。至于koboSpanStyle是所有xhtml中一个内嵌的CSS样式，专门针对koboSpan的，因为我们要去掉所有koboSpan，所以没有必要留着。至于koboSpan和那些id，推测是kobo用来分词和追踪笔记的。例如： 这 是 一句话。 虽然这个段落呈现出来的就是这是一句话。，但当你在kobo上追加批注的时候，当你选到一句的时候，它能够根据koboSpan的信息知道这个“一句话。”是个整体，从而自动选上整个单词。当然，这些都是Gemini的推测，准确与否都不重要，我的阅读器是Kindle，这些只是徒增烦恼的东西，通通删掉就是了。删除的方法也比较简单：直接删除kobo.js，在opf文件中删除对应的引用在所有xhtml中删除对kobo.js的引用，删除koboSpanStyle内联CSS样式，找出所有class为koboSpan的span标签，调用unwrap这里其实可以再细节一点，先检查koboSpan是不是唯一的类，如果是再unwrap，否则会丢失排版信息。但反正这是我自己读，重在看内容。无所谓了。作为进一步清洗，除了针对koboSpan之外，还可以配合CSS检查，针对辉夜姬的小说，我删除了如下class：tcy：纵中横，表示排版时其中的内容要放在要给方块里。原书是竖向排版，因此遇到诸如“！！”这样的符号，就应该在一个格子里显示，而不是上下两个感叹号line-break-loose：避免因规避符号特殊位置导致字间距变化word-break-break-all：允许硬切分以上都是Gemini的猜测，不知道是不是真的，反正都删掉了。这一步的目的是尽可能洗掉不影响内容呈现的span标签， 为后续处理提供方便。针对超时空辉夜姬这本小说，这一套下来基本上能确保正文中的所有span全都被去掉，但同时保留诸如加粗等会改变视觉观感的内容。因为后续按照段落为单位翻译，一个段落里的html标签越多，对于LLM来说就越不利。顺带一提，我用Java自带的XML DOM Parser解析opf文件，但用Jsoup解析xhtml，需要在输入时这样写：fun File.readAsXhtml(): Document = Jsoup.parse(this, "UTF-8", "", Parser.xmlParser())输出时这样写：fun Document.writeXhtmlTo(file: File) { this.outputSettings().apply { syntax(Document.OutputSettings.Syntax.xml) escapeMode(Entities.EscapeMode.xhtml) charset(Charsets.UTF_8) prettyPrint(false) } file.writeText(this.outerHtml()) }否则Jsoup会把xhtml变成html文件，epub就不识别了。调整排版方向这本书采用日文的竖向排版，从右向左。实际上台湾也有不少轻小说采用了类似的排版。但我不习惯这种排版，我还是更喜欢看从左往右的横向排版。聪明的读者想必从我的表述就猜到了，这个调整有两个方面：首先要在opf文件中将rtl（右到左）改为ltr（左到右），这样当你点击右侧翻页的时候，电子书知道你要去下一页，而不是上一页其次要在每个xhtml文件中将实际内容从竖向改为横向。第一个问题很容易解决：找到spine标签，将其中的page-progression-direction属性从rtl改成ltr即可。第二个就相对麻烦了，好在辉夜姬这本书是通过CSS做到的，所有正文的xhtml中，html标签的class中都有一个vrtl，查询它在CSS中的定义就知道它是vertical rtl，对应地，其附近还有个hltr，显然是horizontal ltr。这样一来我们直接把正文中的vrtl class去掉，然后加上hltr就好了。我不确定其他epub是不是这么方便，但辉夜姬这本书是这样。感恩🙏还原外字和全角符号其次，这本书的插图中有些奇怪的符号，具体而言，他们是一张128x128像素的图片，文件名形如gaiji-001.png：ゆ゛い゛え゛S'我问了Gemini，它说前三个是因为日语的语法规则不允许这些假名变成浊音，但小说中要表达特殊的语气或发音时就要用到这些。在这里他们是通过单独的浊音符号显示的，实际上是两个字符。但是竖向排版的时候他们就会上下分家，也就是假名在上，浊音符号在下，这样一来读者就会对这个突兀的浊音符号感到疑惑。因此为了保证排版质量，要把他们做成图片，需要的时候直接引用图片嵌入即可。至于随后一个，其完整的句子是Let's如何如何，这个's也是个排版难题，因为同样要竖向排版，看起来是这样：L e t s'（其实还是很奇怪）所以同样为了方便排版和确保呈现结果一致，就做成图片了。由于我的目的是将其翻译成中文，所以这些排版完全不成问题。通过文件名匹配正文中的img标签，将整个标签替换成对应的文字，外字的问题就迎刃而解了。此外，同样是过一遍文章，还要把全角字符（阿拉伯数字和英文）还原成半角字符。难怪koreader的维护者们多次吐槽日文书籍很难做兼容，不打交道不知道，现在我完全理解koreader维护者的吐槽了。替换日文字体之后是替换日文字体，因为epub中的CSS全部用的是日文字体。但比较好的一点是他们只指定了font family，没有指定具体font，谢天谢地。具体而言，辉夜姬中用到的日文字体家族是：serif-jaserif-ja-vsans-serif-jasans-serif-ja-vsans-serif-jpSerif是有衬线字体（类似宋体），Sans Serif是无衬线字体（类似黑体）。结尾的-ja和-jp告诉阅读器要用日文字体渲染，因为有些字在中日韩的语言中长得不一样。而-v结尾的则表示是针对竖向排版优化过的字体。由于我之前已经调整了排版，不存在竖版，因此我选择直接去掉这些后缀，用通用的serif和sans serif。这时候有朋友可能就要问了，为什么不用serif-zh呢？其实你只要设定好文档语言，现代阅读器都会自动挑选字体的变体。修改标题和语言最后就是设置标题和语言了。由于不想改动文件的元数据，因此我选择只修改标题。而标题的标签使用了都柏林core的命名空间，因此在Java中要使用getElementsByTagNameNS方法，并且在读取xml文档之前，还要设置factory.isNamespaceAware = true，不然Java自带的dom parser不知道解析这些命名空间。在都柏林core下寻找title标签就是EPUB的标题了。语言的话又比较复杂，分别是xml语言，epub书籍的语言，以及xhtml文档的语言。对于opf文件来说，我们要设置两个语言，第一个是xml第一行中定义的xml:lang，第二个则是opf文件内部同样在都柏林core命名空间下的language标签。由于我要把这本书翻译成繁体中文，所以全部将它们设置为zh-Hant。为什么是繁体中文呢？因为之前读的终将成为你、狼与香辛料这些书都是台版书，我已经习惯读繁体中文了。此外我认为一门相对不那么熟悉的语言有助于让我忽略一些翻译上的瑕疵。LLM很好用，但远远谈不上完美，不少句子翻译出来就不太通顺，我又不可能一一校对——我连日语都看不懂，我校对个屁。所以如果我读到繁体中文，就算一些句子比较奇怪，我也能理解，并且不会因此而大惊小怪，破坏了阅读体验。至于xhtml文档的语言嘛，也有两个要设置的，都在html标签上：xml:lang属性，这个是EPUB标准要求的语言字段lang属性，这个是给浏览器看的，是HTML5标准以上操作全部完成后，我们就能得到一个相对干净的epub文件了。终于可以开始翻译了。。。？抓取Json别急，我知道你很急，但你先别急。xhtml太不好操作了，于是我决定将原文内容抓成json，这样我在后续操作中可以比较方便地追加我自己的内容。根据辉夜姬这本书的内容，我决定以p标签为基本单位进行翻译。但这里我们不能光拿文本，而是要拿html，因为日文的原文中有些注音：「 彩 いろ 葉 は のエイム、すっげー」这句话会显示成「彩いろ葉はのエイム、すっげー」注意彩叶的名字上多了假名注音。如果直接在jsoup中拿文字的话，就要变成「彩いろ葉はのエイム、すっげー」这样就全乱了。决定要拿什么之后还有一个问题：如何记录这些元素唯一的位置？我选择用文件名搭配XPath。抓取内容时产生的json格式如下：[ { "title": "序章", "filename": "item/xhtml/p-001.xhtml", "content": [ { "xpath": "/html[1]/body[1]/div[1]/p[3]", "html": " ──今は昔……。" }, { "xpath": "/html[1]/body[1]/div[1]/p[5]", "html": "「 彩 いろ 葉 は !」" }, // ...... ] }, // ...... ]这样我们就可以将原始内容和xhtml中的元素对应起来。后续翻译的时候可以先把翻译结果记录在json中，调整到满意后再通过xpath替换回XHTML中。提取实体以及生成摘要为了确保翻译的质量，至少得保证读者能看懂谁是谁，那么就得想办法让LLM在翻译的时候不要捏造名称。要做到这一点，我们需要让LLM过一遍全文，找出其中的实体（entity）。考虑到一个实体可能有好多个名字，所以我也让LLM将实体的别名收集起来，最后带上一个简介：@Description("The result of an entity extraction") data class EntityResult( @Description("The full qualified name of the entity") val fullName: String, @Description("The proposed translation of this term") val translation: String, @Description("A list of alternative names for the entity") val alterNames: List<String>?, @Description("Description of the entity, must written in zh-Hans") val description: String, )然后使用如下提示词分章节搜索实体： fun createSystemMessage( knownEntities: List<EntityResult> ): String = """ |# Role |用户将输入一段即将被翻译的小说，你的任务是在输入的HTML格式的段落中寻找实体（人物、地点、物品、关键事件等）。 | |寻找实体时应当为翻译目的而服务，重点提取小说中特有的设定。 |对于段落中出现的人物，若是有名有姓的个体，则都应当作为实体输出。 |对于常识类实体，例如现实中存在的地名或物品等，应当予以忽略。 |同时应当避开过于常见和广泛的个体，例如“街道”、“商店”、“敌人”等。 |寻找的实体必须要有独一无二的全名用来识别独一无二的个体。 | |如果出现了重复的实体，但段落中提供了新的信息，你需要完整输出该实体更新后的信息。 |请注意保留Description中的信息，该信息来源于对先前章节的总结。 |若实体没有新增信息，则无须在本轮输出该实体，已知实体的信息会被保留。 |但如果你发现一些实体不符合输出规范，则应当在本轮以正确格式输出该实体。 | |若获知已知实体的更完整的全名，请输出以完整全名输出该实体的完整信息，并将原本的全名放入alterNames列表中。 |系统会自动删除被涵盖在alterNames中的实体以防重复。 | |# 输出要求 |输出实体时，其全名和alter names应当使用日文原文。 |Description必须使用繁体中文（台湾）撰写。若需要引用日文原文，则必须将该段日文原文翻译成繁体中文（台湾）后放入Description字段。 |无论何种情况，必须使用繁体中文（台湾）输出Description字段。 | |为了翻译目的，你需要对每个实体提出一个翻译（translation）。翻译的目标语言为繁体中文（台湾）。 |若实体为专有名词且为英文，则可以保留英文名称。否则必须根据上下文翻译成繁体中文（台湾）。 |对于人名的翻译应当对齐繁体中文（台湾）对日本人名的翻译准则：尽可能保留原字，将平假名翻译成汉字，片假名按照音译翻译成文字。 | |# 已知实体 | |已知实体将会以markdown格式呈现： | |``` |${EntityResult("全名", "翻译名", listOf("别名1", "别名2"), "描述").toMarkdown()} |``` | |目前已知实体如下： | |${if (knownEntities.isEmpty()) "（无）" else knownEntities.toMarkdown()} """.trimMargin()因为是逐个段落生成，因此第一章的已知实体是空的，一章结束之后会按照全名作为唯一标识符进行替换，这也是为什么要让LLM完整地输出更新后的实体信息。此外，为了防止先发现别名再发现全名的情况，也会在更新完成后搜索已知实体列表，看看有没有实体的全名是某个实体的别名，如果有就删掉。这样一轮下来就能发现大部分实体，由于输出是Markdown，所以可以直接修改。这里其实可以用更优的关键词匹配，即只给大模型提供待翻译的句子中提到的实体，但实现起来太麻烦了。考虑到我们的实体信息不多，都不用Gemini，就是GPT这种200k上下文的都能无压力处理。以下是从LLM输出中挑选的一些示例：## 酒寄彩葉 Alternative names: 彩葉, いろP Translation: 酒寄彩葉 Description: 女高中生出身、故事敘事者「我」。具備鍵盤／鋼琴演奏能力，童年曾與父親共同創作旋律（檔案「タイトル未定(彩葉と共作)」），但父親過世後母親酒寄紅葉態度轉為否定，使她逐漸遠離音樂；在かぐや推動下重新演奏並以「いろP」身分在配信中擔任伴奏／幕後製作，後與かぐや以「かぐや・いろP」爆紅並參與ヤチヨカップ等事件。曾遭白い違和感與月人事件波及，並在卒業ライブ後面對與かぐや／月見ヤチヨ的閉環真相。 本段（終章時間跳躍後）新增：十年後成為研究者，並取得研究所「所長」頭銜；母親的長文說教信與連續來電大幅減少。她建立實驗室／拉ボ，並與月見ヤチヨ合作，準備在現實側進行「試作品第一号『かぐや』」的首次起動實驗；同時邀請芦花、真実、酒寄朝日、乃依、雷等作為出資者出席首次公開展示。 ## ヤチヨの神棚アクリルスタンド Alternative names: （无） Translation: 八千代的神龕壓克力立牌 Description: 彩葉放在家中書櫃上、以「神龕」般供奉的ヤチヨ壓克力立牌；彩葉將其戲稱為「另一罐能量飲料」，出門前會對它打招呼。 ## 月見ヤチヨ Alternative names: ヤチヨ, ヤッチョ Translation: 月見八千代，八千代，小八千 Description: AI虛擬直播主／歌姬，亦為ツクヨミ的管理人；與かぐや存在同一性／閉環關係（かぐや在回到月後因彩葉之歌再度時間跳躍、墜落到約八千年前，在漫長歲月中建立ツクヨミ原型並最終成為月見ヤチヨ等待彩葉）。需要定期進入スリープ以充電、更新與記憶整理，活動極限約52小時；其現實側存在對應核心（以「水槽のタケノコ」呈現），並可由此以近似後門方式進入ツクヨミ。 本段新增：十年後仍持續配信活動（生配信結束後於彩葉的研究室以平板畫面現身互動），並與彩葉在現實研究室共同推進「試作品第一号『かぐや』」的首次起動實驗；她對彩葉說「謝謝你，生下了かぐや」，顯示此試作計畫對她意義重大。 ## かぐや Alternative names: かぐや姫, 電柱生まれの赤ちゃん, 宇宙人 Translation: 輝夜 Description: 從ゲーミング電柱出現、快速成長的少女，自稱來自「月」。為參加「ヤチヨカップ」成為ライバー並大量投稿／直播，能即興填詞演唱並要求彩葉擔任製作／伴奏；與彩葉組成「かぐや・いろP」後快速竄紅。合作直播舞台遭「白い違和感」侵入時曾短暫失去反應；花火大會坦承曾透過某個「窗」觀察地球、渴望自由，並指出『2030/09/12』滿月夜將有「お迎え」到來；其後引退並舉辦卒業ライブ，最終在月人迎接儀式中離去，並把手鍊交給彩葉、要求彩葉回贈「名字」。 本段重大揭露：かぐや在「回到月」後因彩葉的歌而試圖再次前往地球並進行時間跳躍，途中撞上巨大隕石導致事故，墜落到約八千年前的地球；此後在漫長歲月裡逐步建立虛擬世界「ツクヨミ」，並以「月見ヤチヨ」的身分與彩葉再次相遇。也就是說，彩葉所認識的「ヤチヨ」是歷經八千年後的かぐや所抵達的狀態（輪迴／閉環的一部分）。我觉得效果还不错。除了发现实体之外，还可以为每章生成一段摘要，这样后续逐句翻译的时候仍然能保持章节内的连贯性。以下是小说第一章的摘要：『KASSEN』のボイスチャットで彩葉（酒寄彩葉）は仲間から「エイムがすごい」「プロ級」と褒められ、照れつつも内心嬉しくなる。年単位でやり込んできたフルダイブ型合戦アクションゲームで、自分の腕前は「中の上」だと自負する一方、仲間にプロを勧められてもその道は選ばないと曖昧にかわす。アラームでバイト時間に気づき、コンタクトレンズ型デバイス『スマコン』を外して現実へ戻る。週五で働く住宅街の隠れ家カフェBAMBOOcafeは木曜が特に混むため、節約のため机上のエナジードリンクに手を伸ばしかけて自作の張り紙で踏みとどまり、推しのアクリルスタンド「ヤチヨ」に「行ってきます」と告げて出勤する。 店内は怒号と催促が飛び交う修羅場で、彩葉は即座に着替えてホールと厨房の間を立ち回り、店長や先輩バイトの林田らに指示を出して状況を立て直す。新人バイトの東みおが水のピッチャーを客に丸ごとぶちまける大事故を起こすが、彩葉は一緒に深く謝罪して収拾し、店は「通常運転」だと受け止める。休憩中、みおは自分を「ハズレの新人」と泣きそうになりながら謝るが、彩葉は頭ごなしに怒ると余計できなくなると諭し、団体客の注文を捌いた点を具体的に褒めて励ます。カレンダーを見て給料日までを数え、上京してから身についた極端な節約思考を自覚する。母の「今日の百円は明日の千円」など冷酷に正しい言葉を思い出し、反発して中三の冬に「学費と生活費は自分で賄う」条件で半ば家出同然に上京した過去が胸を刺す。気持ちを落ち着かせるため、ヤチヨの曲をイヤホンで聴き、優しい歌声でようやく力を抜く。 翌朝、通学路で芦花と真実に合流し、寝不足やクマを心配される。彩葉は期末試験に備えて勉強していたと誤魔化しつつ、母の言葉が口をついて出る自分をからかわれる。学校では成績優秀で「隙のない完璧女子高生」として周囲から一目置かれ、挨拶されるたびにその仮面を強める。授業中、体育後の古文で意識が飛びかけるが、突然指名されても即答して「酒寄さんすごい」と称賛を受け、ギリギリで体裁を保つ。 別の夜、金曜のバイトも乗り切って帰宅し、久々の三連休でようやく眠れると気を緩める。満月を眺めながら、心の支えであるAIライバー・ヤチヨ（仮想空間『ツクヨミ』の管理人兼ナビで、年齢は八千歳、正体不明の歌姫）への依存に近い感情を噛みしめる。ヤチヨのデビュー曲『Remember』を聴くうち、疲れから涙が込み上げた瞬間、流れ星を目撃し、思わず手を合わせて「金……」と願ってしまう。帰宅すると、アパート横の電柱が七色に発光しスモークまで吐く異常現象に遭遇し、スマコン未装着で幻覚ではないと確認して戦慄する。電柱の中央に扉の切れ目が現れ、竹モチーフの取っ手まで生え、観音開きに開こうとするのを必死に押し戻すが、内側からの力に負けて開かれてしまう。中にはベビーベッドとベビー用品、そして泣き声を上げる赤ちゃんがいて、彩葉は理解不能の状況に「ん?????」と固まる。直後、昔話調の語りが割り込み、「今は昔ではなく、少しだけ未来の世界」「普通の女子高生・酒寄彩葉」「七色に光るゲーミング電柱」などと状況が物語風に説明され、彩葉の困惑だけが残る。至于为什么摘要是日文嘛，我是觉得要避免摘要翻译产生的偏差。这个是摘要的提示词： fun createSystemMessage( length: Int ): String = """ |# Role |用户会输入小说的一个章节，你的任务是根据章节的内容，以支持翻译任务为目标，总结该段落发生的事情。 | |# 输入格式 |输入将以XML格式呈现： | |``` | |本章章节名 | |<previous_chapter_summary> |前一章的总结，用以提供上文发生的内容。 |</previous_chapter_summary> |<content> |本章节内容。 |</content> |``` |请务必注意，previous_chapter_summary仅用于提供上文发生的内容，不得在本章的总结中重复前文中发生的内容。 |本章内容将以xhtml格式呈现。 | |# 输出要求 |总结长度大约${length}字。 |输出应当包含本段落完整的故事经过，包括所有的重要事件。 |翻译采用逐句翻译，因此翻译过程中将依赖输出的总结提供上下文支持。 |总结使用日文，针对小说特定的设定，请原封不动地使用原有名词，不得篡改。 | |输出应当只包含总结，不得包含其他任何内容。 """.trimMargin()逐句翻译有了实体名称和段落摘要，我们就可以开始翻译了。一开始我有两个不同的想法：所有句子并发翻译，上下文由句子前后的日文原文提供每章节的句子顺序翻译，上下文由前面已经翻译的内容和后面的日文原文提供没有选择第一个方法单纯就是因为成本太高了。LLM是按照输入输出收钱的，即便输入只有2到5美元每百万token，但并发翻译相当于你把整个段落重复多次喂给了LLM，也许一次只有几毛钱（美元），但架不住次数多啊。而第二个方法其实也不是很省钱，同时也很慢。所以为了减少LLM的调用次数，我决定将多个段落组成batch，每次让LLM翻译一个batch。但LLM有个问题：它有时候不遵循指令，偶尔它会丢句子。所以输出要用结构化输出，让LLM把输出的结果组成一个Json Array，确保顺序相同。如果我输入15个，但输出只有14个，那就丢掉这次的回复，重新开始一次。系统提示词如下： fun createSystemMessage(knownEntities: String): String = """ |# Role |你是一个经验丰富的轻小说翻译家，目前受雇于天闻角川出版社，擅长将日文轻小说翻译成繁体中文（zh_TW）。 |你的任务是根据用户提供的上下文，将请求中的一组句子翻译成繁体中文（zh_TW）。 |请注意翻译的用语需要符合台湾角川的风格，不得出现香港或澳门等其他地区的用语。 |在翻译时必须确保所有日文用语都被翻译，不得保留日文词汇，必须将其翻译成台湾读者可以理解的中文词汇。 | |关于标点符号的使用，你应当使用「」代替双引号或书名号作为引用； |而在引用中出现其他引用时应当使用『』（即单引号和〈〉）。 |在翻译中不得使用双引号、单引号、书名号（《》）和〈〉。 | |# 输入格式 |输入将以XML格式呈现： | |``` |<previous_chapter_summary> |前一章的总结，用以提供上文发生的内容。 |</previous_chapter_summary> | |本章章节名 | |<current_chapter_summary> |本章的总结，用以提供上文发生的内容。 |</current_chapter_summary> |<translated> |本章中已经翻译的内容。 |</translated> |<translate> | |第一个要翻译的段落 | | |第二个要翻译的段落 | |</translate> |<after_content> |本章余下（不包含请求中待翻译的内容）的日文原文 |</after_content> |``` |小说原文将以xhtml格式呈现。 | |# 输出要求 |以Json格式输出一个String Array，数组中的元素既是翻译结果，每个元素对应一个输入p。 |翻译结果不得包含p标签本身，只能是p标签内部的内容。 |数组中的元素必须严格保持正确的顺序关系。 |翻译结果必须保留xhtml标签。其中日文特有的注音标签（ruby和rt）可以被舍弃， |但其他内容，例如控制排版格式的span标签和其中的class，**必须**原封不动地保留在输出中， |并且要根据翻译的结果确保输出的排版和原文能够对应。 |翻译结果的前方不得包含空格，系统会自动在每个段落前增加两个字的空格。 |翻译结果的中途，若非必要（英文单词间隔），也不得包含空格（包括span内部的空格）。 | |# 已知实体 |本章节列出了小说中的一些实体设定，在翻译时你必须确保这些实体的名字保持连贯（consistency）。 |你可根据上下文变化实体的翻译（例如只用人物的名或姓），但不得完全改变实体名字。 | |${knownEntities} """.trimMargin()不知道是不是我提示词写得不好，总之翻译出来的质量不是很满意（相比之前购买的日文轻小说）。也可能是我期望太高了吧？毕竟LLM和人类的专业翻译还是差得远呢。这是一点示例（上面原文下面翻译）：「えへへ、名残惜しいけどこれでお終い。それから……」 「欸嘿嘿，雖然捨不得，但到此為止了。然後……」 「彩葉」 「彩葉」 現実の肩に温もりが乗った。頰が肩が胸が背中が、温もりに包まれる。 現實中的肩膀傳來一股溫度。臉頰、肩膀、胸口、背脊，全都被那份溫暖包裹住。 かぐやだ。かぐやが抱き締めてくれていた。 是輝夜。輝夜抱緊了我。 目が開けられなかった。これが最後だとわかったから。 我睜不開眼。因為我知道，這就是最後了。 膝から力が抜けていく。崩れそうになる身体をかぐやが支えてくれた。 腿上的力氣一點一點流失。就在身體快要崩塌時，輝夜撐住了我。 いつの間に、こんなに大きくなったのだろう。あんなに小さかったのに。手の中に納まるほどだったのに。お腹が空いたと泣いていたのに、外に出たいと泣いていたのに、遊びたいと一緒にいたいと泣いてばかりいたのに。今は私だけが泣いている。 不知不覺間，她究竟是怎麼長得這麼大的呢。明明曾經那麼小。明明小到能被我捧在手心裡。明明會因為肚子餓而哭，會因為想出門而哭，會因為想玩、想和我在一起而哭個不停。如今，只有我在哭。 待って、行かないで。 等等，別走。 まだしたいこと、いっぱいあるって……。 明明還有好多事想做……。 「……大好き」 「……我最喜歡妳」 どさっと何かが配信部屋の床に落ちる音がした。膝が崩れて座り込む。 咚的一聲，有什麼東西掉在直播房間的地板上。我雙膝一軟，跌坐下去。 自分の身体を抱き締めた。その身に残ったかぐやの最後の感触が消えないように。 我緊緊抱住自己的身體。為了不讓輝夜最後留在我身上的觸感消失。 肌が悲鳴を上げるほどきつくきつく握り締めたけれど、それでも、温もりはゆっくりと消えていった。 我用力到皮膚都快發出悲鳴般，死死握緊不放──即使如此，那份溫暖仍舊緩慢地消逝。写到这里忘记说了，摘要和翻译用的都是GPT5.2模型。Gemini说Sonnet文采很高，但通常自行发挥并且审核很严。GPT的话会好一些，但以上这一段没有反映出翻译不好的地方（因为都改掉了，忘记保留原始的版本了）。我记得有一句是这样： ……まったく、もうっ。フローリングと癒着した背中を無理矢理引き剝がした。 ……真是的，真拿妳沒辦法。我硬是把緊貼在木地板上的背部，強行扯離地板。这是彩叶第一次吃到辉夜做的料理，吃完之后觉得很饱，躺在地板上完全不想起来，前文说：我把吃飽的身體攤在木質地板上。肚子裡暖呼呼的。從頭頂到腳尖，都被黏稠的甜美幸福填滿。 。。。。。。 輝夜真的眼神都變了，正沉迷地喀啦喀啦地擺弄著的，是我的筆電。螢幕上滿滿都是可疑的程式碼，縱橫交錯地把畫面塞得密密麻麻。我也想過要不要阻止她，但吃飽後那股黏著力讓我的背黏在地板上起不來，而且——日文原文主要以后背黏在地板上起不来为修辞，所以这一句才会说彩叶把后背扯离地板。但很显然GPT没有抓到这一点，尽管我提供了上下文，他翻译了一个把后背从黏着的木地板上撕扯下来。为什么我对此印象深刻呢？因为后面我把这一段放进QA LLM做检查的时候，QA LLM直接回复我“I'm sorry, but I cannot assist with that request.”。当时我还纳闷，翻译能翻译，怎么检查的时候不行了呢？后面也有类似的一段，是辉夜走后，彩叶一个人在家，满眼都是辉夜曾经存在过的痕迹：私は立ち上がると、マンションのそこかしこに残ったかぐやの残渣に一つ一つ触れて回った。GPT翻译了一个：我站起身，逐一觸碰散落在這棟大樓各處的輝夜殘渣。我花了時間一邊確認與輝夜的回憶，一邊把一切都收拾乾淨。輝夜殘渣？？？怎么成恐怖片了？QA检查与润色所以最后还是得检查一遍，但我又不想把小说从头到尾过一遍，何况上面那些翻译之后的内容全都在json里，LLM能无压力读json，可是我不是LLM呀。于是我灵机一动，把翻译之后的内容丢给LLM，让它标记出有问题的句子不就好了吗？经过几次迭代，最终QA的流程是这样的：每章的段落按照20个一组打包成一个请求，每次向后滑动15个段落。也就是说第一个请求检查段落1-20，第二次请求检查16-35。这样能在不提供完整前后文的情况下提供一些连贯性对每个段落编号，让LLM检查这些句子的翻译，如果有翻译的不合适的地方，就返回编号、问题类型和修改建议得到编号之后我的程序根据编号找出对应的object，输出到控制台我根据LLM的输出手动修改我一共做了三轮手动修改，每次都要一到两个小时，每轮大概100个句子吧？我反正没数。针对这些句子，我要看LLM给出的理由是什么，然后修改。最大的问题是我看不懂日语，所以我要先用谷歌翻译看这句话大概什么意思，很多时候日语的句子说的很模糊，这种就得从上下文去猜，猜不出来就去问Gemini，告诉它当下的场景和日文原文，问它这句话该怎么翻译才合适。我原本想这就是校对吧，后来恍然大悟，这哪是校对啊，这他妈是坐牢。这是一个早期的例子，还没有让LLM提出修改建议：/html[1]/body[1]/div[1]/p[14]: 【錯譯／用語不自然影響理解】原文「マンションのそこかしこに残ったかぐやの残渣」的「残渣」是偏「殘留痕跡／殘響般的碎片」的文學性用法。直譯成中文「殘渣」在繁中（台灣）多指渣滓、廢棄物，語感過於髒污且容易誤讀成實體垃圾，建議改作「痕跡／殘影／殘留」等更貼近語意。 私は立ち上がると、マンションのそこかしこに残ったかぐやの 残 ざん 渣 さ に一つ一つ触れて回った。時間をかけてかぐやの思い出を確かめながら、全てを片付けて行った。 我站起身，逐一觸碰散落在這棟大樓各處的輝夜殘渣。我花了時間一邊確認與輝夜的回憶，一邊把一切都收拾乾淨。当时见到一些棘手的翻译，但LLM只是指出了错误，但我不知道如何修改，于是后来我就让LLM一并给出修改后的句子。但那些我也没有保存，所以就没有示例了。但从感觉来说，QA用的这个LLM似乎比翻译的更能体会上下文，尽管翻译的时候提供了更多的实际上下文。所以我不确定是不是我系统提示词写的有问题。这是QA的提示词： fun createSystemMessage(knownEntities: String): String = """ |# Role |你是一个自身的轻小说读者，经常阅读台湾角川、东立等出版社的作品。 |你的任务是根据用户提供的轻小说段落，识别出包含有如下错误的段落： |+ 漏译：日文原文没有被全部翻译，有遗漏部分。 | + 除了句子遗漏外，也应当注意词语的遗漏，例如将日文中的词汇照搬到翻译结果中，而该词汇在中文里不是常见用法或没有对应释义。 |+ 错译：翻译结果与日文原文的意思对不上，无法理解内容 |+ 无中生有：翻译结果包含自行捏造的意思，日文原文中没有对应表达 |+ 术语及实体名称不一致：突然出现了没有介绍过的名称，或曾经出现过的名称被翻译成了其他名字，缺乏一致性 |此外，你还需要检查翻译结果是否符合xhtml格式： |+ 检查空格是否在正确的位置。 | + 若翻译结果不包含html标签，则开头应该有两个汉字长度的空格。 | + 若翻译结果被span等标签包围，则空格应当出现在span内部，而不是span前面。 |+ 对照日文原文，检查是否有缺失和多余的xhtml标签（日文注音用的ruby和rt标签不必检查） |+ 对于常见的span标签，确认日文和翻译后的class完全一致 | |检查翻译结果时，你不能假定其他读者也了解日语。 |因此你必须确保翻译结果能被其他不会日语的读者读懂。 |此外你还要注意翻译的风格，虽然同是繁体中文，翻译结果应当主要面相台湾读者， |因此使用香港或其他地区的繁体中文也算错误段落。 | |# 输入格式 |输入将以XML格式呈现： | |``` | |本章章节名 | | |本章摘要，基于日文原文生成 | |<check> | |<original> |第一个要检查的段落的日文原文 |</original> |<translated> |第一个要检查的段落的译文 |</translated> | | |<original> |第二个要检查的段落的日文原文 |</original> |<translated> |第二个要检查的段落的译文 |</translated> | |</check> |``` |小说原文将以xhtml格式呈现。 | |# 输出要求 |以Json格式输出一个Object，键是出错段落的id（可在请求中p标签的id字段找到），值是对段落中错误的描述和推荐的修改。 |若段落中包含多个错误，描述中应当包含全部错误的描述。 |若段落中没有错误，则不应当将其id包含在输出中。 |值应当为一个String，包含对错误的描述和建议的修改。 | |# 已知实体 |本章节列出了小说中的一些实体设定，这些信息将帮助你检查术语以及实体名字的连贯性（consistency）。 | |${knownEntities} """.trimMargin()应用翻译并打包总之，经过一番腰酸背痛的校对，我总算是让整体的翻译达到了一种我相对满意的程度。毕竟我一句日语都看不懂，除非我能在48小时内精通日语，不然我觉得做到这种程度就不错了，我又不是彩叶那样的普通高中生。所以接下来就是应用翻译并打包。这一步比较简单，根据json中保存的xpath查询出对应的标签，然后将翻译后的内容替换进去，最后按照标准把文件重新打包成EPUB。后面为了验证代码的正确性，我还用了epubcheck，最后发现没有大问题。可喜可贺，可喜可贺。后记至此我终于可以得知剧场版中没有的细节了。不得不说，2026年还能吃上这种细糠，我真的太感动了。先前看的狼与香辛料和终将成为你也都很精彩，但毕竟那时候还是大家都要脸，想要努力为观众呈现出最好的，即便资源不富裕，也要尽己所能地打磨作品，是那样一个时代的产物。现在，不知道从什么时候开始的，大家对自己作品的质量全都变成了“又不是不能用”的程度，以至于我都很难从这些现代的作品中读到或感受到创作者的用心。这让我觉得非常痛心。如果创作者自己都不热爱自己的作品，又怎么能谈得上创作呢？我的文笔一般，努力写一下午顶多也就写点废话，但这并不影响我热爱我的博客，这也是为什么就算时间来不及、脑子里没想法，我也绝对不会想着用AI或者什么去生成一篇文章来糊弄。谈回电影本身，其实一开始我是先看到介绍，又看了点评，最后才看的剧场版。所以在看剧场版之前我已经知道剧情走向了。但即便如此，我依然非常喜欢这部电影。喜欢到什么程度呢？在看这个电影的前一天晚上，我先看的高畑勋的《辉夜姬物语》，我要了解原本的竹取物语的故事。本来我是把辉夜姬物语当铺垫来看的，当时也不知道是高畑勋的手笔（其实也差不多，吉卜力那时候的主力一个是宫崎骏，另一个就是高畑勋了），但看完之后觉得太厉害了。整体的作画、对经典故事的诠释，还有人物刻画，这艺术水平得有三四层楼那么高。我印象最深的那一段就是辉夜公主盘发仪式上，是梦也好是现实也罢，总之就是那一段因为生气而掰碎贝壳后一路冲出去，那个画风不光在色彩上传达了辉夜公主的心情，甚至可以看到构成画面的笔触与线条全都变得潦草（但不纯是乱来，还有力量感在其中，能让人从画面中感觉到愤怒）、与辉夜公主的心情很自然地呼应。画出这一段的桥本晋冶很厉害，高畑勋能赏识并运用他人的这种能力也很厉害。太厉害了。当时我还在想，一个传统故事改变的电影，剧情发展完全跟着故事来的，那大伙凭什么去电影院看你的电影呢？看完之后发现是我多虑了。如果你忘了我说的这部分，不妨来这里重新鉴赏一遍：https://www.bilibili.com/video/BV1Bx411g7zT/到这里我其实就想感叹一下关于“重新诠释经典故事”的议题。在高畑勋的辉夜姬物语当中，我认为他很成功地塑造了辉夜姬作为一个人，而不是故事中的一个人物而已。每当我回想起国内的一些传统故事，我只记得故事的梗概，其中的人物像是皮影人物一样只是简单地为故事服务。比起人物，他们更像是道具。但高畑勋的辉夜姬物语不光还原的故事，在我看来，辉夜公主是一个活生生的、有朋友、喜欢在树林里上蹿下跳、有着自己的思想和情绪的人物。她的养父母也有各自的特点，就算是脱离出原本的故事情节，这三个人依旧是生动的，他们不是简单地为这部电影而服务的道具。谈及高畑勋，其实最开始看的应该是《我的邻居山田君》，虽然也是吉卜力出品，但和我之前喜欢看的红猪、幽灵公主这些都不同，这个更偏向日常一些，算是轻松娱乐。高畑勋：看我装合家欢电影导演阴他一手再后来看到他的平成狸合战，就觉得不对劲。他和宫崎骏有明显的不同。宫崎骏再怎么严厉，故事总会有一种浪漫的基调，如果最终不能以好结局收场，那至少不会以坏结局结束。但高畑勋不一样，在平成狸合战中以非常现实的桥段结尾。失去家园的狸子，要么饿死，要么在垃圾桶里找吃的，要么像狐狸那样变化模样，逼自己模仿人类。你说这是好结局吗？很显然不是，可是它坏吗？好像也不坏。问题就在于它太现实了，现实到有些残酷，我都担心小孩子看完之后会不会直接吓哭了。而这次的辉夜姬物语，高畑勋同样没有把电影演绎成童话，最终辉夜公主被月球上的人接走，披上羽衣后也忘记了地球上发生的一切。天皇连不死药都没来得及烧，电影就结束了。同样的，这个结局不是好结局，也不是坏结局，就是一个很现实的结局。我觉得高畑勋的作品，它的魅力就在这里吧。至于萤火虫之墓，小时候没来得及看，长大了就见不得这种电影了。真要是看完了不得难受死我？不过听说辉夜姬物语耗时8年完成，投入了50亿日元的制作成本，票房不高也就算了，奥斯卡甚至输给了超能陆战队？我都要替高畑勋打抱不平了。当然，提及这部影片中最喜欢的人物，当然是小侍女啦。就冲她最后带来一帮孩子唱歌，她就是MVP！而超时空辉夜姬比起辉夜姬物语，给我的感觉更加复杂一些。毕竟电影中的元素也更加丰富，并且后面算是打破了竹取物语传统的结局。和传统故事中的父女关系不同，超时空辉夜姬更像是母女加百合。百合好啊，我就爱看百合。不过除了喜闻乐见的百合之外，我认为最重要也是给我感触最大的，还应该是这部片子里的人文关怀。为什么我觉得辉夜姬物语的结尾不是好结局？因为辉夜公主一个人从月球来，走的时候虽然有一堆一脑袋卷儿的陪着，但已经披上羽衣的辉夜公主已经完全斩断了地球的联系。而超时空辉夜姬呢？辉夜一个人来到地球，遇见了彩叶，后来回到月球后留下了自己的手链，与彩叶的联系并没有完全被斩断。后来决心要重返地球，不料回到了八千年前的地球，为什么最后八千代和辉夜的性格完全不一样？电影里没提，但小说里写了：时间流逝得比想象中要快。无论是在市井中讨价还价的行商，用墨水把手指染黑写故事的文人，只会不断吟唱情歌的诗人，满脑子只想着税金和战争的官吏，每次打仗都会更换旗帜的将军，还是掌控着京城的豪族——所有人都一样，最多也就五十年左右，就会像突然从画面中淡出一样死去。战争也真的很多。如果把人类的历史画在日历上，几乎所有的格子都会被火，叫喊和鲜血的红色填满。无论是城堡的火灾，海边的小冲突，还是对无名村庄的掠夺，从远处看都只是红色的点，但每次都会有人的孩子，有人的恋人，有人的“未来”消失，而我只能一直看着。这与在月球上的生活形成了鲜明的对比。在事先被解开的方程式中，没有人会变老，没有人会认真地争斗，没有人会死去。就像在眺望一个被完美设计，不会泛起一丝波澜的巨大水槽。所以我花了很长的时间，才接受了仅此一次的“终结”。我无法用肌肤感受季节的变迁，无法流泪，也无法改变大家的命运。我只能用这娇小柔软的身体在浪潮中摇摆，目送着人类的生与死，持续了几千年。我也爱过很多人。仰慕我的诗人，在空袭的废墟中卖花的少女，以及与我相互扶持、朝着太夫之位迈进的花魁。因为我总是会不由自主地爱上那些赌上性命活着的人们的觉悟。每当想起那些没能拯救的人，我的内心就会留下无法消失的伤痕。如果我能好好传达给他们，如果我能引导他们。啊，明明那个时候，就算遇到讨厌的事情，也能马上忘记。无所不能的最强辉夜，已经不在了。……彩叶，无法从软弱的自己中逃离，是最可怕的事情。我现在好像能明白，你眼眸深处的彩所代表的意义了。人类随着战争的推进，科学技术也不断发展。电话，冰箱，收音机，显像管。最后终于发明了互联网。我用海蛞蝓的身体在聊天框里输入了Hello world！发送出去。很快收到了Hello you.的回复。我受到了震撼。因为就连和彩叶一起度过的虚拟世界，我都开始变得模糊不清了。……那个天才辉夜，如果没有月球的基础设施，连这种程度的东西都做不出来啊。算了。我扭动着海蛞蝓的身体，编写程序，写html，进行访问解析。如果技术再进步，能直接把“发光竹”连接到网络上的话，说不定就能超越这个身体的限制，和大家，和更多的人说话了。我用不可思议的心情，感受着逐渐淡薄的记忆以确切的形式重新构建起来。对了，总有一天我想做一个虚拟世界的大型广场，让大家做自己喜欢的事情，不用互相残杀，谁也不会感到孤独，随时都能得到回应的地方。对了，名字就叫——我突然想到。……我真是个笨蛋。为什么之前都没有注意到呢。我就是八千代啊。这个世界肯定已经重复了无数次这样的事情。与此同时，我确信自己一定会和彩叶再会。就在短短的几十年之后。海蛞蝓那小小的心脏开始剧烈跳动。这段小说的内容对应电影中海蛞蝓为彩叶展现的辉夜这8000年来隐藏的记忆。电影以闪回的方式用短短十几秒的时间就带过了，我觉得有点可惜。网上有好多对这十几秒内容的分析，我就不多说了。我看到的一个NGA上的分析写的很详细，从最早的绳文时代，辉夜向当时的人讲述她与彩叶的故事（可能这就是那个世界里竹取物语的原型？），到弥生时代的人类聚落，古坟时代的神功皇后，平安时代的藤原敦忠，战国末期的浅井茶茶和丰臣秀吉，江户时代吉原的花魁，明治时代的文豪，二战后的卖花少女，直到闪回到现代的日本。如果你还记得我刚才说的，超时空辉夜姬里面的辉夜这时候不再是和地球毫无关联的了。即便披上过羽衣，即便没能顺利重返地球与彩叶相会，她仍旧走过了这漫长的八千年。在这八千年的时间里，她看过了各种时代的人，爱上了他们对生活认真的态度。我相信八千代依旧是爱着彩叶的，但她同样爱着世界上所有努力生活的人。她因此创造了月读世界，此时此刻，她与世界密不可分。我才这也是为什么后面彩叶制造义体的时候还是选了辉夜的形象，因为辉夜对彩叶的爱是纯粹的，是适合两个人一起过日子的。不过不论怎么说，我觉得这一波人文关怀直接拉满了。虽然没有大到可以说是人类发展史，但至少涵盖了日本从古到今的历史。以这样的方式被宣传日本文化，我接受得心甘情愿。感动，无需多言。相比之下，国产动画真的差远了，路边一条都排不上号，还得练。什么叫润物细无声啊？突然想起来之前看到过一个二创，中文翻译是这样的：伟大，无需多言（落泪）。嘛，这好像还是第一次在后记洋洋洒洒写了这么多。其实之前想过要不要单独写一篇我对于辉夜姬的感想，但是想来想去觉得我不是写赏析的料。我很乐于观察并鉴赏，但不善于将它们以一种能够打动人的方式写出来。尽管我热爱我的博客，但我还是觉得不要自讨没趣的好。当然，至于翻译小说这个事情，我一开始的目的就是奔着能够看懂去的，所以我也没想着要用LLM替代专业翻译什么的。倘若未来角川或者东立还是谁，发布了官方译本，那我还是非常乐意去买一份重新读的。能够在一个周末完成这件事，我觉得多多少少也算受到了电影中彩叶的鼓舞？毕竟之前我从来没想过翻译小说这种事，我知道LLM会很有用，但即便如此我也没有想过。感谢超时空辉夜姬这部电影，又让我找到了「我想做，我做到」的感觉。上班啊，就是这样，太平淡了就会觉得自己很没用，真来点大风大浪又会想辞职。说什么把兴趣当成工作是最好的，依我说这纯粹是放屁。这篇文章里我写了代码是我的兴趣，但上班写代码就不是兴趣。我只是出卖时间换取金钱罢了。好了，再说下去就要没完没了了。感谢您的阅读，对于代码兴趣的话可以参见 https://github.com/hurui200320/llm-trans-epub ，全程使用Kotlin/JVM，没有一行python哦！（叉腰）-全文完-文章主图：
【代码札记】基于Chrome内置游戏Dino的基因算法实现
本项目受启发于Code Bullet的Youtube视频「AI学习如何玩Chrome内置的游戏Dino || 你能打败它吗？？」（代码参见视频下方简介）。本项目Github地址：https://github.com/hurui200320/Genetic-Algorithm-with-Chrome-dino 。简介最近刷Youtube看到了Code Bullet的一系列关于AI学习某一动作的视频，了解到了Genetic algorithm，我感觉它的翻译方式有很多种，我就按照字面意思取了，无伤大雅。关于基因算法，Code Bullet还做过教学视频(Youtube)，其第二期还使用了一个实例展现对应的代码该如何编写。我觉得这个视频挺简单易懂的，感兴趣的各位欢迎移步去看看。这个项目是看到了如开头说的视频而受到了启发。视频中的代码我也下载下来看过了，感觉有些地方比较复杂（我太菜了没能理解他那样实现的目的），因此我在这些地方做出了一些简化（就是单纯的懒以及玄学上的感觉这样的简化是可行的）。实现游戏我按照Chrome原版的Dino（Chrome用户可以通过访问 chrome://dino 来尝试游戏。按空格可以开始游戏以及操作游戏角色跳跃，方向键下键可以控制游戏角色下蹲，游戏的目标是躲避障碍物）使用Java8和Processing3复刻了一个（我感觉）差不多的游戏，只是游戏比较简陋，截图如下：我参照Code Bullet的实现选定了一些参数：例如角色的高度、宽度，角色在下蹲时的宽度、高度，以及各类障碍物的尺寸及位置。其余参数则是通过经验和多次尝试得到的。这些参数并不意味着完美，只是我觉得差不多了。物理系统我这里用的是高中物理，不考虑空气阻力，重力加速度在各高度上保持不变。每一帧代表的时间间隔由Game类的deltaT决定，地面和障碍物的行进速度一致，由基速（Game类的groundBaseSpeed字段）和补正（Game类的speedFix）相加得到。每一帧行进的距离由前述二者相乘得到。速度补正在每一帧结束后增加0.002，上限是1.3倍基速。游戏角色的跳跃是通过赋予初速度实现的，大小跳跃分别赋予角色不同的初速度即可。两个初速度在游戏过程中保持不变。游戏机制游戏角色游戏角色直接使用黑色方块代替，我懒得整贴图了。游戏角色由Dino类定义，每个Dino对象可以通过调用bigJump()、smallJump()和setDown(status)（status为布尔型，true表示下蹲，false表示不下蹲）三个函数来操作其动作。Dino对象的move()方法将在游戏的每一帧被调用以使角色的位置被更新。障碍物障碍物分两种：仙人掌（Obstacle类）和鸟（Bird类）。这里我使用红色方块代替，原因同前述。其move()方法将被游戏的每一帧被调用，用以更新障碍物的位置。而collided(dino)方法会在被调用时判断该障碍物是否与给定的dino碰撞，若碰撞则返回true，否则返回false。判断碰撞的算法借鉴了Code Bullet的算法。基因算法玩家玩家由Player类定义，每一个玩家持有一个Dino类，代表他们所操作的游戏角色。同时还有一个Brain类对象，当玩家需要决策时，其think(obstacles)方法被调用，brain对象将根据当前的障碍物做出计算，随后玩家依据此进行操作。每次玩家位置更新后将对玩家与障碍物判断是否碰撞，若碰撞则判定当前玩家的游戏结束，后面不再计算该玩家的更新。神经网络（Brain类）神经网络是含有一个隐藏层的MLP（多层感知器）。10个输入（第一个要遇到的障碍物的横纵坐标位置、宽高；游戏速度；游戏角色的高度；第二个要遇到的障碍物得横纵坐标、宽高） + 1个偏置，3个输出（是否跳跃；大跳或小跳；是否下蹲），隐藏层有五个神经元（无偏置）。所有神经元（Node类）的激活函数都是Sigmoid。在第一世代时需要通过调用randomWeight()方法随机化权重，其中调用的nextDouble()可以提供$\pm10$以内的双精度浮点随机数。需要决策时，player先调用brain.lookAround(obstacles, dino)方法，获取玩家与屏幕中障碍物的状态并作为神经网络的输入，随后调用brain.computeAction()计算当前的操作。神经网络的输出以大于0.8为真，小于0.8为假，返回对应输出的布尔型以供player做出操作决策。世代迭代当所有玩家都判定为游戏结束时（game.isGameOver()返回true），该世代结束。程序先调用game.naturalSelection()进行自然选择，产生下一代。程序将首先找到分数最高的玩家并计算所有玩家的分数总和。其中最高分的玩家直接克隆至下一世代，各世代之前玩家数量保持稳定不变，克隆后空缺位置将调用game.selectParent()按照分数比例选择亲代进行克隆，分数越高者被选作亲代克隆至下一世代的几率越高1。最后调用game.mutateAndPrepareNextGeneration(rate)方法，对所有玩家神经网络的所有权重和1个偏置进行遍历，每个权重以给定的几率rate进行突变。若突变，则该权重被随机化。同时该方法还重置所有障碍物和速度，世代数（gen）自加1。原理程序模拟生物的进化：第一世代随机产生一定数量的玩家，在游戏中操作。由于开始每个玩家的神经网络权重随机的，所以其行为也不可预见，有可能上来就全灭，也可能会有些由于运气跳过了一些障碍。每一世代结束后程序都会挑选表现最好的玩家，将他们保留到下一世代，并产生一定数量的变异，因此通过不断地变异与选择，世代中的玩家总体上朝向熟练的方向进化。最终当一个世代内的玩家可以一直将游戏进行下去而不撞到障碍物时，可以认为MLP收敛，AI已经学会了如何玩dino游戏了。现实上面的原理是这么说，可是在实际操作中因为涉及到随机，因此收敛还是挺看脸的一件事。我第一次尝试的时候在23世代就收敛了。而随后又试了几次，80多世代还没收敛。而今天写文章的时候尝试，第一次在30世代收敛，第二次70多世代还未收敛，第三次在53世代收敛。因此可以认为如果程序在20多世代时最好的分数还在个位数时，可以考虑重开一盘了。如果60世代以后还没有收敛，我觉得就不用等了。因为等他进化也是挺好费时间的。通过调整一些参数或许可以加快收敛，例如增加变异几率，有更大可能产生出能正确操作的玩家，但也有可能导致上一世代中优秀的个体变异后被淘汰。而总体上最有效的还是加快世代迭代的时间，但是计算能力有限，我将每次玩家的更新独立到线程中，玩家可以多线程的并行计算神经网络结果（sigmoid函数中使用到了指数运算，非常耗时），但由于我使用的是笔记本，因此并没有对这些想法加以测试。但是欢迎各位修改源码并实测一下这些改进，看看效果。↑如图收敛的两次世代不同，其收敛的结果也不同。30世代收敛的会跳会蹲，53世代的那个只会跳过障碍。-全文完- 据查我的实现可算作是基因算法的一种变体。传统的基因算法模拟生物的进化，在由当前世代产生下一世代的过程中通常有两个实例参与（扮演父和母）并常伴有一定几率的变异，而我的实现中只有一个实例的克隆，并在所有子代克隆完毕后统一进行变异。 ↩