AI Agent 上下文工程:流式中断、压缩、缓存与持久化

流式输出与中断

流式输出:像水流一样输出文字内容,逐字打印,避免用户在猜测模型是卡住了还是在干活,流式输出就刚好可以解决这个问题,同时,用户在监控输出时,也能随时中断,避免 agent 在错误的方向一去不复返。

先补一个协议知识:模型想用工具时,会在回复(assistant 消息)里带一批 tool_calls,每个有唯一 id;协议要求每个 id 后面必须跟一条对应的 tool 消息(工具执行结果)。

看一下 Ctrl+C 那一刻的消息序列:

call_read 没有对应结果——原样发出去,服务端拒收(400)。而且流是中途切断的,call_read 的参数可能停在 JSON 中间({"path":"READ)。怎么处理这个残缺序列,决定了中断后会话能否继续。

悬空 tool_call 的报错原文:

OpenAI 大意是 “An assistant message with ’tool_calls’ must be followed by tool messages responding to each ’tool_call_id’";

Anthropic 的版本是 “tool_use ids were found without tool_result blocks”。

“parse 留到拼完之后"之所以顺手,是因为 s02 的分层里 dispatch 本来就在那时才 parse——分层带来的便利。

还有一个 readline 细节:终端在 readline 手里时处于 raw 模式,Ctrl+C 不产生进程信号,而是触发 rl 的 'SIGINT' 事件——所以 rl.on("SIGINT") 和 process.on("SIGINT")(非 TTY 时)都要监听。

解决方案

流式侧:SSE 按行缓冲解析(凑齐完整的一行才处理一行),tool_calls 的分片按 index 装配,parse 留到拼完之后。中断侧:一个 AbortController 贯穿 HTTP 层与工具循环,已装配的半截消息照常保留在历史里;对 Ctrl+C 留下的悬空 tool_call,给每个没有结果的 tool_call 回填一条合成 tool 消息,把序列配平——会话就能继续。

image-20260818165809397

实现:

agent.mjs

demo.mjs

loop-budget.mjs

stream.mjs

① SSE 解析:按行缓冲,而不是按 chunk 处理

SSE(Server-Sent Events)是流式输出的传输方式:stream: true 时服务端保持连接打开,把回答切成小片逐个推送,每片是文本流里的一行:

data: 开头是数据行,空行是分隔符,data: [DONE] 表示结束。看起来逐行 parse 即可,但你收到的单位不是"行”:网络传输切分数据时不管语义,一个 chunk 可能停在 data: {"cho 中间,甚至停在一个 UTF-8 多字节字符中间。所以必须按行缓冲,凑齐一行才处理一行(见上文内嵌的 stream.mjs 源码):

② tool_calls 分片:按 index 装配

文本增量直接拼接即可。tool_calls 的增量是被切碎的 JSON 字符串:第一片带 id 和函数名,后续片只带参数的几个字符;并行调用时多个 call 的分片交错到达,靠 index 归位:

常见错误:每收到一片就 JSON.parse——必然失败,{"comm 不是合法 JSON。装配阶段只拼接,parse 留到拼完之后。

③ 中断信号:一个 AbortController 贯穿 HTTP 层与工具循环

中断不能只设布尔标志——正在传输的 HTTP 流不会检查标志。把 AbortController 的 signal 传给 fetch,abort 时连接立即断开;断开会让读流代码抛错,确认是主动中断(signal.aborted)就吞掉错误,已装配的半截消息照常返回——用户看到的内容必须留在历史里,否则模型下一轮缺上下文。

中断也可能落在两次工具执行之间,每次派发前再检查:

Ctrl+C 的策略:第一次中断本轮,第二次退出进程——“停下这一轮"和"退出程序"是两个意图,分开处理(readline 监听细节见文末)。

④ 悬空 tool_call 的修复:回填合成结果

残缺序列有三种处理方式:

做法结果
把半截 assistant 消息整个丢掉不会 400,但用户看到的那半截回答不在历史里,下一轮模型缺失上下文
原样保留,直接发400
保留 + 回填合成 tool 结果序列配平,会话继续

回填就是给每个没有结果的 tool_call 补一条假的 tool 消息。文案是写给模型看的界面(s02),要说清两点:

说"不是失败”,模型不会误判为工具故障而绕路;说"可以重新调用”,用户说"继续"时它知道从哪接。顺带把断在 JSON 中间的参数改为 {}(部分后端会校验它)。修复函数幂等——对完好序列无操作,重复执行安全。

运行:

node .\02-上下文缓存与记忆\s05_streaming_interrupt\demo.mjs

image-20260818171033662

练习

  1. 中断有时过重——用户只是想补一句"顺便用 –verbose 跑",不想丢弃正在生成的回答。codex 和 Reina 都支持 steer:不打断当前流,把用户插话排队,等本轮迭代提交后作为下一条 user 消息注入。给本章 agent 加上这个能力:轮次进行中输入的文字不触发中断、进入队列(提示:难点在插入位置——工具结果和插话的先后顺序,想想为什么插在 tool 结果之后比之前安全)。

  2. 思考题:合成结果的文案,"(用户中断了执行,该工具未运行)" 与 codex 风格的单词 “aborted”,各会把模型引向什么行为?构造一个"中断后用户说『继续』“的场景,推演两种文案下模型的下一步动作差异。

构造场景

用户说"运行测试并读取 README”。模型发出两个工具调用 [call_test → npm test, call_read → read_file README],中途 Ctrl+C 落在 call_test 跑完后、call_read 执行前。修复后历史是:

推演两种文案下模型的下一步

长文案(“未运行 / 不是失败 / 可以重新调用”):

  • 模型读到三块明确信息:测试跑完了(结果在)、README 没跑、不是失败。
  • 用户说"继续" → 模型确定地知道缺的是哪一环:call_read 没跑过 → 直接重新调用 read_file README → 然后汇总。恢复路径是确定的、可预期的。

codex 的 “aborted”:

  • 模型只知道"这个调用被中止了",但不知道:跑了没有?算不算失败?要不要重跑?
  • 用户说"继续" → 模型开始猜:
    • 猜对了:重新 read README(结果一样,但靠的是推理不是指导);
    • 猜错了:把 aborted 当"出错了" → 触发它的失败启发式(“换一条路”),可能把 npm test 也重跑一遍(“保险起见”)——如果测试有副作用,就是白白浪费甚至出错;
    • 或者拿不准 → 反问"您要我继续做什么?"(摩擦,打断流畅性)。

结论

维度长文案“aborted”
“继续"后的行为确定地续上(重调中断的调用)靠猜,可能重跑错的/反问
防"误判为失败”✅ 明确说"不是失败"❌ 可能触发失败启发式、绕路
防"以为跑过了"✅ 明确说"未运行"❌ 可能以为已跑完、跳过
token 成本 / 简洁贵一点便宜
文案污染模型语气可能被模型学舌基本不会

一句话:长文案是"把恢复路径写进协议",“aborted” 是把决策交给模型的猜测。长文案牺牲一点 token,换"继续"这个高频操作的高确定性;codex 选短词,是因为它的产品里修复文案极少被模型真正"用到",它赌的是模型自己会推断——这是产品取舍,没有对错,看你把"继续"这种场景当高频还是低频。

练习 1 的实现思路(steer)

机制一:并发读输入 + 队列

放弃 rl.question,改用 rl.on(“line”) 常驻监听,再配一个"空闲等待"的 Promise:

主循环改成:先看队列有没有货,有就直接起一轮;没有才等键盘:

这样 turn 跑着的时候用户输入的文字会进 queue,一个字节都不打扰正在生成的回答。

机制二:安全点注入(核心难点,提示里的"为什么"在这)

队列有了,关键问题是插到 messages 的哪个位置。先看一轮迭代提交后消息序列长什么样:

为什么插在 tool 结果之后才安全?

① 协议层:tool_call 配对是硬约束。这一章整章都在跟这个错误搏斗——协议要求 assistant 消息里的每个 tool_call,必须紧跟对应 id 的 tool 消息,中间不能夹别的角色(stream.mjs 里修复函数都专门注释了"有些后端要求 tool 消息紧跟发起它的 assistant 消息")。如果你把插话插成这样:

下一次 chat() 发出去,assistant 的 tool_calls 和它的 tool 结果之间隔了一条 user 消息 → 协议破坏 → 400。你等于亲手制造了本章前半段刚修好的那个 bug。

② 语义层:模型得先看到结果,才能听懂插话。插在 tool 结果之前,模型回应"顺便用 –verbose 跑"时还不知道 call_A 的结果——比如 npm test 明明已经失败了,它却可能拿着插话去"继续跑测试"。先给结果、再给插话,模型才能带着完整上下文决定"哦,测试失败了,那我用 –verbose 重跑看细节"。

所以安全点就是当前迭代的所有 tool 结果都入列之后:

放在 runTurn 里 budget.recordTurn(records) 前后都行,关键是在这一批 tool 结果 push 完之后、下一次 chat() 之前。

边界情况(想清楚就是真懂了)

  1. 模型在流式输出、还没发工具调用时插话 → 没有 tool 结果可等。那就等这轮迭代结束:如果它发出 tool_calls,插在该批结果后;如果它直接给最终答案(无 tool_calls),runTurn 返回,主循环里的 queue.length ? queue.shift() 会立刻把它当下一轮的开场。两条路都不会破坏配对。
  2. 插话排队时不打断 → 你代码里 dispatch 是同步的(execSync),line 事件只在 await 缝隙里触发,天然安全。
  3. Ctrl+C 和 steer 并存 → 两者意图不同,分开处理:Ctrl+C 仍走 abort,正常输入进队列。

与真实产品对照(延伸阅读)

Reina(本系列对照的生产级 agent)的对应机制在 packages/providers/src/tool-pairing.ts:normalizeToolPairing 做双向配平——除了本章的"悬空 call 合成占位输出"(合成文案同样说明"可能被中断丢失,需要时重跑"),还处理反方向的"孤儿结果":一条 tool 结果找不到发起它的 call,同样会被后端拒收(“No tool call found for function call output with call_id …")。codex 的做法是把孤儿直接丢弃;Reina 把它降级为普通文本消息——因为 Reina 的孤儿消息里常含有真实信息,直接丢弃会损失内容。这套修复在生产里静默执行,但 REINA_STRICT_TOOL_PAIRING=1 时会直接抛错——配平失守说明上游某个不变量被破坏,开发环境里应当尽早暴露。

引擎侧的中断在 packages/core/src/engine.ts:interrupt() 除了 abort,还会立刻换上一个新的 AbortController 并清空待处理队列——这曾引出一个隐蔽 bug:换新之后,工具批次里尚未执行的调用读到的是新 controller 的未中断 signal,会照常执行;所以 Reina 在批次内每次派发前检查的是 session.interrupted 标志位,而不是 signal。另外中断不只有 Ctrl+C 一种:进程崩溃、断电也是中断——recoverInterruptedTurn() 在重新加载会话时检测"卡在 running 状态的工具调用”,统一标记失败并回填错误文案。这依赖会话落盘,见 s08。

上下文压缩

实现压缩器

把旧消息换成摘要。但压缩自带一个隐蔽问题:摘要是模型写的,转述必然走样——第一次压缩,你的指令变成"用户在重构日期工具函数";再压一次,变成"用户在优化项目代码"——最后 agent 停下来问你想做什么。它没报错,只是把指令转述丢了。

解决方案

压缩不是"全部换成摘要":用模型生成的结构化摘要替换中段历史,同时逐字保留启动本轮任务的用户消息、原样保留最近的尾部(模型正在用的工作记忆)。既然转述会走样,有些内容必须逐字保留过压缩。

触发时机用服务商报告的 usage 判断,不自己数 token;摘要让模型按栏目填写,不是笼统总结;摘要模型调用失败时降级为纯字符串处理的提取式摘要——压缩不能因摘要失败而毁掉会话。

image-20260818172556191

实现:

agent.mjs

compaction.mjs

demo.mjs

loop-budget.mjs 与 s05 完全相同(防空转看门狗),这里不重复贴,见上方 s05 小节。

① 触发时机:用服务商报告的 usage,不自己数 token

第一反应是自己算 token 数。但你没有服务商的分词器,本地估算(tiktoken 也一样)只是近似,中文误差可超 15%。估少了会在真实窗口边界撞出 context too long,来不及压缩。

正确做法零成本:每次响应都带 usage,是服务商报告的准确数字。total_tokens 约等于下一轮要携带的全部历史,超过窗口阈值(默认 75%)就压缩:

留 25% 余量:压缩本身还要调一次模型、摘要也占空间,得在仍有余地时动手。

② 压缩的形状:三段式,启动消息逐字保留

压缩不是"全部换成摘要"。正确形状是三段:

尾部是模型的工作记忆,压掉等于打断手头操作。启动消息逐字保留是本章核心:压缩总在工具循环中途触发,“最近 N 条"全是工具结果,原始指令恰好落在被压缩区——被转述后,模型从此在转述版指令上工作(真实产品踩过同坑)。

实现是把分割点回拉到最后一条真实用户消息:

注意 isRealUser:历史里的 user 消息不全是用户说的——s03 看门狗注入的纠偏消息、上次压缩留下的摘要也是 role:"user"。锚点停在它们身上,指令仍会丢,所以按已知前缀([上下文压缩]、自动纠偏触发:)排除合成消息。

回拉有上限:启动消息若在几百条之前,全保留就腾不出空间——超限时放弃,由下一条兜底。

③ 摘要 prompt:按栏目填写,不是笼统总结

“总结一下上面的对话"得到的是泛泛叙述,丢的恰好是接续任务最需要的信息。让模型按栏目填写,每栏对应压缩后第一轮会用到的内容:

第 1 栏的"逐字引用"是决定②的双保险:即使启动消息因超长没能逐字保留,原话也还在摘要里。第 5 栏容易被忽略:不记录走不通的路,模型会把失败路径重走一遍。

④ 摘要失败时的降级

摘要要调一次模型,而调用可能限流、超时、断网。此刻会话已接近窗口上限,下轮再试来不及——压缩不能因摘要失败而毁掉会话。所以要一条不会失败的降级路径:提取式摘要,纯字符串处理,截每条消息首尾拼成骨架:

有损的记忆也比崩溃的会话好。

⑤ 回退与分支:压缩产物随切点走,不是易失缓存

聊天产品迟早要做"撤回 / 从这条消息创建分支”。此时会话有两份历史:完整文字稿(渲染层展示用,从未被压缩)和模型视图(压缩后的 [摘要 + 尾部],新消息双写进两份)。回退最顺手的实现,是从完整文字稿重建、把摘要当派生缓存清掉——反正超阈值还会再压。这个"反正"就是坑:会话长到压缩过,裁到切点的原文几乎必然仍超阈值,于是每次回退/分支都白付一次摘要调用,用户看到的是"从哪回退都触发压缩”。更隐蔽的是重摘要不幂等:新摘要保留的细节和旧摘要不同,回退一次,agent 的记忆就洗牌一次。

判据只有一句话:切点消息在压缩后视图里找得到 ⇒ 旧摘要只覆盖切点之前的内容 ⇒ 摘要随分支保留,把压缩后视图按切点裁剪即可;找不到(撤回进了被压缩区)⇒ 旧摘要概括了刚被撤回的"未来",复用会把撤回的内容泄漏回去 ⇒ 这种情况才丢弃摘要、用原文重建:

前者是高频路径——用户几乎总是撤回最近几条;后者才需要付重摘要的钱。值得一提 codex 的架构让这条判据不需要写出来:它的 fork 是对持久化事件流做前缀截断,而压缩本身就是流里的一条 Compacted 事件(带着替换历史)——切点在它之后,它自然留在前缀里;切点在它之前,它自然被截掉。快照式的 fork(复制状态对象、清空派生字段)没有这份免费午餐,两条分支都得手写,而且很容易全部写成第二条。

运行:

免 key 演示:

接上真实模型(想看压缩,把 AGENT_COMPACT_PERCENT 调到 5,让它连续读几个大文件):

输出节选(真实运行):

练习

  1. 本章每次压缩都从头生成摘要。改成滚动摘要:把上一次的摘要作为输入传给摘要模型,并在 prompt 里加一条"上一份摘要中仍然相关的部分逐字复制,不要改写"。想想为什么"逐字复制"比"合并改写"更重要(提示:和启动消息逐字保留是同一个道理——转述会累积走样)。

  2. 思考题:压缩后的摘要消息每轮都会重发一遍。它的内容是稳定的吗?如果你在摘要里加上"压缩于 {当前时间}",会发生什么?(这与成本有关,下一章展开。)

练习 1 的答案:滚动摘要

思路(三步)

  1. 在 compactMessages 里先找中段里有没有上一份摘要——它是以 [上下文压缩] 开头的 user 消息,一定排在被压中段的最前面;
  2. 把上一份摘要的内容剥壳后,和本轮新增对话一起拼成摘要模型的输入;
  3. 在 SUMMARY_PROMPT 里加一条"逐字复制"的指令。

改动一:SUMMARY_PROMPT 加一句

改动二:compactMessages 传滚动输入

为什么"逐字复制"比"合并改写"更重要

合并改写 = 让模型把"旧摘要 + 新对话"重新提炼成一份新摘要。但旧摘要本身就是上一次压缩的转述产物——再转述一次,走样叠加:

这正是 s06 开头那个症状,只不过这次发生在摘要层:转述 → 转述 → 转述,信息每层丢一点,最后任务指令面目全非。

逐字复制 = 给旧摘要一个"比特级保真"承诺:一旦某个事实进了摘要,后续所有压缩里它都原样存活(除非被新内容取代)。于是走样只发生一次(第一次写摘要时),不随压缩次数累积。

和启动消息逐字保留是同一个道理——都是对抗"转述走样累积",只是防护手段不同:

防护对象手段防的是什么
启动消息切片:把它留在压缩区外别被转述
旧摘要逐字复制:让它原样携带过压缩进了也别被改写

一个防"别进压缩区",一个防"进了也别被改写"——两条防线合起来,任务指令才活得过长任务。

练习 2 的答案:摘要里加时间戳

摘要内容稳定吗?

稳定。摘要消息在压缩那一刻构造一次,之后每一轮都原样重发,字节不变。这个"稳定"不是巧合,而是后面 prompt 缓存(s07)能生效的前提:服务商按请求前缀做缓存,前缀字节不变 → 每轮只有新增的尾巴需要重新计算 → 省钱省时。

加"压缩于 {当前时间}“会怎样?

分两种情况:

时间戳怎么算内容稳定?后果
压缩那一刻固定(构造一次)✅ 仍稳定缓存不坏,但加它毫无意义——纯废话
每轮重算(发送时的新鲜时间)❌ 每轮都变前缀缓存键每轮都换 → 缓存全 miss → 每轮把整个上下文(几万 token 历史)重新算一遍 → 成本、延迟成倍上升

真正的坑是第二种:写代码时图省事用了 new Date() 且每次构建消息都重算——你亲手把字节稳定变成了字节不稳定,缓存机制瞬间失效。

设计铁律

进入"每轮重发前缀"的内容必须确定性:不能有每轮变化的当前时间、随机 id、易变状态。判断方法很简单——这条消息下一次发送时,字节会不会变?会变就不该放进去。这条原则到 s07(prompt 缓存)会正式展开。

与真实产品对照(延伸阅读)

本章是 Reina(本系列对照的生产级 agent)packages/core/src/compaction.ts 的最小化移植。先补正文各决定在生产版里的出处:

  • 决定①:Reina 的 sessionContextTokens 同样以 provider 报告的总量为准,本地 o200k 估算只在拿不到 provider 数字时兜底(会话第一轮、或服务商不报 total)——源码注释原话:估算比真值 “typically ~15-20% low”。窗口大小同样是配置出来的(models.json);个别服务商的模型列表接口会给 context_length,但不可依赖。
  • 决定②:正是 Reina 修复过的问题(commit ce4724f “keep the launching user message verbatim through compaction”);上游 agent 框架 hermes 也遇到过同一问题(issue #10896)。
  • 决定④:Reina 的 buildCompactSummary 是同样的结构:模型摘要用 try/catch 包住,任何异常落到 extractiveSummary,源码注释原话——“so compaction never blocks the main turn”。

生产版多出的部分同样值得了解:

  • 触发阈值不是固定的 75%:有效窗口 = 窗口 − 20k(给输出留的),默认再留 13k 安全垫(400k+ 窗口留 30k、800k+ 留 50k);用户可用 REINA_COMPACTION_TRIGGER_PERCENT 换成百分比语义。另有净收益门槛:可压前缀不足 2000 token 就拒绝压——否则 /compact 每次剥一条小消息、再注入一条差不多大的摘要,永远压不完。
  • 回拉上限是有效窗口的 25%(COMPACT_TAIL_USER_ANCHOR_WINDOW_FRACTION),超限时靠摘要里的逐字引用段兜底——和本章 maxAnchorChars 同构。
  • 摘要 prompt 是 9 个栏目(Primary Request and Intent / Errors and Fixes / All user messages / Current Work / Next Step…),要求先写 <analysis> 草稿再输出 <summary>,且用 prompt 前后双重围栏禁止工具调用——因为部分 OpenAI 兼容端点会无视 tools: [] 照样发起工具调用。
  • 被压掉的历史没有消失:全文落盘到 .reina/conversation_history/<sessionId>.md,摘要消息里附路径指针,模型需要旧细节时可以自己去读——这是 s04"无损溢出"思想在压缩上的复用。
  • 决定⑤是 Reina 真实修过的坑(2026-07):forkToMessage(创建分支)和 truncateFromMessageIndex(撤回/删除消息)最初都无条件丢弃 modelMessages/summary/compactBoundaries,表现正是"从哪回退都再压一次”;修复后用同一句判据(切点消息是否还在压缩后视图里)决定复用还是重建。codex 侧的对应物:Compacted 检查点是 rollout 流的一等公民,重建从最新存活检查点的 replacement_history 起步(rollout_reconstruction.rs),且有集成测试逐字断言 fork 后的请求仍含压缩摘要(compact_resume_fork.rs —— “after-fork user texts should preserve compacted user history prefix”)。

Claude Code 的行为也可以观察到:上下文快满时状态栏出现 “Context left until auto-compact: 8%",压缩后它对之前任务的记忆变成摘要形式——但最初的任务指令还在,是同一套机制。

Prompt 缓存

跑一个 30 轮的任务,账单会吓你一跳:agent 每轮都把越来越长的对话历史全量重发,第 1 轮发 2k token,第 30 轮就是 100k,累计发送的输入是历史长度的几十倍——花钱大头全在输入侧。

功能相同的两个实现,账单可以差出近 7 倍——不命中缓存的原因往往只是 system prompt 里的一行 当前时间:...。

解决方案

服务商为此提供了前缀缓存:这次请求的开头和上次逐字节相同的部分,单价降到约十分之一。原理:模型收到请求后要把整个 prompt 从头算一遍(prefill,占输入侧几乎全部算力);开头和上次相同时,中间结果(KV cache)可直接复用,几乎不花算力。第 30 轮请求里前 99% 与上一轮相同——命中打一折,不命中全价。以 30 轮、每轮 60k、累计输入 1.8M token 为例:

  • 全部未命中:1.8M × 全价
  • 稳定 95% 命中:1.8M × (5% × 全价 + 95% × 一折) ≈ 0.145 × 全价,省 85%

(DeepSeek / OpenAI 的缓存自动生效;Anthropic 要显式打 cache_control 断点,否则命中率恒为 0。)做法是三条纪律保持前缀逐字节稳定,加一套仪表测量命中率。

实现:

agent.mjs

demo.mjs

① 三条纪律:保持前缀逐字节稳定

请求的开头依次是 system prompt、tools(工具定义)、历史消息,三条纪律分别对应:

  1. system prompt 逐轮字节稳定。时间戳、随机数、“剩余预算 7 轮"这类每轮会变的内容会破坏缓存——变化在最开头,之后的 tools + 全部历史都按全价重算。会变的信息挪到最后一条用户消息:尾部本来就是新字节。
  2. tools 数组顺序稳定。工具定义和 system 一起序列化在请求最前部。不要运行时排序、按条件增删、往 description 里拼动态内容。
  3. messages 只追加,不改写(append-only)。任何一个字节被改动,缓存就断在那里,之后全部退回全价。常见错误是好心截短旧的工具输出——省几十 token,赔掉整段后续缓存(demo 实验 C)。

这三条纪律难的不是做到,而是在后续迭代中不被悄悄破坏——缓存击穿是静默的,没有报错,只体现在账单上。

② 测量:读取 usage 里的命中数据

不打印命中率,就无法验证纪律是否生效。服务商在 usage(响应附带的用量字段)里报了账,但字段名不统一:

每轮打印一行统计(第一轮命中 0% 正常——要有上一次请求才有可复用前缀):

健康的 agent 从第二轮起命中率应在 90% 以上,且随任务变长越来越高。异常时对照下表排查:

现场诊断
每一轮都是 0%前缀在最开头就断了——system 或 tools 里混进了每轮变化的字节(时间戳最常见)
一直 95%+,某轮突然跌到 50%历史中段被改写了——检查是否有代码在回头修剪旧消息(违反 append-only)
压缩后的第一轮跌到接近 0%预期行为:s06 把历史整段重写,缓存必然失效一次(system + tools 那一小段还能命中),用一次全价换之后每轮更短的前缀
命中率正常但账单没降确认服务商确实支持前缀缓存并读对了字段——读不到字段时本章 agent 会明确提示,而不是打印一个假的 0

往尾部追加的机制天然无害(如 s03 看门狗的纠偏消息),危险的只有回头改写。

③ 记账:把每次击穿折成 token 数,并归因

②的统计行和排查表有一个前提:有人在看。但缓存击穿是静默、偶发的——TTL 过期只在用户走开又回来的那一轮出现,一个改写历史的 bug 可能只在特定分支触发。没人会每轮盯着命中率,等发现时只剩一张说不清原因的账单。

所以在每轮统计之上再加一层会话级记账。它不需要请求原文(字节对比是实验室条件,生产里没人保留每轮几十万字符的 payload),只吃 provider 每轮报回的 usage 数字,靠一个推断补出"应然”:上一轮的整个 prompt 刚被算过一遍,这一轮它的每个 token 都应该是缓存读。差值就是被重计费的浪费:

配三条防误报纪律,缺一个数字就不可信:

  • 噪声地板:断点/块粒度天然造成小额差异(DeepSeek 64 token 一块),1k token 以下不计;
  • 首轮与不报账的服务商不计:第一轮没有参照;从不报缓存字段的服务商,cacheRead=0 说明不了任何事——但只要它报过一次,之后的 0 就是真未命中(用一个"报过账"的粘性标记区分两者);
  • 压缩边界重置:s06 把历史整段重写,那次击穿是买来的(用一次全价换之后更短的前缀),记进浪费会把预期成本和意外事故混在一起——压缩时把追踪器清零。

最后一步是归因。浪费数字本身分不清"正常损耗"还是"代码有病”,能分清的是闲置时长:距上一轮超过 TTL(Anthropic 默认 5 分钟),是缓存被服务商淘汰,人回来了它自然回来;没超 TTL 就是前缀被改写了,直接去查 append-only(②表格第二行的现场,现在有了数字和时间戳)。demo 实验 D 里第 4 轮和第 6 轮的 usage 数字长得几乎一样,判词完全相反——归因就差在 idleMs 这一个维度上。

累计值(总浪费 token 数 + 击穿次数)放进会话状态持久化,在 UI 上常驻一行。它和②的关系是仪表盘和示波器的关系:②告诉你为什么断(对着排查表看现场),③告诉你断了多少钱(不看的时候也在记)。

运行:

免 key 演示:

输出节选(直接算出缓存断点位置):

接上真实模型:

给它一个多轮任务(比如"看看这个目录的结构,再读一下最大的那个文件"),观察每轮统计行:第一轮命中 0%,之后应迅速升到 90%+。对照实验:把 SYSTEM 第一行改成 `当前时间:${new Date().toISOString()}`,重跑同样的任务——命中率归零,直接看到这笔差价。

练习

  1. 给本章 agent 加一个 /cost 命令:按你所用服务商的真实价目(命中价、未命中价、输出价)把会话累计花费折算成钱,并对比"如果全部未命中"的假想账单。成本可见,纪律才可验证。

  2. 思考题:s06 的压缩把历史整段重写,缓存必然失效一次。压缩的阈值(75%)和缓存之间存在一个权衡:压得越早,缓存失效越频繁;压得越晚,每轮承担的未命中风险越大。如果服务商的缓存保留时间很短(Anthropic 默认 TTL 只有 5 分钟;DeepSeek 是小时级的闲置淘汰),这个权衡又会怎么变?(s09 讲子代理时会再回到"前缀即资产"这个视角。)

  3. ③的归因只有两档(TTL / 前缀改写)。给"前缀改写"再往下分:在开发模式下保留最近两轮请求的序列化文本,击穿且 TTL 未过时自动跑一次实验 A–C 那样的字节 diff,把断点落进 system / tools / 历史第几条消息,直接报"凶手是谁"。想清楚为什么这只能是开发模式的功能——生产里留全量 payload 的代价是什么?

练习 1 的答案:/cost 命令

实现思路(三步)

  1. 在 s07 agent.mjs 的 printUsage 旁边加一个会话级账本,把每轮的 miss / hit / output token 累加起来;
  2. 定价做成可配置对象(以实际服务商价目为准),命中价、未命中价、输出价;
  3. REPL 里拦截 /cost 命令:算实际花费 + “全部未命中"的假想花费,打印对比。

REPL 里拦截 /cost(不发给模型):

为什么"成本可见,纪律才可验证”

缓存纪律是静默的——不报错,只体现在账单上。没有数字,就无从判断一个改动有没有效果。有了 /cost,你可以做对照实验:把时间戳挪出 system 前后各跑一段任务,看那行"实际花费"的差——差出来的,就是这套纪律的钱。

练习 2 的答案:压缩阈值 vs 缓存 TTL

权衡的基本形状

  • 压得早(阈值低):压缩更频繁 → 缓存失效更频繁(每次压缩 = 一次全价 miss);
  • 压得晚(阈值高):每次压缩间隔长,但每轮携带的历史更大 → 一旦前缀断了(TTL 过期 / 任何改写),整段大历史按全价重算。

缓存保留时间短(Anthropic 5 分钟 TTL)时怎么变

前提先变了:即使不压缩,用户离开 5 分钟再回来,缓存本来就没了——缓存是易碎品,不是长期资产。于是两个理由都指向"压得早更好":

  1. 压缩牺牲的那个缓存前缀,反正很快就过期——没什么可损失的;
  2. 不压的话,每轮拖着巨大的历史,一旦 TTL 过期(短 TTL 下频繁发生),整段按全价重算的代价更大;把历史压小,即使全价重算也便宜。

一句话:TTL 越短,缓存越像易碎品,越不值得为它推迟压缩;TTL 越长(DeepSeek 小时级),缓存越是持久资产,越值得压低压缩频率去保住它。

这也回扣 s07③"压缩边界重置追踪器"的意义:压缩那次 miss 是买来的(用一次全价换之后更短的前缀)。TTL 短时这笔买卖更划算——反正缓存也会自己过期。

练习 3 的答案:开发模式的断点归因

为什么只能是开发模式

保留每轮全量 payload 有三个代价:

代价说明
内存 / 磁盘正比于 上下文长度 × 轮数。100k token 的请求 ≈ 几百 KB,长会话几十轮就是几十 MB,还要在内存里留两份做 diff
隐私 / 安全全量对话文本常驻内存/日志 = 泄密面。真实产品连字段名都要避开 /token/i 打码——全量 payload 是更大的靶子
性能每轮对两份几十万字符做字节 diff,是 O(上下文) 的额外计算,生产里每轮都付不划算

开发模式正好相反:低流量、短会话、你要的就是细节。这就是"仪表盘 vs 示波器"的延伸——生产看 ③ 的累计浪费(便宜、安全),开发用字节 diff 精确定位(贵、敏感,但值得)。

实现骨架

locateSection 靠段偏移表把断点字节 n 落进具体段;配合 s07③ 的闲置时长,就能报"TTL 未过 + 断点在第 3 条工具消息中间 = 有人改写了历史",而不是一句笼统的"前缀被改写"。

与真实产品对照(延伸阅读)

计价细节:DeepSeek 的缓存命中输入价约为未命中的 1/10;Anthropic 的 cache read 同样是基础输入价的 0.1 倍,但缓存不自动生效——要在请求里显式打 cache_control 断点,且缓存写入按 1.25 倍计价。只学本章三条纪律、直连 Anthropic 而不打断点,命中率会一直是 0。

用回归测试守住前缀稳定:Reina 为此写了 packages/core/src/engine-prompt.cache-stability.test.ts:把所有每轮会变的状态(todos、计划、笔记、autopilot 进度、记忆块)全部填满,断言 system prompt 一个字节都不变——这些易变状态走另一条通道 buildVolatileContextReminder,作为尾部消息随每轮追加(正是纪律①的"挪到最后")。测试注释说明了它存在的意义:某天有人把一个每轮会变的值接进 buildSystemPrompt 时,让测试显式失败。

③的浪费计数器:Reina 的实现在 packages/core/src/cache-stats.ts(2026-07 从 pi 的 cache-stats 移植,纯函数一共 60 行):trackCacheUsage(prev, usage, now) 返回下一个追踪状态和本轮的 miss,min(prev, curr) - cacheRead 那行公式、1024 的噪声地板、reportedCache 粘性标记、TTL 归因与本节一一对应;压缩边界的重置由引擎在 compaction 完成时清掉追踪状态。累计值进 session.usage.cacheWaste 随会话持久化,UI 的用量弹窗常驻一行 “Cache waste”。与它互补的 diagnoseCachePrefix(stderr 根因诊断)就是练习 3 的方向——一个说多少,一个说为什么。移植时还踩了个真实坑:trace 打点的字段起名 missed 而不是 missedTokens,因为日志脱敏按 /token/i 打码,后者会被涂成星号。

案例:摘要调用复用主 agent 的缓存前缀。s06 的压缩要把整段被压历史发给摘要模型——看起来注定全价:换了 system prompt(摘要指令)、不带 tools,前缀从第一个字节就对不上。一次压缩 = 10 万 token 全价。Reina 的解法(packages/core/src/compaction.ts,搜 SummaryCacheReuse):摘要调用不换 system,直接复用主 agent 刚缓存过的 [system + tools + history] 前缀——被压缩的大段历史按原样作为消息发出(provider 序列化和上一轮逐字节相同),摘要指令作为追加的一条用户消息放在末尾。于是 10 万 token 的历史按缓存读计费,全价的只有末尾几百 token 的指令。代价是一点风险:主 agent 的 system prompt 鼓励用工具,模型可能不做总结而去调工具——所以指令里带上"禁止调用工具"的围栏,一旦模型仍去调工具,立刻放弃、退回换 system 的常规路径重试。最坏情况多花一次几乎全命中的调用,不会拿到降质的摘要。

案例:fork 子代理继承父会话的缓存。多 agent 场景的痛点是冷启动:每个子代理独立的 system + 历史,第一轮全价。Reina 的 fork 模式(packages/core/src/subagent/fork.ts 的 buildForkContext)让子代理直接继承父会话的消息尾部(按 token 预算选,默认 8k、上限 24 条,且刻意停在消息边界上)——子代理的第一轮请求就是"父会话的前缀 + 一条任务指令",第一轮即可命中父缓存。一次 fan-out 的成本是 “supervisor’s prefix + small delta per worker”,而不是 N 次冷启动。子代理的完整机制在 s09 展开,这里先记住:缓存友好是它的初始设计,不是事后补丁。

Claude Code 的实例:它同样遵守这套纪律——system prompt 会话内稳定,动态上下文(文件变更提醒、todo 状态)全部以 <system-reminder> 消息追加在对话尾部,而不是改写 system。transcript 里那些 reminder 的位置,就是纪律①的实例。

粒度与序列化细节:缓存匹配实际按 token 块粒度对齐(DeepSeek 64 token 一块,Anthropic/OpenAI 也有各自的最小长度和断点规则),demo 用字符近似只是为了让断点位置直观可见,原理一致。system 与 tools 在请求前部的先后顺序因服务商而异,Anthropic 是 tools 在前。

会话持久化与恢复

agent 干了半小时的活:几十轮对话、二十次工具调用。你按错了 Ctrl+C,或终端误关、笔记本没电——重新打开,它什么都不记得。前几章的 agent 都这样:messages 只存在内存里。

常见的第一反应是"存成 JSON 文件,每次变化整个重写"。这个方案有隐患——demo 场景一真实写磁盘、真实模拟崩溃,展示了这个现场:

解决方案

落盘的不是状态快照,而是事件:每发生一件事就往 JSONL 文件末尾追加一行,已写下的字节永远不被触碰;恢复时逐行重放事件,把 messages 重新推导出来。崩溃的影响从"整个文件可能写坏"缩小到"最后一行可能写了一半"——跳过那行即可:丢一条消息,不丢整个会话。

实现:

store.mjs

agent.mjs

demo.mjs

① 为什么"每次全量重写 JSON"不可靠

写文件不是原子操作。writeFileSync(f, bigJson) 在操作系统层面是"打开(清空旧内容)→ 分块写入字节"。崩溃落在中间,磁盘上就是半个 JSON:旧版本已清空,新版本没写完——JSON.parse 失败,整个会话(包括崩溃前完好的部分)一起丢失。演示场景一就是这个现场。

而且越用越危险:会话越长,重写窗口越大,中招概率越高——最有价值的长会话恰恰最容易被写坏。

追加式日志(JSONL:一行一个 JSON)从结构上消除了这个问题:

每发生一件事(用户消息/助手消息/工具结果/压缩边界)就在末尾追加一行,已写下的字节永远不被触碰。崩溃的影响被限制在"最后一行可能写了一半"——恢复时跳过那行即可:丢一条消息,不丢整个会话。这不是额外的容错,是"只追加"结构自带的性质。

② 恢复 = 重放:状态是事件流的推导结果

落盘的是事件,不是状态。恢复时逐行读事件,把 messages 数组重新推导出来:

这个结构带来两个不显眼但重要的自由度:坏行可以跳过(容错);未知事件类型可以忽略(向前兼容——新版本程序写的日志,旧程序照样能加载它认识的部分)。

③ 会话粒度的配置也在流里

恢复会话时,模型配置从哪来?很多实现顺手用当前的环境变量或全局默认。这是错的:用什么模型是这个会话自己的属性,创建时就冻结进第一行 session_meta,恢复时以它为准:

配置跟着会话走,界面显示和实际请求才不会不一致(真实产品踩过事故,见文末)。

④ 工具调用带结构化 status 落盘

s03 有个临时方案:靠报错文案的开头文字(FAILURE_RE)判断工具调用是否成功。文案是写给模型看的,随时会改——拿它当机器判据太脆。本章移除这个脚手架:成败在执行那一刻确定,作为结构化字段落盘。

约定很简单:handler return = completed,throw = failed;报错文案原样回给模型(错误即信息),但"失败了"这个事实走字段:

落盘的 tool_call 事件带着这个 status。从此审计、重放、监督逻辑都读字段,不再解析文案。

运行:

免 key 演示:

三个场景全部真实写磁盘、真实模拟崩溃。场景三的输出节选:

接上真实模型,启动时分岔:带 --resume <id> 就重放恢复,否则开新会话并打印 id:

验收:跟它聊两轮、让它读个文件,Ctrl+C 退出,再 --resume 回来问"刚才聊到哪了"——它应该答得上来。s03 看门狗注入的纠偏消息也走 pushMessage:恢复出的会话必须和退出前一致。

练习

  1. 给 store.mjs 加一个 compacted 事件和对应的重放逻辑:记录"从第 N 条消息之前已被压缩为摘要 S",重放时用摘要替换被压缩的区间。s06 的压缩机制落盘之后,才算完整闭环。

  2. 思考题:demo 场景三里半截行恰好在文件末尾,跳过它显然安全。但如果坏行出现在文件中间(比如磁盘坏块),跳过一条 message 可能让后面的 tool 消息变成"孤儿"(tool_call_id 对不上助手消息)——API 会拒绝这样的序列。恢复时该怎么检测并修剪这种断链?(提示:s05 处理 Ctrl+C 留下的残缺消息序列用的是同一套办法。)

练习 1 的答案:compacted 事件

设计

新增事件类型 compacted,记录"本次压缩把最前面的 N 条消息替换成摘要 S"。压缩永远发生在中段开头(s06 的三段式:中段 = 最旧的部分),所以重放规则是:遇到 compacted 事件时,把 messages 里最前面的 before 条移除,换成摘要消息。

store.mjs:重放分支加一个 case

agent.mjs:压缩完成后把事件落盘

两个要点

  1. 为什么是"移除最前面 N 条"而不是"移除最后 N 条":压缩总是从消息流开头动手(s06 三段式:中段 = 最旧部分)。事件日志是时序的,重放到 compacted 事件时,messages 数组 = 该时刻之前所有消息 = [中段, 尾部],中段恰好在最前面。
  2. 向前兼容的甜头:事件从不删除,老代码就算不认识 compacted 事件(default 分支忽略),也能重放出一份未压缩的完整历史——还是可用会话,只是更费 token。加了 compacted 处理,恢复出的视图才和退出前一致。

练习 2 的答案:坏行断链的检测与修剪

场景

坏行在文件中间,跳过它可能让 assistant(tool_calls) 或 tool 结果丢失,剩下的序列出现两种残缺:

  • 孤儿 tool 消息:tool_call_id 在整段历史里找不到任何 assistant 声明(声明它的那条被坏行吞了)——留着会让 API 400;
  • 悬空调用:assistant 声明了 tool_calls,但结果缺失。

检测 = 双向配平(s05 的同一套办法)

协议约束没变:assistant(tool_calls) 和它的 tool 结果必须配对。s05 处理"中断撕开"的残缺序列,这里处理"日志坏块撕开"的残缺序列——本质都是补配平。重放结束后做两次扫描:

两种残缺的处置

残缺处置为什么
孤儿 tool 消息丢弃协议要求 tool 消息必须紧跟发起的 assistant,没有声明 = 无法配对 = 400
悬空调用回填合成结果(s05 的 repairDanglingToolCalls)或整条丢弃二选一是产品取舍——回填保留上下文,丢弃更干净但可能丢信息

与 s05 的区别

s05 的缺口在末尾(中断只会停在最后),回填即可;坏块可能在中间,两边都可能缺——所以这里要多一步"丢孤儿"。demo 场景三说"半截行在末尾,跳过安全"——这道题就是问"不在末尾怎么办",答案就是配平修剪。

与真实产品对照(延伸阅读)

本章机制对应 Reina 的 packages/core/src/rollout.ts(参照 openai/codex 的 rollout recorder 建模):每个会话一个 .reina/sessions/<id>.jsonl,每次状态变化 append 一行 { ts, type, ... }。示例版三种事件类型,生产版二十多种(message / tool_call / tool_update / compacted / usage / todos……)。几个值得参考的生产细节:

  • 不变量写在注释里:“Writes are O_APPEND only. No code path ever rewrites an existing byte”——跨进程的并发写者也无法互相覆盖历史。进程内则常驻一个文件句柄、用 Promise 链串行化所有 append(示例版每次重开文件,崩溃安全性一样,性能差一些)。
  • 重放跳过坏行:loadRolloutAsSession 对每行单独 JSON.parse,失败(“likely a torn final write from a crash”)就跳过并告警 skipped N malformed line(s)——和本章 demo 场景三相同。
  • 工具调用是结构化记录:packages/protocol/src/index.ts 的 ToolCallRecord 带 status: "pending_approval" | "running" | "completed" | "rejected" | "failed"——比示例版的两态多出审批流和运行中;还有 outputPath 指向 .reina/tool_outputs/ 下的完整输出存档。
  • 决定③的真实事故:Reina 曾在加载旧会话时,模型选择器显示新会话的默认值,而不是会话真正在用的(重放恢复出来的)模型——一个绑定了订阅的会话看起来像在用普通 API key,请求 401,用户以为是配置错误,排查了很久。模型配置随会话重放之外,还有一个细节:config 事件对 model 是整体替换而非浅合并——浅合并会让上一个模型的 baseUrl 泄漏到切换后的模型上,Reina 注释里记着一次真实事故:kimi 切 codex 后残留的 baseUrl 把请求路由到了错误的主机。
  • 仅有的"全量重写"出现在迁移旧格式时(migrateJsonSnapshotToJsonl),而且写法是先写临时文件再 rename 进位——rename 在同一文件系统上是原子的,崩溃也不会留下半个 jsonl。

另一个可观察的例子:Claude Code 的会话也是 JSONL(~/.claude/projects/<项目>/**.jsonl),--resume 的底层就是同一套重放事件流。