Skip to content

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 / p9026k / 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。