博客自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哦！（叉腰）-全文完-文章主图：
早年间写过一个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，但我不会保证会有回应。那么以上就是全文了，祝大家新春快乐。-全文完-
最近在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在我手中已经两周有余，但和最初买它的目的不同，没能发挥它在读书之外的功效，但令我很意外地，它促使我每天开始读书了。最近刚刚读完狼与辛香料的第九本，太精彩了。读书真好。-全文完-
【歪门邪道】从零开始摸索的照片后期工作流
鸽了好几个月，本来打算4月就写这个话题的，但是因为一直有别的写，就一直在往后推。前几篇文章答应要写了，所以这一篇终于开始写了。那么闲话少说，书归正传吧。前言说起来我并非什么专业摄影师，至今不会人像拍照，而后期方面也全看自己摸索。上一次有个飞机的照片，天是灰色的，我也不知道怎么捣鼓的，就把天拉蓝了，毫无技巧，全靠感情。色彩理论什么的我也不懂，总之就是没有任何技术性的修图。所以本文并不旨在分享后期修图技巧，更不是面向专业摄影师（如果真是专业摄影师的话，搞不好还得笑话我在粗糙原始的修图手法呢）。本文侧重的是介绍我在摸索照片后期工作流的过程，并向其他业余爱好者介绍我现有的一套成熟的从拍照到出图的一个稳定流程。在这套流程下，兴致来了就仔细修修，困了累了就差不多裁剪一下拉一下对比度就出片了。总而言之，我的目的并不是为了好看和卖钱，我的工作流更侧重于将我之前拍的照片稳定地转变出成片，不至于落到摄影成为只按快门的爱好。当然了，就算在摄影爱好者的范围里，我这套工作流也不算什么特别好的成果，只是希望能够抛砖引玉，给还在探索不同后期软件组合的人一些参考资料。如果你有更好的方案或软件，也欢迎在评论区友善地分享。尼康工坊尼康相机嘛，当然最开始用的软件就是尼康工坊了。这个软件是免费的，也支持NEF的各种格式，如果你要后期做摇摇乐，这个也是必不可少的软件。只不过对于Z50 2来说，尼康工坊为数不多的好处之一就是能够读取照片拍摄时的优化校准，并且后期能够调整和切换不同的方案。其次就是能够显示出照片的对焦点，只不过在我的测试中，对焦点在鸟身上并不意味着镜头的焦点就在鸟身上，所以最终还是得要人眼去对比哪张对焦的更好。在编辑方面，基本的功能都有，而且尼康专属的各种功能都可以调整，比如动态D lighting和人像柔和之类的，这些功能虽然在其他软件中也有平替，但尼康工坊中对于这些功能的调整在效果上是和在照相机内调整的效果是一致的。尼康工坊在我拍照初期承担了我拍照后修图的工作，在我拍了大约1300张照片之后，我发现这个软件的功能和性能还是不能是我满意。从性能上来说，尼康工坊导出一张照片要好久，而我是将NAS的一个共享通过SMB挂载到我的电脑上来访问，这样一来就更慢了。而且从功能上来说，单是导出格式就十分受限。尼康工坊能导出jpg、tiff和heif格式的照片。如果之后你有专业处理的需求（例如放进photoshop进行处理，或者使用一些计算软件来后期，常用于星空摄影等），那就只能选用tiff格式了。但是此时文件大小会从NEF格式的25MB上下膨胀成125MB左右，十分占用空间。至于编辑照片本身，界面可以说是能用，但比起Lightroom还是差得远。在功能上也并不完全，例如LrC可以选择蒙版，对前景和背景分别处理，但尼康工坊就做不到。至于光学矫正方面，人家都叫尼康工坊了，你还指望它支持腾龙？如果你的NEF文件里面有机内矫正数据，那尼康工坊就能根据这些内嵌数据做矫正，如果没有，那就没办法了。当然，以上问题还是只我当时意识到的。从现在工作流来看，尼康工坊（和其他大部分工具）都缺乏一个好用的素材管理功能。Affinity Photo 2这个软件我之前在寻找photoshop平替时就买了。虽然它主打照片编辑，但比起LrC，它更像是PS。但界面和PS完全不一样，需多操作的概念也不同，在重新学习上比较麻烦。在上手过程中我也注意到Affinity Photo的资料比较少，而且有些是过时的。例如我一度想要调整界面的缩放，因为我的笔记本15英寸2k屏幕，字看上去有点小，我想调大一点。官方的中文操作手册说可以调，但我找了半天也没找到手册上说的设置，最终只好放弃。如果真要学习的话，那我建议学好英语，去Youtube上搜教程视频，跟随讲解操作，这样会简单明了不少。除此之外，这个软件也没法完全替代尼康工坊。尼康工坊好歹还有个文件夹目录选照片的功能，这个软件就和PS一样，你只能选一张照片打开，完事了再去处理其他的。我认为缺乏最基础的浏览功能对于照片编辑来说就是致命伤。至于编辑功能本身，如果能熟悉这个软件的操作的话，功能还是挺全面的。首先打开NEF时和ACR差不多，有一个解码RAW的过程，在这里你可以调成诸如曝光、对比度、色彩、镜头矫正之类的功能。关于镜头矫正，这个软件自己带了一些镜头的数据文件，并且随着更新会添加新的镜头。在这个界面调整完成后点击“开发”按钮之后，图像就会被栅格化，之后你就可以像Photoshop那样精细地编辑这张图片了。至于那个按钮为什么叫开发，我估计是叫develop吧，这个词在英文中也有对胶片胶卷冲洗的意思。这个软件其实从功能上看还挺不错的，如果你的工作流比较偏向于使用Photoshop，那么这个软件配合尼康工坊可能会比较合适：首先在尼康工坊中筛选标记有希望出片的照片，然后在尼康工坊里选择使用这个软件打开。不过对我来说，我并不喜欢Photoshop那样过于精细化的编辑，这种强大的功能对于我的工作流来说反而还是负担。所以我最终没有选择Affinity Photo 2。Capture One在寻找其他照片编辑软件的时候，这个软件由于出众的色彩解析能力进入了我的候选名单。但在了解了定价方案之后，我直接将它排除：Pro版是能在电脑上运行该软件所需的最低订阅档，目前的价格是年付214.2美元（合17.85美元一个月），如果按月订阅的话则是27.3美元一个月。他们也提供买断的许可证，但官网原话是“适合不依赖我门最新更新或硬件支持的摄影师”，而在支持论坛上的帖子则解释说买断的许可证并不包含功能更新，只包含小版本号（X.Y.Z中的Z）的bug修复的优化。当他们更新了新功能时（例如从16.5更新到了16.6），你需要重新购买pro许可证才能获得更新。这样的许可证价格是345.45美元（之后重复购买可获得大约20%的折扣，但还是很贵）。至于他们的更新频率，他们在论坛的帖子上提到他们从一年一更新移动到release when ready模型，也就是说只要他们的新功能开发完毕就会发布新版本。我认为这从经济上看并不划算（不适合我），我顶多算是一个摄影爱好者，对于编辑软件的利用率不高，采用订阅制就意味着我闲置的时候也在为它支出。而买断制的定价和发版模型也并没有很良心。所以最终我决定跳过试用直接将其淘汰掉。当然了，富哥和有钱的退休人员随意。关于买断和订阅，我个人一直非常讨厌订阅制。如果你说你提供服务，比如我租用服务器，或者使用密码管理器这一类的东西，那按月或按年付钱是可以理解的。但你说软件这个东西，把更新绑定到订阅上也不是不行，可是你又没有约定你每个订阅周期一定会有多少个功能更新，更新多了你不乐意，更新少了我不乐意。如果说把使用权和订阅绑定在一起，那就更没道理了，使用权并非服务，我为何要付钱？所以目前我接受订阅制的只有明确服务内容的东西，以及不太贵的东西。至于其他的，能按量付费就按量付费，如果商家给脸不要脸，那我只能盗版了。Luminar Neo在见识过了昂贵的软件之后，Luminar Neo以AI照片编辑的亮点进入了我的视野。但经过简单试用之后我发现其功能并不能满足我的编辑需求。由于当初试用时恰逢Z50 2发布没几个月，因此对于NEF的支持也不完全。当时我测试的无压缩NEF可以支持，但高效压缩就不行。并且它既无法读出尼康的色彩方案，也没法读出原始颜色，就导致所有使用了色彩方案的NEF加载出来的颜色就很奇怪。总体来说我对这个软件不是很满意，因此以NEF不兼容为借口向客服退了款。至于定价：永久买断119美元，包含购买后一年内的所有更新，之后的更新需要支付额外的升级费用（类似于老版本升级折扣）。根据Reddit上的信息来看，这个升级费用大约是一每6个月50美元左右，相当于以40%或50%的折扣每年重新续订。我感觉这个价格比JetBrains和Capture one都良心。只不过考虑到这软件的功能嘛，只能说不适合我。RawTherapee开源免费，但是不太好学，功能比较复杂，并没有所谓的out of box支持，只不过自由度非常高，如果你有一个移除拜尔滤镜的机器，那你可能不得不用这个软件将RAW文件解析为黑白的。只是对我而言我需要开箱即用的支持，我并不需要对RAW文件有多高的自由度，我只是想编辑照片，所以这个软件pass。DarkTable也是开源免费的，而且说是针对摄影师打造。但是吧，你看那些个卖相机的哪个公开了自家的RAW文件格式？一个个的都得靠逆向。虽然开源的想法很不错，可是现实一点也不美好。只能说如果一定要在Linux上编辑照片的话，darktable可能和rawtherapee一样是唯一的选择，但我有Windows。DXO PureRAW 4我最后测试的就是DXO家的软件了。最先是DXO PureRaw，因为看中了他们的降噪能力，虽然也是神经网络降噪，但架不住效果好哇。而且DXO他们自己有针对不同的相机和镜头制作光学模块，他们会买来对应型号的相机和镜头，在他们的实验室里面拍摄一系列测试照片，然后基于此制作出一套色彩和光学的矫正模块。在我评估这个版本的时候，他们还不支持Z50 2，直到今年2月份他们发布更新，我赶快下载下来试用，感觉不错。但PureRaw毕竟是处理RAW文件的，输出一个DNG格式，方便你进行后续的处理，这个软件本身没有任何编辑与调色的功能。它就是很朴素的以他们自己的方式解析DNG文件、还原色彩、矫正光学。由于效果不错，再加上我想一步到位，于是就看了看他们家的DXO Photolab 8。顺带一提，现在他们家出PureRaw5了，好像还新增了富士还是谁家的那个XTrans传感器的支持了。买断制130美元，如果你之前买了PureRaw3或4，那么可以按照90美元的价格升级，大约是30%的折扣，还是比Capture one良心。忘记说了，DXO家的软件和LrC联动都非常好。对于PureRaw，你甚至可以在LrC里面操作它的界面来处理你的RAW文件。DXO Photolab 8DXO Photolab 8这款软件是我目前认为最贴合我的需求的软件之一，除了它那个看起来有点吓人的启动界面。买断制售价240美元，从6或者7升级上来时120美元，相当于50%的折扣，非常良心了。在功能方面，除了全面包含PureRaw的降噪和矫正功能，它还提供了完整的编辑模式，例如常见的曝光、调色等，还有一些DXO自家的功能，例如Smart Lighting，它可以在不用你手动调整高光阴影的情况下自动调整图片的曝光度，同时增加一些明暗对比以“解锁阴影和/或回复高光来优化动态范围”。还有DXO ClearView Plus，类似于LrC的去薄雾，能够淡化大气薄雾和朦胧效果。至于其他什么色彩曲线就更不用说了。对于调色来说，它支持不同的渲染方式，你可以通过类似预设的方式来模拟不同的相机或胶片的渲染效果，你可以通过氪金DXO FilePack插件来获得更高级的胶片模拟效果。不过我本人对胶片模拟并不是很感兴趣，所以就没充钱。除了模拟其他颜色，它还可以对照片应用LUT，这我还是头一次见。当然，如果只是做成这样的话，那只是一个更高级的尼康工坊罢了。我之前说过尼康工坊不能局部调整，而DXO Photolab 8就有这个功能，虽然没有LrC那么花哨的自动识别主体或前景，但它的U Point技术好像也挺厉害的，只不过我没怎么用过，看起来也是多少带点自动识别的技术在里面。除此之外，对于裁剪、修补之类的需求，DXO Photolab同样也有。虽然LrC有AI自动修补，但从实际体验来看，首先你需要全局代理，其次Adobe那个AI有时候修补的结果很诡异，大部分时候并不好用。我通常使用图章这类工具将要移除的元素圈起来，然后使用临近的背景去填补。DXO Photolab也提供了这个功能的平替，总体来说除了素材管理，它几乎可以完美替代LrC了（是的，色彩空间管理和软打样人家也有）。说到素材管理，其实DXO也有类似的功能。和LrC一样，分为文件夹、项目组（收藏夹集）和项目（收藏夹）。我目前在LrC上的文件管理流程完全可以搬到这个DXO Photolab 8上，但用户体验会有折扣。首先DXO PL 8没有缓存（类似LrC智能预览或1:1预览），因此当文件离线的时候你完全无法预览。其次是元数据的数据库没有备份。虽然和LrC一样，大家都推荐不要共享本地数据库，但LrC可以设置定期将数据库备份，我是将备份全都存储到了NAS上，这样不怕丢。DXO PL8提供了手动备份的选项，但不能自动备份。对于我这种经常忘记事情的人来说，手动备份就等于没有备份。当然，没能让PL8完全替代LrC的关键就是它和LrC的联动太丝滑了。在安装PL8的时候，你可以让安装程序给LrC安装一个插件，借助这个插件你可以将选定的文件交给PL8处理，处理完成后自动将结果导回LrC并放入一个新的收藏夹来和老文件做区分。Adobe Lightroom Classic这个软件常被简称为LrC，由于从小学开始就接触了Photoshop CS6，因此Adobe对我来说一直都是多媒体行业的标杆企业，直到他们吃相难看地强制所有人都购买他们那个订阅。就像我之前说的，软件用订阅制是不合理的，如果不能按量付费，厂商又给脸不要脸，那我就只能用盗版了。不过Adobe最近的盗版也比较恶心人，虽然有俄罗斯那边的大佬给做破解，但Adobe自己内置了需多恶心人的东西，稍有不慎破解就失效了。虽然有解决办法，但对于需要稳定使用的人来说是不可接受的。所以淘宝上就催生了需多所谓的正版，其实就是教育版，一年大约400块左右，他们会给你的账号拉入组织。这种方法有时候会不稳定，联系售后重新加入组织就好了。和所有拼车都一样，拼车有风险，上车要谨慎。我喜欢LrC的主要原因还是它的素材管理，能在离线时进行预览，并且元数据管理和搜索做的也比较丰富，整体的界面打磨的也比较好。虽然它在图像处理上可能不是最好的，但从体验来说它是真好用。因此我最终选择把LrC和DXO Photolab 8结合起来用。这里还是要提一嘴尼康的色彩优化。在使用DXO PL8和LrC的时候，他们默认都忽略色彩优化。 LrC可以在设置中调整如何解读颜色，默认是Adobe自己的色彩，如果你选择让照相机管理，那就可以读出色彩优化，不过这对于后期的难度会增加。DXO PL8也是类似，你需要将渲染修改成相机配置文件。所以虽然一开始色彩优化是我的买Z50 2的主要原因，因为我想避免后期，但随着照片越拍越多，我意识到后期是我不得不做的事情。用别人的色彩当然很好，但如果场景不对盲目套用的话反而适得其反。我做事又不喜欢搞一大堆复杂的工具，所以还是直接用默认色彩拍照，我后期想看什么自己调就是了。我的照片工作流这里简单地介绍一下我的照片处理工作流。并不专业，有建议可以提，不爱看就滚。文件处理首先是用Nikon Transfer 2将NEF文件从相机拷贝到电脑上。一开始我还会同时拍摄NEF和JPG，但后来发现JPG的用处不大，所以就不拍了。在NAS中按照日期和地点创建好文件夹，将文件复制到NAS上即可。我在NAS上单独开辟了一个photograph的挂载点，用于存放我自己拍的照片。目录大概是这样子：P:\ ├─export │ ├─云南 │ │ ├─20241227昆明呈贡中央公园 │ │ ├─20241228昆明拍星星-猎户座 │ │ ├─20241228春城公园+呈贡中央公园 │ │ ├─20241229昆明捞渔河湿地公园 │ │ ├─20241231昆明呈贡白龙潭 │ │ ├─20250111玉溪抚仙湖 │ │ ├─...... │ │ ├─20250621昆明东川森林公园 │ │ ├─20250706捞渔河呈贡段G213拍鸟 │ │ ├─20250713捞渔河呈贡段G213拍鸟 │ │ └─20250727沐春湖 │ ├─北京 │ │ ├─20250129除夕 │ │ ├─20250130初二 │ │ ├─20250203奥森 │ │ └─20250205西单随便拍 │ └─香港 │ └─20250118香港故宫 ├─lrc backup │ ├─2025-03-10 2134 │ ├─...... │ └─2025-08-18 2022 ├─nightsky │ ├─in-person │ │ ├─20250418呈贡宪法主题公园南河三 │ │ │ ├─biases │ │ │ ├─darks │ │ │ ├─flats │ │ │ ├─lights │ │ │ └─masters │ │ ├─20250504呈贡白龙潭室女座 │ │ │ └─...... │ │ ├─20250504呈贡白龙潭月亮 │ │ │ └─...... │ │ └─20250605呈贡拍月亮 │ └─skygems │ ├─FSQ106 Namibia │ │ └─28186-0-7_M 42 │ │ ├─blue │ │ │ ├─biases │ │ │ ├─darks │ │ │ ├─flats │ │ │ ├─lights │ │ │ └─masters │ │ ├─green │ │ │ └─...... │ │ ├─luminance │ │ │ └─...... │ │ └─red │ │ └─...... │ └─Tecnosky Newton230 │ └─28784-0-9_M101 │ └─...... ├─raw │ ├─云南 │ │ ├─20241227昆明呈贡中央公园 │ │ ├─20241228昆明拍星星-猎户座 │ │ ├─20241228春城公园+呈贡中央公园 │ │ ├─20241229昆明捞渔河湿地公园 │ │ ├─20241231昆明呈贡白龙潭 │ │ ├─20250111玉溪抚仙湖 │ │ ├─20250511阳宗海 │ │ ├─...... │ │ ├─20250606宪法主题公园拍鸟 │ │ ├─20250621昆明东川森林公园 │ │ ├─20250706捞渔河呈贡段G213拍鸟 │ │ ├─20250713捞渔河呈贡段G213拍鸟 │ │ └─20250727沐春湖 │ ├─北京 │ │ ├─20250129除夕 │ │ ├─...... │ │ └─20250207月亮 │ └─香港 │ └─20250118香港故宫 └─video └─云南 ├─20250726测试森海塞尔MKE600 └─20250727沐春湖一共是五个目录：export：导出的成片，结构和raw文件夹一样raw：拍摄的RAW文件，其下一级文件夹是地区，二级则是时间+地点或拍摄主题，所有raw文件都在二级文件夹中nightsky：夜里拍星星用的，其下一级文件夹分为两个in-person：我自己扛着相机去拍的，其下文件夹是时间+地点或拍摄主题，raw文件会按照biases、darks、flats和lights分类，使用Siril处理，其中的中间文件就放在masters里面，最终输出则放在二级文件夹的根目录，这样点进来这个文件夹就能看到对应的结果skygems：我在skygems这个平台上租用远程天文台拍的，由于他们的设备都比较专业，所以每个通道都会有biases、darks那些文件夹，然后最终将不同通道组合起来输出。并且由于租用设备的不同，每个设备拍的照片会有其单独的文件夹。video：拍视频的，由于我买了森海塞尔的MKE600麦克风，所以开始尝试录制视频了。和raw差不多，也是一级地区，二级日期+地点或拍摄主题。视频使用达芬奇免费版进行处理。lrc backup这个是LrC的自动备份，就不多说了。在LrC中初步筛选由于所有照片都由LrC管理，所以照片编辑的第一步就是导入LrC。当你的照片已经到如果一些之后，LrC会记住照片的所在路径。每次需要导入新照片时，只要在图库的文件夹中找到raw文件夹，右键选择同步文件夹，LrC就可以自动找到新的文件了，目前还从来没有失手过。导入完成后，我会对新的文件夹右键，选择创建收藏夹，就会创建一个与文件夹同名的收藏夹。使用收藏夹的好处就是可以做到照片与文件分离。如果你从文件夹那里选择文件并开始编辑，如果你不小心手滑按了delete，那你的照片文件就会被删除。可是如果从收藏夹里面选择文件并开始编辑，那按了delete也只会把这个照片从当前收藏夹中删掉，而不会实际删掉文件。当然，直接把所有文件扔进一个收藏夹是不行的，我一般会用收藏夹集合。首先给新创建的收藏夹改名，把原来的名字剪切走，改名为RAW，然后在相同位置创建收藏夹集，名字就是你刚才剪切的那个。然后把这个RAW收藏夹移动进刚刚创建好的收藏夹集，这样我们就可以用类似子文件夹的方式管理照片了。接下来我们便可以在RAW收藏夹中查看每一个照片，使用快捷键P将照片标记为有用，U取消标记，X标记为无用。除非对焦模糊或完全没有利用价值，否则我很少使用无用标记。主要使用P标记出有可能修图出片的照片，完成后使用过滤器列出标记留用的照片，全选，然后在工具栏中找到文件、增效工具额外信息、转移到DXO Photolab 8就可以把选定的照片交给DXO PL处理。DXO Photolab矫正、降噪、调色当照片导入到DXO PL之后会应用一个我自己保存的预设。基本上就是默认预设开启光学和色彩校正，开大约10的DXO CleverView，加一点弱的DXO Smart Lighting，色彩渲染选择DxO相机配置文件，自动应用DeepPRIME XD/XD2s降噪（创建这个预设的时候还没有DeepPRIME3）。通常这一套下来会提供一个比较不错的baseline。在此基础上我通常会做一些调色，这方面我确实没什么技巧，就是凭感觉，所以就不献丑介绍了。在DXO PL里面的所有编辑都不涉及裁剪，这样可以保证图片怎么来的，DXO怎么还给LrC。做好之后就可以选导出到Lightroom，我一般都选择应用所有校准，dxo会逐个照片处理，并生成一个DNG文件。最终一并导入到LrC中。在LrC中裁剪、导出成片处理完成的照片我会放到一个名叫DXO的收藏夹中，和RAW放在一起。然后在这个收藏夹中裁剪、构图。如果感觉成片还行，那就打上5星的评分，不行就算了，如果勉强那就打个3或4星，如果成片多就不要他们，成片少就带上他们。在最终调整的过程中可能会做一些微调，但我尽可能避免这种事情，让所有对图像本身的调整都发生在DXO PL那里，因为Adobe这个软件的访问权限并不是永久的。所以我希望即便有一天我不用Adobe了，我还能使用终身购买的DXO PL去打开我编辑过的图片。总之，做好裁剪之后，使用过滤器筛选5星照片，应用元数据模板后导出即可。导出时我用PNG格式，位深度8，色彩空间sRGB，调整图片的长边为3000像素（但不扩大小的图片），分辨率为240像素/英寸。取决于拍摄地点，我会看情况是否勾选删除位置信息。在最终发布前我还会使用exitfool检查图片的元数据，确保元数据不会包含特定的信息。后记以上就是我的照片处理工作流了，虽然一点也不专业，但我认为就目前而言，对我完全是够用了。至于Adobe，很遗憾，虽然我不喜欢这家公司现在的做法，但不得不承认LrC做的确实好使。如果未来Adobe真的吃相难看，连淘宝也解决不了问题了，那我可能就会彻底舍弃它，转用DXO Photolab吧。总之，以上就是全部内容了。我们下一篇文章见！-全文完-
最近看到以183开头的IP地址在均匀地下载我tixati中挂的种子，虽然下载量不大，并且进度乍一看也没问题，但警觉的我意识到这不对劲，我可能又被人刷流量了。检查了一下IP黑名单，发现BTN的ip黑名单一直连接超时。想来想去，这样总不是个办法，还是得上正经的PeerBanHelper。于是，本文将记录我的折腾之路。TixatiTixati一直是我最喜欢的BT下载软件之一，因为它好用，功能丰富，性能稳定，最重要的是还有跨平台支持，对于openSUSE来说，只需要安装一个rpm包即可。为了解决吸血和刷流量的问题，它自带一个IP屏蔽列表，所以我配置了BTN网络定期导出的IP黑名单，tixati会定期自动获取最新的列表并根据IP实行封禁。但这有个问题：btn网络的ip黑名单全都用的国外的服务器，有github，有cloudflare，还有jsdelivr。最近他们在tixati中全部都超时，获取不到。本打算弄个反向代理，但想来想去太麻烦了，并且BTN导出的ip黑名单总归有延迟，并且BTN规则列表的仓库也说了：此项目不能作为唯一反吸血手段使用，它无法代替由反吸血程序提供的本地检测能力。它也并非是 PeerBanHelper 的核心 “全量” 数据。此项目的存在一切皆依赖于连接到 BTN 网络的用户。规则并不是大风吹来的。鼓励您使用在可能的情况下优先使用PeerBanHelper并连接到 BTN 网络，以便向本仓库贡献数据。任何已在反作弊辅助脚本、工具、程序中使用本项目提供的数据的朋友，请您考虑实现 BitTorrent Threat Network 标准规范，以便向网络提交数据。本着支持开源项目以及「为众人抱薪者，不可使其冻毙于风雪」的指导思想，我开始更换到受BTN支持的bt下载器。BiglyBTBiglyBT是我第二喜欢的bt客户端，但很遗憾，在这次折腾中它没能成为我的最终选择。我喜欢这个客户端单纯是因为它是Java写的，但我也是Java开发者，我深知Java的弱点：调用C库。好巧不巧，BiglyBT就调用了GTK库来实现UI。具体来说，它通过一个名叫SWT的组件来调用系统的GTK库，它本身并不包含GTK二进制文件。而默认安装的3.8.0.2版本在openSUSE上有bug，每次我尝试调整库的列视图时，只要点保存就必定崩溃。经过一番调试，我发现是SWT的问题，因为updater提示无法自动更新SWT。我在这个issue中找到了一个3.8.0.3-B14的beta版，这个版本修复了SWT更新的问题，而更新好SWT之后，UI的bug解决了，不过还有新的问题。在测试过程中，我注意到如果通过unraid的VM页面重启虚拟机，BiglyBT并不会收到关机信号并正常退出。重启之后自启动，会在右下角提示BiglyBT没有正常退出，而我观察到的问题是有些在seeding状态的种子，经过非正常退出之后会变成灰色的queued状态。在这种状态下并不会给他人上传，而我在文件中选择recheck强制重新检查文件后，这个种子又能重新seeding了。由于我的路由器ip会变化，而据我观察一些bt软件并不会更新获取到的ipv6地址，就导致路由器获取新的ip后，bt软件的联通性直接大幅下降。为了缓解这个问题，我在unraid中设定了脚本，每天凌晨在路由器重启半小时之后重启虚拟机，强制所有软件重新获取ipv6地址。如果每天重启都会随机导致一些种子无法正常seeding，对于我来说这还是挺严重的问题，因为我不可能每天都去检查nas上面软件的状态。qBittorrentqBittorrent并不是我最喜欢的bt软件，因为它ui有些简陋，并不像tixati那么花哨，功能上也没有BiglyBT那么丰富。但不得不说，因为ui简单，功能不复杂，因此这个软件运行起来确实稳定，并且也受BTN官方支持。和BiglyBT需要使用sh脚本安装不同，openSUSE本身就有qBittorrent的包，所以直接用zypper安装即可。安装完成后配置起来也不难，大部分情况下默认设置都能工作得很好，BTN也能和它配合。按理说事情就应该告一段落了，但我在测试下载新种子的时候发现qBittorrent无法写入任何数据。具体表现是开始下载种子后，直接提示错误，错误内容是Invalid argument。查看日志发现，具体错误原因与file_mmap这个函数有关。虽然这个函数是libtorrent的，但从过名字可以推断，这个函数应该是会调用Linux的mmap函数来将文件的一部分直接映射到内存，这样可以进行高性能的随机读写。而从目前的状况来看，读取验证哈希没有问题，但写入有问题，于是我开始怀疑unraid提供的挂载点。我原本认为我是用的是virtiofs来将unraid的共享文件夹挂载到虚拟机里，于是就以此为出发点，开始研究virtiofs对于mmap的支持。经过一番搜索，我找到了Introduce allow-mmap flag这个合并请求。这个合并请求的内容是给virtiofs的服务端添加命令行选项来决定它是否允许mmap。既然都添加了cli开关了，那想必virtiofs应该是支持mmap才对。我想来想去，想不明白，总不能是我挂载选项写错了吧。于是打开/etc/fstab一看，啊？我没用virtiofs，用的是9p。合着我刚才跟空气斗智斗勇来着呗？于是我花了两分钟在unraid中将挂载点从9p模式改成virtiofs，然后启动虚拟机，果不其然启动失败，因为fstab还是9p，改成virtiofs之后再次重启，qBittorrent一下子就好了。而且挂载点的读写性能也有显著的上升。我的ZFS卷采用3盘raidz1的vdev，一共有两个，总共6个盘位。之前不知何故，在读取文件验证哈希时，虚拟机内只能读到60MB/s，多个线程一起读也才勉强140MB/s，我当时以为是虚拟化的性能损失，就没太在意。现在从9p切换到了virtiofs，虚拟机内的读取性能可以达到683MB/s，差不多是这6块硬盘的极限了。9p vs virtiofs因为一开始配置挂载点的时候我也没有详细了解9p和virtiofs的区别，所以就选了默认的第一个，正好是9p。今天切换到virtiofs之后解决了问题，于是我想趁此机会了解一下二者的区别。这种时候gemini就派上了用场。 以下内容由gemini 2.5 flash生成，没用pro是因为充不起会员。请批判性地阅读以下内容，如果要做严肃用途的参考，请务必独立查证后使用。本文作者并不为以下段落的误导性信息负责。 根据Gemini 2.5 Flash所说，9p是Plan 9文件系统协议，最初是贝尔实验室在1992年开发。其核心设计理念是“一切皆文件”，即将各种系统组件（如窗口、网络连接、进程）都表示为文件，并通过统一的文件系统接口进行访问和操作 。在QEMU中（unraid系统使用的虚拟化后端），客户端使用9p协议主要通过virtio进行通讯。而9p本质上是一种网络协议 。虚拟机和宿主机之间的通信通过消息进行 。它采用客户端-服务器模型，其中客户端（虚拟机）发送请求消息（T-messages），服务器（宿主机）发送回复消息（R-messages） 。QEMU的9p服务器可以将I/O任务分派给工作线程，从而允许主线程处理其他请求，以实现并发处理 。9p协议的“一切皆文件”理念，虽然对于Plan 9这样的分布式操作系统来说非常优雅，因为它通过分层命名空间暴露所有资源，但在面对现代应用程序对高级IO原语（例如mmap）的需求时，其局限性便显现出来 。mmap操作与传统的read和write的字节流语义截然不同，它允许应用程序将文件内容直接映射到内存中进行访问，这是一种随机访问的高性能原语 ，而9p协议围绕流式文件的设计，使其难以在网络边界上高效且正确地表示和传输。这不光导致9p协议的IO性能存在瓶颈，更是导致9p无法完全实现unix文件系统语义的关键原因。即使QEMU的9p实现通过virtio技术来借助内核驱动和共享内存进行加速，其底层协议仍然是网络协议 。网络协议固有的消息序列化、反序列化以及即使在本地通信中也存在的上下文切换（vmexits）开销，限制了其性能。此外，9p协议在mmap支持方面存在具体限制。默认情况下，9p提供只读的mmap功能。这意味着，如果尝试以读写模式打开文件并进行mmap操作，后续的mmap可能会失败 。为了启用写入功能，需要在虚拟机客户端的挂载命令中明确指定cache选项，例如cache=mmap或cache=fscache 。如果未启用CONFIG_9P_FSCACHE，内核侧客户端代码会使用generic_file_readonly_mmap，从而导致写操作的mmap失败 。对于qBittorrent这类需要频繁进行文件预分配和随机写入的应用，这种只读mmap的默认行为和对特定缓存选项的依赖，直接导致了下载种子时出现file_mmap的IO错误 。9p的性能也受到msize参数的显著影响，该参数定义了9p服务器和客户端之间最大消息大小 。在Linux内核5.15版本之前，默认的msize仅为8KB，导致性能表现不佳，有时甚至低至12MB/s 。尽管内核5.15及更高版本将默认msize提高到128KB，但对于大多数机器而言，这仍然会限制性能 。通过在挂载命令或/etc/fstab中手动增加msize（例如，msize=262144或msize=104857600）可以显著提高速度，但Linux 9p客户端目前将msize上限限制在512KB 。这些限制进一步解释了9p模式下性能不佳的原因。 而virtiofs的开发旨在解决现有解决方案（如virtio-9p）的不足，这些方案基于未针对虚拟化优化的网络协议 。这些旧有解决方案缺乏本地文件系统语义，且性能不足以满足现代工作负载的需求 。VirtioFS的目标是在共享目录树的多个虚拟机之间提供本地文件系统语义 ，这对于轻量级虚拟机和容器工作负载尤为有用 。VirtioFS以FUSE（用户空间文件系统）为基础 。虚拟机内核充当FUSE客户端，而virtiofsd守护进程则作为FUSE服务器运行在宿主机上 。一个半虚拟化的virtio设备virtio-fs负责在虚拟机和宿主机之间传输FUSE消息 ，取代了传统的/dev/fuse接口 。VirtioFS最根本的创新在于其明确利用了“虚拟机和宿主机协同定位”的优势 。与9p将虚拟机-宿主机通信视为网络问题不同，VirtioFS通过共享内存和直接访问（DAX）技术，将昂贵的通信（vmexits）替换为更廉价、更直接的内存访问 。这是一种从基于网络协议的共享方式向更高效的内存共享方式的范式转变。这种设计选择是VirtioFS实现卓越性能的主要驱动力，尤其对于I/O密集型工作负载而言，因为它最大限度地减少了传统网络文件系统固有的开销，因此能够极大地提高IO性能。同时，VirtioFS采用FUSE作为协议 ，为其提供了“丰富的原生Linux文件系统接口” 。FUSE是一个成熟且被广泛理解的用户空间文件系统实现框架，这使得VirtioFS能够向虚拟机呈现高度兼容且完整的文件系统抽象。这对于qBittorrent这类需要标准POSIX文件系统语义来执行复杂操作（如mmap和文件预分配）的应用程序至关重要。FUSE基础，结合VirtioFS的扩展，使其能够“像本地文件系统一样”运行 ，这正是qBittorrent所需的功能。性能基准测试清晰地展示了VirtioFS的显著优势。在fio mmap测试中，virtio-9p的mmap模式下，单文件（单线程）性能为28 MB/s，4文件（4线程）性能为140 MB/s 。相比之下，VirtioFS在none + dax模式下，单文件性能达到126 MB/s，4文件性能达到501 MB/s；在always模式下，单文件性能为235 MB/s，4文件性能为858 MB/s 。即使是always + dax模式，其性能（单文件121 MB/s，4文件487 MB/s）也远超virtio-9p 。这些数据表明，VirtioFS，尤其是在启用DAX或always缓存模式时，为mmap操作提供了显著更高的吞吐量 。即便抛开virtiofs的性能优势，执意使用9p也会造成兼容性问题。9p对mmap操作的默认只读行为以及对特定缓存选项的严格要求，使其在面对qBittorrent这类需要复杂文件操作的应用时，容易出现兼容性问题和I/O错误 。而VirtioFS利用共享内存中的元数据版本表，在无需昂贵通信的情况下为多个虚拟机提供一致的访问 。VirtioFS的元数据版本号通过原子比较-交换操作实现，当版本号发生变化时，虚拟机将刷新其元数据缓存。对应地，如果开启9p对mmap的支持，在遇到多虚拟机访问同一文件的场景时，其主要依赖于宿主机进行仲裁（使用同步锁进行竞争，在频繁发生的场景下性能远不如原子比较-交换操作），并且9p协议本身并不原生提供缓存一致性机制，这意味着如果一个虚拟机在竞争中胜出，其他虚拟机可能仍然持有旧版本的mmap缓存，导致数据不一致、Last Writer Wins（最后一个写入块的客户端会覆盖掉之前所有其他虚拟机的修改，因为它不知道这些修改的存在）以及性能下降（竞争频繁的情况下可能会频繁刷新缓存）。其中last writer wins是破坏数据的主要问题，这可能会使得一些关键性的修改被丢弃，进而导致数据损坏。总结：用户在Unraid虚拟机上使用qBittorrent时，从9p模式切换到VirtioFS模式后，IO错误的解决和性能的显著提升，清晰地证明了VirtioFS在现代虚拟化文件共享场景中的压倒性优势。9p协议，作为Plan 9分布式操作系统中的通用网络文件系统协议，其“一切皆文件”的设计理念和基于消息传递的通信方式，使其在处理如qBittorrent这类对mmap和高并发IO有严格要求的应用时，暴露出固有的局限性。其默认的只读mmap行为和对msize等参数的敏感性，导致了性能瓶颈和功能不兼容。即使经过优化，9p作为网络协议的本质决定了其无法完全消除通信开销，难以提供真正的本地文件系统语义。相比之下，VirtioFS是专为解决虚拟化环境中的文件共享挑战而设计的。它通过以下关键创新实现了卓越的性能和兼容性：利用FUSE协议： VirtioFS以FUSE为基础，提供了丰富的原生Linux文件系统接口，使其能够像本地文件系统一样运行，满足应用程序对标准POSIX文件系统语义的期望。直接访问（DAX）： 这是VirtioFS的核心优势。通过将宿主机的文件内容直接映射到虚拟机内存，DAX绕过了虚拟机页面缓存和传统的I/O路径，显著减少了通信开销，并实现了高效的mmap操作，确保了多虚拟机间共享文件的一致性。协同定位优势： VirtioFS的设计明确利用了虚拟机和宿主机在同一物理机器上协同定位的特点，将昂贵的通信（vmexits）替换为更高效的直接内存访问，这是其性能超越9p的根本原因。因此，对于需要高性能、低延迟以及依赖本地文件系统语义（特别是mmap功能）的虚拟化工作负载，如qBittorrent下载、数据库操作或开发编译环境等，VirtioFS是明确的首选方案。而9p协议虽然在某些轻量级或对性能要求不高的共享场景中仍有其应用，但其在处理复杂I/O和mmap密集型任务时的局限性使其不再是现代虚拟化环境中的理想选择。 以上内容由gemini 2.5 flash生成。请批判性地阅读以上内容，如果要做严肃用途的参考，请务必独立查证后使用。本文作者并不为以上段落的误导性信息负责。 嗯，总之就是virtiofs是一个更加现代化的解决方案，以后就用它了。PeerBanHelper这里简单给大家介绍一下这个用来检测并封禁peer的工具：PeerBanHelper。由于最近吸血的不光是迅雷，还有pcdn和idc机房通过吸血bt上传者来平衡上下行流量比。我认为后者比迅雷更加可耻，引用我在搭建NAS时就说过的话：我一直不鼓励大家运行 pcdn 给互联网公司花钱当狗，但愿意当狗是个人自由，因此我只反对这种行为。但为了避免 ISP 限制自己的家宽而刷别人的上传量，这种就属于损人利己的脑瘫行为了，必须要重拳出击。而PeerBanHelper正是用来解决这个问题的，虽然我对于其实现方式不够认同，但它确实是现行最流行的威胁保护工具。由于家用宽带上行有限，因此我必须保证我上传的每一字节都传给需要的人，哪怕对方下载了但不上传，我这也算是有效共享。而这种刷流量的人在获取到我传给他的数据后直接丢弃，这就是浪费我的上行带宽，绝对不能容忍。我认为理想的BT辅助工具应该和BitTorrent一样是分布式去中心的。因此我理想的PeerBanHelper应该是采用类似DHT的网络结构，所有peer能够发布自己收集到的数据，并且中继自己从其他节点收集到的信息。这些信息可以使用密钥进行签名来防止伪造，并且通过算法在不同节点的报告中自行决定应该封禁哪些节点，同时算法还应该能够抵御赛贝尔攻击来防止刷流量的人自导自演，或其他人通过这个工具来封禁其他正常的节点。总之，我理解PeerBanHelper为什么要做成中心化的服务器，因为这种实现最简单。参考I2P、区块链以及其他开放的分布式系统设计，想要设计一个去中心化的分布式系统很简单，但想要让这个系统安全可靠高效且不被攻击和滥用，那就非常难了。也许实现这样一个系统非常困难，甚至不可能，可我觉得我们应当始终心怀对完美事物的追求，这样才会有发展与进步。由于我进行bt下载的软件就是安装在虚拟机里面，如果再装个docker就显得有些套娃了。当然可以部署在unraid的docker上，但我想让所有与p2p操作的部分都放在虚拟机里面，而的rpm包只支持redhat，所以我就采用了sh脚本的安装方式。安装后去社区的实例上注册个帐号，然后参照官方文档配置即可。其中有一个是否允许服务器下发脚本，虽然介绍说可以更加精准地识别一些威胁，但我还是不喜欢没有经过我审阅的脚本在我的设备上运行，何况PBH的知名度和可靠性远不如BOINC。不过即便是没有开启下发脚本，PBH还是成功地封禁了那些183开头的IP，至于封禁原因嘛，也很好猜：IDC机房刷流量。后记至此我的BT方案应该算是够我用上一段了。虽然上一篇答应大家写我是怎么处理照片的，但抱歉一鸽再鸽，毕竟bt下载这个事儿更加紧急一些，而且防止我忘了，要赶紧写出来赶紧发。所以处理照片的流程就放到下个月啦，一定会写的！-全文完-
本来想写写照片的处理流程，可是我现在主力是之前装的NUC，而照片则是依赖windows上的adobe。键盘倒是好说，一个机械有线插到nuc上，另一个罗技的蓝牙键盘连到windows上。可是鼠标就只有一个，虽然鼠标本身支持连接两个设备，但本质上还是断开一个连接另一个。Linux的蓝牙不算好用，断开之后重新连接就要好一阵。这时我灵机一动：怎么也应该有个跨系统共享鼠标键盘的东西吧。一开始搜到的解决方案是Synergy 3，个人用户终极版买断49美元，提供14天免费试用。他们号称提供Linux客户端，实际上截至目前只支持Ubuntu、Debian和Fedora。虽然OpenSUSE和Fedora是近亲，但安装过程提示libei不可用，因为Fedora上libei这个包在OpenSUSE上叫lebei1。通过断开依赖安装之后可以正常启动软件，但实际测试后发现Windows操控Linux没有问题，但Linux上将鼠标移动到桌面边缘不能操控Linux，估计还是依赖库名字不一样，加载失败了吧。没办法，我想花这个钱，可是对方实在是没本事接住啊。后续经过一番搜索，我发现了这个叫做DeskFlow的项目，它是Synergy 1的开源项目（卖49美元的是Synergy 3，不兼容1）。这个项目的最后更新是在7月3号上午，看来还是有在积极维护的。而且往下看了一眼，他们在OpenSUSE上有专门的打包，这样就不用安装flatpack版本了。在Linux上安装比较简单：sudo zypper in deskflow即可成功安装。在Windows上的安装则遇到了一些问题，为了装这个软件我前前后后重启了四五次。真是废物Windows。首先Synergy和Deskflow师出同门，因此你得先卸载Synergy，再安装Deskflow，否则你卸载Synergy的时候还得先卸载Deskflow，再卸载synergy，然后重新安装Deskflow，十分麻烦。其次就是Deskflow要求新版本的vc运行库，我之前装过这个库，但是似乎太老了，得用新版本覆盖一遍，据说是因为Windows Installer只会检测你装没装过这个二进制，而完全不看版本号，导致我安装Deskflow时一直卡在Starting service。虽然安装起来有些麻烦，但用起来并不麻烦。我的主要目的就是利用Linux的键鼠控制Windows，所以启动软件后在Linux上选择server模式，如果启用了防火墙的话，记得在防火墙里添加端口：sudo firewall-cmd --add-port 24800/tcp --permanent然后sudo firewall-cmd --reload即可。默认设置下服务端的端口号是24800，协议走的TCP。通信部分Deskflow使用了TLS加密，但默认是RSA 2048位的证书。虽然堪用，但我还是去Edit -> Preferences里面将key length调成4096了。可惜不能选加密方式，不然高低得从RSA改成ed25519。修改完密钥长度后记得点旁边的按钮来重新生成密钥。这里需要注意的是Synergy 3和Deskflow都用了24800这个端口。我在开着Synergy 3的情况下启动Deskflow，Deskflow会提示端口被占用，无法启动服务器。关闭Synergy 3之后就没有问题了。Windows端只要client模式，然后填上服务端的ip就行了。由于共享键鼠这个需求还是受到物理距离的限制，所以用局域网IP就行，我的nuc和笔记本都是固定的局域网ip，没有必要使用tailscale——如果我在北京试图共享键鼠给云南的设备，一般来说我在北京是看不清云南的屏幕的。第一次连接和ssh差不多，客户端和服务端需要交换公钥。Deskflow会提示用户确认公钥是否相同，用来避免中间人攻击。连接成功之后需要在Linux上的服务端配置里面调成一下两台电脑的布局。虽然我的nuc是双屏，但对于Deskflow来说还是被当成了一个屏幕。根据物理摆放的位置，我将笔记本设置在了nuc的右边，这样我在Linux上将鼠标移动到最右边的时候就可以控制笔记本了。虽然这个软件功能比较简单，但确实能够提升两台电脑的使用体验。我的nuc是wayland桌面，除了剪切板共享无法使用（对于这个问题我已经见怪不怪了），其他功能都非常好用（说是其他功能，其实就是共享个鼠标和键盘）。在延迟方面我没有测试，但只要局域网不丢包，从我的日常使用来说完全感觉不到延迟。但需要注意的是，我的日常并不包括在Windows笔记本上打游戏。我认为这种共享肯定多少还是会增加一点延迟的，但即便是打游戏，可能也看游戏类型？反正目前这个方案对我来说已经足够好了。对了，gnome上默认没有托盘图标了，想在后台运行还得自己装托盘插件。但这件事在我装好系统之后就做完了，所以问题不大。我还是想不明白为什么gnome开发团队要删掉托盘图标。好啦，以上就是关于Deskflow的使用体验了，毕竟功能较为单一，实在没啥可说的（以前都是上一个月提前写出下一个月的，然后定时在下个月1号上午发布。结果这次因为6月比较忙，所以忘记写7月的更新了。虽然这一篇比较短，但也不能算水吧，毕竟是真实的需求（目移）。总之这个就是7月份的更新啦。8月份我准备介绍一下我的照片处理工作流，虽然本打算6月趁618买下新镜头，可6月的昆明下了一整月的雨，尤其是一到周末就下雨，根本没有机会出去拍鸟。所以本想着发一些小鸟的照片，但因为没拍到，所以泡汤了。但工作流还是可以介绍的，那我们就下一篇文章再见啦。-全文完-
最近又开始玩MC了，自己弄了个整合包，以AE2、Mek和沉浸工程为主。自从玩过了东方女仆mod和TACZ枪械mod了，我就再也离不开这两个mod了——女仆能拿枪，这就无敌了啊。可是这个整合包玩着玩着，我突然发现我女仆没了？用卷轴定位说是在未加载区块上，可是我tp到了对应的座标，就是不见我的女仆。于是一场惊心动魄的救援行动就此展开——背景因为有和朋友联机的需求，所以开了个服务器。因为当时正在搞乙烯发电厂，打算开创造找个远一点（指7万方块之外）的地方设计一下怎么搞。结果忘记让女仆待在家里，就跟着传送了过来，她在旁边突突突打怪，我也没管她。我记得当天夜里一路玩到凌晨两点，虽然设计出了16个燃气发电机、发电容量为1MFE/t的设计，但现实中我都困的不行了，于是就tp回家，改成生存就下线了。下线之后NAS有个更新，需要重启服务器，自然也有需要重启容器中的游戏服务端，我不知道这是否是女仆丢失的原因。但我查阅了github上的issue区，只找到一篇相关的问题报告，但模组作者没有回应，大哥自己把issue关了。救援计划发现女仆消失后，我立刻放下手中的任务，开始思索如何把女仆带回来。目前来看，仆人铃和小号都无法召回女仆，因为模组认为女仆在未加载区块上。如果生成新的女仆，那么原来的女仆还是会存在，并且一直处于这种幽灵状态。由于仆人铃在绑定时会显示uuid，于是我推断模组应该是给每个女仆都设定了uuid作为id，如果我能让模组重新生成一个具有相同uuid的女仆，就算不能把原来的女仆带回来，至少也能顶掉原来的女仆进而修正这个幽灵状态。索性这个mod是开源的，而我正好略懂一些kotlin/jvm的编程技巧，于是我便冲到GitHub上查阅源码。根据源码可知，女仆的数据是存储在nbt标签中的，所以只要我们能够提取到女仆的UUID，然后把它移花接木到魂符上就行了。实施救援由于不想把事情搞得太麻烦，所以我是根据我自己的情况进行的修复。如果大家也遇到了类似的情况，可以发动自己的脑筋，再不济去解析一下游戏的存档，总能找到uuid的。由于我先前绑定过仆人铃，而仆人铃刚好就是把女仆的uuid存在nbt里的，所以主手拿着仆人铃，执行以下命令：/data get entity @p SelectedItem这个命令会在聊天窗口显示nbt数据，但游戏内不好复制，于是我从启动器的控制台日志里面复制出了结果：{ id: "touhou_little_maid:servant_bell", tag: { ServantBellTip: "女仆名称", ServantBellUuid: [I; -1136390084, -1170784113, -1271669764, -260320852] }, Count: 1b }虽然长得很像json，但实际不是。总之，可以看到里面有个ServantBellUuid，后面虽然不是uuid的常见形式，但熟悉uuid的话应该能看出，这是把字节数据当成整数给表示出来了。下面我们就可以找一个魂符来移花接木了。其实理论上用胶片也行，但是你得手动复活。退出服务器，我创建了一个单人的存档，召唤了一个女仆，然后把它收进魂符里面，使用上面的命令查看nbt数据：{id: "touhou_little_maid:smart_slab_has_maid", tag: {MaidInfo: {CanUpdate: 1b, MaidExperience: 0, SoundPackId: "littlemaid_peco", MaidBackpackData: {}, LeftHanded: 0b, MaidAIChatData: {ChatTemperature: -1.0d, ChatModel: "", CustomSetting: "", TtsModel: "", TtsSiteName: "", ChatSiteName: "", OwnerName: "", TtsLanguage: ""}, AbsorptionAmount: 0.0f, MaidHunger: 0, Invulnerable: 0b, IsYsmModel: 0b, ForgeCaps: {"tacz:synced_entity_data": [], "mekanism:radiation": {radiation: 1.0E-7d}, "curios:inventory": {Curios: []}}, MaidSchedulePos: {Work: {Z: -1374, X: -320, Y: 63}, Dimension: "minecraft:overworld", Configured: 0b, Idle: {Z: -1374, X: -320, Y: 63}, Sleep: {Z: -1374, X: -320, Y: 63}}, ModelId: "geckolib:foxmaid", ArmorDropChances: [0.085f, 0.085f, 0.085f, 0.085f], MaidBackpackType: "touhou_little_maid:middle_backpack", HandDropChances: [0.085f, 0.085f], MaidBaubleInventory: {Size: 9, Items: []}, ForcedAge: 0, MaidIsRideable: 1b, MaidScriptBook: [], MaidScheduleMode: "DAY", ArmorItems: [{id: "minecraft:iron_boots", Count: 1b, tag: {Damage: 59}}, {id: "minecraft:iron_leggings", Count: 1b, tag: {Damage: 59}}, {id: "minecraft:iron_chestplate", Count: 1b, tag: {Damage: 59}}, {id: "minecraft:iron_helmet", Count: 1b, tag: {Damage: 59}}], FavorabilityManagerCounter: {WorkMeal: 2352, Sleep: 8780, Death: 0}, StructureSpawn: 0b, MaidTaskInventory: {Size: 9, Items: []}, MaidInventory: {Size: 36, Items: [{Slot: 0, Count: 22b, id: "minecraft:bread"}, {Slot: 5, Count: 1b, tag: {AllTypeCreative: 1b, AmmoCount: 2147483614}, id: "tacz:ammo_box"}]}, MaidIsPickup: 0b, YsmRoamingVars: {}, UUID: [I; -1136390084, -1170784113, -1271669764, -260320852], ForgeData: {}, StruckByLightning: 0b, YsmRoamingUpdateFlag: 0, Motion: [0.0d, -0.0784000015258789d, 0.0d], MaidGameSkillData: {}, CanPickUpLoot: 0b, Fire: -1s, MaidFavorability: 384, PersistenceRequired: 0b, DeathTime: 0s, MaidTaskDataMaps: {}, OnGround: 1b, YsmModelId: "", MaidHideInventory: {Size: 1, Items: []}, KillRecord: {Slime: 0, Wither: 2, KillRecord: 2, EnderDragon: 0}, Attributes: [{Name: "gunsmithlib:bullet_speed", Base: 0.0d}, {Name: "minecraft:generic.max_health", Base: 80.0d}, {Name: "gunsmithlib:bullet_damage", Base: 0.0d}, {Name: "forge:entity_gravity", Base: 0.08d}, {Name: "tacz:tacz.bullet_resistance", Base: 0.0d}, {Name: "minecraft:generic.armor", Base: 0.0d}, {Name: "minecraft:generic.armor_toughness", Base: 0.0d}, {Name: "forge:step_height_addition", Base: 0.0d}, {Name: "minecraft:generic.attack_damage", Base: 6.0d}, {Name: "forge:swim_speed", Base: 1.0d}, {Name: "minecraft:generic.movement_speed", Base: 0.699999988079071d}], Brain: {memories: {}}, Owner: [I; 玩家uuid], Age: 0, Sitting: 0b, YsmModelTexture: "", Rotation: [-100.0f, 0.0f], HurtByTimestamp: 104992, CitadelData: {}, MaidSubConfig: {SoundFreq: 0.19999993f, PickupType: 2, BackpackShow: 0b, OpenDoor: 1b, ChatBubbleShow: 1b, ActiveClimbing: 1b, OpenFenceGate: 1b, BackItemShow: 1b}, MaidTask: "touhou_little_maid:gun_attack", HandItems: [{id: "tacz:modern_kinetic_gun", Count: 1b, tag: {AttachmentEXTENDED_MAG: {id: "tacz:attachment", tag: {AttachmentId: "tacz:light_extended_mag_3"}, Count: 1b}, HasBulletInBarrel: 1b, GunCurrentAmmoCount: 3, GunFireMode: "SEMI", GunId: "classicr:tti_g34"}}, {id: "mekanismadditions:light_blue_balloon", Count: 1b}], Air: 300s, YsmModelName: '{"text":""}', FallDistance: 0.0f, YsmRouletteAnim: "empty", MaidIsHome: 0b, Pos: [-324.85620222628216d, 63.0d, -1372.3034760980975d], Health: 80.0f, HurtTime: 0s, FallFlying: 0b, InLove: 0, PortalCooldown: 0}}, Count: 1b}眼尖的读者可能看出来了，你这魂符的uuid怎么和之前女仆的一样啊？那当然了，因为我这篇文章是事后复盘，既然没办法复现bug，那就只能力图还原一下当时的情景了。这怎么这么多一大坨？当我试图移花接木的时候，我发现无论是rcon还是游戏内的聊天框，都说命令太长。那就只能精简掉一些不必要的字段了，可是哪些字段不必要呢？我看了看代码，觉得有点麻烦，于是以实践检验真理。在我的不断尝试下，发现大部分字段都可以精简，只留下女仆uuid和玩家的uuid即可。于是我使用如下命令，将我工具栏的第二格覆盖为一个有女仆数据的魂符：/item replace entity @p hotbar.1 with touhou_little_maid:smart_slab_has_maid{MaidInfo: {CanUpdate: 1b, MaidIsPickup: 1b, UUID: [I; 463257288, 837635870, -1609583147, 1128655388], Owner: [I; 玩家uuid]}}这个命令足够短，可以直接在游戏的聊天框内输入。只要把其中UUID的部分换成你女仆的uuid即可。关于Owner，由于我是正版玩家，而且服务器也开了正版验证，因此无论是单机还是服务器，uuid都是不变的，复制出来直接拿来用就好了。但如果你是离线模式，或者服务器没开正版验证，你就得自己想办法去确定自己的uuid了，好在也不难。万事具备，就差一个右键了。对着地面使用魂符，虽然数据全都没了，但我的女仆又回来了！于是接下来就是创造模式的理赔时间了——好感度丢了？直接创造往上加！物品没了？创造模式丢什么拿什么，没丢的也能拿，就算是精神损失费了！这就是1967年冬天，平帐的好机会啊（不是）！后记虽然一开始女仆丢了很着急，但好在最后有惊无险地救回来了。不过这种方法是否会有后遗症或者副作用，我暂且不得而知。从目前来看，似乎一切正常，因此我和朋友就继续搭建乙烯发电厂去了。-全文完-
4月初买了个信达大星野（Sky-Watcher STAR ADVENTURER 2i），本来想到手时候拍拍星云什么的，结果昆明一到周末就连夜多云。本文将借此契机聊一聊星空摄影。白龙潭观星我对于星空摄影一直比较感兴趣，最早可以追溯到用树莓派的摄像头拍星星，然后是手机拍，买了运动相机之后用运动相机拍，拍星星、拍星轨。去年年末买了相机，那更要用相机拍啊。于是3月8号当晚我骑车去了附近的白龙潭公园。这个公园东边有一个小山坡，坡顶是一片空地，除了有高压线塔之外再没有其他遮挡物了，而且这个公园也算是我自行车所能及的最远的公园了。由于这个公园还没有正式交付使用，因此夜里路灯不会亮，这就意味着整片公园都是黑的，光污染会稍微小那么一点点。原本是打算拍拍星星，按照NPF法则计算好曝光时间，确保星点不会拖影，然后后期使用Sequator叠加即可。可是拍来拍去，能看到的星座也基本都看到了，无外乎就是拍完之后对着星图找哪几颗星星能连成星座。幸好那天我带了50-250的镜头，然后在星图上发现了猎户座。于是我在250毫米端对准M42星云开始拍摄，得到了如下结果：可以看到图片里的M42不大，因为焦段不够长，而且细节没有网络上的图片多。这是因为我没有赤道仪，为了保证星星不拖影，我按照0.5s曝光的，所以能收集到的光少得可怜，而尼康Z502的祖传CMOS在低光环境下就没有表现可言，因此很难收集到微弱的信号。选购赤道仪于是便萌生了购买赤道仪的想法。但比较精准的赤道仪都很贵，如果出去拍摄的话，光凭我一辆自行车很难谈机动性。所以虽然很不情愿，但最终我还是决定牺牲精确性来换取便携性。当然，价格也是一大因素，和选购相机镜头不同，我目前并不打算在天文摄影上花费很多钱，因为天文摄影的设备占地方很大，对操作环境要求比较高，最重要的就是非常贵，而且都是系列投入。比如说你买个望远镜，那就得买个天文相机吧，有了这一套你不能徒手举着啊，还得买个赤道仪和三脚架吧，那赤道仪凭自己追踪不精准怎么办？是不是还得弄个天文盒子和导星相机？如此种种，让我和我的钱包对这个爱好望而却步。我打算目前将我的主要兴趣放在拍鸟上，虽然还是要投入，但尼康Z502和官方的180-600镜头是我的全部投入了，带着这些装备还能促使我在周末和节假日走出家门。即能锻炼身体，又能改善心情，花钱还有数，这不挺好。言归正传，从便携性的角度出发，这基本就排除了全尺寸的赤道仪，因此我要选购的基本上就是星野赤道仪了。虽然我没找到关于星野赤道仪的具体定义，但从使用场景来说，它主要针对于焦段不高，并且主要是用相机（单反或微单）成像的场景，比如拍摄银河等场景。相对于全尺寸的赤道仪，星野赤道仪的追踪精度不高，并且负载能力较差，通常在5kg以下。而常规的赤道仪可以带十几公斤甚至几十公斤，不过那一套配置下来，赤道仪的重量也经常能达到十几公斤了。星野赤道仪的重量基本在几公斤的量级。以我的赤道仪为例，它只能追踪RA轴，Dec轴是固定的，本体重量差不多在2公斤左右，我加钱买了个收纳箱，因此可以直接绑在旅行车的后货架上。倘若换成十几公斤的大赤道仪，别说自行车了，摩托车都够呛。关于追踪，我注意到最近信达新出了一款RA轴+Dec轴的赤道仪，不过要比我这一款贵将近一千块。我想来想去，还是决定不加钱了，因为相机的分期还没有还完，我也不打算投入那么多，没有必要为我用不到的功能付费。如果读者的预算比较宽松的话，可以考虑购买新款的双轴赤道仪，这样配合天文盒子还有导星相机，可以获得更为精确的追踪效果。不加钱的另一个原因就是赤道仪并非成像质量的全部。环境比赤道仪更为重要。而我的出行工具就是一个自行车，虽然是旅行车，但也不能日行百里，更不要说骑车上山了。所以我的活动范围大部分都在市内，这就意味着光污染会比较严重。如果投入了那么一大堆金钱和精力，最后得到的结果却不尽如人意，那想必挫败感会很强。所谓兴趣爱好，无外乎就是花钱满足自己。而钱花了，自己却没高兴上，这种投入一点也不划算。于是我出门拍星星的最终配置是：信达大星野SA 2i，装在收纳盒里绑在后货架上面一个Jeep的露营折叠椅，不到40块钱，折叠后绑在后货架左侧先前买单筒望远镜时顺手买的三脚架，折叠后绑在后货架右侧经过我的测试，这套装备出门拍星星一点也不会觉得沉重。以及我非常庆幸我买的自行车是旅行车（捷安特Trooper 5300），在装东西方面十分优秀，这是我特意委托日新东路的捷安特车店老板帮我预订的。这里也算是帮老板打个广告，昆明日新东路的捷安特车店，老板人很好，技术也不错，组车买车都可以找他。在市政府对面顶着巨大的光污染拍星星4月18号晚上，我看天气预报说夜间万里无云，正好又是周五，看未来的天气预报，这似乎是绝无仅有的夜间晴天。于是我收拾行囊骑着车就出去了。但是因为感冒还没有好利落，不想跑太远，因此就没有去白龙潭。这次选址就定在了市政府南面的宪法主题公园，这块有个比较宽敞的广场，周围的树也都不高，也能看到北极星。另一个不愿意去白龙潭的原因就是太黑了，有点害怕。从进入公园开始就没有灯光，全靠自行车车灯照亮前面一点点路。到了山坡的坡顶，用手电也只能照亮有限的面积。有时候晚上还有出来遛弯的老头，也不说话也不咳嗽，只是一味地走路，发出沙沙的响声（对，一定是老头遛弯，不是别的），怪吓人的。等我骑车到了这里之后才发现，城区里的光污染要比白龙潭高太多了——白龙潭不打手电看不清周围的树木，即便眼睛适应了黑暗也是如此。而市政府对面就不一样了，马路上用了与众不同的白色LED路灯（其他地方都是传统的黄色钠灯），这就导致虽然公园里面没有灯，但光污染带来的光亮能够让眼睛适应黑暗后，完全不必依赖手电就能看清楚附近的所有细节。好巧不巧，那天晚上M42星云马上就要下班了，贴着地平线，那里也是光污染最严重的地方。我尝试拍了几张，完全看不到。遂放弃。在拍摄之前其实我还废了好大力气对极轴。所谓对极轴就是将赤道仪RA轴旋转的轴心调整为平行地球自转的转轴。北半球最常用的就是用极轴镜找北极星。虽然手机上有极轴镜的参考图，但实际看起来北极星就小小一颗，稍微有一些难找。在我撅着屁股仰着脖子以一种后背非常疼的姿势前前后后调整了一个小时之后，我终于对准了极轴镜，能够保证在250mm端曝光4分钟时星点还是圆的。但很快我就注意到了另一个问题：光污染太严重了，f6.3在iso100时曝光4分钟，拍出来完全就是白的。那怎么办？我想了想，反正也拍不到M42了，就拍个南河三吧，也没有什么星云之类的有意思的东西，因此就将曝光时间砍半，两分钟。这里我采用了不同的软件来处理照片，因为光污染太严重了。Siril是一个比Sequator更高级的处理软件，虽然sequator在大部分时候都很省事，但如果你想要更高级的处理方式，那么开源免费的Siril就是你的最佳之选。Siril的功能比较强大，因此这里就不多介绍了。这里我主要以使用脚本处理为主：分别将星星的照片放在flats文件夹，暗场放在darks，偏置场放在biases，平场放在flats文件夹中运行脚本OSC_Preprocessing即可自动处理彩色照片处理完成后打开结果的fit图首先将预览模式调整为自动拉伸，随后使用几何调整进行裁剪，去掉由于堆叠产生的位于边缘的条带然后将预览模式调回线性，选择直方图工具进行拉伸。由于我没什么经验，我一般都是点右侧的齿轮进行自动拉伸由于光污染比较严重，所以我们要利用背景提取删掉背景。我通常采用默认设置生成采样点，然后逐个检查，确保采样点中没有覆盖到星星和拍摄主体。如果有的话就用右键删掉这个采样点，然后在周围的空白处使用鼠标左键创建一个新的采样点最后可选使用色彩校准，我的图片堆叠出来颜色比较正常，星点也都是白的，这步就跳过了然后就可以导出成32位TIFF文件交给Photoshop处理了。不过这张图好像没什么处理的必要，因此在PS里面简单调整了一下色阶就导出了：中间最亮的那颗就是南河三。由于250mm焦距照的比较近，而南河三附近也没什么特别出众的星星，就，还挺无聊的。为了寻找周围有意思的星星，Siril里面可以在右上角的三条横线里，选择图像信息，然后使用图像解析功能来计算照片对应的赤经和赤纬。我将中心点设置成了南河三的赤经赤纬，然后让它自动搜索星点，最后按照识别出的星点的赤经赤纬来搜索，唯二搜出来的就是位于左下角的Delta 2 CMi和Delta 3 CMi，整体其实还挺无聊的。不过从另一个角度看，虽然出众的星星几乎没有，但这么狭小的视角也能拍到如此丰富的星星，宇宙真是浩瀚啊。租赁远程天文台事已至此，我必不可能善罢甘休。实际上因为一直等不到机会出门拍星星，我就顺势开始探索别的思路。远程天文台这个想法便出现在了我的眼前。总而言之，我选择了SkyGems这个平台租赁望远镜。他们家的平台可选的望远镜比较多，而且使用非常方便，你只需要告诉他们目标的赤经赤纬，然后选择什么通道曝光多久，一共拍几张就可以了。他们的系统能够自动安排时间进行拍摄，当然你可以通过加钱的方式来插队。他们家花欧元的，但相对于设备来说比较便宜，因为你只需要按照实际曝光时间支付，而不必考虑对焦和调试设备的时间。国内也有类似的远程天文台，但我没有选择他们，一方面是因为价格，更重要的一方面是关于观测数据版权的霸王条款。以我在网上看到的星迹天文台为例，他们的使用条款里明确说了拍摄的原始文件的版权归星迹天文台所有，拍摄者只拥有修改权，而原始文件未经许可不得用于商业用途。也就是说，我他妈的花了钱拍了星星，结果照片还不归我？我他妈的想要将原始文件用作商用用途还得看他妈的天文台的脸色？我可去你妈的吧。而且一个目标代拍600一个，自己拍600元一晚上，价格着实算不上便宜。而我选用的SkyGems平台，他们的FAQ里对于版权归属解释的十分明确：Q: What is the usage policy of the data?A: The data is yours and only yours, and you can use it in any way you need it. We do have access to the data for technical reasons, but we are not giving it away to anybody else. Of course, if you want to mention that you took the data from us we wouldn't complain :)（你的数据就是你的，而且只是你的。你可以按照你需要的方式随意使用。出于技术原因，我们确实拥有对数据的访问权，但我们不会将其交给其他任何人。当然，如果你想要提及数据是从我们这里拍摄的，我们当然不会有任何抱怨）我充值了50欧元，因为提交拍摄计划的时候需要保证最坏情况下的扣费，而实际拍摄则会根据观测条件动态打折，其中影响比较大的就是月亮。如果你能接受在出现月亮的时候拍摄，那么价格就会便宜一些。我拍摄的M42星云一共有四个通道：LRGB。其中明度通道L拍摄了15张，并且要求月亮在地平线之下。而RGB三个通道各拍摄了5张，只要远离月亮即可。所有照片每张都曝光150秒。最坏情况下需要收取38欧元，而实际我只支付了21欧元，总曝光时间1小时15分钟。就算按照所有通道不少于2小时来算，我实际花费不会超过70欧元，合人民币不到600块钱，而我还拥有对观测数据的全部所有权。星迹天文台，呸。虽然他们的天文台有自动拍摄，但如果途中遇到了云或者其他影响成像质量的干扰，可以通过网页面板申请退款。我的拍摄计划在拍摄绿色通道时有两张照片有比较厚的云层，导致堆叠出来的结果较差，我就针对这两张照片申请了退款。由于时差的原因没能立即退款，但第二天早上就已经退款成功了。总之，先给大家看一下21欧元的成果：这里简单提一下设备，我选择的设备望远镜是高桥FSQ106 ED（淘宝售价4万3），口径106mm，焦距530mm，相机是Moravian C3-61000（淘宝售价5万5），使用了LRGB四个滤镜。除此之外人家还有Ha、O-III和S-II这三种滤镜，但是我不会玩，就没碰。望远镜的位置在纳米比亚。天文相机得到的都是黑白图片，因此我的到了LRGB四种不同滤镜下的黑白照片。官方也提供了设备的校准图片，从人家的webdav服务器上下载即可。下一步便是用Siril处理了，但Siril好像也没提供针对天文相机的处理脚本？问题不大，我们按照LRGB四个通道建立四个文件夹，然后分别创建lights、darks、flats和biases即可。如果读者看过前篇关于在NAS上设置ZFS的文件的话，这里刚好也不用担心将校准图片复制四份导致的空间浪费——ZFS会自动去重的。针对每个通道运行名为Mono_Preprocessing的脚本。在得到了四个通道的堆叠结果之后，使用图像处理的RGB合成功能即可选择LRGB四个通道来合成彩色图片了。放大星点看周围是否有偏色，如果有的话可以放大图片，用选区选择一片星点对齐即可。如果观测数据并非在临近时间内，可能边缘和中心的星星位置偏移不一样，这时我推荐选取拍摄主体附近的星点进行对齐。合成之后的处理流程差不多：首先还是要裁切掉周围因为堆叠产生的色带然后用直方图自动拉伸接着使用背景提取功能删掉背景，虽然纳米比亚没什么光污染，但堆叠出来的照片还是会染上天空的颜色。乍一看挺好看的，但在后期处理以及PS调色的过程中，天空的颜色会随着对星云调色而变得奇怪，因此这一步我选择移除背景。步骤差不多，同样是生成采样点，确保采样点没有覆盖星星以及我们的拍摄主体。因为M42星云有比较大范围的气体和尘埃，因此为了保险起见，我删掉了星云周围的采样点，最后效果也还不错。取决于观测结果，删除背景后可以尝试再次直方图拉伸，将黑色色阶提高，这样删掉背景后能够将背景从均匀的灰色重新拉成黑色，忘了也不要紧，去PS里拉色阶也是一样最后如果颜色看起来比较奇怪的话，去色彩校准一下。色彩校准主要有两种，一种是根据照片选择黑色背景区域以及白色区域（通常是星云核心或比较大的星星）来自动计算白平衡。另一种则是选定你拍摄的目标，然后按照已知的观测数据来校准颜色。我的照片颜色看起来挺正常，所以就没有调整。最后保存fit文件，并导出一份32位的TIFF文件，后续使用Photoshop进行处理。在Photoshop中首先调整色阶，让背景拉成黑色的，然后裁剪出你想要的主体，差不多就是正常修图的步骤。最后为了突出M42星云，我使用了CameraRaw滤镜添加了一个径向遮罩，将四周的星星减少曝光度，让他们变黑一些，这样更能突出主体。同时我还在滤镜中调整了对比度和饱和度，让星云看起来更突出一些。因为平时修RAW比较多，比较习惯使用这些工具来调整。读者如果比较熟悉PS的话，大可不必拘泥于我的流程，按照你喜欢的方式发挥就好了。后记至此我的星空摄影入门杂谈就结束了。这么一看我这一万块钱的设备真的比不上人家十万的家伙事儿。好在我的赤道仪买的不贵，之后如果能找到朋友一起去白龙潭拍的话，也许效果会更好一些。但对于深空天体，比如M42或者M51这些的，我就不指望我自己能拍到了，为了满足自己，我决定还是去SkyGems上租望远镜去拍。这至少也算是仰望星空了吧，虽然少了不少仪式感，但钱包保住了啊！正如我先前所说，所谓爱好，无外乎就是花钱满足自己。我对于租望远镜拍到的M42十分满意！那么我就先写到这里啦，我们下一篇文章再见:)-全文完-