Skip to content

[Bug] 规则全文嵌在请求前缀 + 跨轮历史重建,导致整段 prompt cache 失效(0.21.0 实测定位) #1926

Open

Description

@huangyoudashi

What happened? / 问题描述

长会话里主会话模型的 prompt cache 会被两类「前缀抖动」整段打断,单次约 8 万 token 全量重算。在 0.21.0 上实测并定位到两条目前 issue 区没有报告的线:

  1. AGENTS.md 规则全文嵌在请求前缀头部:会话中途改规则文件,下一轮请求的 developer 系统提示里规则全文随之变化,缓存从规则段起全部失效;
  2. 跨轮历史重建:超长轮结束后,新一轮首条请求把历史整体重新序列化——剥离全部 reasoning 条目、按另一套策略重选工具调用——即使什么都不改,缓存也会从历史开头全部失效。

(同一会话另有一次未命中是子代名册变化导致,#1903 已修且已包含在 0.22.0-beta.1,本单不展开。)

Steps to reproduce / 复现步骤

线 1(规则全文进前缀):

  1. 开一个已运行较长的会话
  2. 中途编辑 ~/.pi/agent/AGENTS.md(或项目规则文件)
  3. 发下一条消息:该请求系统提示「context」段内嵌的规则全文随文件变化,前缀从头部被改写

线 2(跨轮重建):

  1. 跑一个超长轮(实测样本:单轮 25 分钟、88 次模型调用、上下文约 10 万 token)
  2. 该轮结束后发下一条用户消息
  3. 新一轮首条请求:上一轮一直保留的 104 条 reasoning 条目被全部剥离,73 对工具调用/结果换成另一套选样(轮内请求保留最近 73 对,跨轮请求保留开头+结尾的另 73 对),历史条目 254 → 152,且没有生成任何摘要条目——不是 compaction,是换了一种序列化

Expected behavior / 预期行为

  • 会变的内容(规则全文、budget reminder、工具激活提示)不放在请求前缀头部:放请求尾部、按需读取,或会话内冻结、变更用尾部消息送达
  • 跨轮沿用轮内已有的条目序列(例如只剥 reasoning、其余原样保留),重选/裁剪只发生在上下文确实超出预算的场景

Actual behavior / 实际行为

实测会话共 14 次主模型调用:

时间 未缓存输入 缓存读取 说明
12:25 9,902 0 冷启动,正常
12:30–12:43(9 次) 每次 300~9,000 11,520→71,424 正常追加
12:48 82,742 0 子代名册变化(#1903,已修)
12:49、12:54 3,374 / 268 84,096 / 99,968 恢复正常
12:55 80,605 3,840 规则编辑 + 跨轮重建(本单两条线)

两次大未命中合计约 16 万未缓存 token,占该会话全部未缓存输入的 83%;去掉这两次,命中率约 95%。

定位证据(对比网关记录的相邻两次请求体,12:55:02 → 12:55:36,间隔 34 秒,同会话同渠道):

  • developer 系统提示在规则段处变化(16 行差异,与会话内那次 AGENTS.md 编辑内容完全吻合);命中的 3,840 token 恰好等于规则段之前的固定头部;
  • 历史条目构成:reasoning 104 → 0;function_call/function_call_output 73/73 → 73/73 但不是同一批;无摘要条目;
  • 同会话更早的轮次边界(上一轮结束后的首条请求)原样保留了前一轮仅有的 2 条 reasoning,说明重建并非每个边界必触发;本次约 10 万 token 的上下文远未到需要裁剪的量级。

App version / 应用版本

0.22.0-beta.1(实测会话发生在 0.21.0;0.22.0-beta.1 已包含 #1903 名册修复,与本单两条线无关)

Operating system / 操作系统

Windows

Extra environment / 其他环境信息

PI-Desktop 0.22.0-beta.1 · win32 x64 · protocol 11 · host 0.22.0-beta.1;模型经自建 OpenAI 兼容网关接入,缓存数字来自网关逐请求记账,与客户端 usage 记录逐条一致

Logs / 日志

定位方法:网关侧保存的请求体工件逐条对比 + 本地会话 jsonl 的 usage 记录。已排除网关侧原因:同渠道同账号、无 fallback/重试/超时重发、17 个工具定义与缓存键前后一致。

补充

  • 定位依据是请求体工件对比与会话文件记录,未读 PI 源码,机制推断置信度中高。
  • 这两条线与 ADR 0337 提到的 TOOL_ACTIVATION、budget reminder 属同族问题(易变内容改写靠前的前缀段),但 issue 区均无对应报告;[Feature] Compaction 复用当前会话的 Prompt Prefix #1830 讨论的 compaction 摘要是另一种机制,与线 2 不同。

Activity

  1. vastsa commented on Oct 27, 2026

    @vastsa
    Owner

    感谢你提供这么详细的复现步骤、网关数据和请求体对比,定位信息很有帮助。欢迎直接提交 PR,我会尽快审阅;如果实现前想先讨论方案,也可以继续在这里交流。

  2. huangyoudashi commented on Oct 27, 2026

    @huangyoudashi
    Author

    补充分支:新会话的头部缓存永远无法跨会话共享(0.21.0 与 0.22.0-beta.1 均存在)——系统提示前部嵌入了会话 scratch 路径

    现象与定位

    对比任意两个会话的 developer 系统提示(各 43,720 字节,结构相同):首个分歧固定出现在字节 3,200 附近,分歧内容就是嵌在头部里的会话 scratch 目录路径(...\.pi-desktop\scratch\<sessionId>,前 3.2k 字符内出现两次明文)。把两个头部的会话 UUID 归一替换后,其余 4 万字节逐字节一致(末尾仅差一个生成时间戳元数据字段)——即除会话 UUID 外头部完全相同,却被这个 UUID 提前截断了所有跨会话缓存。

    后果

    每个新会话的首次请求只能命中前 512 token,其后整个头部(约 1 万 token,含技能列表、规则全文)全部按未缓存输入计价。实测连续 4 个同模型主会话(间隔 1–2 分钟,缓存必然新鲜)首调全部为 input≈10,14x / cache_read=512:

    会话 版本 首调 input / cache_read
    #1 14:21 0.22.0-beta.1 10,138 / 512
    #2 14:23 0.22.0-beta.1 10,140 / 512
    #3 14:24 0.22.0-beta.1 10,148 / 512
    #4 14:34 0.22.0-beta.1 10,132 / 512

    0.21.0 同样存在:两个 0.21 会话的头部在**同一位置(字节 3,200)**嵌着各自的 UUID,非新版回归。另有旁证:无近期同模型会话时首调 cache_read=0(连 512 都没有)。

    对照

    同一任务、同一模型(deepseek-v4.1-flash)在 OpenCode 下:首调 input=146 / cache_read=16,640(其头部跨会话字节稳定,直接命中缓存),主线命中率 96.9% vs PI 74.6%。差距主体就是这笔每会话 ~1 万 token 的头部冷启动——且会话越短、命中率被拉得越难看。

    建议

    会话级易变内容(scratch 路径)不要放在系统提示前部。可选做法:挪到系统提示末尾或以尾部消息送达;或删除头部中的明文路径、只保留环境变量说明(PI_SCRATCH_DIR / $env:PI_SCRATCH_DIR 本就已注入运行环境,两处明文属重复信息)。

    与主楼两条(规则全文进前缀、跨轮重建)同族——易变内容改写靠前的前缀段;本条触发频率最高:每开一个新会话付一次。

  3. muzimu217 commented on Oct 27, 2026

    @muzimu217
    Contributor

    线 1 已核实,属实;而且同一个函数里已经有先例注释,说明这个原则团队是知道的。

    composeSystemPrompt()(packages/agent-runtime/src/runtime.ts:2486-2510)把 projectInstructionsPrompt(this.projectInstructions) 放进 context 段(:2500-2505),也就是请求前缀;project-instructions-prompt.ts:3-12 会把每个规则文件的全文拼进 # Project instructions 段。而紧邻的 :2496-2499 注释写着:「The resumable-delegation list deliberately stays out: it changes on every settlement, and a system-prompt change rewrites the request prefix provider prompt caches key on (ADR 0337).」——原则已经写在旁边,只是规则全文(用户随时会编辑的文件)仍然留在前缀里。所以会话中途改 AGENTS.md,下一轮从规则段起整段失效,与你的测量一致。

    修法可以照抄那条先例:把规则全文移出 system prompt,改为会话内冻结 + 变更时用尾部消息送达(或按需读取),system 段只保留一句稳定指引。projectMemoryPrompt(:2488)同理,它也是可变文件。

    线 2 我确认了结构上的不对称,但还没把剥离点钉死:跨轮首条请求走 buildSessionContext()(packages/agent-runtime/src/session-context.ts:94-104)从持久化条目重建,而轮内请求用的是 agent 的实时消息;sessionEntryToContextMessages(:53-92)对 type: "message" 是原样透传,所以 reasoning 消失要么是因为持久化条目里本来就没有 thinking 块,要么发生在 provider 载荷转换(requiresCompletionsReasoningReplay 那层)。需要再跟一步确认,我下一轮补结论。

    不过「跨轮重建 vs 轮内实时」这个不对称本身就是前缀缓存失效的结构性来源:只要两条路径的序列化不完全一致,每个 turn 边界都会重算一次历史。这条值得单独提 issue 跟踪。

  4. muzimu217 commented on Oct 27, 2026

    @muzimu217
    Contributor

    线 2 的剥离点补上了,结论是:重建路径无法复现带签名的 thinking,所以任何一次从 transcript 重建都会丢掉 reasoning,与哪个 provider 无关地偏向了 DeepSeek 之外的模型。

    证据链:

    1. 持久化的行只存 thinking 文本,不存签名。crates/host-core/src/sessions.rs:212 的 pub thinking: Option<String> 是唯一相关字段,UiMessage 里没有 thinkingSignature,TS 侧的 UiMessage 类型也没有。
    2. 重建时只能造出无签名的块。runtime.ts:3109-3119(historyToEntries 的 assistant 分支)把 m.thinking 还原成 { type: "thinking", thinking },只有 DeepSeek Completions 才额外补 thinkingSignature: "reasoning_content";:3110-3111 的注释直接写明原因:「Completions DeepSeek replay needs thinkingSignature so convertMessages maps the block to reasoning_content instead of dropping it」。
    3. 因此对 Anthropic / Responses 这类要求 thinking 块带原始签名(或 redacted 内容)的接口,重建出来的块在 convertMessages 阶段被丢弃;而轮内请求用的是 provider 原样返回的带签名块,不会被丢。这正好解释了「轮内 104 条 reasoning,跨轮首条请求 0 条」。

    顺带一个结论:这不是「某次裁剪」造成的偶发现象,而是重建路径的结构性缺陷——只要会话从 transcript 重建一次(重启、切会话后重开、或任何走 historyToEntries 的路径),该会话的缓存前缀就必然作废,且无法通过调阈值规避。

    修法二选一,或两者都做:

    • 持久化签名:给 UiMessage 增加 thinking 的签名/redacted 载荷并原样回放,让重建结果与轮内请求逐字节一致。缺点是签名是 provider 私有的,跨 provider 切换时仍需降级。
    • 避免重建:会话存活期间不要从 transcript 重建上下文,保持 agent 的实时消息为唯一真相,只在进程重启时才走重建;重建时接受一次全量未命中。这条收益更大,也更符合「前缀稳定」的目标。

    如果只想先止血,还可以在重建时把 thinking 块统一丢弃(而不是只丢无签名的),让轮内与跨轮的序列化形状至少一致——但那样每次跨轮仍会少一段前缀,缓存只能保住 thinking 之前的部分,不如前两条彻底。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions