第 3 卷
清单按位置排,不按 seq),会动的只有右边的刀口。
刀口划在哪由保留预算定:做法是从清单末尾往回累加 token,够预算就停,而且它至少留一个节点,再挤也会剩下最后一个。预算为 0 时,刀口就贴着清单最后那个节点,右边只剩它
一个。
刀口还可能被逼着往左挪:若正好落在一次“工具调用”和它的“结果”之间,这两条必须同进同出,于是再往左挪,直到不劈开为止(挑范围时一直守着这条线)。
示例走的正是 compactNow(保留预算 0),所以那一刀切下去,刀右边只剩 22 一个;而替换进去的 27 也落在清单上,replace 把切走的 7 个节点换成它一个,于是压缩后的清单
是“27 + 22”两条(27 里装的什么,正是下面第二点要讲的)。
- 替换进去的不是“裸摘要”,是一个 checkpoint 节点。
它由两段拼成:一段固定的英文“这是自动生成的检查点,把更早的一段对话压缩以腾出上下文……”,再加一段 <compacted-summary></compacted-summary> 包起来的摘要正
文。固定的框架是压缩后端写死的,摘要正文才是模型生成的(示例里用假模型回了句中文)。这样一个节点对模型来说就是“已确立的背景”,它知道这是压缩产物,会把它当既定事
实往下接,而不是当成一句新对话。
三个入口对应三种用它的方式:
compactIfNeeded(agent, trigger, signal)——自动:给一个触发(pressure 或 context-overflow),后端自己判断该不该压。
compactNow(agent, signal)——手动:/compact 这条命令走的就是它,不等有压力,趁会话空闲先压一次;没找到可压的范围会返回 null,真出问题(比如正压着、摘要
不够小、历史被改、落盘失败)则是抛错,不会悄悄给个 null。
次(自动压缩的标记得落在某一轮里)。
日常跑起来,自动那条(compactIfNeeded)挂在两个触发点上(auto 默认就开着;另外两个监听器只做记账,空闲时、模型答复后各重置一次溢出重试计数):
压力:每一步开始之前量一次(agent/pre-step)。够上阈值就先按前面说的裁一刀、再量,够低就到此为止,不必压(这一刀怎么裁是下一节的正题);还超才压,压完再量,
仍超就把范围重挑一段再压(compactionRetries 默认 1,最多压两轮),仍不达标就报错。
溢出:请求失败之后才动(agent/request-error),而且失败必须带上 CONTEXT_WINDOW_EXCEEDED:先裁一刀、再挑范围压,压完重试这一次请求,只要
surface 真被换过(裁那一刀也算)就重试,次数由 maxOverflowRetries 封顶。所以它不是每步都查,是撞上了才救。
手动那条(compactNow)是给“现在就想腾点地方”的场景;它和 compactRegion 这两条都只压不裁。
裁剪:先把超长的工具结果裁短
第三个问题:上下文里那些超长的工具结果,怎么裁?
摘要压缩是“兜底”,但它有个不划算的地方:它要花一次模型调用,换来的还是一段改写过的文字。而单个工具输出偏偏可能长得离谱,一个工具返回几万字符(再大些的巨型纯文本压根
不进上下文,走的是另一条通道,那是第 12 讲的事);对这样一条输出,原样留住头尾往往比写段摘要更管用,要逐字看的东⻄(命令、路径、报错)多半就落在开头那几行(裁剪默认
给头留 4096 个字符、尾只留 1024,差着四倍,正是这个道理)。
更划算的做法是先裁一刀,不花模型、纯规则:把它裁成“头 + 标记 + 尾”,中间那截换成一段写死的字面量 [... tool result middle pruned ...]。这就是裁剪(pruning),
所以自动压缩(compactIfNeeded)在动手之前,会先叫上裁剪服务:把那些超长的工具结果,在 surface 上替换成“头一段 + 一句标记 + 尾一段”(旧事件仍留在日志里)。
裁剪由一个独立的服务承担。它只看当前 surface、不翻日志,把上面每条 tool/result 的文本长度量一遍(有几个文本块就加起来算;长度按 Unicode 码点数,切片也按码点,不
会把一个字符劈成两半,哪怕它在内存里占两个码元;但由多个码点拼成的组合 emoji 仍可能被切开):超过阈值的,保留开头的 headChars、结尾的 tailChars,中间换成那一行
固定标记(它前后各空一行,这四个换行符也一起算进长度;结果里若夹着非文本块,原地不动)。一旦裁,裁完的文本长度是定死的:正好是“保留的头 + 那行标记 + 保留的尾”三者相
加,跟原文是几万字还是几十万字无关,只由这三个配置值决定。
裁出来的新结果,和压缩那条一样,也是一条追加进日志的新 tool/result 事件:用 surfaceOp: { op: 'replace', start: seq, end: seq } 精准替换掉原来那一条
(只换这一个节点),并带上 sourceEventSeqs: [原 seq] 留下“谁替换了谁”的凭据。
新事件把旧数据的其余字段(turn、step、callId、错误等)原样带过来,只换掉内容;
它前面还紧挨着一条只写日志的 compaction/prune,记下遮掉的是哪个节点、它值多少 token。旧的完整事件还在日志里,一条不丢。这也是为什么“裁剪”和上一讲的“配平”是同一
个精神:只追加、不改写,已有的字节一个都不动。
裁剪还是幂等的,同一份 surface 再扫一遍,不会再发出替换。这不是碰运气:配置在构造时就卡死一条硬约束,headChars + 标记 + tailChars 必须不大于 thresholdChars,
否则直接报错;于是每一刀的结果都被钉死,文本长度不超过 thresholdChars,且严格短于原输入(运行期还有一条断言复核这两点,源码注释标明按配置校验它不可能触发)。
判断要不要裁用的正是同一把尺子(只数文本块),所以下一次扫描看到它已经达标,就跳过了;严格变小这一条保证每一刀都会真的省地方,不会越裁越长或原地打转。
跑 npm run start:ch10:prune,看一个超长工具结果被裁前后的账: