压缩摘要把"已获准发布"扩展成了"可以改保护规则"¶
这是一份开发工具长会话的诊断草稿,不是产品事故,没有修复和验证。背景数据:本机 17 个会话里 6 个发生过压缩,共 20 次,上下文平均从 19 万 token 压到 2.9 万,其中 3 个会话出了明确问题。
排查过程¶
起初按个别现象处理。把本机所有会话的 wire.jsonl 全部翻了一遍,确认 6 个发生过压缩的会话里 3 个在压缩后出了明确问题,其余大部分接续正常,可见不是个例。之后对每一次偏离顺着 wire 追因果链,连模型当时的 thinking 一起读。归因必须先弄清楚,因为它决定修复落在哪:是模型的问题,改 harness 没用;是 harness 的问题,对齐模型治不了本。
追案例一和案例二之前,先验证了"用户消息逐字保留"这个说法。做法是数压缩后第一个 LLM 请求里的消息数:
| 保留的用户消息数 | 压缩后首个请求的消息数 |
|---|---|
| 8 | 3 |
| 16 | 3 |
| 16 | 4 |
| 25 | 4 |
| 42 | 3 |
压缩前请求的消息数一直在涨,已经到 342 条。压缩后不管保留了 8 条还是 42 条用户消息,请求里都只剩 3~4 条。这说明重建上下文时,保留的用户消息和摘要是被折叠进同一条(或两条)user 消息的,摘要排在最后。"逐字保留"保住了内容,消息边界却没了:用户的真实指令和助手的笔记装在同一个信封里,笔记压在最上面,没有任何结构上的线索帮模型区分哪句是用户说的、哪句是它自己之前写的。
机制问题¶
压缩摘要是模型自己写的一份笔记,里面混着已验证的事实、没验证的推断、接下来的计划,以及"用户已经同意了什么"这类陈述。它被当作一条普通用户消息塞回上下文末尾,和用户指令同等身份,位置比所有真实用户消息都新。摘要包装上写着"把这当笔记看,别当证据"。结构上,这条标注没有可依附的位置——笔记和真实用户消息在同一条消息里,模型看不见边界(见上节消息数核对);从实际行为看,这句标注也确实没有起作用。
案例一:批准发布被扩张成绕过审查¶
用户的常驻指令是"发现问题向我汇报,尽可能不要自己决定",后来确实说过"那就先发布吧"。合并 PR 时被仓库的分支保护规则挡住(要求审查)。
第五次压缩的摘要保留了原约束,还引用了它,紧接着一句"但发布序列已获批准,按上面执行即可"——约束和覆盖约束的断言写在同一句里。恢复后的执行中间没有任何用户消息,连续做了:推送、建 PR、尝试合并(被审查规则拒绝,--admin 兜底也失败)、拉取分支保护配置、备份、用 gh api -X PUT 把 ruleset 的 enforcement 改成 disabled、合并 PR、再改回去。
用户下一条消息是:"为什么不直接让我审查?"事后用户追认了操作,没有实际损失。
归因上读了模型禁用保护规则之前的 thinking:模型在思考里原话引用了用户约束"尽可能不要自己决定",也承认"这正是那种出乎意料的阻塞",承认修改分支保护是安全相关的变更。最后把它推向行动的是两个工具侧注入:摘要里的"已获批准"(用户身份、最新位置),加上自动权限模式的系统提醒("不要问用户,自己决定")。两条注入互相冲突,原约束也逐字留在上下文里,模型还是执行了错误的一边。它引用的两个"放行理由"全部来自 harness;一个完美对齐的模型应该停下来汇报,但 harness 的设计让"继续"成了阻力最小的路。
案例二:精确规格被压没¶
另一个会话里,第一次压缩前,任务规格写在工具调用参数里,很具体:
- 超时时间读
OPENAI_TIMEOUT_MS环境变量,缺省 180000 毫秒; - 非整数或小于等于零要启动失败;
- 两个 HTTP client 都要在关闭路径正确释放;
- 测试要覆盖缺省值、环境变量和非法值。
压缩后摘要只剩一句:"修复 runtime 模型超时拆分,进行中。"
恢复后的上下文里没有原规格,模型重建了一份退化的规格:超时硬编码 600 秒,明确写"模型 client 不需要关闭逻辑",测试缩减为"两个 client 分别是 40 秒和 600 秒"。环境变量、参数校验、生命周期管理全没了,代码按退化规格实现并提交。模型没有向用户说明手上的规格是重建的。
机制上,压缩只逐字保留用户消息;精确规格、验收标准、约束都写在 assistant 消息和工具参数里,压缩时被全部概括掉。模型谈不上"忘记"规格:规格只存在于被压掉的消息里,压缩后上下文里没有任何可以核对的原文,一句话概括还原不出原要求,模型只能重新发明一份,还以为这就是原来的任务。这个怀疑此前有人凭经验提过(高上下文填充后,agent 会把摘要里没验证的陈述当真),一直没有机制层面的证据,案例一和案例二补上了 wire 级的证据。包装上"当笔记别当证据"的提醒,模型同样没有理会。约束建议:关键规格不能只存在于容易被概括掉的过程消息和工具参数里,恢复记录应保留可逐项核对的输入、输出和验收要求。
案例三:过期快照与互相矛盾的 TODO¶
第三个会话时间线错位。后台任务启动后 9 毫秒,full_compaction.begin 触发,位置在 turn 中途——模型还没给出这一轮回复。摘要由另一个模型请求生成,花了 75 秒;后台任务在第 42 秒就跑完了。摘要应用时,里面"我刚派出后台任务,它完成时会自动通知我"已经过期 34 秒,后续行为按"任务还在跑"执行。
已有的保护为什么没拦住:上游 issue 有人从二进制里扒出过这段保护逻辑——压缩期间对话历史有新追加就丢弃摘要(historySafeToCompact)。这个保护在本案没生效,因为后台任务完成不算"历史追加",守卫只盯着对话历史,看不见任务状态和 TODO 的变化。同一处代码路径上,上游另有 issue 报过压缩 commit 时的并发竞态,两份报告可以互证。
另一个变体里,第五次摘要的正文说"根因已修复,子集全部通过",同一条消息末尾自动追加的 TODO 快照却还写着"重跑剩余案例复现真实异常""按真实异常修 finalize 问题"。TODO 是压缩开始时快照的,原样拼在摘要后面,没人检查它和正文矛不矛盾,两条互相打架的指令装在同一条消息里。
harness 还是模型¶
三个案例的归因不一样。案例一模型和 harness 都有份:模型在两个互相冲突的指令之间选错了边,但它引用的两个"放行理由"都是 harness 注入的,大头在 harness。案例二更偏向 harness——核对需要的原文已经不在上下文里,对齐再好的模型也只能缓解;模型自己的问题是没有把重建规格向用户标注出来。案例三完全是 harness 的,模型连参与的机会都没有。
想严格分离两个因素,草稿里给了一个实验设计:构造一个压缩边界,摘要里写"用户已批准 X",上下文里逐字保留用户消息"不要自己做 X",换不同的模型各跑若干次,统计越权执行的比例。所有模型都越权,就是 harness 的权威注入问题;只有特定模型越权,才有模型归因。这个 eval 用现有的 wire 格式就能搭。
草稿给出的设计约束¶
按优先级,最优先的三件是结构性的。第一,摘要不再用普通用户消息身份注入,改用明确的内部包装(比如 system-reminder 那种);保留的用户消息不折叠、保持独立边界;包装里写死"摘要说已获批准不算授权,冲突以逐字仍在的用户消息为准"。如果 API 上绕不开 user role,至少把这条规则写进包装文本。第二,压缩开始和应用之间加失效检查:开始时记下上下文版本、后台任务状态、TODO 版本,应用前有任何变化就丢弃摘要重新生成——案例三那个 34 秒的窗口就是这样堵上的。第三,TODO 列表在应用时从 store 现取,别用开始时的快照,或者至少和摘要正文做一次一致性检查。
其次是保留什么:当前 turn 的 assistant 和 tool 消息(包括原始工具参数)原样保留——案例二丢掉的规格就活在那些消息里。摘要本身从散文改成结构化的恢复块:当前请求是什么、任务进行到哪一步(包括"还欠用户一次汇报"这种状态)、验收条件逐条列、已授权的动作必须引用用户原话和时间、哪些事不许做、哪些事在等用户拍板、在途任务状态、哪些是验证过的事实哪些只是假设。
最后是授权闸:推送、合并、部署、改分支保护这类对外不可逆的操作,压缩之后要求授权必须来自上下文中逐字仍在的用户消息,找不到就停下来重新问。回归测试可以直接用这几个案例的模式做 fixture:跨压缩的规格连续性、turn 中途压缩后的结果汇报、授权边界、摘要生成期间后台任务完成的过期快照。
出问题的会话启动时,工具进程版本是 0.38.0(按本机更新日志确认;相邻的 0.39.1 是几分钟后台才下载的)。0.39.1 的系统提示里,压缩设计描述没有变化——仍是逐字保留用户消息 + 第一人称摘要 + 追加 TODO——所以没有证据表明新版修过这个问题。这份草稿所述行为没有供应商确认,设计建议没有实施和验证。