生成式软件工程
搜索

运行环境:Harness

一百五十行的助手能干活,但它第一次遇到断网就崩了。

这一章讲什么
  • 一个极简助手靠什么工作,它第一次翻车暴露了什么;
  • 幂等性:为什么「没收到成功消息」不等于「没执行」;
  • 架构是用一部分自由换一类便利,什么时候会反过来咬人;
  • 插件化带来的协同问题,以及几个支撑大型应用接入的核心概念。

一、一百五十行就能干活

一个可用的编程助手,核心机制短得出奇:接收目标,把当前上下文交给模型;模型提出要调用某个工具; 程序执行工具,把结果放回上下文;继续请求模型,直到这一轮结束。

用这个循环就能完成相当复杂的任务。有人用它做过一个校园三维场景:从公开地图数据里取建筑与道路轮廓, 把经纬度换算成平面坐标,按建筑多边形生成楼体,再加道路、水域、绿地与相机交互。 建筑高度资料缺失时需要估计,立面与树木是程序化生成的示意。

这类结果足以让人惊讶:一百多行的循环,加上模型与几个工具,能做出这样的东西。

二、第一次翻车:断网

同一份代码很快用实际故障反驳了「它完全可以工作」这句话。网络或者模型服务中断,程序直接断了。 原来它悄悄假设了每一次请求都能顺利结束。

如果需求只是「在网络正常时演示一次」,这个假设可以接受。如果需求是「让用户把真实工作交给它,遇到中断也能接着做」, 故障处理就是问题本身的一部分。这种复杂性来自我们对产品行为的要求,不会因为主循环写得短就消失。

生活类比 自动售货机在正常情况下一按就出货。但如果你投了币、机器断了一下电,恢复之后它该不该出这瓶水? 该不该退钱?「断电时发生了什么」这个问题,比「怎么出货」难得多。写助手也是这样。
flowchart TB
  A["发起一个操作"] --> B["执行实际修改"]
  B --> C["记录执行结果"]
  C --> D["返回成功"]
  B -.->|"此处崩溃"| E["结果未知"]
  C -.->|"此处崩溃"| E
  E --> F["恢复时先查记录"]
  F -->|"记录说做过"| G["直接返回旧结果"]
  F -->|"记录说没做"| H["可以安全重试"]

三、幂等性:「没收到成功消息」不等于「没执行」

「失败就重试」听起来简单,实际分三种情况,处理方式完全不同:

情况例子重试是否有用
暂时性故障短暂断网、服务限流有用,保留进度并有限次重试
确定性故障输入损坏、权限不足没用,原样再来几遍结果一样
结果未知没有收到成功回应,但操作可能已经执行危险,直接重放可能重复执行

第三类最难。假设文件已经移动,但工具结果还没来得及记录,进程崩了。恢复时若直接重放, 可能再次移动、覆盖文件,或者把「原文件不存在」误当成另一个错误。

幂等性(idempotency)就是处理这类问题的工具:同一个逻辑操作重复提交,不应产生额外的业务效果。 实现方式通常有三件:给每次操作一个稳定的标识、记录已执行过的结果、让重复请求直接得到之前的结果。

这里有顺序要求:记录标识与执行修改必须协调好,不能留下「记录说做过、实际上没做」或者「做了但没记录」的缝隙。 这类缝隙正是恢复时最危险的时刻。

关键结论 「接着做」必须建立在对实际状态的判断上,不能建立在对消息的判断上。 判断状态靠的是可核查的标识与记录,不是「上一次有没有收到回复」。

四、需求会突然改变方向

需求不只是越列越长,还会换方向。这一点在助手类产品上格外明显。

最初的产品设想是「打开一个项目、发起一次编码任务」。后来用户开始期待持续工作的助手、定时任务、 以及不同助手之间的协作。原来围绕单次任务组织起来的界面、状态、权限,都需要重新审视。

「之前的需求全崩了」是一句夸张但贴切的感叹。底层模型未必换过,用户理解产品的方式却变了。 这时能复用的东西是什么?会话、模型执行、工具调用这些能力层可以留着;需要重做的是组织方式与交互入口。

如果当初把会话、执行、工具都设计成可组合的部件,新需求出现时可以重新拼一遍;如果全都写死在一个大循环里, 就得从入口改到最深处。这就是下一节要讲的架构问题。

五、架构:用一部分自由,换一类便利

「在某个架构下开发」的意思,是接受它的表达方式、接口与边界。你不能随意做所有事情, 但因此获得一批不必自己重写的能力。这个交换在需求吻合时非常划算,在假设改变时会反过来限制你。

几个熟悉的例子

这组例子的共同结论是:边界与需求吻合时,你得到解耦、复用、可测试性与共同理解的接口; 假设改变时,被隐藏的细节重新影响上层。架构开始成为阻力,往往来自它替你固定下来的选择不再合适,并非它突然变坏了。

六、从集群计算到流处理:同一笔交换的三次重做

有一个经典例子能把「用自由换便利」讲得非常清楚。

把一个计算组织成「分发映射、按关键字分组、汇总」三步。接受这个形式,你不用管怎么切分输入、怎么并行、 怎么调度任务、某台机器坏了怎么办,框架全包。代价藏在细节里:如果映射阶段顺手发了一封邮件, 任务重试就会再发一封。框架只保证计算可以重新执行,副作用要你自己处理。

需求变成「反复迭代同一批数据」之后,一轮一轮作业之间交换中间结果成了负担, 于是出现了能缓存可复用数据、用更一般的依赖关系组织多阶段计算的框架。

需求再变成「持续显示最近五分钟的访问量」之后,不能等数据到齐再算,于是有了流处理: 算子持续接收记录、维护状态。新的麻烦随之而来:日志可能乱序、可能迟到,按事件发生的时间统计还是按系统收到的时间统计? 等多久才输出结果?恢复时怎么把输入读取位置与算子状态一起恢复到一致的检查点?

三次变化说明一件事:需求变了,不只是换一个计算接口,整套配套机制都要跟着重组。

七、插件化:先解决扩展,再引入协同问题

「一切皆插件」是一条常见的架构选择:模型适配器、工具、会话日志,乃至默认的执行循环,都可以作为插件组织。 想增加行为时,使用共同的扩展接口,而不必修改一个越来越大的中心程序。这样做的收益是扩展成本低。

代价出现在协同上。插件之间需要共同理解少量稳定的概念,并通过有含义的接口交换信息。几个真实的冲突来源:

叠加效应有个很好的思想实验:安装十个自动压缩上下文的插件,每个把输入压到一半。如果它们确实依次作用于同一份内容, 剩余长度是二分之一的十次方,约千分之一。这个算式不是真实算法的测量结果,但它清楚说明:每个插件单独看都在节省,合起来可能把有用信息全压没了。

八、会话语义:把「发生了什么」与「下一步怎么办」分开

大型助手产品里,会话通常被设计成一串有序事件,而不只是「用户说一句、模型回一句」的聊天记录。 工具调用、权限变化、业务事实都可以是事件。

在这之上,有几组概念值得理解:

插件卸载时框架会撤销它注册的监听与其他需要清理的作用。但要清楚这条边界: 清理不会撤回插件此前已经发出的邮件、已经修改过的文件。

九、把助手接进已有应用

很多应用早已有成熟的对象模型与编辑能力:画布里有图形,文档里有段落,表格里有单元格。 比起重做一个应用,更省事的做法是在用户意图与已有对象之间加一层很薄的连接。

这层连接做三件事:

  1. 锁定对象:明确在修改期间它负责哪个范围,避免用户与助手同时改动相同对象。
  2. 序列化对象:把应用内部数据转换成助手与工具能处理的表示,例如一段结构化的文本。
  3. 界面:让用户选择对象、表达要求、查看结果,必要时可以停下来。

一个完整的流程通常是:用户选中若干图形并输入要求,系统锁定选区与其关联内容,把它们的表示交给会话; 助手提出替换内容,工具检查输出是否越出选区,通过检查的暂存,本轮成功结束后才真正应用; 停止、超时或失败时解锁并保留原状。每张画布对应一个会话,因而可以继续讨论、切换与恢复。

这比「把整张画布交给助手随便改」多了几条约束,却给人机协作划出了清楚的边界,失败恢复也从一开始就设计进去了。

还有一个方向是让界面本身由模型生成:输出一段可交互的界面,界面与会话之间建立双向通道, 用户在界面上的操作与当前状态送回上下文,模型再据此回应或更新界面。这里仍要分清职责: 表示、回合顺序与合法性由程序维护,模型负责选择与解释。

现在就做 ① 找出你日常使用的一个工具,画出它的「输入、状态、输出」三层。② 写出它的三个隐含假设 (例如「网络一直可用」「只有一个用户」「数据量不会超过内存」)。③ 对每个假设写一句「如果它不成立了会怎样」。
这三步做完,你就完成了一次架构假设的排查。做完之后再看这一章的架构例子,会顺很多。

自测

1. 一百五十行的助手为什么会因为断网崩掉?
因为它隐含假设了每次请求都能顺利结束,没有把故障处理当成需求的一部分。当需求从「演示一次」变成「用户把真实工作交给它」时,网络中断就是必须处理的情况,这与主循环写得多短无关。
2. 「没收到成功消息」为什么不能推出「操作没有执行」?
因为消息可能在执行之后、记录之前丢失。此时操作已经生效,但系统不知道。如果直接重放,就会重复移动文件、重复扣款或重复发信。正确做法是用稳定的操作标识与已执行记录来判断状态。
3. 幂等性要靠哪三样东西实现?
稳定的操作标识、已执行结果的记录、重复请求返回之前的结果。三者还要协调一致,不能出现「记录说做过但没做」或「做了但没记录」的缝隙。
4. 「十个压缩插件叠加」的思想实验想说明什么?
每个插件单独看都在节省上下文,合起来可能把有用信息压到千分之一。它说明局部合理不保证整体合理,也说明插件化架构必须处理组合与顺序问题,不能只保证每个插件自身正确。
5. 会话事件与执行前钩子的时间方向有什么区别?
会话事件记录已经发生的事实,纠正应当追加新记录,不能改写历史;执行前的钩子作用于尚未发生的步骤,可以参与决定下一步。混用会让「改变行为」变成「篡改历史」。