人和 AI 都在改,工作流究竟执行哪一版?
文稿候选、真实基线与自动保存中的并发问题
你让 AI 帮忙精简一段文案。等待的时候,自己又删掉一句夸张的宣传语,补上了正确的商品容量。几秒钟后 AI 返回,编辑器把整段内容替换成了新答案。
语句更顺了,刚刚改对的容量却又变回旧值。
这样的编辑器很容易做出“AI 改写成功”的演示,也很容易在真实使用中失去信任。问题不一定出在模型:即使模型完全遵守生成时收到的输入,它仍然可能在使用一份已经过时的材料。
我在 ProductFlow 的方案编辑里处理这个问题时,逐渐分清了两件需要同时成立的事:用户正在输入的内容不能被异步响应抹掉,AI 生成的建议也不能因为生成完成就自动获得采用资格。
这条链路最后通向一次生图:输入框里还没保存的半句话、刚返回的旧建议、执行中途修改的共享方案,都可能让结果偏离用户眼前的内容。我们要让编辑继续流动,同时让每一次执行有明确的依据。
1 一份方案里,有几种不同的内容
ProductFlow 用方案组织商品图片制作。商家可以让 AI 起草构图与文案,也可以手工修改,然后用确认后的方案生成图片。一份方案可能包含画面目标、场景、文字和约束,这些字段既能分别编辑,也会共同决定同一张图。
考虑一个保温杯卖点图的示例:
当前方案写着“500ml,适合日常通勤”。用户请 AI 精简文案;等待期间发现资料有误,将商品容量改成 600ml,并删去一句没有依据的保温时长描述。
这里至少有三份内容:
| 内容 | 表达什么 | 谁可以推进它 |
|---|---|---|
| 已保存的正式文稿 | 当前被业务系统认可的方案 | 通过校验的保存或采用操作 |
| 编辑器里的本地草稿 | 用户正在修改、可能尚未保存的内容 | 用户输入与明确的草稿操作 |
| AI 候选 | 基于某份输入生成的建议 | 生成任务产生,采用后才影响正式内容 |
如果它们共用一个“当前文本”变量,任何一次返回都可能覆盖另外两条路径。分开保存这些内容,才有机会表达“你的草稿还在”“建议已经生成”“正式方案尚未改变”。
这种区分对单用户系统也有必要。同一个人的自动保存、多个标签页、后台任务和 AI 返回,本来就可能并发发生;多人协作只是进一步扩大了这个问题。
2 版本号记录的是一次读取,不是发送时刻
2.1 把版本号换成最新值,会伪造编辑依据
假设用户在图版本 10 开始改第二张图片的方案。另一张图随后保存了配色,让整张图前进到版本 11;浏览器缓存也收到新版本。用户的草稿此时才准备提交。
一个看似方便的写法是:发送请求时,从缓存读取最新版本号。服务器收到“基于版本 11 的修改”,自然以为用户已经读过版本 11。
实际并没有。草稿仍然来自版本 10。
版本号在乐观并发控制中表达的是前提:这份修改依据哪次已知状态产生。用新查询结果替换草稿的旧基线,会让服务器失去判断丢失更新的机会。输入框里的文字即使没有变化,提交合同也已经被破坏。
ProductFlow 曾沿检查器、自动保存与图命令链路修正这一点:真实编辑基线必须跟随草稿传递,不能在请求最后一刻重新猜测。
这里的“乐观”,是编辑期间不一直占着数据库锁。人可能花几分钟修改文案,其他任务也应该能够推进;等保存时,再检查修改所依赖的版本。服务端仍会在短事务里保护版本判断和正式写入,避免两个保存请求都通过检查后相互覆盖。乐观并发与短事务中的加锁可以一起使用,它们保护的是不同时间尺度。
仅仅提高数据库隔离级别也解决不了浏览器拿旧稿覆盖新稿:用户读到版本 10 的那次请求早已结束,几分钟后的保存通常是另一笔事务。数据库能协调当前事务之间的读写,应用仍要把这份跨请求的编辑前提带回来。
2.2 旧版本也不必然等于冲突
坚持真实基线以后,又会遇到相反的问题:别的节点保存了颜色,为什么当前文字草稿也不能保存?
整图版本是粗粒度的并发标记。它说明图发生过变化,却不说明变化碰到了哪个对象。
当前服务端允许一种有限的旧基线调整:本次全部是节点配置更新,基线之后的历史完整,中间也全部是其它节点的配置更新,而且没有碰到本次目标,才允许将操作应用到当前图。
| 基线之后发生的变化 | 当前处理 | 能够作出判断的依据 |
|---|---|---|
| 其它节点只更新配置 | 可按规则调整旧基线 | 完整操作历史证明目标未被写入 |
| 同一个节点被修改 | 冲突 | 无法保证不会覆盖先前更新 |
| 图中出现结构、改名或移动操作 | 不进入这条自动调整规则 | 超出已经定义的可接受操作集合 |
| 历史有缺口或版本来自未来 | 冲突 | 无法建立可靠的版本关系 |
这样做会让一些本可合并的修改也发生冲突,例如中途移动了节点。我们暂时接受这个代价:一旦历史里出现超出配置更新的操作,就请用户重新确认,避免用不完整的判断覆盖原稿。
2.3 不同字段,也可能在表达同一个决定
继续把冲突粒度细化到字段,似乎可以提高保存成功率。但字段不同并不保证语义独立。
例如,用户把构图改为“只展示杯体,不出现包装”;AI 基于旧方案,把文案改成“杯体与礼盒组合展示”。两次写入各占一个字段,JSON 可以顺利合并,内容却发生矛盾。
这也是当前合并判断停在节点级的原因。一份节点文稿中的构图、文字和约束经常一起表达同一个决定,把它们拆成互不相关的 JSON 键,反而容易把矛盾合并进去。
3 自动保存成功,只确认了发出去的那个快照
服务器正确判断冲突,还没有保护请求等待期间的新输入。
假设浏览器发送草稿 A。请求尚未返回,用户继续输入,形成草稿 B。服务器保存 A 成功以后,若客户端将表单重置成返回内容,B 的新增部分仍然会消失。
因此,自动保存需要分别维护已保存基线、当前草稿和正在提交的快照。
图 1. 保存完成推进的是基线,用户的新输入仍留在草稿中。
在当前实现里,同一草稿会话一次只处理一个进行中的保存。成功后,以本次提交快照更新基线,再检查当前草稿是否还有变化;有变化就继续保存。
可以把核心关系写成:
是否还有未保存内容 = 当前草稿与已保存基线是否不同
而不能简单写成“最近一次请求是否成功”。后者很容易出现界面显示“已保存”,其实只保存了用户前半句的情况。
冲突之后,草稿要留在谁手里
发生版本冲突时,当前实现保留草稿,阻止同一份失败内容自动反复重放。显式重试也仍需携带真实基线,不能偷偷换成最新版本号强行写入。
产品还需要让用户有可理解的处理方式:查看当前正式内容、保留自己想要的部分、重新确认后再保存。仅弹出一个 409,并没有完成编辑体验。
同样,用户点击“运行”时,实际生图应使用已经确认的配置。当前自动保存提供 flush,用来等待未保存变化完成;保存失败时,运行流程不能假装已经使用了眼前的草稿。这个顺序连接了编辑器正确性与下游生成成本。
4 AI 返回以后,不急着替换原稿
4.1 “生成”与“采用”有不同的决定者
用户点击 AI 改写,允许的是生成建议。对于已有正式文稿,是否接受哪些变化,还需要一次独立决定。
ProductFlow 将持续改写产生的内容作为候选,允许按业务章节比较和采用。初始空白内容可以有自动起草路径,但它与覆盖已经编辑过的方案具有不同风险,不能自动沿用同一规则。
图 2. 候选让 AI 可以继续工作,同时保留用户决定正式内容的机会。 校验失败时不进入正式写入,图中省略错误展示。
候选还改变了用户的心理负担。直接替换之后,用户必须检查“原来满意的内容有没有被悄悄改掉”;并列比较则可以围绕“这次建议值得采用哪些部分”进行判断。
4.2 章节粒度要对应实际审阅
构图、文案和约束有不同用途。用户可能愿意采用更好的构图,同时保留已经校对过的文字。章节映射因此应由业务模型定义,而不能把 AI 返回的所有字段随意合并。
部分采用还有删除语义。假设候选的文案章节明确删掉了一句宣传语,采用这个章节后,原字段就不应因为浅合并而残留。未选择的章节则应保持原样。
这类细节很容易在“对象合并成功”的测试中漏掉。当前章节测试分别检查只替换选中部分,以及候选省略某个被选字段时的删除行为。
即便章节采用在结构上正确,用户仍需检查章节之间是否协调。软件可以保证“只写了这些字段”,不能由此证明“这些字段组合起来是一份好方案”。
5 一份刚返回的建议,也可能已经过期
5.1 返回时间不代表依据的新鲜程度
回到保温杯示例。AI 开始生成时,容量还是 500ml;用户随后把上游商品资料改为 600ml。正式方案可能一字未改,AI 也可能刚刚返回,但候选的依据已经失效。
只检查目标文稿是否变化,会漏掉这类问题。候选还需要记录生成时实际使用的上游输入。
ProductFlow 的候选带有文稿基线摘要与编译输入摘要,读取和采用时重新检查:当前文稿是否偏离生成基线,当前编译输入是否仍然相同。偏离时,候选标为过期,采用被拒绝。
这里同时存在三种版本依据:
| 依据 | 要回答的问题 |
|---|---|
| 图版本 | 用户提交修改时,依据哪一次图状态 |
| 文稿基线摘要 | 模型生成后,这份目标文稿是否已经改变 |
| 编译输入摘要 | 模型使用的上游资料与依赖是否发生变化 |
它们不能互相替代。无关节点保存让整图版本前进,当前草稿可能仍能写入;商品容量变化没有修改目标文稿,却足以使旧建议失去依据。
5.2 摘要能证明的范围,由输入决定
摘要相同只说明参与计算的表示相同。若容量根本没进入编译输入,容量变化就不会让摘要变化;若无关的画布坐标被混入摘要,一次移动又可能误伤所有候选。
因此,候选有效性最终依赖输入模型:它是否完整包含生成实际使用的事实,并排除与生成含义无关的噪声。一个哈希函数再可靠,也无法弥补错误的依赖集合。
资料一直错误但从未变化,摘要同样会保持一致。有效性检查只能说明“依据没有漂移”,内容是否正确仍需商品证据与审阅。
还有一个时序要求:采用时的校验必须与正式写入处在受保护的业务边界中。只在打开候选弹窗时检查,用户阅读期间再次发生变化,旧建议仍可能落地。
6 保存以后,运行器应该读哪份图
如果用户点下运行以后仍能继续编辑,执行器就不能一边运行一边随意读取最新配置。否则第一张图按旧方案生成,第二张图读到新的共享资料,一次任务便混用了两份方案。
ProductFlow 在运行开始时冻结图快照;排队中的运行在出队时取快照,开始后使用这份输入。在线图可以继续编辑,后续修改进入下一次运行。于是“当前方案”和“这次执行所用方案”有了各自明确的身份,查看历史图片时也能回到它当时的依据。
快照固定了输入,接下来还要确定顺序。商品资料 P 同时供主图方案 A 和卖点方案 B 使用,两个方案再连接各自的生图节点。共享上游使它成为一张 DAG,运行器需要在前驱就绪后才处理后继。
6.1 依赖关系怎样变成执行顺序
当前图规则使用基于入度的拓扑排序。入度可以读成“还有几个前驱没有处理”:初始把入度为零的节点放进队列,取出一个节点后减少其后继入度,后继降到零便入队。最后若仍有节点未被取出,就发现了依赖环。
在这个例子里,P 先处理,A 和 B 随后都具备前进条件。它们之间没有依赖,容量允许时可以并行。拓扑关系决定谁必须等谁,准入限制决定同时允许多少工作,两个问题需要分别回答。
下面保留算法主体,节点编号已经校验为 0 到 n-1:
func topo(n int, edges [][2]int) ([]int, bool) {
next := make([][]int, n)
indegree := make([]int, n)
for _, e := range edges {
next[e[0]] = append(next[e[0]], e[1])
indegree[e[1]]++
}
order := make([]int, 0, n)
for id, degree := range indegree {
if degree == 0 {
order = append(order, id)
}
}
for head := 0; head < len(order); head++ {
for _, id := range next[order[head]] {
indegree[id]--
if indegree[id] == 0 {
order = append(order, id)
}
}
}
return order, len(order) == n
}
每个节点入队一次,每条边访问一次,主体复杂度为 O(V+E)。项目还会对初始零入度节点的 ID 排序,以便输入相同时获得稳定顺序;算完整成本时,要加上这部分比较排序。
DFS 也能完成拓扑排序,使用“未访问、访问中、已完成”标记检测环,再反转完成顺序。这里的队列写法直接表达就绪节点,也不需要让调用栈随依赖链增长。显式数据结构保留的状态同样占内存,但在很深的链上,更容易看清等待集合和处理进度。
6.2 保存合法,和可以执行,是两道检查
用户搭图时经常只完成一半:先放一个生图节点,稍后才接方案和参考图。如果保存时就要求全部输入完整,用户会被迫一次性完成整条链。因此,不完整的图允许保存,运行前再检查目标范围所需的输入。
运行单个节点、运行到某节点和运行选区,也有不同含义。用户只想重做一张图时,不能因为它连接着共享方案,就顺手把上游方案也重新生成。输入解析沿实际入边进行,运行范围则由这次命令明确选择。
这一步把前面的编辑规则接到了执行器:先等待草稿保存,再决定运行范围,冻结对应状态,最后按依赖与容量推进。执行过程中的重复投递和超时重试,留给异步可靠性篇继续讨论。
7 协同编辑库能替我们决定多少
讨论并发编辑时,很容易想到 CRDT 和 Yjs。它们解决的副本收敛很重要:多个参与者并发更新,最终仍能得到一致数据。但在这里,还需要决定一份 AI 建议是否应该进入正式内容。
如果把 AI 结果转换成“删除旧段落、插入新段落”,协作算法接收到的就是这两项操作。它并不知道用户刚修正了容量,也不会自动把整段替换理解成“只精简宣传语”。相同的最终文本,仍可能是一份大家都不想要的结果。
所以当前方案把 AI 输出保留为候选,通过真实基线和采用动作进入正式文稿。以后即便为正式文稿引入多人实时同步,候选仍有它的作用。这是我们选择当前编辑模型的原因,而不只是为编辑器增加一个版本字段。
8 让保存响应故意晚回来
并发问题最容易被顺序测试漏掉。“保存接口成功”“AI 返回成功”“采用接口成功”分别通过,也不说明它们交错时不会丢稿。
可以用可控 Promise 或测试屏障安排返回顺序,让错误确实有机会发生:
| 测试安排 | 应当检查什么 |
|---|---|
| 版本 10 开始输入,随后收到版本 11 查询结果 | 保存仍携带真实的版本 10 |
| 提交 A 后继续输入 B,再释放 A 的响应 | 基线成为 A,草稿保留 B,后续保存继续 |
| 同节点在服务端被改写 | 冲突可见,本地草稿仍然存在 |
| 只有其它节点更新配置 | 服务端按完整历史判断可否保存 |
| AI 生成期间修改上游容量 | 旧候选无法被当作当前建议采用 |
| 只采用构图章节 | 文案与未选约束保持 |
| 候选删除选中章节的原字段 | 旧字段不因浅合并而残留 |
| 自动保存失败后点击运行 | 不以旧配置冒充眼前草稿执行 |
我们分别在自动保存状态、图命令事务与章节合并中保留了相关测试。浏览器层仍要检查真实按钮和调用链:基线有没有在中间组件丢失,冲突提示能否被看到,运行是否等待保存。
验证时应同时断言版本、草稿、正式内容和写入次数。单独检查输入框文字没有消失,可能遗漏伪造基线;单独检查请求带了正确版本,又可能遗漏响应覆盖了后续输入。
9 允许人继续写
一个可信的 AI 编辑器应允许用户在模型思考时继续工作,也允许用户在模型返回以后只采纳一部分建议。
做到这一点,需要几项具体承诺:保存响应只确认已发送快照;版本始终对应真实读取;旧依据产生的候选不能直接采用;冲突发生时,用户的草稿仍然留在手里。
模型生成速度越快,这些机制越容易在演示里被忽略;任务越真实、等待越长、修改越频繁,它们就越重要。用户愿意把已经校对过的内容交给 AI 继续处理,取决于能否确信自己的决定会被保留下来。
延伸阅读
保温杯与并发改稿场景用于解释设计。正式文稿的同步、AI 建议的采用和商品事实的正确性,需要分别验证。
评论