愿知识带给你我勇气，用那温暖的光，驱散冰冷的黑夜。
天下苦SnapBridge久矣，因此我决定用ESP32做一个替代它的硬件。本篇介绍SnapBridge的配对和下发数据的过程。前言上文提到我买Z8了，满心欢喜地设置好SnapBridge之后发现，Z502和Z8不能同时用，也就是说，如果我出门拍鸟想要双持，那么至少有一个相机收不到GPS数据。更何况尼康这个App做的，又费电又容易被杀后台，切出去看一眼地图再切回来，蓝牙就断了，十分不稳定。我没有用App下载照片的用途，我唯一需要的就是这个App把手机的GPS数据传给相机，这样拍摄的照片就有位置信息，方便日后回忆。关于实现方式，我想了几种方案。第一个就是自己重新写一个App，虽然安卓的蓝牙相关API不好用，但至少是最稳定的。可是我无法解决三星杀后台的问题，更何况三星一直不舍得给电池，弄这么一个App常住后台，怕不是半天就给手机整没电了。Z8和Z9机身上有个10pin针脚，一番搜索之后发现这个10pin针脚可以用NMEA-0183协议给相机传输GPS，但问题有二：首先，这个10pin针脚不好买，好多快门线并没有连GPS的数据线，你把它剪开只会发现没有要用的线；其次则是国行系统在相机菜单内屏蔽了GPS设置，插上了10pin接口的GPS也无法启动，这是国内法律要求的。虽然淘宝上可以给相机刷地区，但我没找到公开资料，自己做不了。我有精神洁癖，所以不打算让别人远程到我电脑上给我的相机搞奇怪的东西。于是只剩下一种办法，利用SnapBridge的协议给相机写数据。好在我不是第一个觉得尼康App难用的，隔壁gkoh/furble利用M5Stack的设备针对各家厂商做了替代品，是基于ESP32的，虽然尼康设备的支持很有限，但社区已经基本上把握手和消息的格式都逆向出来了，唯一的问题在于这个项目用了NimBLE，它不支持传统蓝牙，因此无法完成尼康的配对。由于我并没有ESP32的开发经验，所以在购买ESP32和GNSS模块之前，我准备先在借助其他平台验证一下蓝牙握手的部分。概念验证一开始我想到了安卓，但安卓的蓝牙栈API实在是太糟糕了，从PoC的角度来讲，整理握手的流程被API搞得很零碎，反而让其他人摸不到头脑。相比之下，Linux的Bluez就好了不少。目前安卓和Linux Bluez（使用Kotlin编写）的代码可以在以下地方找到：安卓：https://github.com/hurui200320/nsg/tree/7bbe7a45f7ef3f0fd2dbe6772181e40c4fa149c7/androidBluez：https://github.com/hurui200320/nsg/tree/7bbe7a45f7ef3f0fd2dbe6772181e40c4fa149c7/kotlin-poc这两个代码都不算完整，但全都实现了完整的握手和下发时间以及位置数据，位置数据是随机生成的。下面来说说我测试过、能跑通的SnapBridge协议。智能设备蓝牙协议 时效性提示 以下内容纯靠社区逆向和我的探索及尝试。只保证在2026年7月上旬时有效，未来尼康可能会通过更新修改握手流程，因此不保证以下内容总是能用。建议首先做概念验证确保以下内容仍然可用。 尼康相机在菜单中对SnapBridge的叫法是智能设备，所以我的项目名字也选用了类似的命名方法：NSG，全称Nikon Smart GPS，但为了避免吃Nikon的版权炮，名字里没有用Nikon，而是只取了首字母N。智能设备首先需要配对，配对过程涉及BLE和传统蓝牙双栈，因此对于ESP32设备来说，你必须购买原版的ESP32，不能带任何后缀，什么S3啊P4啊C6啊这些全都是只有BLE没有传统蓝牙的。隔壁nRF52系列也说了硬件不支持传统蓝牙，就算你实现了传统蓝牙栈也没用。BLE UUID尼康相机的握手和数据下发主要依靠BLE，其中：相机的Service UUID：0000de00-3dd4-4255-8d62-6dc7b9bd5561握手（PAIR）用的Characteristic UUID：00002000-3dd4-4255-8d62-6dc7b9bd5561握手成功后写入智能设备名称（ID）的Characteristic UUID：00002002-3dd4-4255-8d62-6dc7b9bd5561写入TIME的Characteristic UUID：00002006-3dd4-4255-8d62-6dc7b9bd5561写入GEO的Characteristic UUID：00002007-3dd4-4255-8d62-6dc7b9bd5561相机的BLE广播相机在BLE广播的时候根据情况调整Manufacture Data，但总会宣告Service UUID。如果相机当前处于配对模式，则Manufacture Data为空，或者前两个字节按照小端序解读（也可能被有些框架解读为key）不为0x0399。此时发送握手消息即可触发配对流程。如果相机当前处于已配对模式，则Manufacture Data至少为6字节，前两字节按照小端序解读应为0x0399，随后跟随4字节的device id，同样按照小端序解读。配对多个设备的时候应该在相机里选择要连接哪个设备，这样相机才会在BLE广播中宣告正确的device id。如果相机宣告的device id和我们保存的device id相同，那么我们就可以发送握手消息来重新连接。BLE握手BLE握手的消息格式相对固定，但内容依据用途会变化。配对和重连时都需要重新进行BLE握手。消息格式和阶段握手消息分为四个阶段，每个阶段的消息格式都是17字节：Offset长度（字节）参数描述01stage握手阶段标示，值为0x01、0x02、0x03和0x04之一18timestamp小端序，虽然名字叫时间戳，但不一定非得是时间戳，随机生成就行94device id小端序，配对时需要随机生成（需要确保Least Significant Byte为0x01），重连时需要使用保存的值134nonce小端序，配对时需要随机生成，重连时需要使用保存的值第一阶段：我们需要随机生成一个timestamp，并根据需要填入device id和nonce。设置stage为0x01，序列化后写入PAIR。第二阶段：读取PAIR获取相机发给我们的握手消息，验证stage为0x02，此时timestamp为相机随机生成的，而device id和nonce是相机生成的challenge id第三阶段：此时timestamp应当和第一阶段一致，device id和nonce则填入经过blowfish算法和challenge id计算得到的结果。设置stage为0x03，序列化后写入PAIR。第四阶段：读取PAIR获取相机发给我们的握手消息，验证stage为0x04，此时timestamp通常和第二阶段一致，但不一致也无所谓。而device id和nonce应当按照8字节的ASCII数组解读，应该是相机的内部序列号，但与相机机身的序列号不同。FurBLE说完成握手后会在NOT1上收到确认成功的消息，但我测试Z502和Z8均没有返回这样的数据。完成四阶段握手后，如果是配对的话，需要再向ID写入32字节的ASCII数组，这个数组长度固定为32字节，最后一字节应为0，因此设备名称最长为31字节，不足的部分在后面填充0。这里写入的设备名字会被记录在相机的已配对设备中，不必和设备的BLE或BT名称一致。如果懒得做判断，那握手成功后固定写入也可以，重连阶段写入这个characteristic不影响。配对阶段握手成功后，相机将暴露自己的传统蓝牙地址，设备可以搜索同名设备并发起配对。重连阶段握手成功后，相机将显示“已连接到智能设备xxxxxx”。此时TIME和GEO将可以写入。Blowfish 开源许可 以下代码复制自 https://github.com/hurui200320/nsg ，该仓库使用AGPL v3授权。请读者在复制粘贴代码时记得遵守该许可证。 好在尼康当了一回人，使用了标准的Blowfish实现，在JVM和安卓上可以直接： private val cipher: Cipher = Cipher.getInstance("Blowfish/ECB/NoPadding").apply { init(Cipher.ENCRYPT_MODE, SecretKeySpec(KEY, "Blowfish")) }而C和C++可以直接使用https://github.com/cjwagenius/bfish/blob/main/bfish.h，这是发布进入公有领域的代码，因此我们可以自由使用。其中Blowfish加密的key也是固定的：FF FF AA 55 11 22 33 00static const uint8_t KEY[] = { 0xFF, 0xFF, 0xAA, 0x55, 0x11, 0x22, 0x33, 0x00 }; private val KEY = byteArrayOf( 0xFF.toByte(), 0xFF.toByte(), 0xAA.toByte(), 0x55.toByte(), 0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x00.toByte() )在计算challenge时，协议中预设了8组salt，每组salt有两个32bit的数据：SALT[8][2] = { {0x704066e4, 0x0433d552}, {0xed4b8fac, 0x15f7e47b}, {0x24471f11, 0x8b5ea1fc}, {0x05960c31, 0x2b8c7f41}, {0xfda588c1, 0xeba8b1f3}, {0x99166056, 0x1bd3d550}, {0xcd32687f, 0xa9e28a30}, {0x2a8fe834, 0xdec7ebf4} }; @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") private val SALTS = arrayOf( intArrayOf(0x704066e4.toInt(), 0x0433d552.toInt()), intArrayOf(0xed4b8fac.toInt(), 0x15f7e47b.toInt()), intArrayOf(0x24471f11.toInt(), 0x8b5ea1fc.toInt()), intArrayOf(0x05960c31.toInt(), 0x2b8c7f41.toInt()), intArrayOf(0xfda588c1.toInt(), 0xeba8b1f3.toInt()), intArrayOf(0x99166056.toInt(), 0x1bd3d550.toInt()), intArrayOf(0xcd32687f.toInt(), 0xa9e28a30.toInt()), intArrayOf(0x2a8fe834.toInt(), 0xdec7ebf4.toInt()) )我们需要遍历这8组salt，找出正确的一组。对于第i组salt，我们首先需要拼接需要进行hash计算的数据：src[6] = { SALT[i][0], SALT[i][1], cam_ts_lo, cam_ts_hi, // 阶段2的timestamp，相机生成 our_ts_lo, our_ts_hi // 阶段1的timestamp，我们生成 };这里需要注意的是，在进行BLE发送和接收的时候是按照小端序处理的，但计算的时候这些数据必须是大端序。例如相机发送的timestamp是[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]，那么我们接收到的timestamp应该是0x0807060504030201，拆分时低4字节应该是0x04030201，高4字节是0x08070605。但在构建hash的数据时，高低四字节应当分别被转换为大端序表示，因此cam_ts_lo应该是0x01020304，而cam_ts_hi是0x05060708。将前述数据进行hash计算，这个计算过程是尼康自己整的：struct HashResult { uint32_t left; uint32_t right; bool valid; }; HashResult hash(const uint32_t *blocks, size_t count) { if (count % 2 != 0 || blocks == nullptr) { return {0, 0, false}; } uint32_t left = 0x01020304; uint32_t right = 0x05060708; for (size_t i = 0; i < count; i += 2) { uint32_t inL = blocks[i] ^ left; uint32_t inR = blocks[i + 1] ^ right; bfblk_t blk; blk.hi = inL; blk.lo = inR; bfish_enblock(&bf, &blk); left = blk.hi; right = blk.lo; } return {left, right, true}; } fun hash(blocks: IntArray): Pair<Int, Int> { require(blocks.size % 2 == 0) { "blocks length must be even" } var left = 0x01020304 var right = 0x05060708 for (i in blocks.indices step 2) { val inL = blocks[i] xor left val inR = blocks[i + 1] xor right val enc = encryptBlock(inL, inR) left = enc.first right = enc.second } return left to right } private fun encryptBlock(left: Int, right: Int): Pair<Int, Int> { val input = ByteBuffer.allocate(8) .order(ByteOrder.BIG_ENDIAN) .putInt(left) .putInt(right) .array() val output = cipher.update(input) ?: throw IllegalStateException("Blowfish encryption failed") val buffer = ByteBuffer.wrap(output).order(ByteOrder.BIG_ENDIAN) return buffer.getInt() to buffer.getInt() }计算得到两个大端序的数字，第一个是阶段2相机产生的device id的大端序，第二个是阶段2相机产生的nonce的大端序。只要两组结果都匹配，就说明我们找到了正确的salt。记录下这组salt，接下来计算阶段3的回应。阶段3的回应和寻找salt类似，但数组是这样构造的：src[6] = { SALT[matched_salt][0], SALT[matched_salt][1], h_our_ts_lo, h_our_ts_hi, // 阶段1的timestamp，寻找salt时这里是阶段2的 h_cam_ts_lo, h_cam_ts_hi // 阶段2的timestamp，寻找salt时这里是阶段1的 };放进hash计算后同样得到两个数字，将这两个数字转换回小端序，第一个数字填入阶段3的device id，第二个数字填入阶段3的nonce即可。这里贴两个测试用例供大家验证：void testCapturedStage2FindsSaltSixAndBuildsExpectedStage3() { // Real values captured from the Z50_2 camera handshake. const PairingMessage stage1( 0x01, 0x677da144ec13e1dbULL, 0x3c3ae501U, 0x3fdaa451U ); const PairingMessage stage2( 0x02, 0xb9943d5e8026fa29ULL, 0xa8b3f2e4U, 0x16d56a13U ); const PairingMessage stage3 = engine.verifyStage2AndBuildStage3(stage1, stage2); TEST_ASSERT_EQUAL_UINT8(0x03, stage3.stage); TEST_ASSERT_EQUAL_UINT64(stage1.timestamp, stage3.timestamp); TEST_ASSERT_EQUAL_UINT32(0x79f1ad53U, stage3.device); TEST_ASSERT_EQUAL_UINT32(0x23838a35U, stage3.nonce); uint8_t encoded[PairingMessage::SIZE]; stage3.encode(encoded, sizeof(encoded)); static const uint8_t expected[PairingMessage::SIZE] = { 0x03, 0xdb, 0xe1, 0x13, 0xec, 0x44, 0xa1, 0x7d, 0x67, 0x53, 0xad, 0xf1, 0x79, 0x35, 0x8a, 0x83, 0x23 }; TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, encoded, PairingMessage::SIZE); } @Test fun capturedStage2FindsSaltSixAndBuildsExpectedStage3() { // Real values captured from the Z50_2 camera handshake. val stage1 = PairingMessage( stage = 0x01, timestamp = 0x677da144ec13e1dbL, device = 0x3c3ae501L, nonce = 0x3fdaa451L ) val stage2 = PairingMessage( stage = 0x02, timestamp = 0xb9943d5e8026fa29uL.toLong(), device = 0xa8b3f2e4L, nonce = 0x16d56a13L ) val stage3 = engine.verifyStage2AndBuildStage3(stage1, stage2) assertEquals(0x03, stage3.stage) assertEquals(stage1.timestamp, stage3.timestamp) assertEquals(0x79f1ad53L, stage3.device) assertEquals(0x23838a35L, stage3.nonce) val expectedBytes = byteArrayOf( 0x03.toByte(), 0xdb.toByte(), 0xe1.toByte(), 0x13.toByte(), 0xec.toByte(), 0x44.toByte(), 0xa1.toByte(), 0x7d.toByte(), 0x67.toByte(), 0x53.toByte(), 0xad.toByte(), 0xf1.toByte(), 0x79.toByte(), 0x35.toByte(), 0x8a.toByte(), 0x83.toByte(), 0x23.toByte() ) assertArrayEquals(expectedBytes, stage3.encode()) }这是根据log抓取来在Z502上的握手数据，保真的。传统蓝牙配对在配对模式完成BLE握手后，相机将暴露自己的传统蓝牙。此时任意设备都可以配对，并不一定是进行握手的设备。需要注意的是相机每次的BLE地址都是随机的，只有传统蓝牙的地址是固定的。因此在写代码的时候，不能直接利用BLE地址进行createBond()操作，这样是没用的。必须启动传统蓝牙的扫描，去找和BLE广播一样的设备名字的蓝牙设备，然后手动发起SSP配对。对于嵌入式设备来说，其传统蓝牙profile不能是空的，否则相机会在确认安全码之后握手失败。在ESP32上的解决办法是将蓝牙栈初始化为SPP slave模式，这样相机就能找到ESP32设备的SPP profile。此外，在ESP32上确认安全码之后，即便得到了成功的回调，也建议额外等待一段时间再关闭传统蓝牙，否则相机可能会因为来不及找到我们的SPP profile而失败。配对成功后我们可以保存相机的BLE名字、device id和nonce，这些是日后重连所必须的信息。至于传统蓝牙，对于我们的使用场景（仅发送TIME和GEO消息）没有什么重要性，可以不必保留传统蓝牙地址，也不用检查传统蓝牙的配对是否存续。BLE重连相机的BLE逻辑比较奇怪。开机后会开始广播BLE，此时可以握手重连。但当相机熄屏时，BLE连接会断开，但很快相机会在熄屏状态重新广播BLE。此时握手重连大约要花费22到30秒，而平时只需要几秒钟即可。熄屏状态下重连也可以下发时间和位置信息，并且半按快门唤醒相机后，BLE连接会保持。但唤醒后再次进入熄屏状态，BLE又会断开。理论上BLE可以同时连接多个，例如ESP32使用的ESP-IDF在默认情况下允许同时连接3个设备，经过我的测试，由于我只有一台Z502和一台Z8，因此只能确认ESP32是可以同步连两台的，两台可以分别下发时间和位置，有效解决了SnapBridge一次只能连一台相机的问题。同步时间在下发位置数据之前，我们首先需要同步相机的时间，否则GEO消息中的时间和相机的时间差距太大时，GEO消息会被相机拒绝（表现为写入失败）。要同步时间，我们需要向TIME写入一个10字节的消息：Offset长度（字节）参数描述02year无符号数值，小端序，年份，例如202621month无符号数值，月份，1到12月31day无符号数值，日期，1到31日41hour无符号数值，小时，0到23时51minute无符号数值，分钟，0到59分61second无符号数值，秒，0到59秒71dst offset尚不明确，应该是夏令时。推测是有符号数值，例如+1表示启用夏令时，调快1小时。为0则禁用。或者是bool，1或非0数值表示启用，0表示禁用81tz offset hours有符号数值，时区的小时偏移量。例如+8表示东八区，-8表示西八区91tz offset minutes尚不明确，推测是有符号数值，时区的分钟偏移量这里面的时间都是UTC时间，而后面三位则决定了相机的时区。同步位置要同步位置，我们需要向GEO写入一个41字节的消息：Offset长度（字节）参数描述02header小端序，固定数值，0x007F21lat direction纬度方向，N （0x4E）或 S（0x53）31lat degrees无符号数值，纬度的度数，0到90度41lat minutes无符号数值，纬度的分的整数部分，0到59分51lat submin1无符号数值，纬度的分的百分位部分，0到99。例如52.1234分，这里是1261lat submin2无符号数值，纬度的分的百分位余数，0到99。例如52.1234分，这里是3471lon direction经度方向，E （0x45）或 W（0x57）81lon degrees无符号数值，经度的度数，0到180度91lon minutes无符号数值，经度的分的整数部分，0到59分。例如52.1234分，这里是52101lon submin1无符号数值，经度的分的百分位部分，0到99。例如52.1234分，这里是12111lon submin2无符号数值，经度的分的百分位余数，0到99。例如52.1234分，这里是34121satellites无符号数值，用于定位的卫星数量131alt direction海拔方向，0或正海拔高度用P （0x50），负海拔用 M（0x4D）142alt无符号数值，小端序，海拔的绝对值162year无符号数值，小端序，GPS时间的年份，例如2026181month无符号数值，GPS时间的月份，1到12191day无符号数值，GPS时间的日期，1到31201hour无符号数值，GPS时间的小时，0到23211minute无符号数值，GPS时间的分钟，0到59221second无符号数值，GPS时间的秒，0到59231subseconds无符号数值，GPS时间的厘秒，0到99241validGPS是否有效，valid fix用0x01，无效则用0x00256standard字符串WGS-843110padding全部填充为0在实际操作中，GPS的时间一般用系统的UTC时间，设置厘秒为0。特别需要注意的是：当设备丢失GNSS fix之后，只能发送一个valid为0x00的数据，不能连续写入valid为0的消息，否则相机会拒绝写入。当重新获得有效的GNSS fix之后，可以随意发送valid为0x01的数据，相机不会拒绝。当valid为0的时候会告诉相机丢弃已保存的位置数据，这样拍出来的照片就不会有位置信息。在更新频率上，只要消息有效，且相机跟得上，可以很频繁地更新。目前测试过相机可以支持每秒更新一次时间和位置，但这样太费电了。通常15秒或30秒更新一次就很及时了。后记以上就是SnapBridge的蓝牙协议介绍了。目前我已经完成了基于M5Stack Core2的原型验证，设计了配对和重连模式的UI与按钮交互逻辑。但最近发现这一套有点不太便携，并且续航不佳。因此重新购买了ESP32 DevKitC和GNSS模块，准备只用ESP32和GNSS，舍弃掉多余的按钮、屏幕什么的，尽量缩小体积、增大续航。下一篇文章准备先更新Core2的部分，等我把轻量化研究明白了还可以再水一篇。-全文完-
最近工作不是很顺利，但这次是因为公司快要倒闭了，已经发了解约通知，现在正在履行30天的通知期。但好消息是，我趁着618买了Z8。最近手头上也没什么能拿出来分享的项目，于是灵机一动决定，决定来闲聊一篇，顺便分享一下春节后回昆明以来拍的乱七八糟的照片。杂谈虽然买Z8的时候就知道公司要干不下去了，但那个时候还没有正式收到公司的解约通知，所以在预期上会相对好一些，而且正赶上618，虽然今年的打折力度感觉半死不活的，但最终我还是成功以18149元的价格买到了全新国行的Z8，正好尼康还有活动，618期间买Z8还送一个蒲公英的贴牌背包，就是外面印的Nikon，里面印的PGYTech。上淘宝看了一眼，这个包自己买的话得小一千块呢。倒也不错，再后来又给Z8配个了24-120 f/4 S的挂机头（6866元全新国行），可能是人生中的第一个S line镜头。再后来为了能录视频，又配了一张1T的努米诺橙卡（1560元，可是年初才卖一千多点的）。基本上这一套下来花了2万6上下，我觉得未来五六年的摄影应该是够用了，我也不太可能一拍脑袋就非得弄个什么400mm f/2.8，或者600 f/4什么的，所以不出意外的话，之后就要和手里这些器材做伴好长一段时间了。要说最近做的事情嘛，三月四月部署的本地LLM现在已经完全闲置了，毕竟当时只考虑了算力，没有考虑功耗。而且当时还是冬天的尾巴，就算他们满功率跑，也顶多是个电暖器。可是现在不行了，现在已经六月底了，两块显卡跑起来，用不了十分钟，屋子里就待不了人了。正好最近公司也不提供API key了，我得自寻出路，前阵子抛弃OpenRouter的时候发现了zenmux.ai（提示：这个是推广链接，不想要推广的话直接访问zenmux[.]ai），一开始是完全按照按量付费用的，因为我自己的用量不多，所以还负担得起。但公司不提供API Key之后，公司的开发对LLM的消耗量比较大，我就有点绷不住了，无意间发现了他们的订阅API，定为是个人开发和非生产使用，我直接梭哈了200USD的顶配订阅，现时大约每月配额在480USD，每周配额月110USD，每5小时配额26USD，好处是你可以随便调用模型，可以用Anthropic的，也可以用OpenAI的，国产模型也可以。公司那边我基本上是Sonnet打底，之前用公司的API都是Opus站起来蹬，可是自己的订阅蹬Opus很快就烧完了。所以Sonnet打底，然后Kimi K2.6作为调度器，调用MiniMax M3审阅（review）代码，然后调用Kimi k2.7 code按照审阅结果修改，直到m3给出approval。因为我对Python不是很感兴趣，所以公司的Python项目我不怎么关心代码，只要把功能做了，代码别堆成屎山就行了。自己用的话，我还是以Gemini 3.1 Pro作为对话的主力，Kimi k2.7 code和GLM 5.2作为编码，个人认为MiniMax M3的能力有点拉，同为1M上下文，GLM 5.2更好一些。在常规任务，例如Kotlin/JVM和安卓方面，Kimi和GLM不分伯仲，平时我还是喜欢Kimi一点，因为它听话，并且不会总想着一步登天。但最近买了个ESP32的设备，对于嵌入式开发来说，GLM 5.2的能力似乎更强一点，不过GLM 5.2总想着一步到位，所以用起来有点不是很顺手，最近逐步摸索，发现你得给它一个明确的目标，而不能像kimi那样问一步看一步。不过好在ESP32用C++，我不讨厌这门语言，就是太难了。自己写了一个周末，什么堆内存占内存虚函数全都回忆起来了，又因为对C++开发没什么经验，所以就让GLM自己写，保证能用。然后让Kimi对着git的修改一步一步解释给我听，不懂就追着问，一边问一边查文档。这两周下来感觉学了不少C++的新知识，感觉大脑都要升级了。至于为什么要做ESP32的项目，那还得从最近做的事情说起。五月净忙着给网站升级了，升级完了还魔改了一下主题，但毕竟主题是闭源的，我自己偷偷魔改也不方便发出来，所以就没发。六月买了Z8之后，除了拍照之外也做了一些周边的工作。例如在Fedora上想办法安装达芬奇，毕竟之前都买了正版的，又买了显卡，得用上才行。装起来有点麻烦，毕竟官方不支持Fedora，但distrobox解决了这个问题，为了更进一步简化安装过程，有人开发了davincibox，基本上就是轮椅。虽然也有坑，但无外乎就是不能用中文路径，更新distrobox的时候会导致正版验证失效，得先取消激活才能更新。整体也没啥好写了。而另一方面则是在研究怎么给Z8加GPS，原本想用机身自带的10pin接口，但后来发现国行系统直接屏蔽了GPS菜单，你插上GPS也启动不了对应的功能。所以国行就只剩下SnapBridge一条路了。于是我就想，索性买个ESP32来模拟app的配对和下发指令。目前已经攻克了蓝牙协议的部分，买的GNSS模块还在路上。估计不出意外的话，下个月的更新应该就是介绍这个项目了。以上就是本期杂谈了，下面是看图时间。摄影接下来分享一些3月到现在的摄影成果，其中也包含新相机和新镜头的试片。三月和四月拍的照片都还是Z50 2拍的，镜头基本上就是双头套机和一个180-600mm。三月底街拍溜达了一晚上，我着实是没什么街拍的艺术细胞，眼睛里看不到美好的人文风景，所以就拍了一张感觉还算满意的：不知道本地的万达广场从哪里弄来两棵金色的树，我一看，这不黄金树吗？怎么没有落叶捎来讯息呢？清明假期 宝丰湿地公园噪大苇莺，当时怎么调整都没办法躲开这个树枝，不过好在尼康的对焦还是争点气的。白胸苦恶鸟，在水上走的鬼鬼祟祟的，不过还挺麻利，一转眼就钻进草丛里了。黑水鸡，这个老熟人了。小䴙䴘，这次没有空军。白骨顶和黑水鸡同框。苍鹭，这算是为数不多的飞版吧。老远看到岸边一个拍鸟的大姐，感觉姿势很帅，但不知道图该怎么修。尝试了一下黑白风格，但感觉对比度有点不够大，导致整体看起来有些不太吉利。三张凤头䴙䴘。不知道是什么花，但让我想起来早年间Win7的一个自带的壁纸，也是湛青碧绿的。黄臀鹎，这也是老熟人了。珠颈斑鸠，不知道为什么，这边的斑鸠都很怕人。不像在北京，站那随便拍。清明假期 昆明植物园、黑龙潭之前元旦的时候去过一次，觉得那地方拍鸟还不错，这次又去了一次，顺便还把上次没去的小园子也逛了。红头穗鹛，第一次见，这是在裸子植物林里面拍的，欠曝加上背光，后期硬拉曝光拉回来的。黑胸鸫，也是第一次见，在地上觅食应该是。似乎不怎么怕人，让我们拍了几张才飞走的。绣眼鸟，具体是哪种就不知道了。当时远远地看到什么在动，这又是阴影又是和树叶打成一片的颜色，要不是它动了，我还真看不见。应该是鹊鸲？也是树林里拍的，但因为毛色就是黑的，所以拉不回来。这张应该是雄鸟，因为颜色比较黑。来了植物园总得拍点植物吧，但这是什么植物我忘了。鹊鸲，比刚才那张颜色要淡，应该是雌鸟。红头长尾山雀，本来想躲开这个树叶的，但是人家没给机会，抓拍了这一张就飞走了。黑胸鸫，我就说它不怕人吧，在水池边上活动了好久。但这里也是个背阴的地方，略微欠曝了。树鹨，这也是第一次见到。在草地上活动，跑得飞快。牡丹？忘了。但我在等上伊那牡丹的蓝光，我很期待（唐突）。凤头雀嘴鹎，发型还是那么飒。灰腹绣眼鸟，我就说拍鸟不能拍正面吧。忘了是什么花了。很小一只，一片一片的。偶尔拿长焦当微距用也还行。没有在水里的小鸊鷉，有点难得。鸿雁，看起来和农村的鹅一样凶，放大看还能看到嘴边锯齿状的牙，这让它咬一下估计就要见血了。Z8试拍以上是Z50 2拍摄的照片。从成片来看其实还行，但如果看整个照片库来说，对不上焦或者追不上焦是常态，经常是expeed 7处理器识别到了鸟的身体或者眼镜，但由于cmos比较慢等原因，经常导致追不上焦，废片率比较高。此外这块cmos的高感四舍五入等于没有，我把ISO限制到了6400，因为再高的话放进DXO就会有涂抹感，如果ISO被限制了，说明天黑了，该回家了。夜晚的舞台不属于这块cmos。不过APS-C也有它的好处，最大的好处就是偷焦段，如果把Z8放到DX模式，从像素数量上来说，Z8比不过Z50 2，但其余方面，Z8的性能远超Z50 2。由于刚买Z8没有配全画幅镜头，所以只能挂着180-600去街拍。并且我想试试看它的低光场景表现，于是就选择晚上7点出门，一只溜达到天黑。这颗镜头在180mm的光圈是5.3，在600mm的光圈是6.3，总体来说比较严厉了。再坏也不会怎样了，除非你买了那个很便宜的焦段很广但没怎么变焦就已经F8的一镜走天涯的那个镜头（型号忘了）。随手拍了拍远处的大楼。感觉方方正正的线条倒是有点意思。狗。楼下有一片绿地，原本是想去这里架电台，但实地考察一下就放弃了。因为这篇绿地被周围几个楼的居民用来遛狗，里面全是生物地雷。顺带一提，这些照片看起来不锐是因为ISO已经干到16000以上了。对于大多数风景来说，即便是180mm端，视角也有点太窄了，但对于拍云来说似乎恰到好处。这两张都是180mm拍摄的。长焦在街拍上也并非一无是处，例如这张就是我在马路对边拍的，不信你看左下角还有路中间的安全岛栅栏呢。等红灯的时候觉得这一排车的视角很独特，于是赶忙拍了一张。这个是路口斜对面的大妈们在跳舞。不过不知道为什么，大妈们看起来不是很开心的样子，是不爱笑吗？楼下的小贩吵架，我听到后第一时间架起180-600观察战况。你别说，Z8的高感表现其实还行。反正经过一番测试，我觉得我能接受的ISO大约在16000，因此将它设定为上限。如果16000的ISO还不够，说明天黑了，该回家了。当然了，拍鸟也少不了，只不过附近的公园没遇到过几只鸟。从拍鸟来看，Z8的对焦逻辑似乎比Z50 2要难用。首先感觉就是对焦框变小了，同样的对焦区域设置，在Z50 2上能覆盖将近1/4的画面，到了Z8上就变得很小。其次就是鸟类识别对焦偶尔识别不到鸟类，可能是因为距离比较远，外加全画幅不能偷焦段，所以鸟在Z8的视野里变小了，导致对焦困难。最后就是区域对焦因为鸟占比很小，识别不到鸟，因此就会对到躲不开的树枝上。我深度思考了一会儿，决定利用AF-ON这个按钮，按下时将对焦区域改成单点AF，这样如果自动对焦找不到鸟，我就可以利用这个功能修正对焦。又因为AF-ON离得比较近，所以用起来也很方便。稍微熟悉一下这个操作之后，会觉得Z8的追焦比Z50 2更灵敏一些。此外Z8的像素很高，因此后期裁剪的时候有些不适应，在Z50 2上都不敢怎么裁，到了Z8上，稍微放纵地裁剪了一下，结果发现还有很多裁剪空间。我觉得这个比偷焦段强一些。六月中旬爬梁王山因为新买了相机，所以总想着进山拍鸟。看了一圈，最近的大概就是梁王山了。我原本以为这山顶上怎么也得有个观景台，没想到这就是纯野山。一开始我们从最下面开始爬，爬到风口新村我就拉了，于是原路返回，在胆战心惊地摔了一跤之后，决定驱车前往山顶，从山顶脚下开始爬。一路上也看到了一些风景，也经历了一些风雨。如果不需要考虑安全的话，在雨中开山路其实还挺惬意的，我很喜欢。但如果考虑安全的话就算了，有太多拐角处会车不按喇叭的，经常转角遇到爱。灰林䳭，可惜没对上焦距。酒红朱雀老旧的科研设施。这张在山顶上拍的。我寻思山顶上得有点不一样的鸟吧，结果回家一看，黄臀鹎。像素变化拍摄 / 摇摇乐由于Z8有机身防抖，所以可以精确控制cmos的位置，从而让每个RGB像素都拍摄到真正属于自己的数据，这样在尼康工坊里就可以合成出一张超级照片。不过尼康工坊最近更新了，必须要登录尼康ID才能用，有点讨厌。但最讨厌的是最新版本只支持Win11，在win10上安装会报错，根本装不了，十分烦人。不过最终合成的效果倒是还行，就算是弥补了这一点吧。由于是合成出来的，所以DXO无法处理，基本上就是扔进LrC里面裁切了一下就导出了。但对比独立的原图，在噪点和清晰度上都有提升。测试用的是我前几天买的百合小摆件。对了，这两张是24-120 f/4 S拍的，是给Z8配的新镜头。这里放的是缩放到2000x3000的图片，原图比较大，一张大约110MB的png，而合成出来的RAW有1GB上下。这里可以找到png的原图：正面.png反面.pngNikkor Z 24-120mm f/4 S试拍周末我带新镜头出去，准备看看S line有什么不一样的。结果出门的时候天公作美，溜达到一半就不对劲，我就赶紧往回走，走到楼底下就开始下雨了。上次带着180-600溜达了两个公园没看见鸟，这次带着24-120却看到了，只得120mm拍鸟了。本地的大聪明，试图通过太阳能来充共享充电宝。图片中心有个小䴙䴘（疑似）。某种伯劳。电线杆Pro Max。钓鱼佬。不知道是什么花。黄臀鹎应该是。蓝花楹。太小了看不清。还是黄臀鹎。这张照片其实经过裁减，成片只是原片的1/4左右，不过即便如此感觉也还可以。高像素机型的裁切空间实在是太大了。快下雨了，远处的山平时可以看到，现在就只剩下轮廓了。结语怎么说呢，好像没太品出来S line的镜头有啥不一样。不过恒定f4的光圈倒是能让我天黑之后晚点再回家，日常挂机来说还算不错。以上便是本月更新了，闲聊的部分在开头的杂谈已经说过了，这里就不再磨叽了。那我们下次更新再见。-全文完-
博客自2017年独立设站以来，历经几次更新。最近一次还是2023年，从Debian换成了Ubuntu 2204，后来又升级到了2404。那个时候lnmp脚本还没卖给国内的公司，脚本也没有挂上后门，于是就一直用到上个月。大约是4月底，Ubuntu 2604 LTS发布，我开始盘算这次的升级。确定升级方案由于Typecho一直更新缓慢，所以我的服务器每次更新妥当之后也就没动过。升级前用的是typecho 1.2.1和php 8.1。前阵子typecho突然发布了1.3.0，但这次更新包含很多破坏性修改，按semantic versioning来说不应该继续沿用1.x版本。我之前试过更新，结果更新之后主题不兼容，导致网站爆炸。幸好服务器和数据库都有快照，分别恢复之后继续如常运行，后来为了缓解xss攻击的隐患，我在垃圾评论检查的插件中配置了一些规则，虽然不能完全防止xss攻击，但至少能防住一些靠复制粘贴搞事情的人。前几天日常检查的时候发现我这个主题难得更新了，这次更新适配了typecho 1.3.0和php 8.3，于是想着要不要就此机会升级一下服务器的系统。虽然我自己平时用OpenSUSE风滚草和Fedora，但服务器上我的日常之选还是Ubuntu LTS，主打一个更新方便，支持周期长。但那是2404到2604的升级路线还没有发布，要等2604.1发布之后才行。而另一个迫在眉睫的问题在于，lnmp脚本已经不能使用了，要升级服务器必须得自己搞一个替代品。很幸运的是，现在的我比建站时的那个17岁小伙子已经进步了不少了，有了更多的见识和经验，即便我不会写php，也能搭建出一套可持续的部署方案了。鉴于我写了那么多年Java，谈到服务器部署，自然首选是容器化。目前网站只有一台服务器，所以大可不必上什么docker swarm甚至k8s这种东西，为了确保配置能够追踪，我选择在私有repo上存储docker compose的yaml文件。这样未来我做的每一次修改都可以追踪并记录原因。同时未来迁移服务器的时候也可以直接克隆后部署，不必考虑一套配置重复写好几次的事情。另外容器化部署的好处就是不挑系统，因此未来可以直接升级后续的LTS，或者我要是突然来了性质，重装个别的系统然后重新部署docker compose，也是轻而易举的。因此正式的升级路线就确定了：重装到Ubuntu 2604 LTS使用容器化部署PHP升级主题和typecho升级到PHP 8.3虽然PHP 8.3已经结束支持了，到2027年年底，安全支持也要结束了，但这也没办法，主题还在用老的jQuery呢。先把服务器整明白了，后面再慢慢修改主题。容器化部署PHP一开始我打算每个网站一个独立的nginx + php-fpm，最外层在主机上用Caddy做反向代理。这样对于现存环境的兼容性是最高的。但想来想去总觉得别扭，一来是php不能顺利复用，二来要配置的东西重复性比较高，第三就是尽管Nginx很高效，但我并没有真正理解过它那个难写的配置文件，大部分时候都是照葫芦画瓢，能用就行了。后来请LLM评论我的计划时，LLM提到Caddy也能配合PHP使用，我喜出望外。搜索了一番发现，Caddy不光能和PHP一起用，对于typecho伪静态的支持是开箱即用（Caddy2），不需要配置什么rewrite规则。此外使用Caddy还有一个好处，就是它能够自动处理TLS证书，不需要我手动每年更新，也不需要我再花钱买专门的TLS证书了。于是根据Nginx的配置写了Caddyfile，根据旧的php配置（主要是php.ini和php-fpm.conf）编写新的php配置。Caddyfile的编写其实还挺简单的，由于不需要处理rewrite之类的东西，并且caddy自带了对php fastcgi的支持，因此不停include的nginx配置文件就简化成了这一小段：skyblond.info, www.skyblond.info { # 网站挂载到容器的/srv/目录下 root * /srv/skyblond.info # 启用gzip和zstd压缩 encode gzip zstd # 配置PHP，超时时间匹配nginx配置 php_fastcgi php-fpm:9000 { dial_timeout 30s read_timeout 5m write_timeout 5m } # 允许caddy返回静态文件 file_server # HSTS header，本站很久之前就加入HSTS preload列表了 # 这个header告诉浏览器skyblond.info以及所有子域名必须强制使用https header { Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" } # 对于可变文件的缓存，主要是css和js，因为后期要对主题进行修改 @cacheMutable { file path *.css *.js } handle @cacheMutable { # 缓存12小时 header Cache-Control "public, max-age=43200" } # 匹配旧的Nginx配置，允许最大50MB的请求体 request_body { max_size 50MB } # 不可变文件的缓存，主要是字体、图片等 @cacheImmutable { file path *.ico *.gif *.jpg *.jpeg *.png *.svg *.woff *.bmp } handle @cacheImmutable { # 缓存30天 header Cache-Control "public, max-age=2592000" } # 禁止访问.开头的文件，但.well-known除外 @forbidden { not path /.well-known/* path /.* } respond @forbidden 403 # https://caddy.community/t/example-typecho-blogging-platform/9476 # Caddy2的php_fastcgi对index.php自动开启了rewrite，因此对于typecho来说，伪静态不需要做任何额外配置 }什么？你问TLS证书怎么没配置？Caddy在配置好域名之后能够自动申请并更新TLS证书，不需要手动配置，开箱即用。非常好使。PHP的配置则麻烦一些，根据官方docker镜像的说明，我这里主要覆盖两个文件：/usr/local/etc/php/php.ini：主php.ini文件，基础文件从/usr/local/etc/php/php.ini-production获取，同步旧的php.ini这里关闭了一些旧php.ini的兼容设置，例如short_open_tag设定为off，cgi.fix_pathinfo设定为1/usr/local/etc/php-fpm.d/www.conf：php fpm的配置此外，由于typecho和主题需要一些额外的扩展，因此还需要自己构建要给php容器来启用这些扩展：FROM php:8.3-fpm # 安装扩展依赖的系统库 # gd # zip # intl RUN apt-get update && apt-get install -y --no-install-recommends \ libpng-dev libjpeg-dev libfreetype6-dev libwebp-dev \ libzip-dev \ libicu-dev \ && rm -rf /var/lib/apt/lists/* # 配置gd，启用freetype、jpeg和webp支持 # 自PHP 7.4.0开始，png是强制支持的，不可关闭 # ref: https://www.php.net/manual/en/image.installation.php RUN docker-php-ext-configure gd \ --with-freetype --with-jpeg --with-webp # 安装扩展 # 其中curl、mbstring和iconv已经自带了，不需要重复安装 RUN docker-php-ext-install -j$(nproc) gd pdo_mysql zip intl # 启用opcache RUN docker-php-ext-enable opcache 由于完整版的php.ini有一千多行，这里就不贴出完整版了，只是列出几点：max_execution_time：设置为300秒，匹配旧nginx设置post_max_size / upload_max_filesize：50M，匹配Nginx设置date.timezone：Asia/Shanghai，这个不多解释了opcache.enable：设置为1启用opcacheopcache.jit：设置为function，即CRTO = 1205，这个意思是尝试启用AVX支持、使用全局寄存器分配、脚本加载后立刻编译所有方法、优化整个脚本。不明白什么意思就去查php的文档这里我禁用掉了open_basedir，在原本的lnmp脚本中，这个配置旨在限制php能够读取的文件。如果网站被黑，攻击者通过php脚本读取允许列表外的文件，php会拒绝。但注意我的用词：“旨在”。根据 https://www.bencteux.fr/posts/open_basedir/ 这篇讨论，这个选项并不能真正保证安全，能够轻而易举地绕过这个限制。而production的php.ini同样也没有开启这个选项。因此我也选择不开启。php-fpm的配置就简单许多了，同样从容器里复制出来一份，修改的内容是：listen = 0.0.0.0:9000，默认只听127.0.0.1，因为在容器里，所以可以听0.0.0.0pm：基本上匹配旧配置，一共20个线程，每个线程执行1024个请求就杀掉，防止内存泄漏。部署的之后用这个compose就可以了：services: caddy: image: caddy:2 restart: unless-stopped cap_add: # for HTTP/3 - NET_ADMIN ports: - "80:80" - "443:443" - "443:443/udp" volumes: - "/root/server-configs/blog-server/webserver/caddy/etc:/etc/caddy" - "/root/www-content:/srv" - "caddy-data:/data" - "caddy-config:/config" networks: - shared deploy: resources: limits: memory: 1G php-fpm: build: context: . dockerfile: php-fpm.Dockerfile restart: unless-stopped volumes: - "/root/www-content:/srv" - "/root/extra-data:/data" - "/root/server-configs/blog-server/webserver/php/php.ini:/usr/local/etc/php/php.ini" - "/root/server-configs/blog-server/webserver/php/fpm-www.conf:/usr/local/etc/php-fpm.d/www.conf" - "php-session:/session" networks: - shared deploy: resources: limits: memory: 1G volumes: caddy-data: caddy-config: php-session: networks: shared: name: app-shared-network external: true 容器化部署其他服务之前为了方便我做评论推送，我写了个插件，在评论时自动给一个webhook发送请求，然后我编写一个配套的程序来接受请求，处理发邮件之类的事情。这样就能避开我不擅长的php，从而在jvm的世界发邮件、给pushover推送，或者是直接在telegram bot上通知我。不过这也意味着我需要容器化部署另一个服务，并且让这些服务能够互通。为此，我考虑了一下，决定将存储部署文件的repo这样设计：repo-root ├── blog-server │ ├── kopia │ │ └── compose.yaml │ ├── README.md │ ├── typecho-mate │ │ ├── compose.yaml │ │ └── config.properties │ └── webserver │ ├── caddy │ │ └── etc │ │ └── Caddyfile │ ├── compose.yaml │ ├── php │ │ ├── fpm-www.conf │ │ └── php.ini │ └── php-fpm.Dockerfile └── README.md根目录下一个readme简单介绍一下项目。未来还会加入agents.md，因为我打算让AI辅助检查一些设置，虽然不准确，但能够提供一些我的经验与见识之外的事情。并且AI在搜索和总结方面做的不错，我可以让它根据配置的说明（尤其是php这种每个配置都有详细解释的ini文件）去验证我的配置合不合理，并自动发现一些问题。根目录下的每一个文件夹对应一个服务器，目前只有blog-server，我现在还有一个hetzner的服务器在跑别的，还没有迁移到docker compose，后续也会加入进来。服务器文件夹内每一个文件夹就对应一个服务了。目前我有webserver、typecho-mate（评论通知）和kopia做备份。为了方便，我决定让服务器的文件组织标准化：这个repo会克隆到/root/server-configs文件夹所有需要备份的容器数据就放在/root/container-data/app-name/volume-name里面，未来切换服务器的话，这些是可以通过备份恢复的对于session一类需要持久化，但是可以重建的数据，统一采用docker volume对于需要访问但不需要备份的数据，则映射到符合Linux HFS的路径。例如kopia的日志映射到/var/log/kopia文件夹这样一来每台机器只要克隆server configs，配置好对应的文件夹，就可以直接部署了。测试升级路线为了最小化停机时间，我决定先测试一下我这个升级路线行不行。当然，也是因为上次盲目升级，给服务器弄炸了，属于是害怕了。Linode的托管服务器有个十分优秀的功能，就是允许你根据过去14天内，精确到秒的时间点创建数据库的fork，因此我直接从现在的数据库fork出了一个测试用的库，放在了德国。在Hetzner上创建了一个很便宜的服务器（毕竟主服务器只有3核2GB的配置）进行测试。首先通过rsync将旧服务器的文件同步过去，修改typecho的配置，将数据库指向测试库，确保dns完全传播，在浏览器里打开测试网站一看：NOTICE: PHP message: PHP Warning: Unknown: open_basedir restriction in effect. File(/srv/skyblond.info/index.php) is not within the allowed path(s): (/home/wwwroot/skyblond.info:/tmp/:/proc/) in Unknown on line 0？我的脑袋里浮现出一个问号。这是为什么呢？我寻思我的新配置也没有写open_basedir，更没用lnmp脚本，哪来的wwwroot呢？仔细查阅后发现网站的根目录下有一个.user.ini，里面配置了要给open_basedir。罪魁祸首就是它，删掉即可。由于网站更新时需要写入权限，为了方便，我直接chmod 777 -R了。首先升级主题，升级后发现控制台渲染不正常，抓请求说资源不存在，我检查了文件，确实在。仔细一看chrome抓的请求，原来是请求到博客去了，因为typecho配置的域名还是主服务器，不是测试服务器。更新了就好了。此外，由于开启了opcache，并且扫描间隔设置为了120秒，因此主题更新的php文件并不会很快利用。要么等120秒，要么直接重启php容器，我选择后者。接下来是更新typecho 1.3.0。首先需要暂时关闭webserver stack，然后删除下列文件：/admin/ /var/ /install/ /index.php /install.php再从typecho的发布版里面将上述文件上传进去。这次1.3.0版本还包含了一个新的默认主题，在usr/theme中，但我不用默认主题，所以就没上传。完成后启动webserver stack，访问控制台，需要更新数据库结构，更新后简单测试了一番，一切正常。最后就是测试新版本的php了。因为我需要自己构建php容器启用扩展，因此只需要修改dockerfile里面的基础镜像，然后重新构建容器再启动docker compose就好了。测试了一下也没有问题，那么就可以正式迁移了。正式迁移为了最小化停机时间，我决定在hetzner上创建一个临时服务器来替代主服务器。首先要提前一小时将DNS记录的TTL设置为一分钟，这样确保DNS能够快速更新。可以使用这个工具检测dns传播： https://www.whatsmydns.netCaddy的自动申请TLS证书只在启动时进行，如果申请失败，后续请求不会触发重新申请，一开始我还不理解，后来我就亲身实践了。在等待一个小时后，我更新DNS记录，将域名指向hetzner的临时服务器，检查DNS传播状况，等绝大部分dns记录都更新之后就可以重启caddy容器了。重启后caddy应该可以正确获取TLS证书。等到全部DNS都更新之后，就可以关闭主服务器，重装系统了。在等主服务器重装系统的时候也别闲着，按照刚才的路线进行升级。实际上主服务器重装系统比较慢，我在hetzner的服务器上连php8.3都升级好了，主服务器还没起来呢。等到主服务器成功启动，配置好github凭证和docker后，就可以再把数据同步过来，更新DNS，等待服务恢复了。不过由于我最后比较心急，不停地在重启caddy容器请求证书，因为失败的次数过多，所以被let's encrypt限流了，因此被TLS证书硬控了10分钟。大约5月15日18点，网站重新正常运行在主服务器上。可喜可贺，可喜可贺。后记至此，博客以一种更先进的形式部署，我的心头一患也算是暂时放下。接下来我准备对主题做一些修改。虽然是买来的，但考虑到作者更新缓慢，并且我不太需要什么新功能，因此我打算从当前的版本分叉出去，删去一些我用不到的功能，并自行维护。不过这些是后话了，希望能够在2027年12月之前完成对PHP 8.4的测试吧。-全文完-
四月初谷歌发布了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乘，就相当于筛选出了V矩阵中真正有用或相关的信息。（线性代数，很神奇吧）由于输入是确定的，模型的权重也是确定的，因此对于输入和模型预测出来的内容，K和V是完全不会变的，所以我们只需要每次将模型输出的token的k和v计算一下，然后追加到现有的k和v缓存中，就避免了每次都要重新运算前面内容的麻烦，但这种提速是用显存或者内存换来的。对于Gemma 4 31B来说，它有10层全量注意力（4个kv head，每个512维）和50层SWA（sliding window attention，16个kv head，每个256维，长度1280）。因此该模型在最长上下文262144下的显存需求就是：全量注意力：262144（上下文）x 10（层）x 4（head）x 512（维） x 2（f16占用两字节）= 10GiB，那K和V加起来就是20GiBSWA：1280（滑动窗口长度）x 50（层）x 16（head）x 256（维）x 2（f16占用两字节）= 500MiB，K和V加起来就是1000MiB，不到1GiB因此整个模型光是KV Cache就要占用21GiB的显存，而我选择的Q5_K_M量化的模型本身才21.06GiB。这还没算用来存放中间计算结果的compute buffer（不到2GB）。所以这你就看出来了，想要上下文开的大，一块24GB显存的显卡根本不够用。所以我后来又买了一块。双显卡配置由于一块显卡显存不够吃，而kv cache放在系统内存又会让显卡受制于PCIE总线的速度，因此我决定下单第二块显卡。但问题在于怎么装。很显然，我的华硕GT502机箱并没有考虑让你装第二块显卡，我左思右想，欸，机箱前脸还有一片空间，不如就让它斜躺着吧：我还从淘宝上花了300块买了一根PCIE4.0x16的延长线，虽然那个插槽只支持PCIE4.0x4并且是通芯片组的，但令我惊讶的是，70cm的延长线，竟然能完美撑起来PCIE4的速度，国内的制造业有点东西的。当然，人家店里还有卖PCIE5.0延长线的，但是要500还是600来着，我寻思我这卡也就4.0的速度，没有这个必要。当然，最大的问题还是电源。我这个主机是从亲戚那里继承过来的，不过这也是我亲手组装的就是了。当时组装的时候就没考虑双显卡的需求，因此电源用的是海韵的PX1000，额定功率1000瓦。可是蓝宝石的7900XTX，总板功率420瓦，这还没提短时间内的spike，要知道这电源可不是ATX3的电源。果不其然，在测试的时候灭了三次，其中prefill阶段（prompt processing）最为敏感，因为两张卡的利用率能接近100%并持续一段时间（这个时候是按照batch运行的）。至于文本生成阶段，因为每次计算出一个字都要两张卡交替运算，所以这个阶段的显卡利用率不是很高（大约50%）。于是我决定痛定思痛，更换电源（没钱了）限制功率。首先在主板里把CPU功率的短期功率限制为190瓦，长期功率限制在160瓦。我用的Fedora，安装LACT后启用超频，将功率限制到最低的272瓦，这个是长期功耗。为了防止短期功耗触发电源的保护，我将GPU核心频率限制到了2.6GHz，GPU电压偏移设置为-20mV。只要频率和电压限制住，他们就不会突然请求巨大功率导致电源触发保护。当然，代价就是性能打了折扣。英特尔13代14代的CPU主要是靠推功耗提供性能，所以一旦把功耗限制住了，对于计算密集的任务就会感觉到明显的变慢。至于GPU嘛，我没看过他们自由奔放的样子，没见过他们跑得快，自然也就不觉得他们跑得慢。总之，现在无论怎么造，电源都不会熄火了。部署方案考虑到ROCm在fedora上并没有官方支持的包，而ROCm7的包要等到下一个大版本才行，我决定使用docker。早期由于llamacpp一直在更新，而官方的docker则是每天构建，想要用上最新的版本就得自己构建。不过最近对于gemma 4的支持稳定了不少，所以我就切换回官方的docker了。至于为什么不选择vllm或者其他部署方案呢？归根结底还是显存不够，深究的话就是没钱。没钱是真难啊，我要是中了个2.2亿的彩票，我高低得整两块英伟达的H200尝尝咸淡。一开始直接使用Docker命令进行部署，我整了个巨大的txt文件来保存对应的命令，但后来发现这样不方便切换模型，尤其是后面需要测试各种uncensored模型。一旦需要调整某个参数，就得对所有命令都进行调整。在测试了大约五六个模型之后，我有点受不了了。开始探索能够自动切换模型的方法。llama-server本身可以通过编写preset的方式切换模型，但他们切换的比较生硬：如果来了一个请求需要其他模型，他们会根据LRU算法杀掉最不常用的模型。但问题在于我的显存只够部署一个模型的，因此当我请求其他模型的时候，llama-server就会杀掉现在的模型，也不管当前请求有没有处理完，反正就硬杀。在机缘巧合之下，我了解到了llama-swap，它能够根据请求自动切换模型，但一个好处是它能够等让请求排队，等当前的请求处理完了再切换模型。这真的太契合我的使用场景了。于是我的部署方法是在宿主机上运行llama-swap，然后让llama-swap调用docker来拉起容器。配置文件大概是这个样子：logTimeFormat: "ansic" captureBuffer: 256 startPort: 10001 globalTTL: 0 macros: # common start of docker run llama server # for debugging, use `llama.cpp:full-rocm` as image # for production, use just `docker pull ghcr.io/ggml-org/llama.cpp:full-rocm` "docker-server-rocm": > docker run --name ${MODEL_ID} --init --rm --device /dev/dri --device /dev/kfd --group-add=video --group-add=render --security-opt label=disable --security-opt seccomp=unconfined -p ${PORT}:8080 -v /mnt/unenc-xfs/models:/models:z ghcr.io/ggml-org/llama.cpp:full-rocm --server --port 8080 --host 0.0.0.0 --alias ${MODEL_ID} # cache and ctx-checkpoint settings for gemma 4 # ctx-checkpoint will be huge since gemma 4 has a lot of standard attn layer # so we have to shrink the number of ckpts （default to 32） to reduce RAM usage, otherwise get OOM kill # on gemma 4 31b, each checkpoint takes 1GB of RAM "gemma4-ctx-ckpt": > --cache-ram 0 --ctx-checkpoints 20 --checkpoint-every-n-tokens 13107 apiKeys: [] models: "google-gemma-4-31b-q5km-text": name: "Google Gemma 4 32B Q5_K_M text" description: "256K context, no vision" cmd: | ${docker-server-rocm} ${gemma4-ctx-ckpt} --model /models/gemma-4/google-31B-it/google_gemma-4-31B-it-Q5_K_M.gguf --jinja --reasoning on -b 2048 -ub 256 -ngl 99 --main-gpu 0 --split-mode layer --tensor-split 30,31 --ctx-size 262144 --temp 1.0 --top-p 0.95 --top-k 64 --min-p 0.0 --repeat-penalty 1.0 --mmap --no-kv-unified --flash-attn on --parallel 1 cmdStop: docker stop ${MODEL_ID} proxy: http://127.0.0.1:${PORT} checkEndpoint: /health concurrencyLimit: 3 timeouts: connect: 30 keepalive: 30 # pp may take a long time so we don't set a timeout here responseHeader: 0 tlsHandshake: 10 idleConn: 90 "google-gemma-4-26b-a4b-q80-vision": name: "llmfan46 Gemma 4 28B A4B uncensored Q8_0 vision" description: "256K context, vision, image 560 tokens" cmd: | ${docker-server-rocm} ${gemma4-ctx-ckpt} --model /models/gemma-4/google-26B-A4B-it/google_gemma-4-26B-A4B-it-Q8_0.gguf --mmproj /models/gemma-4/google-26B-A4B-it/mmproj-google_gemma-4-26B-A4B-it-bf16.gguf --jinja --reasoning on -b 2048 -ub 1024 --image-min-tokens 560 --image-max-tokens 560 -ngl 99 --main-gpu 0 --split-mode layer --tensor-split 13,18 --ctx-size 262144 --temp 1.0 --top-p 0.95 --top-k 64 --min-p 0.0 --repeat-penalty 1.0 --mmap --no-kv-unified --flash-attn on --parallel 1 cmdStop: docker stop ${MODEL_ID} proxy: http://127.0.0.1:${PORT} checkEndpoint: /health concurrencyLimit: 3 timeouts: connect: 30 keepalive: 30 responseHeader: 0 tlsHandshake: 10 idleConn: 90通过定义macro的形式，能够让指令复用。之后我还测试了qwen3.5，还打算测试3.6，对于qwen来说用vulkan可能比用ROCm后端还好，因此只需要重新复制一份docker命令的开头，换个镜像就行了。这里主要解释一下Gemma 4的两个版本和一些参数。首先是docker命令： docker run --name ${MODEL_ID} --init --rm --device /dev/dri --device /dev/kfd --group-add=video --group-add=render --security-opt label=disable --security-opt seccomp=unconfined -p ${PORT}:8080 -v /mnt/unenc-xfs/models:/models:z ghcr.io/ggml-org/llama.cpp:full-rocm --server --port 8080 --host 0.0.0.0 --alias ${MODEL_ID}这里用了一个不太常见的参数--init，这个参数的作用是用docker的init来代替默认的entrypoint。开启后容器内pid为1的进程就会被替换为/sbin/docker-init。通常这能确保孤儿进程被及时杀死，以及我们通过docker stop来关闭一个容器时，SIGTERM信号能被正确转发，尤其是full版本的镜像使用了一个tool.sh脚本来根据你传入的参数调用对应的二进制，这个脚本本身并没有对信号有什么特殊处理，所以这个参数对于能够正确及时地退出还是很重要的。后面就是常见的传入设备，加入对应的组，调整安全设置之类的。因为fedora启用了selinux，所以挂载卷的时候跟了个:z的后缀。千万要注意必须是小写z，表示共享。如果写成大写Z，那么就会变成容器独占，可能会引发权限问题。后面有一个专门针对prompt cache进行调整的宏： "gemma4-ctx-ckpt": > --cache-ram 0 --ctx-checkpoints 20 --checkpoint-every-n-tokens 13107默认情况下checkpoint-every-n-tokens是8192，ctx-checkpoints是32。这对于qwen系列模型来说没什么问题，因为qwen的架构用了一些技巧来减少注意力对显存和内存的占用。对于Qwen3.5 27B模型来说，这样一个checkpoint大约是150MB不到，所以默认的32个检查点吃满也就才不到5GB的内存占用。但是Gemma 4 31B可是不一样，它一个检查点能够达到1GB的大小，如果按照默认的32个检查点来的话，光是这些检查点就要占用32GB的内存，还不算prompt cache（默认8GB）和其他一些组件占用的内存。这样一来就很容易触发OOM kill。对于Gemma 4来说，由于内部采用了滑动窗口注意力机制，因此传统的prompt cache经常会遇到匹配失败，导致整个输入被重新计算。对于我这种单用户单请求的场景来说，关掉prompt cache，提高ctx checkpoints反而是体验更佳的做法。但受制于内存，我将数量缩减到了20个，并调大了生成检查点的间隔。由于显存比较紧张，所以我部署了两个版本：Gemma 4 26B A4B + Vision，由于MoE层数少，所以在最大上下文的情况下，我依旧能塞入Q8_0量化加上一个BF16的多模态投影模型。对于需要视觉的对话，我会选用这个模型。Gemma 4 31B，稠密模型在编码等需要严谨思考的任务上表现比MoE模型好太多了，尽管速度比较慢。为了能够尽可能塞下位数更多的量化，我通过舍弃视觉模型将Q4_K_M改成了Q5_K_M，这种量化精度的提升在工具调用等方面带来了肉眼可见的改进。Q4量化的模型经常会出现工具调用错误的情况，而改成Q5之后，这种错误就显著减少了。我觉得Q6K量化可能更好，可惜我实在想不到办法它Q6_K模型全部塞进显存。除非牺牲上下文窗口。这里面Gemma 4的视觉比较特殊，它使用了双向注意力机制，这意味着图片和当轮消息的文字必须要在同一个ubatch下处理，而调大ubatch尺寸会显著增加compute buffer对显存的占用。因此我把视觉留给了26B这个小一点的模型。此外，受制于显存，我的ubatch不能开特别大，所以就把图片的token数量限制到了560，剩下的内容留给文字。双向注意力机制下的图片意味着图片里的不同部分能够相互看到，相互呼应。理论上来说这样有助于提升模型的原生视觉能力。隔壁Qwen3.5没有这个问题，因为他们使用的是casual attention，一个图片会变成一串token，随后这些token和文字一起可以被拆成小批次处理。这样做的好处是可缩放，但坏处就是图片前面的部分不能在注意力机制下引用后面的部分。对于部署这两个模型，后面还有一些参数： --main-gpu 0 --split-mode layer --tensor-split 13,18 --ctx-size 262144 --temp 1.0 --top-p 0.95 --top-k 64 --min-p 0.0 --repeat-penalty 1.0 --mmap --no-kv-unified --flash-attn on --parallel 1第一行是告诉llama-server如何分隔模型。我显存有限，并且电源功率有限，因此选择了传统的layer分层，也就是一些层在一个显卡上，另一部分在另一块显卡上，这样就创造出了一种串行计算模式：前面的层在第一块显卡上完成计算，然后结算结果来到第二块显卡上继续计算。如果想要提升效率，可以考虑基于tensor拆分，这样两张卡可以同时计算，但对电源和显存的需求就显著上升了。第二行是上下文，因为我有agentic coding的需求，大上下文是必须的。实际上256K我都嫌小，不过考虑到这个模型还没达到sonnet或者opus的能力，我并不会使用那么长的上下文。第三行是采样参数，因为谷歌的模型一直比较独树一帜，包括他们家的Gemini 3系列也是这样，温度必须是1，topP和topK按照官方推荐的参数。至于minP和各种penalty，也要确保他们是关着的。llama-server的默认minP是0.05，需要调整成0才行。第四行我开启了--mmap，因为我的显存能完全装下模型，并且考虑到我要频繁切换模型，因此这个参数能够提高加载速度。第五行是关闭了统一kv cache。所谓统一kv cache，就是当你有多个slot的时候，这些slot可以共用一个kv cache。例如我想同时处理两个请求，但我想让两个请求都是256K上下文，那么通过开启统一kv cache，你就不需要一个512K的kv cache给两个请求平分，而是在两个请求的总量不超过256K上下文的情况下，共用这一个256K的kv cache。但由于llama server的实现是乐观实现，因此当所有请求的上下文总长超过256K的时候，所有请求都会同时失败。这一点在我进行测试时发现，如果opencode启动了多个sub agent同时干活，并且他们的上下文总和超出了256K，那么就会非常频繁地触发错误，导致所有agent都无法继续。因此我选择配合第七行，一次只处理一个请求，但这个请求独占256K上下文。第六行是开启flash attention，这对于提升计算速度、减少显存占用有显著功效，不开不行。尝试过的优化方案我确实花了好一段时间尝试能不能缩减显存占用，这样我就可以塞进去量化精度更高的模型了。节省缓存无外乎三种方法：去掉视觉模型减少上下文占用减少上下文使用kv cache量化调整ubatch size减少compute buffer占用如你所见，对于31B模型我已经去掉了视觉模型。减小上下文长度是不可能的，本来256K就不算大，如果agent抓一些网页读一些代码，很快就不够用了。因此选择落在了最后两个方法上。默认情况下llama server使用f16作为KV cache的数据类型，之前说过，一个参数占用2字节。理论上来讲你可以和主模型一样对其进行量化，例如设置-ctv q8_0 -ctk q8_0，但量化本身其实是一种无奈之举，通过牺牲模型的能力和精度换取更小的内存和显存占用。据传Gemma 4的模型本身对量化就比较敏感。模型本身从Q4提升到Q5之后我就感觉到了不同，所以我不是很想对KV Cache进行量化。况且llamacpp官方的docker构建对于非对称量化支持很差。总之，这是我尝试的过程：首先是自己编译docker，要在cmake的时候添加一个额外的flag：-DGGML_CUDA_FA_ALL_QUANTS=ON，这是告诉编译器对flash attention开启更多支持。默认是支持kv同时量化为q8_0的，但如果我想保持ctk为f16，ctv为q8_0，那么官方的docker就会在CPU上计算，这个额外的flag能够修正这个问题。在我测试的时候还有内存泄漏的问题，尽管一个contributor嘴硬说不是内存泄露，但我觉得这就是嘴硬，解决方法是修改源代码：https://github.com/ggml-org/llama.cpp/issues/19979#issuecomment-4275846824在完成这些之后重新编译容器便可以使用非对称量化。但测试时发现计算时需要从Q8_0反向量化，这样就导致计算时需要占用额外的显存来存放反量化后的V cache，这样一来虽然量化节省了5GB VRAM，实际却省不了这么多，并不能塞进Q6_K的模型，而V cache量化本身就已经让模型出现了工具调用出错的情况（例如调用sub agent时丢失字段），所以我认为整体得不偿失。最终放弃了方案。作为最后的一点点挣扎，我尝试通过减小ubatch size来缩减compute buffer，配合上述量化看看能不能把Q6_K塞进去。最终我把ubatch size调到了32，但代价是PCIE通信显著增加，光是prompt processing的速度就跌到了100t/s，对于opencode来说，内置的agent提示词大约就10K token，也就是说哪怕你第一句只说个hi，也得等两分钟才能见到模型开始输出思考。这太慢了，根本用不了。最后只得放弃。我还尝试了-nkvo，来把kv cache放到系统RAM，但这样一来显卡每次计算都要把数据从系统RAM发给显存，速度极慢，根本用不了一点。所以这个参数压根儿就没被列入可选方法。当然，如果你的设备是类似AMD新的那个AI系列的处理器，内置内存，可以尝试一下。对于一般的PC而言，系统RAM到显存要经过PCIE通信，这是巨大的瓶颈（主要是延迟），完全不值得尝试。尝试过的use cases之前我一直用OpenCode，因为我觉得这个软件在人类干预和Agent自动化之间达到了一个比较不错的平衡点，同时因为是开源的，所以我可以直接clone代码，遇到问题让agent辅助我探索代码寻找问题和解决方案。前阵子OpenClaw比较火，但当时觉得这玩意儿很像厂商拿来骗token的，所以我直到完成部署本地llm之后才尝试。太长不看：vibe coding只能得到一坨屎，无论是OpenClaw还是其竞品，都是一样的屎。具体而言，OpenClaw的代码毫无设计可言，感觉就是AI吐了一地，我当时用的2026.04.15版本，dashboard连个配置文件都改不明白，有点招笑了。一开始为了防止感染赛博花柳病，所以我决定使用docker，但后来发现这东西的大部分场景还是针对物理机，尤其是mac进行优化的。容器化部署只是一个样子货，不好使。于是我掏出树莓派准备给它第二次机会，奈何它不中用呢。NodeJS编写的CLI在树莓派上太卡了，唯一说是有用的还是能够通过telegram访问一个agent，但对我来说用处也不大，我不是很喜欢让现在的LLM在我的设备上无监督free style。后来我尝试了OpenClaw的其他竞品。第一个是Hermes agent，不支持本地部署模型的原生视觉，其他的还行，这个属于巧克力味儿的屎。第二个是NanoBot，用uv安装完成后，配置好本地llama server，跟我说缺少API KEY，配置了个假的也不行。连用都用不了。这是纯屎。第三个是ZeroClaw，官方的安装脚本需要现场编译rust，很慢。同样配置好之后本地模型不支持原生视觉，而且配置和文档脱节严重，这是精心雕琢的屎。果然一个好的软件还是需要良好的设计与代码组织。我觉得以当下的LLM来看，就算是opus或者mythos来了，在没有人为干预的情况下照样写不出来和人类一样优秀的项目。遂放弃尝试。不过酒馆的体验倒是不错。虽然不如Gemini 2.5 pro和3.1 pro，但好处是本地模型不要钱。后记以上就是我尝试在本地部署LLM的经历了。目前我还在研究uncensored LLM，因为现在hugging face上有很多不同的作者都在做类似的事情，我需要找到哪个人的方法比较稳定。像是coder3101和llmfan46这些，他们主要用开源的heretic方案，还算易于验证的。闭源流派有HauHauCS和huihui.ai，他们的方法无从考证，因此只能通过实际测试来考查这些模型是否符合我的use case；更别说还有一堆新秀，比如wangzhang，试图革新uncensored model领域的评测方法，并在heretic的基础上进行了改进，虽然KL散度非常低，但实际测试却拉了一地。我认为开放权重最优价值的就在于微调了。虽然OpenRouter上对Gemma 4模型的支持比较一般，有的量化打折，有的没开思考，但如果我只是想要原生模型的体验，那其实给OpenRouter或者其他hosting服务商充值就是了。而他们很难或者无法做到的，就是提供不同的uncensored或者微调的模型。我看到有人给qwen3.5用opus的语料进行微调，显著强化了它的思考能力；还有在R18语料上进行微调，能够提升NSFW角色扮演的表现；还有缝合怪（例如DavidAU），一套微调语料加上去对齐，角色扮演一把梭（coding就另当别论了）。在Reddit上还有讨论进行heretic权重消融后，通过微调来弥补消融对模型能力产生的负面影响的，只不过这种微调会比较昂贵，很少见hf上有这样做的。又因为我这个ROCm的环境，unsloth不好装，所以我也没发很好的尝试。总之我觉得在这方面真的有好多种探索不完的事情，非常有趣。当然，这也意味着相关的文章可能要有一阵子才能发出来，因此下次更新大概率不会是uncensored模型相关的事情。但一如既往地，感觉大伙的阅读与捧场。我们下次更新再见。-全文完-
【歪门邪道】使用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哦！（叉腰）-全文完-文章主图：
突然想起来三月份的更新还没写，但好在春节的时候回家拍了拍照片。于是赶忙写一篇。今年春节一如既往地回到了家，也一如既往地没能完成什么事情。今年也带相机回家了，但架不住冬天太冷，没有拍星星，只拍了拍鸟。在家拍拍因为顾虑室内外温差而导致水汽在镜头内部凝结并导致发霉，因此春节这几天并没有频繁地把相机拿出来。但尽管如此，还是在家拍到了一些鸟，只是因为黄昏时刻光照不足，所以大部分都是废片。珠颈斑鸠在北京算是非常常见的鸟类了，而且和昆明的斑鸠不同，北京的斑鸠一点也不警觉，所以你可以看到我可以优哉游哉地近距离拍摄他们。乌鸦当然也是常见鸟类。这张照片的灵感源自于前些年用手机+望远镜拍到的一张黄昏时逆光的小鸟。因为是逆光，所以背景的天空是棕黄色的，而小鸟则因为光线不足而呈现黑色，就像剪影一样。那天在家里正好看到远处的建筑上有几只乌鸦，因此就想复刻一下当时拍的照片。但是很遗憾的是我和乌鸦之间有很多树枝，他们造成了如图所见的虚影。但整体大约就是这个效果了。北京植物园第二天和朋友约了去北京植物园。因为根据我在昆明和香格里拉的经验，想要遇到丰富的鸟类就得去当地植物园。但是北京毕竟是北方城市，大冬天除了松树一类的之外，全都秃了。难怪先前一直不喜欢冬天——太单调了，太无聊了。这么一看我还是更喜欢昆明的冬天，想起来1月中旬有天下午，我在拿快递的路上路过一个绿化带，不禁感叹这个时间还能看到书上挂着绿叶。到了2月初的时候已经有粉色的花朵挂在树上了（我一直分不清桃花樱花和梅花，所以这里就叫它粉色花朵好了），五颜六色的冬天也十分让人喜爱。言归正传，这次去植物园倒是多多少少也有些收获。我记得之前在昆明拍到过这个鸟，是北红尾鸲的雌鸟。我就想这个鸟怎么分布这么广泛？到Bird Of The World上一查，原来人家是候鸟，并且有两个亚种：Phoenicurus auroreus auroreus（指名亚种）：繁殖区（夏季）在西伯利亚中南部（从贝加尔湖以西起），蒙古北部和东北部，向东至黑龙江流域和乌苏里江流域，向南至朝鲜半岛北部和中部，以及中国东北地区（向南延伸至河北和山东北部）。非繁殖区（冬季）在朝鲜半岛中部和南部，日本（本州中部以南），台湾，中国东南部（河南东南部和安徽以南），海南岛以及越南北部。Phoenicurus auroreus leucopterus（西南亚种）：繁殖区（夏季）在中国中部和西部（青海东部，甘肃西北部和陕西，向南至西藏东南部和云南北部）。非繁殖区（冬季）在喜马拉雅山脉东部，向东至泰国北部和中南半岛（印支半岛）北部。所以同样是北红尾鸲，我在昆明看到的应该是西南亚种，而这次在北京看到的是指名亚种。这两个亚种的分布区域并不重叠，因此算是两个不同的群体。大自然，真神奇啊。黑尾蜡嘴雀，这是以前没见过的鸟。查阅Birds Of The World之后发现它的群体分布和刚才的北红尾鸲类似，同样有两个亚种。这个是Eophona migratoria migratoria亚种。而云南同样也有分布，是Eophona migratoria sowerbyi，可惜我还没见过云南这边的亚种。这个是锡嘴雀。一开始我以为是黑尾蜡嘴雀的雌鸟，因为他们一起觅食，并且这只颜色比较淡，但后来查了AI才知道认错了。至于为什么锡嘴雀会和黑尾蜡嘴雀一起在地面上觅食，Gemini说这个叫“冬季混群（Mixed-species foraging flock）” 和 “生态位分化（Niche partitioning）”。所谓冬季混群是说，在冬天，鸟类没有繁衍的任务，并且食物变得稀少，因此他们就不再争抢地盘。而混群则是鸟类的一种常用生存策略，比如城市中流浪的宠物鹦鹉可能会和麻雀一起行动。对于体形和食性都比较相近的鸟类，他们会共同行动，这样就能借助数量优势来提前发现猛禽，从而提高群体的生存率。同时出于食物稀少、难以寻找的压力，群体觅食可以提高找到食物的概率。而生态位分化则可以减少他们互相冲突的情况。锡嘴雀的嘴部鸡肉非常发达，因此可以咬碎非常剪影的果核，这样一来即便和黑尾蜡嘴雀一同行动，黑尾蜡嘴雀也咬不开锡嘴雀的食物。最后就是鸳鸯了。在植物园北园的水域里，鸳鸯和绿头鸭都有。这么一说这还是我第一次拍到鸳鸯。虽然在传统文化中，鸳鸯一直是夫妻和美的代表，但其实鸳鸯的脾气一点也不好。我甚至还看到了绿头鸭抢了鸳鸯的食物而被鸳鸯驱逐的画面（很遗憾，没拍到），而鸳鸯的体形明显比绿头鸭要小。结语以上便是春节期间在北京的摄影成果了。冬天一冷就什么都不想做，不想做事情就天天躺在床上刷手机，刷到假期结束又在懊悔为什么自己虚度光阴。好在我已经回到了昆明。暖和的天气至少不会想要让人虚度光阴。那么这篇文章大概就是这样了，我们下一篇文章再见 :)-全文完-
早年间写过一个utf8-ctc，用来将文字转换成中文电码表示，当时还写了一篇文章来介绍它的基本原理。但当时有几个东西让我耿耿于怀：压缩、加密和校验。终于在机缘巧合之下，本文将记述如何改进这些问题。前言简单来说，utf8-ctc这个程序已经能够做到将任意输入文本转换成一系列中文电码，以便使用者可以将其方便地（相比于base58）抄写在依赖光学进行阅读的物理媒介上（例如明信片或信纸）。在遇到中文电码表之外的字符时，这个程序可以自动以utf8编码将其转换成字节数据，然后利用base9992表示。在这个过程中，程序还会尝试使用deflate压缩算法进行压缩，如果压缩后的结果更短，则使用压缩后的结果，进一步减少用户需要抄写的电码数目。同时在输出前，还加入了对称和非对称的密钥生成机制，以密钥作为CSPRNG的种子将结果分块进行置换加密（Transposition Cipher）。但目前有三个问题：Deflate压缩算法在长文本的情况下效率不佳，而对于中文中夹杂的外文短语，几乎大部分场景下未压缩的数据量都更小一些。加密算法实质上是置换加密，虽然程序中有估计拖延时间的设计，但本质上多次置换和一次置换没有本质区别。无法校验用户的输入，因此用户输入错误一位或几位数字，程序无法判断。最好情况是对应的字变成乱码，最坏情况是utb8字符解码失败，导致整块外文引用无法解析。更换压缩算法最开始由于Java标准库内置Deflate算法（zip同款），所以我当时毫不犹豫地就使用了这个算法。但随着后续测试发现，对于中文夹杂的外文短语或emoji，由于其数据量非常小，通常不到50字节，在这种情况下几乎任何常规的压缩算法都难以发挥作用。因此大部分时候我们需要用base9992表示原始数据。而当文本中出现成片的外文引用，例如引用了一整段外文，此时的数据量使使劲能达到大约1KB的量级，此时压缩算法能够略施拳脚。而考虑最坏情况，比如一个外国人输入了一段从头到尾都没有中文的文字，那这种情况对于压缩算法来说是最好不过的了。我选取了三组数据进行测试，第一组是一段英文文字，第二组是一段日文文字，第三组是电影利兹与青鸟的台词。 DeflateLZMA2174B115122393B22122829KB102189517可以发现在文本量比较少的时候，选用LZMA2算法会导致我们比使用Deflate时多产生7个左右的字节，这对应大约4到5个电码。而对于长文本时，我们能够节省超过500个字节，大约对应300个电码。由于能够使用的符号有限，程序中只能选取一种压缩算法。经过一番思索，我认为能够在长文本上取得较好压缩效果更为重要，这样能够在处理长输入时尽可能减少用户需要抄写或处理的电码数量。其实在选择LZMA2算法之前，我更中意的是zstd算法，可惜zstd并没有纯Java实现，因此它的依赖会对便携性产生影响。由于符号有限，9992和9993分别表示未压缩的UTF8数据的开始和结束，而9994和9995表示压缩的UTF8数据的开始和结束，此外我便找不出其他可以挪用的成对的电码了。因此我不得不将Deflat替换成LZMA2，这也意味着如果有人用新程序解码了旧程序压缩的数据，由于压缩算法的变化，新的程序会直接报错，导致所有非中文段落无法解析。更换加密算法原本的置换加密就是图一乐。置换加密有点像是y=kx+b：你可以做很多次置换迭代，相当于多次洗牌，对应地，你可以在一元一次方程上迭代多次y=k( k( k ( kx + b) + b) + b )+b，但如果你化简这个式子，你会发现最终可以用一套新的参数替换这一系列迭代y=k' x + b'。这对于置换加密也是类似的：你可以多次洗牌，但本质上迭代N次和迭代1次并没有对攻击者造成实质性的困扰。由于明文本身没有变，只是打乱了顺序，因此攻击者可以根据统计学数据来做类似小学时候看词造句这样的攻击。虽然这种方法无法解析出抽象的句子，但你总不能从头到尾句句抽象吧。因此我们需要一种更安全的加密手段。但由于我们的输入输出是电码，并不是传统意义上的二进制数据。如果我们将输入转换成utf8编码进行加密，那样又会破坏使用电码的本意。因此我将目光转到了ChaCha20，TLS1.3的核心标准之一。ChaCha20本身是一个加密算法，它将明文与密钥流进行XOR运算来加密，其设计方案具有诸多特点，这里就不多赘述了。我们的问题是如何将电码这种非二进制数据利用ChaCha20进行压缩。如果从数学的角度入手，二进制的XOR或许可以被加法和取模替代。假如我们有个密钥流，它的元素是0000到9999的数字，那么我们就可以这样加密：enc = (input + key) % 10000而解密时：plain = (enc - key + 10000) % 1000虽然没有xor那么简洁对称，但我认为也足够好了。同时由于ChaCha20使用XOR加密数据，那我们只要不停地加密字节0，0 XOR key = key，这样我们的输出就是二进制的密钥流了。通过拒绝采样，我们可以拒绝所有大于等于60000的值，剩下的值对10000取模，就可以根据ChaCha20得到我们需要的密钥流了。采取拒绝采样的原因是我们需要保证0000到9999中每个元素的出现概率不会因为我们的操作而产生偏差。例如我们不使用拒绝采样，而是简单地将两个临近的字节解析为short数值，然后对10000取模，那么受制于short的0到65535的取值范围，我们可能会得到5536、15536、。。。、55536，但我们得不到65536；而对于1234来说，我们有1234、11234、。。。、61234。这样你会发现，对于1234来说，我们有六个可能的来源，而对于5536来说，我们只有五个可能的来源。这便是人为引入的偏差。对于ChaCha20本身的参数而言，我们需要一个256bit的密钥，和一个96bit的nonce。受制于ChaCha20的原理，这个nonce必须不能重复，否则就会出现使用同一个密钥流加密文本的情况：enc1 = (plain1 + key) % 10000 enc2 = (plain2 + key) % 10000 enc1 - enc2 = (plain1 - plain2) % 10000可以看到当我们使用同一个密钥流两次加密不同信息的时候，对密文应用取模减法就可以抵消密钥流带来随机性（同样地，原版ChaCha20使用的XOR也有类似的缺陷）。虽然攻击者无法获得明文，但两次明文的差值也足够提供丰富的信息以便后续攻击。这里我们也可以认为ChaCha20不是加密，而是提供了一个密码学安全的随机流。我们的数据安全就建立在攻击者无法猜到下一个密钥流是什么随机数值的事实上。合法的用户通过提供正确的密钥和参数可以重复可靠地产生这个确定但不可预测的流，而一旦攻击者能够通过某种手段消除我们引入的随机性，那么整个ChaCha20加密的安全性也就此被打破因此我们需要保证nonce永远不会重复。然而时间是最大的敌人，只要时间足够长，总会有重复的可能。怎么办呢？把时间变成我们的朋友就好了：前64bit（8字节）使用当前时刻的毫秒精度的时间戳后32bit（4字节）使用SecureRandom生成的随机数只要我们保证在同一毫秒内生成的随机数不会产生碰撞即可。但随机数碰撞（或不碰撞）是一个概率问题，最简单粗暴的方式就是保证每毫秒只进行一次加密。但考虑到系统的时间可能会漂移（例如人为调整时间，或者NTP同步），因此同一个毫秒可能会重复出现。根据生日问题的原理，2的32次方大约有42亿种不同的可能（记作k），假如我们采样N次，那么N次不发生碰撞的可能性是：$$ \frac{k!}{(k - N)! \times k^N} $$感觉42亿的阶乘好像不太好算，那我们用其近似形式来计算一下碰撞的概率：$$ P = 1 - e^\frac{-N^2}{2k} $$这样我们就可以计算出不同的采样次数下，发生碰撞的可能性：| P | N ||:-:|:-:|| 0.000010% | 29 || 0.100000% | 2931 || 1.000000% | 9291 || 10.000000% | 30083 || 25.000000% | 49710 || 50.000000% | 77162 || 75.000000% | 109124 || 99.000000% | 198892 |假设我们每毫秒采样2931次，那么发生碰撞的概率就是0.1%，根据数学期望，当发生时钟漂移导致同一时间戳重复出现长达1秒时，或1000次（每次一毫秒），即总共取样293万次时，可能会发生一次碰撞。不过我实在想象不到得是发生什么事才能导致这种状况。就算万一真的出现了这种状况，我想那应当不在我的程序的使用范围内。引入每行校验解决了加密的问题，下面就要考虑如何验证并提示用户有没有输入错误。常见的校验码，如CRC32，大多会生成4字节的数据用来校验，但四字节用hex表示就是8个字符，对于书面抄写而言，这是极大的负担。如果秉承着正文只要抄写数字的目标，那么ISO 7064 mod 97-10会是个不错的选择。因为它的主要用途是验算IBAN（国际银行账号）号码。这下我也可以说我的程序会进行“金融级校验”了 XD简单来说，它的计算公式如下：mod(98 - mod(data * 100, 97), 97)而计算结果只需要简单地拼接在输入的数字串末尾，然后一并进行如下校验：mod(data_check,97) == 1如果输入的数字串（包含2位末尾校验位）对97取模余1，就说明数据是正确的，否则数据就是错误的。因此当即将输出电码（加密或未加密）时，我们对电码进行分组，例如每行包含N个电码以便用户抄写，将这一行的电码视为一个数字，然后计算校验，在每行最后会产生两位校验位，用户将他们一并抄写在同一行。在读取时，我们在每行结束时将一行的输入进行校验，如果成功则剥去最后两位校验数字，余下当作电码按四位一组解析；如果失败则告诉用户打错了。这样就能有效避免输入错误导致的解码错误。关于先加密后校验还是先校验后加密：我的程序采取了先加密后校验，这样对于解密时就是先校验数据，确保数据没有损坏或篡改，再输入解密算法。如果不做校验，或先解密后校验，那么你无法确保输入进解密算法的数据不包含恶意数据，例如针对你的解密算法而特制的攻击数据。这样早晚会出现安全问题。对输出整体进行签名虽然我们有每行校验，可以在一行结束时判断用户是否输入正确，但这无法确保用户不会抄串行，因为每行都是正确的，但行的顺序变了。考虑到我们的ChaCha20加密算法并不包含对数据完整性的校验，所以在加密时对数据进行签名显得尤为重要。在众多HMAC算法中，SipHash能够给出较短的密码学安全摘要，长度为64bit，用hex表示则只需要抄写16个字符。但SipHash需要128bit的密钥，而程序里用的都是256位的。我们当然可以直接截断，将前128bit作为SpiHash密钥，但这样好像不够优雅，因此我使用了HKFD（Hash Key derivation function，这里使用SHA256）来生成一个专门给SipHash使用的密钥。至于输入嘛，考虑到我们的输入是0000到9999的数字，我们可以将其作为short数值，按照大端序转换成字节，依次喂进SipHash。因此加密时的输出流程是：电码 -> 加密 -> SipHash摘要 -> 按行校验而解密时则是反过来的：按行校验 -> SipHash验证 -> 解密 -> 电码还原成文字效果展示以如下明文为例：天匠染青红，花腰呈袅娜。使用对称加密（密钥为test-key）：# ./ctc encode -t "天匠染青红，花腰呈袅娜。" -w 4 -k test-key Nonce = 0000 019B D1DD 117C ADE4 95E3 MAC = 7026 79EA E6BB 074B 4442 4214 2130 3200 35 1120 3419 2668 8328 70 6938 6776 9452 0880 03其中-w 4表示每行打印4个电码，可以看到程序输出了nonce和mac，分别用于解密和验证消息完整性。而实际的电码输出有三行，每行后面都会有一个两位校验码。解密时假如不提供密钥（使用-i选项忽略错误强行打印文字）：# ./ctc decode -i >> 4442 4214 2130 3200 35 >> 1120 3419 2668 8328 70 >> 6938 6776 9452 0880 03 >> EOF 秩瞀抹涞外泽□岬锅鄱□嘶你会发现加密后的数据是完全无从下手的。而正确的解密则是这样：# ./ctc decode -k test-key -n "0000 019B D1DD 117C ADE4 95E3" -m "7026 79EA E6BB 074B" >> 4442 4214 2130 3200 35 >> 1120 3419 2668 8328 70 >> 6938 6776 9452 0880 03 >> EOF 天匠染青红，花腰呈袅娜。当某一行的数据输入错误时，即便没有提供密钥也能检测出来：# ./ctc decode >> 4442 4214 2131 3212 35 >> 1120 3419 2668 8328 70 >> 6938 6776 9452 0880 03 >> EOF Detect error line: 4442 4214 2131 3212 35如果每行都对了，但是摘要不对的话，也是能检测出来的：# `-m` 最后一位从B换成了C # ./ctc decode -n "0000 019B D1DD 117C ADE4 95E3" -m "7026 79EA E6BB 074C" -k test-key >> 4442 4214 2130 3200 35 >> 1120 3419 2668 8328 70 >> 6938 6776 9452 0880 03 >> EOF Exception in thread "main" java.lang.IllegalArgumentException: MAC mismatch at info.skyblond.ctc.commands.DecodeCommand.run(DecodeCommand.kt:49) at com.github.ajalt.clikt.core.CoreCliktCommandKt.parse(CoreCliktCommand.kt:107) at com.github.ajalt.clikt.core.CoreCliktCommandKt.main(CoreCliktCommand.kt:78) at com.github.ajalt.clikt.core.CoreCliktCommandKt.main(CoreCliktCommand.kt:90) at info.skyblond.ctc.MainKt.main(Main.kt:6)虽然报错的方式不太体面，但好歹是报了。后记本文修正了utf8-ctc中尚未解决的一些问题，至此我认为这个项目可以算作是完成了。这样一来我也可以光明正大地邀请大家前往 https://github.com/hurui200320/utf8-ctc 下载并体验。当然了，一如既往地，这是我一时性起编写的项目，因此不存在任何保证，也不会许诺大伙任何新增的功能。如果有什么想法可以尝试着开个issue，但我不会保证会有回应。那么以上就是全文了，祝大家新春快乐。-全文完-
原本一月份是有预订的更新的，但是想着既然都新的一年了，总该写点总结。可是从12月底一直到元旦假期结束，我老实想着，却从来没开始写。因为最大的问题是不知道写点啥。但是元旦假期都结束了，总不能再拖了吧。流水帐总结翻阅了一下以往的记录，2024年没写正经的年终总结，而是把2024年拍的照片发了出来。今年当然也没落下摄影这个爱好，虽然我平日里不爱出门，不喜欢旅游（除非不花钱而且够档次），但2025年还是增加了出门的频次，并且还特意去了一趟香格里拉。并且经过大约一年的摸索，也终于得到了一个稳定处理照片的工作流。除此之外今年也试图涉足了一下星空摄影，买了赤道仪，骑着自行车在夏夜拍星星，只不过因为设备不够昂贵，并且没有覆盖范围足够远的交通工具，所以没能拍到效果不错的星空。不过花钱租远程天文台倒是取得了不错的结果，就是太贵了，家境贫寒，告辞。在游戏方面我今年也有进步，虽然switch真是一点没碰，但好歹和朋友联机玩了一阵Minecraft（还顺手水了一篇文章），年末又买了逃离鸭科夫，虽然我强烈质疑官方运营创意工坊的水平，并且强烈要求官方关闭创意工坊，让玩家自寻出路，但除了mod方面的问题，这个游戏还是好游戏，像我这种反应慢又菜的精神老年人也能乐在其中。得益于社区开发的联机mod，我和朋友也从中获取了不少的快乐。在技术方面，由于上班导致我没有太多精力可以在尝试杂七杂八的事情上，但这一年还是尽力尝试了一些新事物，例如在Unraid上使用ZFS，尽管初始设置并不完美，但这个元旦假期我也做了一些调整，花了好多不必要的时间在上面。还有我为了遏止BT吸血客户端做了一些努力。至于Kindle越狱，一开始我确实是奔着折腾去的，但是最后发现kindle的性能实在是太差了，只能看书。但看书也有看书的好，今年看完了狼与辛香料小说的正传，看完了终将成为你的漫画版。平日里狼与辛香料能让我专注于工作之外的事情，是一种十分难得的放松。但工作上的压力大到我读不进去书的时候，终将成为你这个系列漫画给予了我走下去的力气。这就是百合漫画的力量吗，太棒了。生活与工作自从2024年中搬到昆明，截至2025年底已经住了一年多了。好多生活习惯也已经成为日常惯例，几乎不需要思考就可以完成每日的吃穿用住。但随之而来的就是生活变得无聊，而工作来带的压力无处消解。也正因如此，消费主义趁虚而入，大约我每个月都得花点钱买一些新奇的玩意解解闷儿。今年的大额消费差不多就是尼康的180-600mm镜头，和在溥仪眼镜配的两幅眼镜。说起配眼镜，起因是因为原来的镜子镜框坏了，加上本身度数不准，我就想找个高档的眼镜店配个高点的。早年间在北京的时候去Alexa配过眼镜，一套下来7千多，我寻思这溥仪眼镜8千怎么还下不来呢？确实下来了，结果被导购一忽悠，又加了一副太阳镜。说是免费送一副4000元以下的镜框，结果哪知道我这慧眼识珠，一睁眼就拿了一副六千多的镜框。羊毛没抓到，自己还贴了2000多进去。除此之外就是每月买的一些小玩意儿了，比如新的显示器，一套校色仪（屏幕+打印机），一个比较专业的家庭打印机（爱普生L8168），星野赤道仪，碳纤维三脚架，kindle paperwhite 6，卡式磁带播放器这些。当然，我也反思过我的消费习惯，每个月花钱太多不好，不过转念又一想，这些也不算是吃灰浪费。挣钱早晚都要花，早花早享受嘛。不然死了留下一堆钱，我又没儿没女，留给谁呢？当然，花钱也不是生活的全部，除了上一节提到的各种科教文卫活动，我想起来这一年还和朋友一起看了一些影视作品。其中印象最深的还是芙莉莲和你的颜色。当时看完之后拿起手机就写了小作文，只不过碍于体量，只发在朋友圈了。我想既然都写了，还是应该和大伙分享一下。所以正好就在这里放上来吧。首先是芙莉莲的观后感：今天把Frieren动漫看完了，感觉做的真棒。不敢相信在京阿尼都能拉坨大的的时候还能看到这么好的动漫。（别问为什么打英文，问就是三星输入法打不出正确的词，不想挑字）既然都是寿命论，那我看过的也就只有狼辛和小林家的龙女仆了。目前正在看狼辛的小说，因为已经完结了，再续的羊皮纸那就之后再说了。龙女仆的漫画之前也看了，不过单行本一年一本等不起。现在也打算去买Frieren的漫画。感谢台湾电子书商，使我钱包旋转。我认为单从寿命论的角度来看，Frieren的氛围应该介于龙女仆和狼辛之间。龙女仆里提到过即便是托尔他爹也只能让小林活到自然死亡，但从漫画剧情来看，作者似乎并不太在意寿命的问题，故事的重点比较关注当下的冒险，非要说的话那就是享受当下的生活。而狼辛虽然结尾俩人过上了幸福生活，但就目前的进度来看，其实二人的旅途中总是蒙着随时要离别的悲伤，和二人心照不宣极力避免分别的异样。虽然这种场景能让二人经历的每一场冒险都显得尤为珍贵，但说实话，要不是我知道结局是好的，我真会越看越着急。Frieren之所以介于二者之间，是因为其中有对勇者一行人过去的追忆，也有与年轻人的新的冒险。Frieren在勇者死后才尝到源于不了解却已经无从开始的悲伤，每次过回忆我都觉得勇者有点委屈。他为Frieren什么都想到了，可是他死后Frieren才想起他的好。不过这里面Frieren的性格设定也很方便，就是对情绪情感什么的很迟钝，所以就很方便地淡化了这种悲伤。而恰好在此契机之下，和年轻人的冒险也很有料，每次战斗都很新奇，虽然最后打泥人的时候已经能感觉出来原画已经不知道该怎样表现那些奇异魔法了，只能通过怪异的颜色充满画面来使观众眼花缭乱。但总体来说都挺有意思的。不过我比较好奇的是这里面精灵的设定是不是参考了托尔金的设定，后面黄毛精灵说事情拖一千年也不会对精灵造成什么伤害。按照托尔金的设定，精灵的命运是和地球绑定在一起的，死后灵魂也不会消散，他们的命运是固定的。而Frieren的老师是人类，按托尔金的设定来讲就是人类的命运不受束缚，因此人类才有无限的可能。挺好的，很喜欢。狼辛，龙女仆，Frieren，这仨可能是我现在最喜欢的作品了。之前肯定也喜欢过别的，但当下我最喜欢这三个。这是山田尚子导演的「你的颜色」的观后感：看了山田尚子导演的电影，你的颜色。自从看过利兹与青鸟之后我就牢牢记住山田尚子这个名字了。那一部电影至今仍然是我最喜欢的电影之一，细节丰富，人物细腻，情感充沛。你的颜色比起利兹与青鸟，脱离了他人的剧本，可以算是山田尚子自己的风格化故事。它比利兹与青鸟拥有更多山田尚子的特色在里面。一贯地，电影中有丰富和细腻的人物细节，表现手法依旧出众。例如电影开篇窦子得知纪美退学，没有一句台词，只是通过人物动作的定格以及随着转场将画面逐渐去掉色彩来表达。或许你可以用一句很高明的台词来表明窦子的情绪，但你顶多只能表达一种。依靠这种转场来表达情绪就可以很丰富了：可能有一些震惊在其中，也可能有一些失落，还可能是其他的什么。这一刻观众自己的解读也成为了电影的一部分。我认为这是自我看过利兹与青鸟以来，山田尚子善用的表现手法。「圣神，那些无法改变的事物，请给予我接受它们之内心平静吧。那些能够改变的事物，请赐予我改变它们之勇气吧。而“不可改变”与“可以改变”之事物，请给予我区别它们之智慧吧。」——宁静祷文从故事来说，利兹与青鸟在于描写二人关系的发展，而你的颜色则更注重与个人在别人投射来的期望与自己想要做的事情中取得和解。虽然最终三人组成了乐队并在文化祭中登台演出，但这部电影并非孤独摇滚那样的乐队番。实际上你可以把组乐队换成其他任何事情，只不过我猜组乐队应该是时下潮流，并且容易在电影中体现出来。毕竟无论如何电影总是需要原声配乐的。电影中不同角色的叛逆程度各不相同。日吉子老师作为学校的老师，对她而言叛逆可能已经是过去式了，但在影片中仍然乐于帮助处于叛逆进行时的主角一行。窦子在主角三人中可能是不需要面对什么叛逆的，或者说她的叛逆更多来自于保护朋友。其次是路易，这大哥想组个乐队，但家人期望他学医来继承家业，最终大哥也是和自己家里人聊开了，在继续学业的同时兼顾乐队。而纪美大概是三人中最强烈的，面对家人与学校过高的期待时选择了逃跑。虽然最后和奶奶解开了心结，但影片中并没有给出明确的后日谈。我觉得一方面是不好交代，另一方面是回应「无论多少次，我们都可以重新选择开始」这句话吧。在影片结尾，所有人随着乐队的音乐舞动，所有人也都接受了无法改变的，同时也接受了新的变化。「接受无法改变的，勇于改变可以被改变的。这些，就是我稍微明白的道理吧。」——日暮窦子当然，这部电影并非乐队番，所以音乐不是重点。山田尚子细腻的表现手法同样展现在了音乐上。在影片结尾的演奏中，乐曲的旋律单调，填词尚可，表演中穿插着一些小小的不和谐。毕竟是三人第一次以乐队的形式演奏，但我认为正是这些细节才让故事不会显得过于架空和理想化。从真实性的角度来说，我认为你的颜色也继承了利兹与青鸟中关于童话的叙述。山田尚子的目的不在于用这个故事影射或关联现实，而是构造了一个能够支撑主角们的场景，就和拍电影时那些刻意设计的场景一样。这样山田尚子才能将镜头聚焦在人物该如何面对自己的内心。正因如此，影片所讲述的故事才有了比童话更能治愈人心的可能性，我认为这是山田尚子表现手法的一个特点之一。不然你想，真要从生活来说这一定是科幻片：在学校闯了祸，你父母能在不骂你的情况下理解你并说出「原来是交到想让自己保护的朋友了呢，本来想狠狠说教你一顿的」这样的话吗？试想若是你中途退学，你的老师能邀请你回学校登台演出，理解你体贴他人，教导你「无论多少次都可以重新选择开始」，并鼓励你「你是以自己的时间从学校毕业的」吗？同样地，你能想象你的老师在你无法及时返回宿舍的时候告诉你「一定不要责怪自己」，然后给你出主意吗？我活了这么大，我想象不出来。不过就算在现实中，我觉得影片想要传达的意图还是可取的。至于影片的故事性本身，好多人批评故事没有结构，和商业化叙事差得远。我倒是觉得无伤大雅。毕竟大多数人的人生都没有明确的结构，也不需要明确的结构。那何必还要苛责这个影片的叙事和音乐必须要有结构呢？要说起对于生活中唯一不满意的，大概就是我的运动和饮食习惯了。虽然我竭尽所能降低了油脂的摄入，但每日工作和做饭是在时间与精力上都发生冲突的事情。我上班，就不能做饭，不能做饭就只能点外卖，而点了外卖，第二天一定会涨秤。可是自己做饭吧，又要牺牲工作时间，无形中放大了工作中的压力。并且随着工作内容的进行，任务不断增多，也不能像一开始那样一周有个一两天下午没事可以出去骑车。现在我将骑车的安排放到了周六下午，每次20公里的样子，因为我住的地方附近并不平坦，一次骑多了膝盖疼。所以按道理来说还是应该少量多次才对。但没办法，现在的生活是建立在当前工作的收入之上，因此保障工作就不得已成为了第一优先级，而身体，尤其是健康，则因为没有立刻遭到报应而被默默牺牲着。我终于也没能成为我理想中的人。不过说起这个，这一年来我认为变化最大的就是我看待事情的方式变化了。去年我没有在年终总结中写什么太主观的东西，只是说生活正在稳中向好，我对此很满足，也很热爱；而前年由于没找到工作，压根儿我就没心情写年终总结，不过心情大约和大前年一样，多少显得有些死气沉沉的，或许夹杂了一些悲愤，但总体还是悲伤的。大前年我的跨年文章写的很悲观，以「这篇文章不会祝你新年快乐。」开头，以鼓励大家自己创造道路为结尾。虽然看待事情的方式变化了，并非是说我现在不认同当时的我了。正相反，我认为得益于我对自己的信任与认可，无论再怎么改变，我都不会后悔曾经的我做出的决定。也正因如此，我仍记得当初的我是以何种心情写下那些文字的。时至今日我也觉得当时的我说的有道理，人必须自己创造他自己的道路，也正因如此，自由、责任与希望都存在于他本身，他不能再以借口推脱。不过随着收入稳定，生活条件改善，尽管我仍然担忧之前提出过的问题，我也依旧认为当下的政府远不及我心目中的预期。但比起没有工作时的终日焦虑与担忧，工作像是一个飘渺空间中的锚，无论我的内心被什么样的情绪所左右，工作的收入以及来自老板的肯定总能为我带来一些自信，进而让我在生活中增添了些许底气，以便我能够面对这些潜在的问题而不退缩。也许未来这种感觉会演变成对问题的麻木，也许不会，这是未来的事情，我没有能力预知未来。但我希望我能够保持敏锐，不要变得麻木，并且在力所能及的范围内促进改变。或许这些话对于2022年的我来说根本不可能说出口，甚至都不可能这样想，但现如今在2025年之后，我这样想了，也这样相信着，因此便敲了这些话出来。我知道我这叽里咕噜说了一大堆有的没的，像谜语人一样，始终没有说出来我看待事情的方式到底怎么变的，从哪变到哪。实话实说，我不是文学巨匠，我真没法用具体的文字说明白这种事。但我相信就和看电影一样，如果你也有类似的经历，读完上一段之后或许你会有一些感觉。《狼与辛香料》和《终将成为你》当然，老板的肯定不常有，而同事的捣乱天天在。因此过日子总有质疑自己几斤几两的时候。非常值得庆幸地，Kindle给我提供了远超手机的阅读体验，使得我终于可以安心地看书，而不必担心看着看着书就开始看b站了。平日里看《狼与辛香料》最多，我是看了动漫之后就变得爱不释手，于是没等到新版动画就直接跑去买了电子书大看特看。我很敬佩作者能够写出这么跌宕起伏的剧情。我大约一周里能有五天，每天拿出大约半个小时来看书，通常是工作日，开始上班之前。在看过罗伦斯和赫萝将要面对什么挑战，或者看到他们想到了怎样的办法，我觉得心中变得充实了起来。我想这也能侧面提醒自己，工作并非生活的全部，做不到的事情就是做不到。大约是11月下旬，忘了是因为什么事了，大概还是同事的问题吧，就觉得这个班上的没意思。狼与辛香料有点看不进去了，毕竟都是字，因为我买的台版书，所以还是繁体中文，就更难读了。后来想要不找点漫画，但是我确实不怎么看漫画，正好最近b站给我推了几个女同漫画的视频，就去搜了搜。奔着不完结不看的原则，买了终将成为你的全系列（漫画和小说），紧接着就大看特看。十分爱看，简直爱不释手，八本漫画我记得四天好像就全都看完了。我长眼睛就是为了女同漫画的。其实原本打算借机写写读后感，但是想来想去没什么特别想说的。当时读狼辛的时候画了好多高亮，因为真的很喜欢作者写的内容。但是正传有16本，真的要一本一本说，感觉会很麻烦，而且会剧透。当时读这两部作品的时候我就觉得有些惋惜，因为这么好的作品，读一本少一本了。一旦你读过，再读的时候或许会有其他的收获或心得，但你绝不会再有第一次那样为读到这么跌宕起伏的故事而感到幸福、喜悦的心情了。另外从情感线来看，狼辛和终将成为你都有很精彩的感情线。从赫萝遇上罗伦斯并一起启程寻找故乡，二人在旅途中逐渐构建感情关系，最终能够互相信任，并愿意在余下的时间里与对方共同度过。小糸佑与七海灯子从相遇，到灯子推着佑往前走，再到中途换成佑想要推灯子一把，再到两个人逐渐想明白二人的关系，我觉得这种故事性是非常精彩的。当然还有热爱学习的佐伯女士。我想过简单提要一下剧情，但最后我还是觉得真正好的东西应当留作惊喜让读者自己去发掘。平日里我读书的时候都喜欢先做功课，看看剧情有没有雷点，结局怎么样之类的，然后再决定看不看。但对于这两部作品来说，我可以以推荐的目的向大家打包票：他们的结局都很好，剧情也很精彩，感情线也很细腻。非常值得一看，是不可多得的优秀作品。元旦假期我一直觉得国内的假期分配的有些不够平均。自从十月份国庆之后，一直到元旦，两个多月的时间一个假期都没有，这让我对于上班这件事感到十分枯燥。因此我决定好好利用元旦假期。12月31日游昆明植物园和黑龙潭本打算赶在元旦前一天早早地去昆明植物园拍鸟，结果没想到有个中学来这里做校外活动。而且单纯从人流量来说，明明还没有到元旦，可是公园里的人却一点也不少。单纯从观鸟来看，还是西园北边的树林比较清净。从观鸟的角度来看，我觉得植物园应该是绝佳的观鸟地点了。在植物园的时候也遇到了两个同好，毕竟脖子上挂着长焦，很容易就互相认出来了。凤头雀嘴鹎当时正在找别的鸟，结果那只鸟飞走了。正准备继续走，突然听到有鸟叫，于是变开始在附近的树上寻找。比较遗憾的是这只鸟所在的树在一个山坡上，我没办法方便地移动来调整角度。并且树比较高，所以只能呈现一个仰视的视角。不过这个鸟的发型倒是挺飒的，符合鹎类的一贯形象。蓝额红尾鸲这种鸟是在北门附近的裸子植物林里面拍到的。光线不够充足，导致我只能降低快门速度。幸好尼康的防抖足够厉害，在没有机身防抖、纯靠镜头防抖的情况下能够在大约1/320到1/640s的快门时间下捕捉到这些图片，除了运气好之外，我认为尼康的技术还是比较起作用的。另外眼尖的读者可能看出来了，最后一张的鸟的颜色好像和前面几张不太一样。前面的都是雌鸟，最后一张是雄鸟。据说是因为鸟类在演化中变成了雄鸟鲜艳，所以可以吸引掠食者，通过牺牲自己的方式来保证雌鸟有机会将自己的后代养大。不过雌鸟雄鸟颜色差异大，对于我来说要准确识别还是有些难度。黑头金翅雀这只鸟是在快出植物园的树上拍到的。树比较高，树枝比较密，不过好在还是拍到了。我在植物园只看到了几只，但今天我的朋友去了南滇池湿地公园，看到树上有一群的黑头金翅雀在树上觅食，而且见人不跑，他用手机都拍到了很不错的照片，可以说是很幸运了。红尾水鸲宏伟水渠红尾水鸲，b站观鸟区的顶流之一，不必多介绍了吧。这是在黑龙潭拍到的。白喉扇尾鹟这个是在黑龙潭公园深处的山里拍到的。我正准备爬梯子，余光刚好看到有什么东西在树林中动，但奈何树林太密，别说拍了，看都看不清。恰好它飞到了旁边开阔视野中的一个树枝上。那当然是机不可失时不再来，我赶忙拍下了几张照片。它的尾巴好长诶，几乎和身子一样长了。还是头一次看到这样漆黑的鸟。1月1日2日东川两日自驾游玩儿完植物园，第二天就是正式的元旦了。想着昆明城里哪的人都多，那不如去附近的地方看看吧。于是租了一辆大众探岳，准备去东川的山里玩玩。住宿嘛，还是选的城里，云上酒店的行政套房80平米只要不到800一晚，这也就是东川，放在上海想都不敢想。第一天开车去看了红土地，说实在的，确实一般，没有网上的照片那么红。后来经过朋友的提醒我才想起来，呈贡这边的绿化带里用的就是红土啊，想看红土找个绿化带就完了，干嘛非得开山路好几十公里。这是从我们下榻的酒店的窗户拍的。山景房，风景不错。这个是2号拍的，山上有雾，而1号我们入住时雾几乎没有，可以看清楚山的轮廓，而日落的时候夕阳把山照成金色，颇有日照金山的味道。只不过1号开了一天山路，完全没力气拿出相机拍照，算是一个遗憾了。第二天我们计划的是去东川森林公园。去是去了，可是一路上坡，我们爬了十分钟，作为生活在城市里且不怎么运动的胖子，这个运动还是有些激烈了，于是我们一商量，原路返回开车吧。虽然进不了森林公园，但沿着山路上山看看也是好的。这一次的山路和昨天不同，山景真的非常好看：虽然路比较凶险，到后面李子沟公路，路边都没有护栏，但谨慎驾驶，我认为这景色完全值得。东川原本因为铜矿辉煌，而现在虽然前景有待商榷，但自然风光是真好。如果能够便捷交通，踏踏实实地开发，我相信旅游业的潜力是非常大的。后记嗯，2025年的年终总结大概就是这样了吧，其实大部分都是想到什么写什么。既然之前没有祝大家新年快乐，那今年也不祝了。但我希望大家都能够找到自己热爱的生活。纵然生活不会一帆风顺，我祝大家都能找到在自己面对艰难险阻时仍能支持前行的力量。无论它来自你热爱的小说还是漫画，亦或是影视作品，还是源自你身边真切而具体的人，都请务必依赖这宝贵的力量，尽情创造自己的道路。-全文完-
其实本来早就想写这篇文章，但是因为11月底不幸感染了甲流，劲儿还挺大，给我干发烧了。所以一直拖到现在才开始写。这篇文章主要分享我11月在公园拍的一些照片。前言11月整体比较无趣，因为工作上比较心力交瘁，每天靠着百合漫画度日（终将成为你真的太好看了，我太喜欢了，感动到落泪）。而周末的两天只能说算是补偿精神，还没真正开始享受周末，它就结束了。想了许久上四修三的打算，最终也没能跟老板提。但月底的时候朋友路过我这里，那两个周末可真是太不一样了（要是第二个周末朋友没把甲流传染给我就更好了）。要不说呢，这日子怎么会跟谁过都一样呢？纵观整个11月份，一共就出门了两次，两次去的还都是同一个公园：渔浦寒泉湿地公园。虽然不知道名字为什么这么叫，但是它地理位置偏僻，风景还算是不错，偶尔还能看到一些鸟，算是还不错的去处吧。第一次第一次是本着探路的性质去的，虽然带上了相机，但是没指望拍出些什么东西。不过话虽如此，还是拍到了一些不错的照片。首先是在一个栈道上，我远远看到树上有鸟，虽然听到叫声就知道是老熟人（黄臀鹎）了，但是这次老熟人在吃树上揪来的果子，还是有点新奇的。顺着栈道继续走，就能看到一池子莲花。我问了Gemini 3，它说这是睡莲。因为我实在是不怎么认识植物，所以它说是就是呗。上图是我用600mm当微距拍的。虽然很近，但是感觉细节上还是差了一点。尼康的刀法也不比老黄差啊。这一张可能是我最喜欢的几张之一，我趴在栏杆上使用了一个相对低的角度拍摄，拍摄的时候就刻意让水下的倒影和水面上的花朵尽可能一致。而且这是整个池子里唯一一朵粉色的睡莲。等我们一周之后再去的时候，这朵花就已经谢了。在发现水面倒影的好处之后，我又尝试拍了几张手段类似的照片。这一张是白色的睡莲，后期时我故意将水面倒影提亮了。我朋友建议将这张照片竖着裁出来（后面一张），但我个人认为还是横着的好看（前面一张）。我觉得放的太大了，会有一种傻大黑粗的感觉。不过我也拿不准到底哪个更好一些，所以就都放出来了，读到这里的朋友们可以在评论区留个言，说说看你觉得哪种裁切好看一些。关于在LrC中同一张照片按照不同方式裁切，网络上似乎并没有一个同一的答案，至少我看到好多人在一个reddit帖子里说了各种不同的方案。我的方案是调色完成后，创建一个虚拟副本，这样一个按照横屏切，另一个按照竖屏切。这几位也是老朋友了。分别是普通翠鸟，白鹡鸰和小白鹭。白鹡鸰和小白鹭似乎比较常见，我们第二次去的时候就没有看到翠鸟。等等，你是？夜师傅？公园中间有一片湖，湖中间有个小屋。这一次看到一个疑似夜鹭的鸟在里面睡觉。第二次的时候我们看到黑天鹅在围栏外面睡觉。以上就是我第一次去公园探路时所拍到的照片了。第二次第二次是和朋友一起去的。我一个人出行的时候，大部分情况都是我一个人走的飞快，像是在完成什么走路任务之类的东西。而两个人的话，走走停停，要比我一个人逛公园细致很多。所以这一次拍到的内容也更丰富一些。一开始我还以为是大花蚊子（夜鹭幼鸟），但秉持着严谨的态度询问过懂鸟之后，才知道池鹭。在栈道上寻找鸟的时候忽然看到一只鸟飞过来，落在了不远处的岸边。我赶快调整角度拍摄。拍完之后却发现：这谁啊？我咋一点都不认识啊？回家查了一下懂鸟，说是扇尾沙锥。喜加一。拍完扇尾沙锥，我一个回身，走到了另一条栈道上。我一看，这又是谁啊？回家查了懂鸟，说是白腰草鹬。又是一个加新。因为很小只，而且离得很远，相机的自动对焦根本不好用。而且这只鸟动起来非常快，颇有白鹡鸰的风范。回家查了一下，您猜怎么着，人家是黄头鹡鸰，确实跟白鹡鸰论亲戚。来都来了，那就再拍个莲花吧，不过这朵有点老了。只胜在阳光的角度还行。老朋友了，就不多介绍了。近距离欣赏小䴙䴘。第一次去的时候也见到了，但是因为光线角度不对，照出来全是黑的。这次正好光线合适，就拍下来了。本来还想用高速连拍拍一下它入水捕食的动作，可惜我这机器缓存太小，而它的动作又难以预判，折腾了二十分钟也没拍下来。虽然花了很多努力却得不到想要的结果是一件很令人沮丧的事情，但我仍然要强调诱拍是每一个鸟类爱好者都应该人人喊打、积极反对并积极制止的行为。出片当然是一件好事，但我拍鸟是因为我喜欢看各种各样的小鸟，如果出片是建立在伤害他们的基础上，那这个片也就没必要出了。蒲公英，被树林间漏下的阳光照亮。同样因为不知道是横屏更好还是竖屏更好，所以就两者都尝试了。后记以上便是本月的更新了，看起来比较水，实际上也很水。值得一提的是，11月底我从法国订了一台很普通的磁带播放器（类似于早年间的磁带随身听）。我知道他们的音质当然不如卡座播放器好，但是现在想要找到高质量，还在产的磁带播放器可太难了。虽然可以收老家伙的二手，但我认为这样终究是不可持续。虽然磁带早就被时代抛弃了，但是看到人类曾经做到登峰造极的东西就此消失，还是多多少少有些难过。扯远了，就这个磁带播放器而言，我当然是没什么艺术细菌去搞remix。不过我也尝试了往上录制一些音乐，除了蔡琴，效果都不错。不知道为什么，蔡琴的歌曲总是会有很明显的齿音，消不掉。而我购买磁带播放器最大的目的还是想试试看用自己的方法在磁带上存数据——我有一台二手的LTO6磁带机，我知道往磁带上存数据是一个很精密很高端的活儿，所以我也不打算和LTO6作比较。就是单纯地玩儿。不过这些就是以后的事情了。以上，便是全部内容了，感谢各位的阅读。我们下次更新再见。-全文完-
最近在YouTube上看到了一个关于kindle越狱的视频，心里痒痒就去网上搜了一下kindle的价格。没想到当年对上学时候的我遥不可及的价格，如今已经变成了我唾手可得的东西。于是就买了，并不是因为爱看书，就是单纯想玩儿。 关于购买建议 由于之前我没用过任何e ink屏幕的产品，也没了解过电子书这个产品线，所以当时购买的时候不是很了解，并且抱着主要以越狱的目的购买了kindle。事后来看，kindle在这方面并没有给予我太多的回报，因为单纯从硬件上来说，我买的这一款kindle性能只够看书的，连浏览器都不能正常流畅使用。虽然我取得了系统的root权限，也成功地在上面运行了java，但性能实在是太糟糕了。如果不考虑越狱这种玩儿的目的，单纯为了阅读，那么市面上有更好的选择：比如主打开放系统的PocketBook，以及可以无痛安装KOReader的Kobo（尽管我不喜欢这个外形）。如果大家不能接受花1600买个只能振奋你一个周末的玩具，那么请务必不要因为这篇文章的鼓动而去无脑下单kindle。如果你真的想要购买一个电子书，请务必根据自己的需求做好产品调研，寻找到真正适合自己需求的设备。毕竟挣钱不容易，现在经济不好看，花钱更要谨慎。 为什么Kindle也要越狱？我最早知道越狱这个词还是因为早年间亲戚传给我一个好几手的iPhone4，那时候还是ios6到7的过度时段。越狱之后取得了系统的很多权限，装了很多插件，是非常有意思的一段经历。后来因为家境贫寒，用回了安卓，又踏入了root和刷机的领域。只不过安卓刷机刷来刷去，最终还是依赖于自己给自己售后——或许一个系统更新或者一个重启就再起不能了。现如今我用上了港版三星，再也不用为了Google全家桶刷机了。至于Kindle，越狱也是出于同样的目的：取得更多对设备的控制权。抛开安卓系列，大部分kindle运行的是基于linux的定制操作系统，除了精简大部分组件以适配寒酸的硬件之外，最重要的是将用户锁在亚马逊的生态里面。你想要阅读在亚马逊上购买的书？不好意思，你购买并不等于你拥有，一般情况下亚马逊会通过版权保护的手段来加密你下载的电子书，但最终还是亚马逊控制这些书，他们可以不经过你允许地撤销你对一本书的访问，或者修改一本书的封面来为他们的电视剧做广告（即便你购买了无广告版本）。而从阅读体验来说，越狱之后安装的KOReader作为原厂阅读器的替代品，可以说功能非常丰富。例如你可以从calibre利用无线设备连接，直接将你的电子书收藏发送到kindle上，也可以安装插件，比如在阅读过程中选中一段话，让ai解释，以及生成二维码（手机扫码后可以复制到手机上），或者直接绑定telegram bot来发送到telegram上。对于养成类玩法比较看中的读者还可以利用内置的阅读统计功能记录你哪天读了什么书，读了多久，一个月下来看着统计日历，满满的成就感。当然，事后我还发现kindle这种独立的设备极大地提高了我阅读的意愿。原本我是在平板上看漫画，手机上看小说。但最近看小说的频率越来越低，我上一次在手机上看书已经是一个月之前了。为什么呢？手机那在手里就不自觉地往YouTube和b站跑，读到一半弹个通知指不定又干什么去了。总之就是我在手机上可以读书，而且真的读起来体验也不错。但问题就在于，我在手机上读不起来书。所以我想这也是一个单独的电子书所带来的好处吧——手机往旁边一扔，拿起电子书大读特读，管什么朋友发消息同事找我有事，统统看不见。我可以获得一个不受打扰的阅读时段。而且这种体验再配上一套好书，比如狼與辛香料，哇，那这简直是人间天堂，死而无憾了。Kindle越狱导论由于不同版本的固件需要使用的越狱方法不同，并且亚马逊对于未停止支持的设备还在积极更新系统，封堵越狱使用的漏洞，因此越狱的实操会不断更新，但本文不会随之更新（因而我懒），所以这里只简述越狱的大体流程，并给出寻找详细资料的指引。 开始动手越狱之前 如果你决定越狱，请务必熟读相关教程后再操作。为了避免你的kindle自动更新，请务必启动飞行模式，断开网络连接。并采取必要措施阻止更新（例如填满剩余空间，删除根目录下任何update开头的文件或文件夹）。本文不是越狱教程。 截至目前（2025年11月8日），亚马逊已经发布了5.18.6版本的固件，在该版本中，最新的越狱方法已经被修复。一旦升级到这个版本将无法越狱，需要等待新的越狱方法（取决于发现的漏洞，可能需要几个月，也可能需要几年）。在社区的Discord中可以得知，固件版本5.18.5.0.1就已经会对最新的AdBreak造成影响了。而固件5.18.6则是彻底封堵了AdBreak越狱。由于我手上没有任何Kindle设备，因此在简单搜索了一下之后决定购买Kindle PaperWhite 6签名版，因为是32GB存储空间。但新购入的设备对于出厂固件充满了不确定性，我比较期待的是出厂固件在5.18.1以下（即最高版本5.17.0.4），这样就可以使用较为稳定的WinterBreak。目前有两个主流版本，对于5.18.1以前的固件，WinterBreak只需要你登录亚马逊帐号即可，原理大约是通过修改缓存文件，在启动亚马逊商店时执行自定义内容，从而获取系统权限。但5.18.1及以后的固件中修复了这个问题，需要改用AdBreak。正如其名，它需要依赖广告机制来触发，本质上是修改广告以及special offer的缓存来实现点击广告执行自定义内容，因此AdBreak的前置依赖于你的kindle必须能够接收广告。无论哪种越狱方法，他们的目的都是通过高级权限修改文件系统，让原生kindle系统能够接纳我们的自定义密钥，这个密钥可以用于签名固件更新，而我们便可以通过自己创建固件更新文件、自己用自定义密钥签名的方式来向原生系统注入我们想要的修改，从而达到越狱的目的。WinterBreak和AdBreak的目的是向系统中注入密钥，而后面的hotfix则是利用这个密钥修改系统，在系统中刻意为我们用户留一个后门。而执行hotfix则是持久地修改系统，让它为我们保持这个后门的打开。所以后续即便更新了系统，只要安装的hotfix还在，我们就可以运行它来修改更新后的系统，再次打开这个后门。越狱海外版Kindle PaperWhite 6我在下单时赌了一把，我本以为由于库存的缘故，到手后的kindle会老一些，我还提前下载了kpw6的新固件，准备到手之后升级到5.17.0.4再越狱。结果到手一看，它已经是5.17.0.4了，实在是惊心动魄。考虑到这已经是亚马逊退出中国的好几个年头了，国内的货还能这么新，属实令我惊讶。下面简单来说到手后从开箱到越狱的流程吧。首先检查外观，确认没有问题，拆封、开机。开机后直接跳过WiFi设置，因为kindle一旦连上网络就会自动下载最新固件并安装，如果它自动更新到了最新版本，那么我们就没法顺利越狱了。因此跳过WiFi设置后我前往设置页面确认当前固件版本，并连上USB将kindle的存储空间占满，只留下20MB的剩余空间。通常Kindle下载一次更新要50到300MB，只要我们不给它留出足够的空间，它就没法自动更新。确认好版本之后就可以配置WiFi激活亚马逊帐号了。因为无论哪种越狱方法，它都依赖亚马逊帐号。配置好WiFi、登录账号。登录完成后启动飞行模式，将Kindle断网，然后再次插入电脑检查根目录，此时根目录已经出现了update.bin.tmp.partial文件夹了，说明kindle已经在自动下载新固件了。删除之后弹出kindle，它自动重启进入更新了，但好在我早已填满了存储空间，更新失败。最后按照教程下载WinterBreak需要的文件，并根据指示操作即可。大概流程是：下载WinterBreak需要的文件，将其解压后覆盖进Kindle的根目录根据教程触发越狱，看到成功提示后安装hotfix安装成功后运行hotfix，持久地取得系统权限安装KUAL和MRPI，这一步需要注意释放剩余空间。原本我只剩下20MB，多次安装都失败。后来删除了一些无用文件，留了差不多300MB的空间，这样才安装成功安装插件禁用OTA更新，禁用OTA更新后即可关闭飞行模式，放心地让kindle连接wifi了安装KOReader和其他一些实用工具越狱国行Kindle PaperWhite 4我在越狱成功后的下一个周末，将我朋友的PaperWhite 4也越狱了。他的设备是国行，序列号里面有一堆0。但好在固件版本够低，是5.16.6，我将它升级到了最新的5.17.0.3然后进行越狱。当我正准备进行WinterBreak越狱时，突然发现他的设备上没有购物车这个按钮。因为kindle业务早就退出中国了，国行的书店也都关了。在刷了新固件之后可以看到系统刚刚启动时是有购物车按钮的，但很快就消失了。因此我们判定亚马逊并没有使用定制硬件或者定制固件，而是一套固件根据帐号区域来行事。经过一番思索，我决定在他的kindle上登录我的帐号，因为我的帐号是外区的。登录后果然购物车按钮重新出现，但点了之后并没有触发WinterBreak，我猜测应该是切换帐号导致固件清空了商店页面的缓存。于是重启kindle后重新用WinterBreak的文件覆盖进去，这样就成功触发了越狱。越狱之后国行版本就没什么太大区别了，我如常安装KUAL和MRPI这些东西。但切换帐号时遇到了个问题：kindle切换帐号会清空document文件夹，这虽然不会删掉越狱的状态，也不会删掉KUAL和安装的插件，但KUAL和hotfix确实依赖document文件夹中的一些内容作为起动器。于是在切换帐号之前我备份了document文件夹，其中有两个疑似比较相关的文件：KUAL.kual，就是一个0字节的文件Run Hotfix.run_hotfix，内容是当前hotfix的版本号，我的是v2.3.2，就一行，没有空行结尾备份妥当document文件夹之后，我便退出帐号，让朋友登录回他的国区帐号。系统重启之后果然什么都没了，在kindle系统内找不到任何关于hotfix或者KUAL的痕迹。于是我将document文件夹还原，系统重新索引后，KUAL和hotfix的图标就重新出现了。越狱参考资料如前文所述原因，本文并不提供任何越狱的具体指导，但本节将列举越狱过程中我用到的参考资料。首先是本文的开端：It's Time to Jailbreak Your Kindle. by Dammit Jeff (YouTube)（WinterBreak）Your Kindle Can Finally Be Jailbroken Again. by Dammit Jeff (YouTube)（AdBreak）我一开始看了第一个视频，心里有点痒痒，但是没买。后来看了第二个，觉得如果想玩儿就得马上行动了。于是当天晚上就下单了kpw6。随后是一些更细节的资料：https://kindlemodding.org这个网站提供了step by step的教程，并且所有的文件都是从这里下的https://kindlemodshelf.me越狱成功后可以安装这个网站里提到的使用工具，比如scriptlet或者KOReader插件来丰富kindle的功能https://scriptlets.notmarek.com可能有些实用的scriptlethttps://discord.kindlemodding.org/Kindle modding社区的Discord服务器，为我尝试在kindle上运行java时提供关于glibc相关的情报我推荐的实用工具本节将介绍我安装在kindle上的一些实用工具。ScriptletScriptlet是hotfix中附带的一个功能，能够将位于document文件夹中的sh脚本展示在kindle的界面中，并且能够点击运行。https://raw.githubusercontent.com/neura-neura/Check-OTA-status/refs/heads/main/Check%20OTA%20status_current.shCheck OTA Status，运行后能检测并告诉你当前系统的OTA状态。在执行完禁用OTA更新的插件之后，我会使用这个脚本来确认是不是真的禁止了更新。https://scriptlets.notmarek.com/scriptlets/koreader_launcher_no_framework.sh使用无Framework模式启动KOReader。由于kindle内存和性能有限，因此我在启动KOReader时会选择杀掉系统自己的framework。实际上在阅读时我不怎么会离开KOReader，除了它没有密码保护之外，能够完美替代Kindle原生ui。https://scriptlets.notmarek.com/scriptlets/disable_ads.shDisable ADs。虽然我的签名版没有广告，但我朋友的普通kpw4有。我们在装好KOReader后发现它没法展示自定义屏保，后来我们发现他的kindle是有广告的。如果是外区则需要花费20美元去除广告，但我们都越狱了，干嘛还花那种冤枉钱。于是我直接运行这个scriptlet去掉了设备上的广告。重启KOReader后就可以让KOReader接管屏保了。KUAL扩展KUAL是Kindle Unified Application Launcher的缩写，它提供了一个统一的kindle应用启动器，用来启动我们自行安装的扩展。除了KOReader之外，我还额外安装了两个扩展：kterm这是一个终端模拟器，运行起来就有一个虚拟键盘，你就可以在终端中运行命令了。usbnetlite这是一个提供ssh访问的扩展。虽然它的ssh终端兼容性不佳，例如缺少环境变量，无法提供正确的terminfo，导致诸如top这类命令无法启动。但对于执行复杂命令来说，能够在电脑上远程敲命令，怎么不比kterm在e ink上的模拟屏幕强多了。KOReader插件KOReader最初的使命就是打造一个Kindle/Kobo Open Reader，这也是它名字的由来。它本身就内置了好多插件，并且还提供一个灵活的插件系统以及用户补丁系统（允许用户无需重新编译软件即可修改软件的行为），但本节将介绍我额外安装的插件：Telegram Highlights即便KOReader本身能将选中的文字生成二维码，但操作起来还是比较复杂。这个插件能够将选中的文字以及截图发送到你绑定的Telegram Bot上。不过这个插件并不是直接和telegram通信，而是需要经过一个中转服务器。插件将数据发送到这个中转服务器上，然后中转服务器再通过telegram服务器发给你。这就意味着你通过这个插件发送的一切都会被运营中转服务器的第三方知晓。不过这个中转服务器是开源的，你可以自己搭建。AssistantAI助理，我用的Gemini 2.5 flash，因为pro太贵了，用不起。这个插件本身内置了一些提示词，比如翻译、解释、词典之类的。你可以自定义提示词，但我现在也想不到什么独特的提示词。我认为这其中最有用的就是词典和解释了，你能够选定一些词来问AI这是什么意思，在词典模式的话，它还可以根据上下文来详细解读。非常好使，不过缺点是需要挂代理。后记以上便是我给Kindle越狱的经历了。很幸运地赶上了非常方便越狱的固件版本，但受制于硬件，虽然后来我成功地跑起来了Java 11（再新的版本就因为过时的glibc版本而无法运行了），但基本上也做不了太复杂的东西。就算是TUI，由于e ink屏幕只能显示黑白或灰度的像素，因此能使用的库也十分受限。这个Kindle在我手中已经两周有余，但和最初买它的目的不同，没能发挥它在读书之外的功效，但令我很意外地，它促使我每天开始读书了。最近刚刚读完狼与辛香料的第九本，太精彩了。读书真好。-全文完-