← 返回目录

第 4 卷

第 4 / 6 卷日期:

我们看场景二:

复制代码 1--- ③ 场景二:工具调用已发出,崩了(磁盘 5 条)---

2 3--- ④ 新进程 load 场景二:8 条 ---

4seq 0turn/start 5seq 1step/start

6seq 2user/message 7seq 3assistant/message

8seq 4tool/call

9seq5tool/resulterror.code = TOOL_OUTCOME_UNKNOWN
10「The tool call was interrupted after it was rec…」
11seq6step/end
12seq7turn/end{"turn":1,"reason":{"kind":"interrupted"}}

多出来的第一条是 tool/result,但它装的不是编造的结果,而是一个诚实的交代,而且分两种:

tool/call 已经写进日志(调用被记录为已开始)→ 补 TOOL_OUTCOME_UNKNOWN:这次调用已被记录为开始,但没有持久化的结果,结果未知。请根据工具语义决定:只读或幂

等的才重试;可能有副作用的,先核实外部状态或问用户,不要盲目重试。

连 tool/call 都还没来得及写(只有模型消息里的调用意图)→ 补 TOOL_NOT_STARTED:这次调用在记录为开始之前就被中断了,需要的话重试即可。

这两句话不是随便写的。它明确区分了“可能已经发生了副作用”和“肯定没发生”:前者要谨慎,后者可以放心重试。修一份崩溃的日志,还要替模型把能不能重试想清楚,这才是配平真

正的分量:不只是把结构补闭合,还要把后果交代明白。

load 有两个规矩,都是讲什么时候不该用它。

理由很实在:一个还活着的会话,它的权威状态在内存里,磁盘上的可能只是还没 flush 完的片段。这时候补一条合成的 turn/end 进去,反而是在给一个还在跑的轮次盖棺定论。所以

规则是活着的会话请用内存里那个对象;只有冷会话(进程已经没了)才谈得上崩溃修复。

规矩二:只是想看看,就用接口里那个“看”inspect。它跟“读”(load)返回的内容一样(都是配平后的 header + 事件),区别只在动不动磁盘。这点可以用日志的修订号

(listSnapshots() 返回的那个不透明 token)量出来:

操作返回修订号变了吗
inspect(id)5条否——只在内存里配平,一个字节都不写
load(id)5条是——它把合成的收尾真正写了回去

所以想看看这份日志现在什么样用 inspect,真的要恢复才用 load。

至于撕裂的部分:最后一条记录只写了一半(压缩 frame 不完整、或最后一行残缺)时,后端会从 frame 开头截断,丢掉那一截,再重新编码前面完整的记录。只丢最后那一截,不丢

前面做过的。

还有一条边界值得知道:修复只闭合它认识的括号,turn、step、工具调用。插件自己声明的开闭对(下一讲要讲的上下文压缩就用 compaction/start / compaction/end)core

一概不碰,留给插件自己判断;而它判断的依据,正是上面那条 session/end-seed:出现在边界之前的未匹配标记,属于一个已经结束的生命周期。

不过这条判断还有个容易被漏掉的前提,它只覆盖本会话自己继承的那些括号。另一个并发存活的会话,完全可能在同一段历史上持有开放括号,而它自己的边界却在别处。所以要容忍并

先说清楚:Resume 不是崩溃之后才有的动作——任何一份落了盘的日志都能读回来接着干。崩过的那份只是要多一道修补,所以才排在上一节后面讲。

我们先把这两节分清楚,它们名字像,其实是两件事:

崩溃修复管的是日志:它断在哪儿就补到哪儿,让这份历史重新变得可读;

Resume 管的是会话:拿这份(已经完整的)历史当起点,开一个会话继续往下干。

一个修过去,一个开未来。它们不是两件各管各的事,而是同一条流水线的上下两道,下面第一步那个 load,既是上一节讲的修复动作,也是把修复成果交出来的那一步。所以 Resume

不是修完之后另起炉灶,而是直接接过修好的日志往下走。

日志已经读回来了,接下来是把它变回一个能继续干活的会话。三步:

复制代码

1//①从持久化读回(冷会话,顺带做崩溃修复)*const tool = await ctx.sessionPersistence.l
2seed: tool.events,
3meta: {
4parentSession: toolId, //记下父会话*seedLength: tool.events.length,
5},
6})

7 8// ③ 接着干

9resumed.append('turn/start', { turn: 2 })

10resumed.append('user/message', ask('那算了,换个问题'), { surfaceOp: 'append' })

11resumed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })

跑出来是这样:

全站分卷索引 · 6 卷
  1. 1第 1 卷
  2. 2第 2 卷
  3. 3第 3 卷
  4. 4第 4 卷
  5. 5第 5 卷
  6. 6第 6 卷