一篇文章已经写好,摆在手机屏幕上,有段落,有配图。你按住屏幕下方那根条说一句:「把第 3 行改口语一点。」
要让这句话生效,系统必须保证一件事:你嘴里那个「第 3 行」,和模型脑子里那个「第 3 行」,是同一行。
这句话听起来像废话。做起来是这一整章。
先说这件事有多不寻常
2026 年 9 月那次竞品调研,25 个产品挨个查「能不能改稿」:
- 所有会议纪要工具(Otter、飞书妙记、通义听悟、腾讯会议、Granola)都提供「对纪要提问」,没有一个提供「改纪要」。你能问「刚才谁负责这件事」,不能说「第二段太长了,砍一半」。
- AudioPen 能 rework,但那是个按钮,而且整篇重来。
- Aqua Voice 是唯一带「语音改写」概念的产品,但它改的是你刚说出来、还漂在输入流里的那句话(「删掉上一句」)。它是一支更聪明的笔,管的是正在流出来的墨水。
对一篇已完成、有段落有配图、已经落盘的文章下语音指令做定点编辑——一家都没查到。这是整张表上最干净的一块空白。
翻译成人话:别人做的是「说话时能改口」,这里做的是「文章写完了,对着它说话就能改」。差一个字,差一整套工程。
它抄不走,因为它不是一句提示词:你得把文章结构化到「行」和「图」的粒度,把一句自然语言解析成一处定点修改,还要有版本回滚接住改坏的情况。这是纵深,不是点子。
闭环长什么样
按住说话,声音进 ASR(自动语音识别)变成一句中文,通过 WebSocket(建立后一直开着的双向通道,不像普通请求问一句答一句就挂断)打到 /agent/edit。接住它的是一个 Durable Object(Cloudflare 上的一种小服务:有身份、有持久存储,同一篇文章的请求都路由到同一个实例,所以它能记住事情)。它读出文章、读出你的个人文风、调模型重写、写回存储,再把整篇推回 App 就地刷新。这一次改稿花多少算力是第 8 章的事,这里只当它是一道闸门。
写回时字段是「继承加覆盖」而非「按白名单重建」,所以像 wechatMediaId 这种模型压根不知道的字段能活下来——它记着这篇对应公众号后台哪条草稿。于是你语音改完再发公众号,是原地更新那条草稿,不是又发一篇新的。
说话可以连着说,改稿必须排队
人说话是连续的:说完「第 3 行改口语一点」,两秒后又想起「第 5 行那个词也换掉」。麦克风若被「正在重写」锁住,体验就断了。但模型编辑必须有因果顺序——第二条若基于旧正文算出来,会改错地方,甚至覆盖掉第一条的成果。
于是把两者拆开:麦克风永远不锁,指令严格排队。连说三句都收下、都堆成气泡,但真正跑起来的时候严格串行。最初这个串行是客户端做的——上一条的 updated 回来了才发下一条;后来队列的真源搬到了服务端(下一节),客户端变成有话就发,由服务端按序号一条一条地跑。不管由谁来排,不变量始终是同一个:每次修改都基于上一次的结果,不是上一次的请求。嘴上可以抢话,手上必须排队。
队最初排在手机里,而手机里的队有个致命问题:App 被系统杀掉,队就没了——你说了三句,走进电梯,出来只改了一句,还不知道另两句蒸发了。后来真源搬到服务端:DO 里一张 SQLite 持久队列表加一个自驱的排空循环,稳定 id 做幂等(同一件事做一次和做十次结果一样,重连重发认得出是同一条),重连时对快照两边对账,而 App 只落盘 pending、绝不清队列——清空的权力在服务端。断网、切后台、进程被杀都不丢。客户端瘦,真源在服务端。
锚点协议:本章的核心
一、给用户一个可以指的东西
屏幕上没有「第 3 行」这个东西,用户就没法说「第 3 行」。所以有了定位浮标:一按住说话,正文左边空白里淡入一列小小的「第 N 行」,每张图左上角淡入一个「图 N」,松手淡出。
这里有个不起眼但很硬的约束:文字一个像素都不许动。浮标绝对定位在左边距(.overlay(alignment:.topLeading) 加 offset),不占布局空间,正文不重排。要是行号一冒出来正文就挪一下,这功能从第一秒起就是错的——你想指的那一行会在你按下去的瞬间跑掉。
二、编号必须连续,图片也占一行
最初的「第 N 行」只数文字段落,图片被跳过——逻辑上说得通,图片又不是文字。结果是:文章前面有两张图,图后面那段屏幕上显示「第 5 行」,正文里的真实位置是第 7 行。用户说「改第 5 行」,模型老老实实去改第 5 行——改到了别的地方。
改法是把编号变成一个连续的计数器:段落算一行,图片标记也算一行。于是一张图同时显示两个东西——左边距上的「第 N 行」(在正文里排第几)和角上的「图 M」(第几张图)。顺带一提,正文里那行看不见的注释也曾把编号顶歪一格,那是第 4 章的故事:一个隐形注释怎么让行号差 1。
三、模型读行号,永远不数行号
这是全章最重要的一句话。
前两步做完,服务端发给模型的还是整篇正文,指望它自己数到第 3 行。这条路能跑通,但脆:文章一长就容易数错,带图的更容易——模型得自己判断哪个是段落、空行算不算、那个 [[photo:xxx]] 标记该不该占一行。每个判断都可能和 iOS 那边不一样,错一格就足够改错地方。
所以 2026 年 6 月 28 日改成:不让模型数,把数好的结果递给它。服务端多了个文件 agent/src/linenum.js,用和 iOS 完全相同的算法给正文编号,生成一张「行号对照表」注进发给模型的消息里,形如 第3行:那天下午……、第4行 = 图2:[[photo:…]]。提示词里明写:按这张表定位,不要自己数,输出时也不要带行号。
翻译成人话:别让模型做算术,算术让代码做。它擅长「理解这句话想干什么」,不擅长「这是第几行」。把它不擅长的那部分从它手里拿走。
由此产生的一条契约
代价必须写在墙上:linenum.js 和 iOS 里的 bodyRows() / segments 是同一套算法的两份实现,必须成对修改。改一边不改另一边,模型的编号就和用户看到的漂移,而且漂移是无声的——不报错,只改错行。所以 linenum.js 有自己的单元测试。
连带一条:iOS 新版和 Worker 新版必须一起上线。老 App 的行号只数文字,新 Worker 的对照表含图片,用户看到「第 5 行」,模型收到的表里第 5 行是另一段。这不是崩溃,是安静地改错地方,比崩溃难查得多。
多篇文章同理:一条录音常挖出好几篇,App 每切一篇都从「第 1 行」重新编号,所以每条指令带 articleIndex,一路传进持久队列的 article_index 列,Worker 拿它决定给哪一篇编号。老版本不带,默认第 0 篇。
v2:不光传行号,还传那行原文
当你用长按菜单选中目标时,App 知道你精确指的是什么,那就别让这个信息退化成一句自然语言。它把目标结构化地传上去:图片是 {type:"image", key},段落是 {type:"line", line, text}——注意 text,整行原文。
为什么带原文?因为行号会漂移,文本内容不会。你长按第 7 行时,队列里可能还压着一条没执行完的指令,它一执行第 7 行就成了第 8 行。服务端拿整行原文回正文里做唯一匹配,能把行号自己修正回来;匹配不上就丢弃这个锚点——宁可退回「模型自己理解」,也不要拿一个错锚点去精确地改错地方。副作用是提示词反而变简单:不必再用占位符往里塞坐标,回归纯自然语言,坐标走另一条管子。
长按菜单:新入口,不新开管道
长按一张已出图的配图,弹出「图片风格」:卡通、广告、水彩、素描、油画、胶片。长按一个段落,弹出「改写这段」:更简洁、更口语、更书面、扩写一点,还有「插入图片 → 公众号题图」。
关键在于这些点选没有新开一条路:点一下等于把一条配置好的指令塞进同一个语音编辑通道——同一个 enqueue、同一个持久队列、同一个「正在改」指示器、同一套占位图。一个入口修好的 bug,另一个入口自动也好了。视觉上倒是放弃了系统自带的 contextMenu:它改不了颜色和圆角,做不出设计稿要的暖纸调子,于是改成自绘覆盖层。菜单发出的指令还带 itemId,服务端出图时把这条提示词的「魔法数字」写进图片的 XMP 元数据(文件里一块给机器读、肉眼看不见、跟着文件走的说明区)——每张 AI 配图都能追溯是哪条提示词生成的。
不该经过模型的,就别经过模型
长按菜单里还有「编辑」,调出键盘就地改字。这条路完全不经过大模型,复用通用的文章写入接口 PUT /files/api/articles/<stem>,白拿版本记录和撤销重做。但省事只是顺带的,真正的理由是:把「改掉这个错别字」包成自然语言喂给 AI,AI 顺手把别处也润色一遍的概率不是零——你只想动三个字,回来一看整段语气变了。用户手敲进去的字就该原样落盘,中间别站着一个有创作欲的东西。写回时另有一条纪律:只 merge articles 这一个 key,免得冲掉模型没建模的字段。
两个事故
「说话和没说一样」
语音听写从 Apple 端上识别换成火山流式 ASR,中文更准。凭证不能放 App 里,所以服务端做哑中继:客户端持全部协议、服务端持密钥,收到帧后剥掉客户端自己的 Authorization 头、换上火山凭证转出去。App 永远接触不到第三方凭证。
上线之后,完全不工作。说话,没反应。
根因在一行转发代码里。Cloudflare Workers 把二进制帧交给你时是个 Blob 对象,而 send(Blob) 会把它隐式 toString 成字符串 "[object Blob]"——13 个字节。每一帧音频,原来多少 KB 都一样,到达服务商的都是这 13 个字节的文字。人家当然不转写:你给它的根本不是声音。修法很短:两端 socket 都设 binaryType="arraybuffer",转回 ArrayBuffer 保序转发。
但真正的教训在于:这个坑两次上线都没被发现。因为冒烟测试是这么写的——连上去,发一个配置帧,看有没有字节回来。有字节回来(就是那 13 个字节),测试通过。它一直在验证一个被强转出来的字符串,还把它当成「有效回包」。
翻译成人话:只验「有没有回音」的测试,不能证明「说的话被听懂了」。合格的冒烟测试必须流真实音频进去,断言解码出来的文字是对的。测「通了没有」是最容易写的测试,也是最容易骗自己的测试。
「UI 停在旧正文」
第二个事故在弱网下:你说完一句,气泡消了,正文没变。原因是服务端改完广播 updated,而弱网下 WebSocket 每十几二十秒断一次,广播落在一条已经死掉的连接上。App 于是永远显示旧内容——它在等一条永远不会到的消息。
两处修法。一是 25 秒心跳,保活空闲连接,顺便让死连接提前暴露立刻重连。二是更根本的:App 不再依赖任何一条推送来收敛,每条指令走到终态(updated 回来、报错、或重连对账发现它已完事)都主动发一次普通 HTTP 请求,把权威文档重拉一遍覆盖本地状态。
推送是优化,拉取是保证。凡是「只要那条消息送到一切就对了」的设计,在真实网络里都会有一部分用户永远看到旧东西,而且他们不报 bug,只觉得这功能坏了。(同期做过四版「实时预览」最后整体撤掉,那是第 12 章讲减法的内容。)
最后:让改动看得见
所有语音修改落地后,App 把新旧正文做一次 diff(逐行比对,找出哪些行变了),变动的那几行涂上淡黄底色,3.5 秒后淡出。你说了一句「把第 3 行改口语一点」,不该再去通读全文找哪里不一样——那一行自己会亮一下告诉你:是我,改的是我。
用户标的那一行,模型改的那一行,屏幕上亮起来的那一行。从头到尾,同一行。