第 4 卷
我们看场景二:
复制代码 1--- ③ 场景二:工具调用已发出,崩了(磁盘 5 条)---
2 3--- ④ 新进程 load 场景二:8 条 ---
4seq 0turn/start 5seq 1step/start
6seq 2user/message 7seq 3assistant/message
8seq 4tool/call
| 9 | seq5 | tool/result | error.code = TOOL_OUTCOME_UNKNOWN |
|---|---|---|---|
| 10 | 「The tool call was interrupted after it was rec…」 | ||
| 11 | seq6 | step/end | |
| 12 | seq7 | turn/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 |
|---|---|
| 2 | seed: tool.events, |
| 3 | meta: { |
| 4 | parentSession: 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' } })
跑出来是这样: