ADR 0043:历史只存对话——按 token 预算的历史窗口与引用消息
状态:Proposed
关联:ADR 0041(消息头、状态变更、段组装器)、ADR 0042(固定前缀瘦身)。本 ADR 处理两者都没覆盖、但实际花费最大的部分:历史;并补上 0041 漏掉的引用消息场景。另对 0041 §1 的消息头做两处细化(§5)。
背景
2026-10-28 测试 workspace 全天真实 trace:40 轮、135 次模型调用、名义输入 424 万 token,缓存命中约 47%。单次调用输入 中位数 26k、p90 58k;最大的几轮里历史段占 3–4 万 token,固定部分(ADR 0042 处理的系统提示词、工具 schema 等)只占约三分之一。
拆开一条存进 agent_messages 的用户消息(某群会话,161 条用户消息共约 70 万字符):
| 块 | 字符 | 现在的命运 |
|---|---|---|
[Turn context](时间、模型、Git、语言回落、命令前缀…) | 879 | 每轮内容不同(时间在变)→ 每条历史都重放 |
[Already told the user](已告知清单) | 353 | 每条都重放 |
| 技能说明块(contextRef) | ~1000 | 相同则投影去重 |
workspace-rules(AGENTS.md,contextRef) | 2504 | 相同则去重(ADR 0042 已改为不再重复注入根 AGENTS.md) |
deferred-tools(工具索引,contextRef) | 2922 | 工具集一变就再存一整份 |
| 用户实际说的话 | 26 |
结论:历史里存的主要是每一轮的指令与上下文,不是对话。 每轮都变的块随每条旧消息重放;条数窗口(maxRecentMessages: 100、summaryThreshold: 50)又按条不按 token,一条长消息、一段工具输出、一张图片描述都会把窗口撑大。
另外,引用消息目前只存一个引用 id([Untrusted conversation references] - message id=… reference=ref-1),模型要再调 inspect_conversation_reference 才知道引的是什么——多一个来回,也常常干脆不看。
决策
1. 历史只存对话
每个块归入两类之一,存储与投影都按此执行:
| 类 | 内容 | 存储 | 发给模型 |
|---|---|---|---|
| 对话 | 用户正文、参与者事实(actor)、引用关系(§4)、媒体占位、EventMessage、助手回复、工具调用与结果 | 存 | 按 §2 / §3 的预算与裁剪 |
| 本轮上下文 | [Turn context] 各行、已告知清单、相关记忆召回、语气提示、延迟工具索引、技能说明、workspace 规则、记忆快照、会话规则、合并 / 弹幕窗口说明 | 不存 | 只附在当前这条消息上;能稳定的移入系统提示词(platform / scene 段) |
normalizeUserMessageForStorage只保留「对话」类;「本轮上下文」类在构建时即标记为 per-turn(复用ephemeral语义并扩展到非人物块),存储时丢弃。- 已有历史不回写:投影时识别旧格式的本轮上下文块(
[Turn context]、[Already told the user]前缀,及deferred-tools/workspace-rules/skill:*/file-memory:*/session-rules:*等 contextRef key)并从非当前消息上剥离。 - 当前消息仍带完整本轮上下文,行为不变;变化只发生在它成为历史之后。
2. 按 token 预算的历史窗口
- 历史窗口从「最近 N 条」改为「token 预算」:从最新往前装,装满即停;更早的部分交给现有压缩(summary)。
- 默认预算:群聊
8k、普通私聊12k、主人私聊24k(ai.context.historyTokens,可按场景覆盖);maxRecentMessages保留为上限(两者取先到者)。 - 预算按 ADR 0042 的
estimatePromptTokens口径计算,在投影之后计(即只算真正发给模型的内容)。 - 窗口边界对齐回合:不在一个回合中间截断(tool call 与结果、合并回合成组进出)。
- 缓存:窗口起点只在越过预算时整段前移(按「块」推进,比如每次前移到下一个回合边界后再留 25% 余量),避免每轮都移动一条导致前缀缓存每轮失效。
3. 旧回合的工具结果只留结论
- 当前回合的工具结果完整发送。
- 已结束回合的工具结果:保留调用(名字 + 参数摘要)与结果的前
200字符 +…(已省略 N 字);isError与错误码保留。助手在该回合的最终回复本身就是结论。 - 存储保留完整结果(审计、回放需要),裁剪只发生在投影;随
agent_messages现有保留策略清理,不新增落盘。 - 例外:结构化小结果(≤ 200 字符)原样保留。
4. 引用消息
目标:模型不调工具就知道「这条在回复谁、回复的是什么」。
- 被引用消息在窗口内:当前消息头加
reply_to:<别名>(§5 的消息别名),不重复内容。被引用的是 Bot 自己的消息时同样指向那条助手消息的别名。 - 不在窗口内(或不在本会话历史里):在正文前加一行引用摘要,作为对话类内容存储:
[quote id:<别名或平台 id>;user_id:<发送者>;time:<时间>;] <被引用文本前 120 字>媒体以占位表示([图片]、[语音 3s]),转发 / 嵌套引用只展开一层。 - 引用文本是成员内容:保持 user 角色、不进 system,沿用现有「不可信会话引用」的约定与转义。
inspect_conversation_reference保留,用于需要完整内容、长文或多层转发时;工具结果按 §3 处理。- 引用关系来自可信入站(适配器解析的回复 / 引用段),不从正文文本推断。
5. 消息头细化(修订 ADR 0041 §1)
- 消息别名:平台消息 id 动辄 20–30 字符,是短头的主要开销。窗口内给消息编短别名
m<n>(按窗口内出现顺序,窗口前移时重排不影响已发送前缀:别名以会话内单调递增序号生成,不随窗口变化),运行时维护「别名 → 平台 id」映射;需要平台 id 的工具(引用回复、撤回、表情回应、inspect_conversation_reference)同时接受别名。 - 时间粒度:
time精确到分钟(HH:mm),并且只在与上一条可见消息间隔 ≥ 1 分钟时出现;跨天首条加date。毫秒对模型无用。 - 全量 / 短格式、
origin:peer_bot、状态变更等其余规则不变。
6. 度量:真实账本
- 新增
zhin usage report(或pnpm usage:report):读取data/agent-traces/*.jsonl,按天输出单次调用输入 p50 / p90、历史段占比、固定段占比、缓存命中率、每轮调用次数分布、撞迭代上限的回合数。只读,不新增落盘。 - ADR 0042 的合成场景度量(
eval:budget)继续作回归;真实账本用于判断优化是否真的省钱。
预期
| 指标 | 现在(真实) | 目标 |
|---|---|---|
| 群聊单次调用历史段 | 最高 3–4 万 | ≤ 8k |
| 单次调用输入 p50 / p90 | 26k / 58k | ≈ 10k / 16k |
| 引用消息需额外调用 | 常见 | 窗口内 0 次 |
与 ADR 0042 叠加后的整体效果以 §6 真实账本为准。
实现分工(多 agent 并行,最后一个 PR)
| 工作项 | 内容 |
|---|---|
| H1 历史只存对话 | §1:块分类、存储丢弃、旧格式投影剥离 |
| H2 token 预算窗口 | §2:预算、回合对齐、块状前移 |
| H3 旧工具结果裁剪 | §3 |
| H4 引用消息 + 消息别名 + 时间粒度 | §4、§5 |
| H5 真实账本 | §6 |
H1 先行(其余依赖它的块分类);H2 / H3 依赖 H1;H4、H5 独立。
待 Fin 确认的默认值
- 历史预算:群聊 8k、私聊 12k、主人私聊 24k。
- 旧工具结果保留 200 字符;引用摘要 120 字符。
- 时间精确到分钟、间隔 ≥ 1 分钟才显示。
- 窗口前移后保留 25% 余量。
非目标
- 改变压缩(summary)算法本身。
- 删除或改写已存储的历史数据。
- 跨会话的历史共享。
结果
- 历史回到「对话本身」:每条旧消息只占它真正说的话,指令与本轮上下文不再随历史无限重放。
- 窗口按 token 有界,长消息、工具输出不再撑爆上下文。
- 引用消息不调工具就能看懂;消息头更短。
H4 实现说明(引用消息 + 别名 + 时间粒度)
- 别名序号不新增存储:
m<n>的n是该消息在会话活动路径上、带首条平台消息 id 的消息记录中的序号;物理消息重复出现时沿用原别名,回复的后续气泡不占下一条记录的序号。仓库加载上下文时一次遍历整条活动路径,定向扫描 payload 的messageId与小型messageIds列表,不解析完整历史 payload;结果写入加载消息的运行时aliasSeq/aliasParts(保存时均剥离)。这样即使同组消息后来以不同子集出现,尾窗口也能重建原来的 part 别名。本轮新消息接着最后一个序号往下数;窗口前移、压缩都不改变已有别名。分支切换会改变活动路径,别名随之按新路径重数。 - 别名到平台 id 的映射:每个回合一个
MessageAliasTable,每次向模型投影消息时登记其中出现的别名;TurnToolRuntime在执行工具前把参数里名为message_id/message_ids/*_message_id/messageId/reference的值从别名改写成平台 id(日志里保留模型原写法),结构化回复里reply段的message_id同样改写。工具本身不需要认识别名;inspect_conversation_reference另外接受被引用消息的平台 id。 - 助手回复与物理消息:一次助手回复可能发成多条平台消息。整体发送成功后,投递槽按发送次序保存在最后一条带文本助手记录的
messageIds中;没有确认 id 的槽为null,messageId兼容旧记录,指向第一条已确认 id。第一条已确认 id 保留m<n>,后续新消息使用实际投递位置的m<n>.2、m<n>.3,不占用下一条会话记录的序号。多条回复的头显示按序parts:m<n>,unknown,m<n>.3,未知槽没有可操作别名;模型若猜测该明确未知位置,运行时会报告引用未确认,不把猜测值当原生 id 发送。身份查询失败时也可显示已确认的id/parts,不推断 Bot 身份和角色。每个别名始终只解析成一个平台 id,撤回一个别名不会自动扩展为整次回复的批量撤回。部分发送失败时,回执保留已完成部分,已确认的真实消息仍逐条写入平台事件;不会把未发完的整段助手正文记成已送达。 - 数据库记录与平台 id 分开:
agent_messages.id是会话树节点的数据库主键;一个节点的 JSON payload 可用有序messageIds关联多条真实平台消息,不需要增加消息行或新表。分支、尾窗口和压缩从活动路径重建别名,旧记录的单个messageId继续有效。 - 引用以结构化事实存储:入站时可信引用经
ReferencePort(3 秒超时,失败只保留 id)解析成UserMessage.quote(id、senderId、timestamp、一行摘要 ≤ 120 字、truncated)。reply_to:m<n>与[quote …]行都在 LLM 边界按当前窗口渲染:目标在窗口内 →reply_to;否则 → 正文前一行。这样窗口前移时同一条引用自动从reply_to退化为内联行,不会留下指向窗口外的别名。完整内容仍走inspect_conversation_reference;摘要已覆盖全文时不再列出[Untrusted conversation references]。 - 时间:
time:HH:mm,与上一次显示时间的消息相隔 ≥ 60 秒才再显示(相对上一条消息比较,在 40 秒间隔的连续对话里会永远不再显示时间);窗口首条与跨天首条带date并同时带time。