运行环境:Harness
一百五十行的助手能干活,但它第一次遇到断网就崩了。
- 一个极简助手靠什么工作,它第一次翻车暴露了什么;
- 幂等性:为什么「没收到成功消息」不等于「没执行」;
- 架构是用一部分自由换一类便利,什么时候会反过来咬人;
- 插件化带来的协同问题,以及几个支撑大型应用接入的核心概念。
一、一百五十行就能干活
一个可用的编程助手,核心机制短得出奇:接收目标,把当前上下文交给模型;模型提出要调用某个工具; 程序执行工具,把结果放回上下文;继续请求模型,直到这一轮结束。
用这个循环就能完成相当复杂的任务。有人用它做过一个校园三维场景:从公开地图数据里取建筑与道路轮廓, 把经纬度换算成平面坐标,按建筑多边形生成楼体,再加道路、水域、绿地与相机交互。 建筑高度资料缺失时需要估计,立面与树木是程序化生成的示意。
这类结果足以让人惊讶:一百多行的循环,加上模型与几个工具,能做出这样的东西。
二、第一次翻车:断网
同一份代码很快用实际故障反驳了「它完全可以工作」这句话。网络或者模型服务中断,程序直接断了。 原来它悄悄假设了每一次请求都能顺利结束。
如果需求只是「在网络正常时演示一次」,这个假设可以接受。如果需求是「让用户把真实工作交给它,遇到中断也能接着做」, 故障处理就是问题本身的一部分。这种复杂性来自我们对产品行为的要求,不会因为主循环写得短就消失。
flowchart TB A["发起一个操作"] --> B["执行实际修改"] B --> C["记录执行结果"] C --> D["返回成功"] B -.->|"此处崩溃"| E["结果未知"] C -.->|"此处崩溃"| E E --> F["恢复时先查记录"] F -->|"记录说做过"| G["直接返回旧结果"] F -->|"记录说没做"| H["可以安全重试"]
三、幂等性:「没收到成功消息」不等于「没执行」
「失败就重试」听起来简单,实际分三种情况,处理方式完全不同:
| 情况 | 例子 | 重试是否有用 |
|---|---|---|
| 暂时性故障 | 短暂断网、服务限流 | 有用,保留进度并有限次重试 |
| 确定性故障 | 输入损坏、权限不足 | 没用,原样再来几遍结果一样 |
| 结果未知 | 没有收到成功回应,但操作可能已经执行 | 危险,直接重放可能重复执行 |
第三类最难。假设文件已经移动,但工具结果还没来得及记录,进程崩了。恢复时若直接重放, 可能再次移动、覆盖文件,或者把「原文件不存在」误当成另一个错误。
幂等性(idempotency)就是处理这类问题的工具:同一个逻辑操作重复提交,不应产生额外的业务效果。 实现方式通常有三件:给每次操作一个稳定的标识、记录已执行过的结果、让重复请求直接得到之前的结果。
这里有顺序要求:记录标识与执行修改必须协调好,不能留下「记录说做过、实际上没做」或者「做了但没记录」的缝隙。 这类缝隙正是恢复时最危险的时刻。
四、需求会突然改变方向
需求不只是越列越长,还会换方向。这一点在助手类产品上格外明显。
最初的产品设想是「打开一个项目、发起一次编码任务」。后来用户开始期待持续工作的助手、定时任务、 以及不同助手之间的协作。原来围绕单次任务组织起来的界面、状态、权限,都需要重新审视。
「之前的需求全崩了」是一句夸张但贴切的感叹。底层模型未必换过,用户理解产品的方式却变了。 这时能复用的东西是什么?会话、模型执行、工具调用这些能力层可以留着;需要重做的是组织方式与交互入口。
如果当初把会话、执行、工具都设计成可组合的部件,新需求出现时可以重新拼一遍;如果全都写死在一个大循环里, 就得从入口改到最深处。这就是下一节要讲的架构问题。
五、架构:用一部分自由,换一类便利
「在某个架构下开发」的意思,是接受它的表达方式、接口与边界。你不能随意做所有事情, 但因此获得一批不必自己重写的能力。这个交换在需求吻合时非常划算,在假设改变时会反过来限制你。
几个熟悉的例子
- 进程与地址空间:普通进程在自己的地址空间里运行,通过系统调用申请资源,不能任意修改别的进程的内存。 接受这些限制,编辑器、编译器和浏览器才能同时运行,一个程序写坏内存不至于毁掉另一个程序的数据。
- 托管语言的自动回收:接受对象、类型与引用的规则,把对象布局与内存回收交给运行时。 代价是停顿与额外开销;需要低延迟时,运行时隐藏的成本又会回到开发者面前。
- 传统的 Web 应用组合:接收请求、执行逻辑、读写数据库、返回页面。 成熟的服务器、事务与备份设施都能直接复用。可一旦要加「所有人一直在线的讨论」,原先「请求结束就释放资源」的节奏就不适用了。
这组例子的共同结论是:边界与需求吻合时,你得到解耦、复用、可测试性与共同理解的接口; 假设改变时,被隐藏的细节重新影响上层。架构开始成为阻力,往往来自它替你固定下来的选择不再合适,并非它突然变坏了。
六、从集群计算到流处理:同一笔交换的三次重做
有一个经典例子能把「用自由换便利」讲得非常清楚。
把一个计算组织成「分发映射、按关键字分组、汇总」三步。接受这个形式,你不用管怎么切分输入、怎么并行、 怎么调度任务、某台机器坏了怎么办,框架全包。代价藏在细节里:如果映射阶段顺手发了一封邮件, 任务重试就会再发一封。框架只保证计算可以重新执行,副作用要你自己处理。
需求变成「反复迭代同一批数据」之后,一轮一轮作业之间交换中间结果成了负担, 于是出现了能缓存可复用数据、用更一般的依赖关系组织多阶段计算的框架。
需求再变成「持续显示最近五分钟的访问量」之后,不能等数据到齐再算,于是有了流处理: 算子持续接收记录、维护状态。新的麻烦随之而来:日志可能乱序、可能迟到,按事件发生的时间统计还是按系统收到的时间统计? 等多久才输出结果?恢复时怎么把输入读取位置与算子状态一起恢复到一致的检查点?
三次变化说明一件事:需求变了,不只是换一个计算接口,整套配套机制都要跟着重组。
七、插件化:先解决扩展,再引入协同问题
「一切皆插件」是一条常见的架构选择:模型适配器、工具、会话日志,乃至默认的执行循环,都可以作为插件组织。 想增加行为时,使用共同的扩展接口,而不必修改一个越来越大的中心程序。这样做的收益是扩展成本低。
代价出现在协同上。插件之间需要共同理解少量稳定的概念,并通过有含义的接口交换信息。几个真实的冲突来源:
- 顺序冲突:一个插件改写输入,另一个插件检查输入。先检查再改写,与先改写再检查,检查到的不是同一份东西。
- 决策冲突:两个钩子各自合理,一个要求继续执行,一个要求停止,谁说了算需要规则。
- 隐形依赖:插件表面上互不依赖,实际上依赖另一个插件产生的事件、注册顺序或某个副作用。
- 叠加效应:每个插件都在局部做优化,合起来效果相反。
叠加效应有个很好的思想实验:安装十个自动压缩上下文的插件,每个把输入压到一半。如果它们确实依次作用于同一份内容, 剩余长度是二分之一的十次方,约千分之一。这个算式不是真实算法的测量结果,但它清楚说明:每个插件单独看都在节省,合起来可能把有用信息全压没了。
八、会话语义:把「发生了什么」与「下一步怎么办」分开
大型助手产品里,会话通常被设计成一串有序事件,而不只是「用户说一句、模型回一句」的聊天记录。 工具调用、权限变化、业务事实都可以是事件。
在这之上,有几组概念值得理解:
- 投影:同一份事件历史,按不同规则可以算出模型该看的消息、界面该显示的内容、业务当前的状态。 这就是第 13 章讲过的投影,在助手产品里再次出现。
- 三种输入方式:普通后续输入(排入新一轮并唤醒执行)、补充上下文(只补材料,不唤醒)、 中途引导(交给最近的后续步骤,空闲时也可以唤醒)。分开设计的原因是它们的语义不同, 混成一种会导致「补个材料就莫名其妙开始干活」。
- 钩子:挂在明确执行节点上的处理逻辑,可以查看甚至修改待执行的决策。 这里要区分两种时间方向:会话事件面向已经发生的事实,纠正应当留下新的记录; 执行前的钩子面向尚未发生的步骤,可以参与决定下一步怎么走。把两者混在一起, 「插件想改变行为」就会变成「插件悄悄改写历史」。
插件卸载时框架会撤销它注册的监听与其他需要清理的作用。但要清楚这条边界: 清理不会撤回插件此前已经发出的邮件、已经修改过的文件。
九、把助手接进已有应用
很多应用早已有成熟的对象模型与编辑能力:画布里有图形,文档里有段落,表格里有单元格。 比起重做一个应用,更省事的做法是在用户意图与已有对象之间加一层很薄的连接。
这层连接做三件事:
- 锁定对象:明确在修改期间它负责哪个范围,避免用户与助手同时改动相同对象。
- 序列化对象:把应用内部数据转换成助手与工具能处理的表示,例如一段结构化的文本。
- 界面:让用户选择对象、表达要求、查看结果,必要时可以停下来。
一个完整的流程通常是:用户选中若干图形并输入要求,系统锁定选区与其关联内容,把它们的表示交给会话; 助手提出替换内容,工具检查输出是否越出选区,通过检查的暂存,本轮成功结束后才真正应用; 停止、超时或失败时解锁并保留原状。每张画布对应一个会话,因而可以继续讨论、切换与恢复。
这比「把整张画布交给助手随便改」多了几条约束,却给人机协作划出了清楚的边界,失败恢复也从一开始就设计进去了。
还有一个方向是让界面本身由模型生成:输出一段可交互的界面,界面与会话之间建立双向通道, 用户在界面上的操作与当前状态送回上下文,模型再据此回应或更新界面。这里仍要分清职责: 表示、回合顺序与合法性由程序维护,模型负责选择与解释。
这三步做完,你就完成了一次架构假设的排查。做完之后再看这一章的架构例子,会顺很多。