ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

远程Agent+本地Blender建模示例(二)

远程Agent+本地Blender建模示例(二) 2026-09-21 · 让重复无害、让零改动可证日志系列第二篇整理日期2026-09-21承接上一篇docs/log/2026-09-18.md第 0–2 章命令怎么发、结果怎么回、人批准的到底是什么。这一篇回答那两个问题之后的两个新问题。本篇范围第 3 章幂等同一件事被提交两次怎么办、第 4 章守卫把零改动变成执行顺序的结论以及章末的接口设计视角——把这两章的做法对应到通用的接口设计幂等键、乐观并发、长任务与在途未决事务并给出一份可直接自查的清单。读完你会知道为什么重复提交必须被设计成无害的为什么失败和不确定必须分开以及这些做法在标准接口语义里各自对应什么。配套代码examples/tutorial/一条命令就能跑完全部 7 个场景本篇引用的输出就是它第 3、4 步的真实输出python examples/tutorial/run_demo.py本篇新出现的英文词词含义idempotent / 幂等同一个操作做一次和做很多次结果一样按电梯按钮是转账不是idempotency_key给这次请求起的名字。重试时必须用同一个名字重放 / replay /replayed认出这是同一次请求之后把第一次的结果原样给它而不是再做一遍hash/digest把内容算成一串字符内容改一个字这串字符就完全不同指纹409HTTP 状态码意思是你的请求和服务器现状冲突IDEMPOTENCY_CONFLICT错误代号同一个幂等键被用在了不同内容上guard / 守卫动手之前先核对现场的门卫failed/unknown干净失败确定没改/ 结果不可知可能已经改了系列后续章节回执事实、unknown的完整语义、上下文预算在docs/BLOG.md。3. 幂等为什么同一件事提交两次不能靠小心3.1 先厘清概念什么叫幂等幂等是个音译词英文idempotent意思很朴素同一个操作做一次和做很多次结果一样。按电梯按钮是幂等的按一次和按五次电梯都只来一趟转账不是幂等的点两次账户就真的少两笔钱。而在真实网络里重复是常态不是意外网络抖了一下客户端自动重试手抖连点了两下执行连接断线又重连正在路上的消息被重发一次。如果系统没为这些做准备同一段脚本就会在设备上跑两遍——而第二遍面对的已经是改过的场景轻则白干重则把改好的东西又弄坏。这一章讲的就是用四道防线让重复变得无害。3.2 四道防线各管一段路在哪一层用什么认身份它挡住什么① 消息层会话 幂等键同一条消息不重复写进对话历史② 调用层谁在请求租户/用户 幂等键 内容指纹同一次调用不重复执行③ 设备层回执重报断线重连后只重发结果不重跑命令④ 本地层设备那侧命令编号 内容哈希同一条命令被塞进不同内容时当场判失败三个词先解释清楚幂等键idempotency_key给这次请求起的名字由客户端生成重试时必须用同一个名字内容指纹digest把请求内容算成一串字符内容改一个字这串字符就完全不同第 2 章用过哈希hash和指纹是一回事只是不同地方叫法不同。最容易漏、也最贵的一条同一个幂等键换了内容必须报错而不是覆盖。因为幂等键本质上是这件事的名字——如果允许名字不变、内容随便改那重发和改主意就分不清了更糟的是有人会用换个新名字绕过检查那样幂等就成了摆设。3.3 跑一遍3. 幂等同键同内容重放同一条同键换内容直接冲突 [OK] 同一个幂等键重放 → 同一条调用且标记 replayed [OK] 同键换内容 → 409 IDEMPOTENCY_CONFLICT对上两行输出的说明输出含义重放 → 同一条调用且标记 replayed第二次提交被认出是同一次请求系统没有再做一遍而是把第一次的结果原样给你replayed 这次是重放409 IDEMPOTENCY_CONFLICT名字用的一样、内容却换了系统拒绝。409是 HTTP 的状态码意思是你的请求和服务器现状冲突IDEMPOTENCY_CONFLICT是给程序看的错误代号3.4 读代码服务端那一道existing_idSTATE.idem.get(body.idempotency_key)ifexisting_id:existingSTATE.calls[existing_id]ifexisting.digest!digest:raiseHTTPException(409,{code:IDEMPOTENCY_CONFLICT,detail:这个幂等键已经属于另一份内容})existing.replayedTrue# 同内容重放返回同一条调用returnexisting.row()STATE.idem[body.idempotency_key]call.id逐行翻译代码含义STATE.idem.get(body.idempotency_key)拿这个名字去查“我以前见过它吗”if existing_id:见过if existing.digest ! digest:名字一样内容不一样 → 直接报冲突existing.replayed Truereturn内容也一样 → 标记这是重放把第一次的结果给它STATE.idem[...] call.id没见过 → 记下这个名字从此属于这条调用生产代码里同一件事分散在两张表上src/xsaios_agent/store.py:97与:163raiseAgentError(IDEMPOTENCY_CONFLICT,Message key reused with different content)raiseAgentError(IDEMPOTENCY_CONFLICT,Invocation key reused with a different proposal)为什么分两层消息和调用是两种东西——一条用户消息可能触发多次调用一次调用也可能被重发。分开记账才不会把同一句话说了两遍和同一个动作做两遍混为一谈。3.5 读代码设备那一道服务端防住还不够服务端的幂等只能防同一个请求进来两次防不了下面这件事设备执行完了但回执在回来的路上丢了。这时服务端只知道自己没收到结果就会再问一次——如果设备不加防备它就会再跑一遍。所以设备那侧也有自己的一本账examples/tutorial/connector.py的Journal.claim对应src/xsaios_agent/connector.py:549rowself.db.execute(SELECT hash,result,stage FROM commands WHERE id?,(command_id,)).fetchone()ifrow:ifrow[hash]!digest:return{state:failed,error:Command ID/hash conflict}# 同 id 不同内容ifrow[result]:returnjson.loads(row[result])# 已做过回放结果return{state:unknown,error:上一次运行没有留下结果不重放}代码含义按id查这本账“这条命令我以前见过吗”row[hash] ! digest编号一样、内容不同 → 判失败Command ID/hash conflictrow[result]以前做过并且有结果 → 直接把结果回放给它绝不重做其它没有结果以前开始了但没留下结果 → 判unknown也不重做3.6 这一章要记住的三件事幂等不是一个开关而是每一层各自保护消息层、调用层、设备层、本地层。同一个幂等键换了内容 → 必须报错不许换个键绕过。设备侧也要自己记账只防服务端那一侧挡不住回执丢了之后的重问。一句话重试是网络世界的常态所以重复无害必须被设计出来而不是靠运气。4. 守卫把零改动变成执行顺序的结论4.1 先厘清概念为什么看着没问题就动手不行AI 提案时看到的场景和设备此刻的场景可能已经不是同一个了中间可能有人手动改了文件、上一次执行改过了、或者 Blender 被重启过。照着旧信息执行好比拿着三天前的地图开车。所以设备那侧要有一个门卫守卫英文 guard动手之前先核对现场。这个门卫有两条铁律它自己不能改任何东西——否则门卫拦下了就不再等于什么都没发生它必须跑在任何改动之前——顺序错了第 1 条就白说了。真项目里最早踩的坑正是这个一次纯读检查失败却被系统记成了unknown不确定整台设备被锁住只能靠人工来解。4.2 图脚本被包起来之后的执行顺序connector 生成的代码发给设备的那段 ① 记录 _file_before / _meshes_before ← 事实快照纯读 ② _xs_guard() ← 四条纯读检查 pid 变了 文档变了 场景变了 不在 OBJECT 模式 │ 任一命中 → result[guard] 原因**到此为止** ▼ 全部通过 ③ undo_push() ← 从这里开始才可能改动 ④ exec(用户脚本) ⑤ 计算回执saved / file_before / file_after / dirty_after / meshes_without_uv上图逐项说明按 ①→⑤ 顺序图上编号说的是什么①先量一遍现在的状态比如当前文件叫什么、场景里有哪些网格——只是记录不动手②门卫上场做四条只读检查程序还是原来那个吗文件还是那个文件吗场景还是那个场景吗当前在物体模式吗③任何一条不过 → 把原因写进结果里行程到此结束后面的步骤一步都不执行④全部通过 → 先记一笔可以撤销的改动undo_push然后才真正执行 AI 那段脚本⑤算回执文件有没有换、磁盘上有没有变、这次新建的网格有没有 UV第 5 章会专门讲这几个字段一句话总结顺序先量、再查、最后才动。4.3 读代码把这段门卫读一遍defguarded_source(pid,expected_file,expected_scene,source):return(import os, json\nresult {}\ndef _xs_guard():\nf if os.getpid() !{pid!r}:\n return Blender process changed; re-inspect the instance before executing\nf if bpy.data.filepath !{expected_file!r}:\n return Document changed; expected_file must be the exact bpy.data.filepath\nf if bpy.context.scene.name !{expected_scene!r}:\n return Scene changed; expected_scene must be the exact scene name\n if bpy.context.mode ! OBJECT:\n return Blender is not in Object mode\n return None\n_file_before bpy.data.filepath\n_meshes_before set(bpy.data.meshes.keys())\n_reason _xs_guard()\nif _reason is not None:\n result[guard] _reason\nelse:\n bpy.ops.ed.undo_push(messagexsaios approved operation)\nf exec(compile({source!r}, xsaios-approved, exec), globals())\n...\n)逐行说明只看“含义”一列即可代码含义result {}先准备一个空盒子用来装结果def _xs_guard():定义这个门卫if os.getpid() ! {pid!r}“对面还是原来那个程序吗”——pid是操作系统给每个运行中的程序编的号if bpy.data.filepath ! {expected_file!r}“文件还是那个文件吗”——必须和提案时看到的一模一样if bpy.context.scene.name ! {expected_scene!r}“场景还是那个场景吗”if bpy.context.mode ! OBJECT“当前在物体模式吗”在别的模式下执行脚本容易出事_file_before/_meshes_before动手之前先量一遍之后好对比if _reason is not None: result[guard] _reason门卫拦下了 → 只把原因写进结果一个字段都不改else:之后undo_push(...)到这里才记一笔可撤销的改动exec(compile(source, ...))到这里才真正执行 AI 那段脚本4.4 门卫的身份必须现场重探配套的live_pid()才是关键的另一半守卫里的 pid 必须是派发前现场问设备要的不能用 connector 启动时的快照。生产代码里那段注释把事故写在了原地src/xsaios_agent/connector.py:614initial是 Connector 启动时的快照于是 Blender 一重启每条命令都会以“Blender process changed” 被拒而inspect报的偏偏是新 pid错误信息还把锅推给调用方。—— 现在改成派发前现场重探一次而且绝不用启动快照兜底那是同一个 bug 的静默版本。4.5 跑一遍4. 守卫是纯读、且在任何改动之前过期 expected_file → 干净失败 [OK] 守卫拒绝 → failed不是 unknown 守卫拒绝零改动Document changed; expected_file must be the exact bpy.data.filepath [OK] 真的零改动对象数没变 4 - 4 [OK] 设备仍然可用守卫失败不挡机器逐行读这次输出输出含义守卫拒绝 → failed不是 unknown门卫拦下了系统就判这次没成——failed是干净失败确定什么都没改守卫拒绝零改动Document changed; ...拦下来的原因写清楚了文件路径和提案时看到的不一致这里故意用了一个过期的路径真的零改动对象数没变 4 - 4再看一眼核对设备里还是 4 个物体确实一点没动设备仍然可用因为确定没改设备不会被锁住可以马上重试4.6 这一章要记住的failed和unknown的分界线这条分界线一句话就能记住“AI 那段脚本到底有没有跑过”。门卫拦下脚本一行都没跑 → failed 确定零改动设备保持可用可以马上再试 脚本跑过之后出错 / 设备没回话 → unknown 可能已经改了一半设备锁住等人核对为什么这条线这么重要因为两种情况的正确处置完全相反判成系统的动作为什么会这样failed允许重试我们确定没改过再试一次是安全的unknown禁止重试、锁住设备我们不知道改没改再试一次可能变成做两遍真项目里这条规则最早的版本是错的门卫失败也被记成unknown于是第一台真机上设备直接被锁住了。修法不是放宽判据而是服务端定规则、设备端用执行顺序保证规则成立——门卫必须真的在任何改动之前跑完failed才配得上零改动这四个字。附从程序接口设计的角度重看这两章可迁移一、先回应一个可能的观感这些不是补丁如果把这两章读成这个项目踩了坑之后打的补丁那确实只剩特例。但它们对应的三个问题在任何跨进程、跨网络的接口里都存在必然存在的问题不设计会发生什么这两章的答案消息可能丢失或重复重试让同一个动作执行两次幂等键 重放原结果副作用不可逆一次误执行就改坏了东西授权绑定内容指纹执行前先过守卫视图会过期别人也在改基于旧信息执行冲突或被静默覆盖显式前置条件 乐观并发校验判断标准如果一个接口没有定义重试会发生什么“操作的前置条件是什么”“结果不可知时算哪种状态”那么每个调用方都会各自发明补丁而且彼此不兼容。把这些写进契约补丁才不必存在。二、幂等是写接口的语义不是实现细节设计问题常见做法本项目的选择为什么幂等键由谁生成客户端生成客户端重试是客户端的动作只有它能声明这是同一次键的作用域绑定身份(租户, 用户, 键)同否则两个用户碰巧用同名键会互相踩重放时返回什么第一次的结果同并标记replayed调用方要的是上次成功了结果在这里不是你重复提交了同键不同内容拒绝409IDEMPOTENCY_CONFLICT否则重发与改主意不可区分键保留多久明确的窗口只要那条调用还在就能重放窗口外的同名键会被当成新请求这一点必须写清无害的重复与真冲突分开重复批准 中性提示不是所有重复都是错误见第 2 章ALREADY_APPROVED两个通用对照支付类 API 普遍要求客户端带Idempotency-KeyStripe 是常被引用的例子交付语义上网络只能做到at-least-once至少一次所谓恰好一次是用**“至少一次 接收方幂等”**拼出来的工程上称 effectively-once。反例把幂等实现成服务端按内容去重——两个用户提交了相同内容会被误判为重复。三、前置条件是接口的一部分守卫的本质不是Blender 的特例而是乐观并发控制optimistic concurrency control读的时候拿到一个版本标识写的时候把它带上由执行方校验HTTP 里现成的机制是ETagIf-Match数据库里对应 compare-and-swap比较并交换本项目没有现成的ETag就把三个可观测量当作版本程序编号pid、文件路径、场景名并把它们做成显式的前置条件字段expected_file/expected_scene。三条接口规则前置条件必须是接口的一部分字段不能藏在实现里——否则调用方无法表达我是在什么状态下批准的校验必须由掌握真实状态的一方执行这里只能是设备侧而且要在任何改动之前前置条件失败要有独立语义它是确定未执行本项目记failed既不是服务器错误也不是结果未知。把这一节和上一节合起来看一次写操作的结果集合应当是四态而非两态——成功/确定未执行/确定被拒/结果未知。只留两态的接口最后一定有人用补丁凑出另外两态。四、长任务把交付保证写进接口形状同步阻塞不适合长任务调用方只能靠超时猜。常见形状是202 Accepted 状态资源可轮询事件 / webhook 推送。本项目的三阶段回执accepted/started/result就是最小的事件流accepted让已被接管和还没送达可分started让正在跑和还在排队可分。结果未知要有名字和出路。分布式事务里称这种状态为in-doubt transaction在途未决事务处置方式是对账reconcile先看现场再决定下一步而不是重试。所以接口必须保留未知这个状态位并给出解除方式本项目acknowledge-unknown同时阻塞该设备的新命令。错误码是给程序看的分支设计标准是调用方能否据此选择动作。至少要能区分可安全重试 / 不可重试 / 需要人工。文案可以改、可以本地化错误码不能悄悄改。五、契约演进与审计状态枚举要留位置新增状态不能靠把未知归进失败来兼容unknown这种值要在第一版就存在。只加不删请求/响应模型按 additive 方式演进。本项目的契约有单一来源protocol.py导出产物 CI 漂移检查防的就是两端理解分叉。授权是资源不是一个布尔值批准对象的指纹 能力版本共同构成我批准了什么批准动作要留痕谁、何时、针对哪一份。回执是可观测量的集合saved/file_after这类字段的价值在于能回答你是怎么知道的只回一个形容词调用方既无法验证也无法审计。六、这份清单可以直接抄去自查#检查项不通过时通常出现的症状1每个写接口都有幂等键且绑定到身份重试造成重复副作用2同键不同内容会被拒绝静默覆盖或把改主意当成重发3不可逆动作的授权绑定内容指纹与版本旧批准被新内容复用4有显式前置条件字段失败能区分确定未执行资源被锁、人工救火5长任务返回状态资源 / 事件而不是同步等待调用方靠超时猜结果6结果未知是一等状态且有解除路径重复执行或出了不一致却没人发现7错误码能指导调用方的下一步动作调用方按错误文案的字符串判断8回执写可观测量不写形容词无法判断到底改了没有9契约有单一来源与漂移检查两端各写一份慢慢分叉10状态与模型可扩展只加不删新增一个状态就破坏兼容这两章的接口观可以收成一句把网络会丢、会重、会慢别人也在改写进接口语义而不是留给调用方各自处理。下一篇第 5–6 章让回执说事实、让不知道有去处第 5 章 · 回执设备回一句成功了到底意味着什么我们会把回执拆成几个能量出来的事实文件换了吗磁盘上的大小和修改时间变了吗这次新建的网格有没有贴图坐标顺带解释一个看起来显然、其实会说谎的字段为什么不能用它判断保存了没有。第 6 章 ·unknown的完整语义这一篇埋的伏笔在那里收。我们会故意让设备那侧的程序在写回执之前死掉然后看系统怎么处理改判不确定、锁住设备、等人核对——而那处改动其实已经发生了。这也是整套设计里最值钱的一课拿不到回执 ≠ 没有执行所以绝不重放。
返回列表