乐于分享
好东西不私藏

Pi Agent 源码详解

Pi Agent 源码详解

最近,Pi Agent 频繁出现在智能体开发者的讨论里。OpenClaw 早期采用过它的运行时,Composio 的专项评测给出了亮眼成绩,DeepSeek Harness 又把 pi-ai 纳入了模型适配层,越来越多开源工具也开始直接复用它的包。热度背后,更值得研究的是一套克制而完整的 Agent 设计。

Pi Agent 是一套可拆分的 Agent Harness

大模型能够生成文本,也能给出结构化的工具请求。可它并不知道文件系统应该怎样访问,命令执行到一半怎样向界面报告进度,更不会自动保存会话、控制上下文长度或恢复到旧分支。这些连接工作都要由模型外部的程序完成。

Pi Agent 处理的正是这段中间地带。它通常被称为 Agent Harness,也就是把模型、工具、上下文、事件和会话组织成一次可运行任务的外壳。可以把模型想成一位能力很强、工作记忆却有限的工程师。Harness 像它的工作台,桌上放哪些资料,能拿哪些工具,每一步结果记在哪里,旧材料何时收起,都由这张工作台安排。

Pi 同时有三种身份。

  • 对使用者,它是一款可以在终端里工作的编码 Agent。
  • 对开发者,它是一组能够分层引入的 TypeScript 包。
  • 对研究 Agent 的人,它是一份规模相对克制、关键机制齐全的实现参考。

默认编码能力围绕四个核心工具展开,分别是 readwriteedit 和 bash,另外还有 grep、find、ls 等辅助工具。核心提示词也保持简短,更多上下文在运行时根据工具、技能和项目文件按需拼接。这个选择让行为更容易追踪,也把产品特有的能力留给上层扩展。

Pi 提供的入口并不局限于交互式终端。它还支持适合脚本消费的打印或 JSON 输出、供其他进程驱动的 RPC 方式,以及直接嵌入应用的 SDK。扩展、技能、提示词模板、主题和 Pi Package 则构成了主要的定制入口。同一套运行时由此可以落到终端产品,也可以藏在其他软件内部。

这种克制也有明确代价。它刻意不构建 MCP、子 Agent、计划模式、权限弹窗、后台 bash。很多产品级能力需要使用者自己补齐,安全隔离尤其不能靠默认设置。编码 Agent 继承当前进程能够访问的文件、命令和网络资源,需要更强边界时,应把它放进容器、沙箱或受限执行环境。

三层分工让复杂度各有归处

Pi 的主体可以沿着三层理解,另有一套相对独立的终端界面。

pi-ai 负责和模型通信。 它定义模型、消息、工具描述和统一的流式事件,把不同供应商的协议差异收进适配器。

pi-agent-core 负责维持任务循环。 它保存 Agent 状态,组织模型与工具之间的往返,发出生命周期事件,并提供上下文处理所需的扩展点。

pi-coding-agent 负责形成编码产品。 文件读取、文本搜索、命令执行、内容编辑、扩展系统和本地会话都在这一层靠近真实使用场景。

pi-tui 负责终端交互。 它处理流式文本、输入框和终端组件,本身不依赖 AI 包。界面层与模型层解耦以后,同一套 TUI 能服务不同后端,Agent 运行时也能换成网页或原生界面。

实验性的 pi-orchestrator 还在这套结构之外探索多 Agent 协调、RPC 进程通信和运行监督。它说明 Pi 的包结构可以继续向上组合,但不改变三层主干的职责。

分层的关键约束落在依赖方向上。越往下越通用,越往上越接近产品。上层可以直接使用下层的基础类型,下层不会反过来导入上层。移走 coding-agent 以后,agent-core 仍能独立运行。移走 agent-core 以后,pi-ai 仍是一套完整的模型访问层。

类型也沿着同一方向逐步变丰富。pi-ai 的 Tool 只描述模型能看到的名称、说明和参数结构。agent-core 的 AgentTool 加入真正的执行能力。coding-agent 的 ToolDefinition 再补充界面展示、扩展和产品配置字段。消息类型也遵循相同规律,从模型协议能够理解的 Message,扩展到 Agent 内部使用的 AgentMessage。

这种递进避免了一个常见问题。若所有字段都塞进同一个万能对象,模型适配层会被迫知道界面细节,界面也会意外依赖某家模型协议。Pi 让每一层只承担自己能够作出决定的部分。

复用路径因此很清楚。只想统一模型接口,可以只取 pi-ai。已经有自己的界面和工具体系,可以接 agent-core。希望快速获得完整编码体验,再使用 coding-agent。判断分层是否有效时,可以看三点,依赖是否单向,类型是否按职责递进,移除上层后下层是否仍能独立使用。

Agent Loop 驱动一次任务持续运行

Agent 能连续完成任务,靠的是一个不断重复的循环。模型读取当前上下文,决定回复文字或调用工具。工具执行完,结果被放回上下文,模型继续判断下一步。直到没有新的工具调用和待处理消息,或者遇到明确的终止条件,这次运行才会结束。

Pi 把一次完整运行叫作 Trace,把其中一次模型调用以及随后的一批工具执行叫作 Turn。一个 Trace 往往包含多个 Turn。Trace 负责表示一项连续任务,Turn 则给事件、日志和界面提供更细的观察边界。

每个 Turn 大致经历六步。

  1. 汇总当前消息、系统说明、可用工具和模型配置。
  2. 发起模型流式调用,把思考、文字和工具请求逐段送出。
  3. 读取完整助手消息,分析停止原因和其中的工具调用。
  4. 有工具请求时准备参数并执行这一批工具。
  5. 把工具结果按原调用顺序追加进消息历史。
  6. 发出轮次结束事件,再检查上下文、外部干预和停止钩子。

流式输出采用原位更新。助手消息开始生成时,系统先把一个部分状态的消息壳放进上下文,后续片段到达时持续替换最后一项,完成后再写入最终状态。界面可以实时渲染,消息数量不会随每个文本片段增长,会话记录也不会被切成许多碎片。

模型消息里的 stopReason 有五种结果。toolUse、stop 和 length 来自模型接口,分别表示产生工具调用、自然结束和长度截断。error 与 aborted 由流式层补入,用来表达调用失败和用户中止。后两种属于硬停止,循环不会再检查排队的后续任务。

实际驱动循环的条件比 stopReason 更精确。系统会检查助手内容里是否真的存在工具调用,还要确认工具批次没有要求终止。即使返回 length,只要已经生成了完整的工具请求,工具仍会执行。即使返回 toolUse,若工具结果满足终止规则,循环也会停下。这里以可执行内容为准,避免把供应商给出的结束标签当成唯一事实。

工具的终止结果采用整批判断。只有这一批工具结果全部标记 terminate,批次才会直接收束。这样,一个辅助工具提出结束时,不会吞掉同批其他工具仍需交还模型的结果。产品还可以通过 shouldStopAfterTurn 在完整轮次结束后施加额外停止条件。

内层循环继续运行的条件由两部分组成,仍有工具调用,或仍有待处理消息。prepareNextTurn 可以在下一轮模型调用前调整上下文和模型配置,外部输入也能在明确的时点进入,不必侵入模型流式请求的中间状态。

Pi 为外部消息安排了两条队列。

steering 用于及时改向。 它会在进入循环前和每个工具轮次结束后检查。用户发现方向偏了,可以要求停止继续搜索或更换方案,新指令会进入当前 Trace。

follow-up 用于排队续作。 它只在内层循环自然退出后检查。当前任务完成,再把下一项要求接到同一个 Trace 和事件流里,适合先分析、随后整理结果的交互。

两种队列的处理时点不同。steering 关注当前任务的方向,follow-up 关注当前任务完成后的接续。Agent 因而不必把所有新消息都解释成中断,也不会把它们悄悄塞进正在生成的模型请求。

统一协议隔离多模型差异

不同模型服务看起来都在接收消息、返回内容,接入以后很快会碰到四类差异。它们使用的消息格式不一样,流式传输机制不一样,推理强度参数不一样,提示词缓存规则也不一样。若这些判断直接进入 Agent Loop,核心逻辑会随着供应商数量一起膨胀。

pi-ai 用三段结构隔开变化。

最上面是统一入口。调用方提供模型、上下文、工具和选项,不需要知道请求最终会发往哪种协议。

中间是一套公共流式事件。文本、思考和工具调用都有开始、增量与结束状态,整个响应再用完成、错误或中止事件收尾。Agent、终端界面和日志系统只消费这套事件。

最下面是供应商适配器。它把 Pi 的统一消息翻译成目标接口需要的请求,再把 SDK 返回的结构化片段或原始 SSE 数据翻译回来。新增供应商时,主要变化留在这一层,Agent Loop 和工具系统无需重新理解协议。

统一事件还有一个容易被忽略的价值。文本和思考可以边到达边显示,工具调用的参数也可以在流中逐步组装。上层不用等整条回复完成才知道发生了什么,也不用分别处理每家 SDK 的事件名称。

推理强度经过适配器映射。Pi 对上层暴露 off、minimal、low、medium、high 和 xhigh 等等级,供应商实现再把它转换成预算、开关或专有参数。某个模型不支持完整范围时,适配器把请求收敛到有效值。产品表达的是用户希望投入多少推理资源,协议细节仍由最了解供应商的一层负责。

缓存也没有强行使用同一种写法。稳定的系统说明和工具定义尽量位于前部,较新的用户消息形成滚动边界,各适配器再按供应商规则添加缓存控制标记。公共层提供稳定前缀与动态后缀的组织原则,供应商层负责具体落点。

失败仍然遵循统一流协议。网络异常、限流和主动中止会变成可识别的结束事件,已经生成的部分内容会留在助手消息里。Agent 能据此完成状态收尾,界面也能展示失败前已经收到的内容,不会因为异常抛出而丢掉半条回复。

统一层只能覆盖协议的公共部分。各家新增的推理参数、缓存行为和流式边界仍需持续维护。Pi 的做法是把这种变化集中起来,让变化不继续向循环、工具和界面扩散。

工具错误进入下一轮自我修正

模型发出工具请求,只代表它表达了一个执行意图,离安全、可靠的运行还有一段距离。Pi 先把请求送进一条受控管道,逐步完成校验、拦截、执行和结果整理。

工具类型与三层架构保持一致。pi-ai 的 Tool 向模型说明名称、用途和参数。agent-core 的 AgentTool 增加执行函数与进度回调。coding-agent 的 ToolDefinition 再补充界面和产品字段。模型描述、运行能力与产品呈现没有挤在同一层。

一次工具调用经过五个阶段。

  1. 定位工具并准备调用参数和执行上下文。
  2. 按工具的结构定义校验参数。
  3. 运行 beforeToolCall,让产品检查、修改或阻止操作。
  4. 执行工具主体,并接收它持续发送的进度更新。
  5. 运行 afterToolCall,整理结果并决定是否提前结束。

工具不存在、参数准备异常、结构校验失败、前置钩子阻止、工具主体抛错和后置钩子异常,这六类失败最终都会变成带 isError 标记的 ToolResultMessage。原始异常不会穿透工具管道打断 Agent Loop。模型在下一轮看到具体原因后,可以修正参数、换一条路径,或者向用户解释无法继续的原因。

错误文本因此属于执行协议的一部分。只有“调用失败”四个字,模型很难判断下一步。缺少哪个字段、哪个路径不存在、哪项操作被策略拒绝,这些信息越明确,自我修正越有机会成功。已知错误由工具给出有针对性的说明,未识别异常由框架把错误信息转换成统一结果兜底。

进度事件也有顺序保障。工具完成或失败以后,系统会先等待已经发出的 tool_execution_update 全部送达,再发布结束事件和结果消息。界面不会先看到报错,过一会儿又收到那次执行的最后一段进度。工具承诺结束后,迟到的更新回调也会被忽略,防止完成状态被旧消息反向覆盖。

批量工具调用分成准备、执行和收尾三个阶段。准备过程按顺序运行,因为前置钩子可能修改共享状态。执行阶段可以并发,但只要其中一个工具声明必须顺序执行,整批就改为串行。结束事件可以按实际完成时间出现,送回模型的 ToolResultMessage 仍按原工具调用顺序排列。

Pi 还把文件和进程操作抽成较小的 Operations 接口。工具只依赖读取文件、执行命令等必要能力,具体实现可以落在本机、远程环境、测试替身或沙箱。这样既便于单元测试,也允许产品替换运行环境,而无需改写工具的业务逻辑。

扩展钩子仍不等于系统级安全边界。它可以承担确认、白名单和审计,覆盖范围取决于产品配置。进程隔离、文件权限和网络限制才是更稳定的外部约束。可靠的工具系统需要同时拥有可恢复的内部管道和独立于模型判断的权限边界。

两层消息格式服务模型与产品

模型通常只认识用户消息、助手消息和工具结果。一款投入实际使用的编码 Agent 还要保存命令执行记录、自定义通知、压缩摘要和分支摘要。若把这些内部信息全部伪装成普通对话,模型上下文会被无关状态占据,界面需要的结构也会丢失。

Pi 因此维护两层消息。

AgentMessage 面向 Agent 自身。 它包含三种标准 Message,也允许产品加入自己的消息类型,服务界面渲染、扩展处理和会话恢复。

Message 面向模型。 它只保留 UserMessage、AssistantMessage 和 ToolResultMessage。工具结果通过 toolCallId 精确关联到助手消息中的工具请求,面向界面的结构化详情可以留在额外字段里,不必进入模型文本。

自定义能力来自 CustomAgentMessages。这个接口在核心包里默认是空的,coding-agent 再通过 TypeScript 的声明合并加入 BashExecutionMessage、CustomMessage、BranchSummaryMessage 和 CompactionSummaryMessage。核心包不需要依赖编码产品,应用层仍能得到完整的类型检查。

每次调用模型前,消息要穿过两道转换。transformContext 仍在 AgentMessage 层工作,可以裁剪历史、调整顺序或为当前任务补充上下文,输入和输出的类型不变。随后 convertToLlm 才跨过边界,把需要进入模型的自定义消息转换成标准 Message,其余内容在这里过滤。供应商适配发生得更晚,只负责把标准消息翻译成目标协议。

阶段顺序决定了各层能看见什么。若切换模型供应商,前两道转换无需改动。若新增一种产品消息,也不必修改所有供应商适配器。内部语义先被整理成模型语义,模型语义再被翻译成协议语义,职责由此保持稳定。

消息还可以拥有不同的可见范围。常规消息同时面向模型和界面。带 excludeFromContext 的 Bash 执行记录可以继续显示在 UI 与会话里,却不进入模型上下文。纯元数据则只参与持久化,两边都不渲染。模型所见、用户所见和磁盘所存因而可以分别控制。

这种设计没有强迫模型理解所有产品状态,也没有为了保持模型消息纯净而牺牲恢复能力。两层消息对应两个读者,各自得到需要的信息。

观察事件与干预事件分开处理

Agent 每走一步,界面、日志和扩展都可能关心。有人只想知道发生了什么,有人需要在动作发生前作出决定。Pi 用两套通道承接这两种职责。

核心生命周期一共有十类事件。Agent 层有 agent_start 和 agent_end,Turn 层有 turn_start 和 turn_end,消息层有 message_start、message_update 和 message_end,工具层有 tool_execution_start、tool_execution_update 和 tool_execution_end。它们按 Agent、Turn、Message、Tool Execution 的层级嵌套,界面可以据此还原一段运行的完整过程。

session.subscribe 面向观察者。Agent 把事件发给订阅者后继续运行,不读取监听器返回值,也不会等待它作出业务决定。终端渲染、日志记录、使用量统计和远程 SSE 推送适合挂在这里。订阅会返回取消函数,界面销毁或任务结束时可以解除监听,避免旧观察者继续收消息。

这种非阻塞语义也有边界。观察者内部的异步异常不会成为 Agent Loop 的业务结果,监听器需要自己处理错误。需要保证送达、重试或落盘的审计记录,不应只依赖一个普通 UI 订阅函数。

扩展事件面向干预者。Agent 会等待处理完成,也会读取返回结果。上下文可以在模型调用前被补充,工具请求可以在执行前被修改或阻止,工具结果也可以在返回模型前被加工。权限确认、策略过滤和结果审查都需要这种可等待语义。

两条通道分开以后,慢日志不会天然拖住任务,需要作决定的钩子也不会被当成普通通知略过。实现扩展处理器时,框架还需要隔离单个扩展的异常,避免一个插件的实现错误破坏所有监听者。

对工具调用这类有副作用的动作,失败策略应更保守。检查器自己报错时先阻止执行,再把原因交给用户处理,可以避免失效的安全扩展悄悄放行操作。这里的选择属于产品策略,Pi 提供的是能表达观察与干预差别的通道。

上下文工程支撑长期任务

上下文管理常被简化成对话太长以后做一次摘要。Pi 实际同时治理即将进入模型的输入和已经积累的历史。前者防止一次工具调用突然挤满窗口,后者让长任务能够跨过窗口上限继续运行。

工具输出首先接受行数与字节双重限制。参考实现的默认上限是 2000 行和 50KB,先触发哪一项就按哪一项裁剪。双重阈值能同时应对许多短日志和少量超长行,避免单一行数或单一字节规则留下明显盲区。

不同工具采用不同保留方向。读取源文件时优先保留开头,因为导入、类型和整体结构通常集中在前部。命令输出优先保留结尾,因为报错栈和退出状态往往靠后。搜索结果还会限制单行长度,防止压缩后的文件或生成内容用一行占满预算。

截断过程会照顾 UTF-8 边界和超长单行,避免把中文字符切成乱码。若输出被截断,完整内容会保存到临时文件,提示中写明位置。模型随后可以用读取工具查看相关片段,截断由此成为一次分层检索,而非不可逆的信息丢弃。

项目说明沿目录层级收集。系统先加载 Agent 全局目录里的说明,再从项目根目录走到当前工作目录,依次加入更具体的上下文文件。内容会带着来源路径进入提示词,模型能够分辨一条规则属于全局、项目还是当前子目录。

顺序决定了规则的解释空间。靠近当前目录的说明更具体,却不会把上层背景抹掉。模型同时看到从通用约束到局部约束的路径,可以在冲突时结合来源判断适用范围。

技能采用延迟加载。初始提示只列出技能名称、用途和文件位置,任务确实需要某项能力时,再读取完整的 SKILL.md。若把每项技能的全部说明都常驻上下文,大部分 token 会花在当前任务用不到的操作细节上。目录式提示保留了发现能力,也把正文预算让给真正相关的材料。

历史侧除了常规压缩,还提供分支摘要。用户从旧路径切到新路径时,系统先找出两条路径的最近公共祖先,只总结已经离开的分支,不重复当前路径共有的历史。分支摘要使用目标、尝试、发现、未解决问题和后续建议等较轻结构,再作为 BranchSummaryMessage 带到新分支。

分支摘要解决的是探索复用。方案甲虽然被放弃,里面发现的接口限制、错误原因和无效尝试仍可能帮助方案乙。最近公共祖先限定了摘要范围,模型拿到有价值的教训,也不会重复读取整段废弃路径。

输入裁剪、项目上下文、技能延迟加载、分支摘要和历史压缩共同构成上下文工程。它们共同调整有限窗口里的信息优先级,并为重要内容留下恢复路径。消息数量只是处理后的表面结果。

上下文压缩保留任务状态

Pi 会在一次 Agent 运行结束后判断是否需要压缩。压缩发生在两个 Trace 之间,不会在模型或工具正在执行时改写当前上下文。新生成的压缩记录由下一次 buildSessionContext 读取,运行中的事件序列因此保持稳定。

常规触发线由模型上下文窗口减去预留输出空间得到。实现还准备了溢出错误后的应急路径,防止粗略估算没有及时触发。参考版本用文本字符数除以四估算 token,这种方法对英文足够轻便,对中文占比较高的对话可能明显低估,因此不能把预防阈值当成唯一保护。

确定需要压缩以后,系统从较新的消息向前累计,尽量留下足够多的近期上下文。切点只能落在用户消息或助手消息边界,不能落在孤立的工具结果后面。否则模型会看到工具返回的答案,却找不到触发它的工具调用。

只允许在完整 Turn 之间切割,有时会让一轮特别长的工具交互占满保留预算。Pi 因此允许在助手消息边界切开 Turn。被切断的早期部分会单独生成 turnPrefix 摘要,近期后缀保留原文。主历史摘要与 turnPrefix 可以并行生成,随后合并为新的上下文入口。

两种摘要承担不同任务。主摘要覆盖较早的完整历史,使用六个稳定信息槽位,记录目标、约束、进展、关键决定、后续步骤和重要上下文。turnPrefix 只覆盖半个 Turn,采用原始请求、早期进展和后缀所需背景三部分,避免把局部状态写成一份过重的全局总结。

再次压缩时,旧摘要会作为已知状态参与更新。系统无需每次重读从会话开始到当前节点的所有内容,只需把旧摘要与新增历史合并。摘要由此是一份递增维护的任务状态,仍要接受新证据修正。

已读文件和已改文件会被单独追踪。系统从旧摘要与新增工具调用里继续累积两份清单,因为“模型读过”与“工具改过”对恢复任务的意义不同。重新进入任务时,模型可以知道哪些文件只用于理解,哪些文件可能已经改变工作区状态。

压缩过程会发出 compaction_start 与 compaction_end 事件。开始事件标明手动触发、阈值触发或溢出恢复,界面和日志能够解释这次延迟来自哪里,也能记录压缩前后的使用量变化。

生成结果保存为 CompactionEntry,其中记录摘要和 firstKeptEntryId。下一次构建上下文时,系统注入摘要,再从这个入口接上近期原始消息。旧历史没有被删除,压缩只改变当前路径交给模型的视图。

这种方式延长了任务寿命,也无法保证每个细节零损失。路径、约束、未完成动作和外部副作用需要稳定地进入摘要,关键项目规则还应保存在会话之外的项目说明中。压缩适合保留任务现场,不适合充当唯一事实库。

会话记录采用树结构存储

聊天界面常把会话画成一条直线,编码任务却经常回退。模型尝试方案甲,走了几轮发现不合适,用户退回某个节点改走方案乙。若直接覆盖后续消息,旧尝试里的证据、失败原因和文件变更线索都会消失。

Pi 的会话记录采用追加式树结构。每条 Entry 都包含 type、id、parentId 和时间戳。节点只记录父节点,不维护子节点列表。追加新记录时无需回写父节点,磁盘上的旧数据始终保持不变。

当前的 leafId 决定正在使用哪条路径。新增消息时,将它的 parentId 指向当前叶节点,再把 leafId 移到新记录。回退只需要把叶指针移向旧节点,随后继续追加,就会从那里长出一个新分支。追加操作保持常数级,分支也无需复制整段历史。

恢复上下文时,系统从当前叶节点沿 parentId 一路走到根节点,再把结果反转,得到当前分支的时间顺序。其他分支仍保存在文件中,却不会进入这次模型调用。JSONL 的一行一条记录与这种追加方式自然匹配,普通写入不需要重写整个文件。

会话共有九类 Entry,可以按作用分成三组。

  • MessageEntry、CustomMessageEntry、CompactionEntry 和 BranchSummaryEntry 会影响模型上下文。
  • ModelChangeEntry 与 ThinkingLevelChangeEntry 用来恢复运行状态。
  • LabelEntry、SessionInfoEntry 和 CustomEntry 保存标签、会话信息与产品元数据。

构建当前路径时,模型与思考等级遵循最后一次设置生效。状态变更本身也是树上的节点,切回旧分支以后,系统读取的是那条路径上的最近设置,不会把另一条分支后来选择的模型误带过来。

压缩记录同样属于树节点。CompactionEntry 用 firstKeptEntryId 指明原始消息从哪里继续,前面的内容由摘要代替。旧节点没有擦除,其他分支也没有失效。回退、分叉、压缩和恢复因而可以落在同一种数据结构上。

分支切换还可以选择生成摘要。branchWithSummary 会提炼离开路径上仍有价值的信息,再把摘要接到新分支。纯回退适合彻底重来,带摘要的分支适合保留旧探索里的证据和限制。

写盘时还有一个细小的产品判断。会话不会在只有用户输入时立刻创建持久文件,而会等到首条助手消息出现,再把此前内容一起写入。这样可以减少用户取消输入、模型尚未返回时留下的孤儿会话。进入正常运行后,每条新 Entry 都立即追加,降低进程意外退出造成的损失。

存储抽象需要准确区分两套实现。agent-core 提供通用的 SessionStorage 接口,并有 JSONL 与内存参考实现。coding-agent 的 SessionManager 直接管理自己的 JSONL 文件,复用了会话类型,却没有实现前述接口。开发 Web 版本时可以为 agent-core 实现数据库存储,但不能把 coding-agent 的 SessionManager 直接当作同一接口替换。

整文件重写只出现在复制分支、修复会话等少数维护操作中。日常消息、状态变更和压缩都沿追加路径写入。这个选择用少量磁盘空间换取可追溯性,也让会话恢复不依赖一连串易碎的撤销操作。