Skip to content

fix(agent): 超大 session 准备上下文时每条历史消息只转换一次 - #1710

Merged
chen-ran merged 7 commits into
felinics:mainfrom
ChrAlpha:fix/memoh-247-history-prep-cpu
Nov 1, 2026
Merged

chen-ran merged 7 commits into
felinics:mainfrom
ChrAlpha:fix/memoh-247-history-prep-cpu

Conversation

@ChrAlpha

@ChrAlpha ChrAlpha commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Author

  • Human
  • Agent

Type

  • bug
  • feat
  • test

Summary

线上一个每 5 分钟触发的定时任务 session(3,574 条未压缩消息,约 106 MB),每轮准备上下文要 1–5 分钟。其中 121 个 run 在 300 s 预算内没完成,以 context deadline exceeded 失败;每轮还要分配约 3 GB 内存,拖慢整个 pod。

原因是 resolve 把同一条历史消息反复转换成 SDK 消息,每次都要整段 JSON 解码、规范化、再编码,一条消息每轮要转换约 15 次:

  • prefix 长度、整体规范化、current/memory 两次索引 remap,各跑一遍 sanitize 加工具闭合修复,修复内部每条消息又要转换两三次;
  • 来源匹配给每条消息和每条记录各算一次内容哈希(MessageContentHash,内部先做一次完整转换),而且要做两遍;
  • provenance 和最终请求再各转换一遍。

这个 PR 只处理进程内的重复计算,作为止血:

  • contextMessage 把消息和它的 SDK 形式配对,sanitize 之后统一转换一次。工具闭合修复、prefix 长度、索引 remap、provenance 和最终请求都复用这份结果,只有被修复改写或新合成的消息才重新转换。工具闭合与决策结果搬移沿用 main 的来源规则:补上的结果和从决策读出的结果算在调用行名下,搬移的结果保留自己的来源行。
  • 来源按消息自带的行 ID(FromDBMessage 写入的 ContextSource)匹配记录,只有 summary 还按内容匹配它的 compaction log。按内容匹配用的 MessageContentHash 因此没有调用方了,一并删除(main 的 provenance 已不再携带内容哈希)。
  • messageconv 去掉几次多余的整段 compact,turn.ModelMessage 的 TextContent / HasContent 改为按首字节分派,避免对 parts 数组先按字符串完整解码一遍。两处输出都和之前一致。
  • 历史准备的各阶段检查 ctx(checkpoint 保留行和之后的行逐行解码、规范化、编译 context)。预算耗尽时及时以 context.Cause 失败,错误文本带上停在哪一步,例如 decode history rows: schedule execution budget expired。

行为上有两处有意的变化:

  • 被过滤的 tool 消息、被决策投影搬移的消息、做过图片规范化的消息,fork 来源行 ID 之前因为内容哈希对不上而是空的,现在能对应回来源行;补上的工具结果按 main 的来源规则对应到调用行。
  • legacy tool_calls 行的 token 估算,之前会因为窗口里碰巧有一个已决的 ask_user 投影而变化,现在不再受它影响。

不在这个 PR 的范围内:

  • contextfrag 的 fragment / payload 哈希(resolve 尾部剩下的约 0.9 s),由 context identity 的重构处理。
  • 积压过大时先压缩或直接拒绝,以及 pod CPU limit 的核查。
  • 下面验证中看到的 ListHistoryMessagesAfterWithinBytes 慢查询。

Related Issues

Refs MEMOH-247 (Linear)

Validation

合并 main:已合入 #1697、#1700(history checkpoint)。下面的基准和 golden 对照是在合并前、以 main bbedfb5f5 为基线测的;单元测试、PostgreSQL 集成测试和开发环境验证都在当前 head 上重新跑过。

基准(合并前;AMD Ryzen 7 7700,合成 676 行,含嵌套 JSON 工具输出和 memory 召回消息,go test -bench,benchtime 5x):

main bbedfb5f5 本 PR
历史准备 6 MB 9.60 s / 4.46 GB 0.72 s / 307 MB
历史准备 1 MB 2.54 s / 1.14 GB 0.20 s / 85 MB
再加两次 RefreshContextFrag 和 applier,6 MB 10.45 s / 4.79 GB 1.62 s / 641 MB

耗时仍与字节数成线性,没有新增整段历史遍历。

与 main 的一致性(合并前):用一份由 main 生成的 golden 对照,覆盖孤立调用和孤立结果、部分过滤的多结果 tool 消息、决策投影、legacy tool_calls、字节数组图片、中断 reasoning、空失败行、重复内容的提问。provider 消息和 provenance 逐字节一致,差异只有上面说明的 source ID 和一处估算。基准和 golden 只用于这次对照,没有放进仓库。

测试(当前 head)

  • 新增 TestPrepareHistoryContextStopsWhenTheRequestEnds:请求在加载后结束时,以原始 cause 和步骤名失败。
  • 新增 TestHistorySourceMessageIDsSurviveToolClosureRepair:被修复过滤过的 tool 消息仍对应其来源行。
  • TestNormalizeContextMessagesRemapsCurrentAndMemory 增加断言:sent() 等于转换 models() 的结果。
  • main 新增的闭合来源、决策结果搬移来源测试改为走 normalizeContext,全部通过。
  • go test ./internal/agent/... ./internal/messageconv/... ./internal/chat/... 全部通过;golangci-lint run ./internal/agent/... ./internal/messageconv/... 无告警。
  • 本地 PostgreSQL 18 + Valkey 上跑 TestPostgres*:internal/agent/application、cmd/agent 各 27 个通过、0 跳过;internal/chat/checkpoint、internal/chat/message、internal/agent/context/compaction、internal/runtimefence 通过。
  • CI 全部通过。

开发环境(当前 head,memoh-stack,docker compose + air,源码挂载)

  • 重新构建并启动后迁移到 170(history_checkpoints),server 健康。
  • 模型用本地 OpenAI 兼容 stub,窗口 2,000,000,配置未做改动。
  • 10-29 灌入并压缩过的 session 经迁移转成 checkpoint;在聊天界面发送后正常回复,请求为 system、1 条 summary 和之后的行,共 5 条。
  • 新建 session 灌入 850 轮、3,383 行、9.8 MB 的定时任务形态历史(每 50 轮留一个未答的工具调用),在聊天界面发送 "What changed since the last check?":加载被字节预算截断,先压缩 3 个 pass(3.2 s),再发出 187 KB 的模型请求(含 1 条 summary),正常回复。从压缩结束到发出模型请求约 0.1 s。
  • 同一流程每 0.5 s 采样 pg_stat_activity 和 server 容器 CPU:发送到开始压缩之间约 16 s 都在 ListHistoryMessagesAfterWithinBytes(递归逐行的历史窗口查询)里,连续执行两次、每次约 8 s,期间 server CPU 接近 0。这条查询来自 feat(compaction): 以 history checkpoint 取代压缩覆盖层 #1700,和本 PR 改动无关,单独跟进。

Screenshots / Recordings

以下截图来自 Agent 在当前 head 的开发环境里的操作,只说明 UI 流程走通,不代表人工 QA;耗时的依据是上面的日志和采样。

迁移成 checkpoint 的旧 session 正常打开。
迁移后的 session

在该 session 发送后收到回复。
迁移后的 session 收到回复

新灌入的 850 轮历史在会话里正常渲染。
850 轮 session 打开

发送后经过压缩收到回复。
850 轮 session 收到回复

Human QA

  • Human QA passed

- 内容以 [ 或 { 开头时不再先按字符串解码:json.Unmarshal 要扫完整段才报类型不符
- parts 数组由已紧凑的各 part 直接拼接,原样保留的 part 单独 compact,不再整体重新编码
- 交给 sdk.Message 解码的信封直接拼字节,它只被读一次

输出字节与之前一致,messageconv 测试与历史 golden 均不变。

Refs MEMOH-247
TextContent、ContentParts、HasContent 原先先把整段内容当字符串解码,失败后
再按 parts 解码一遍;HasContent 还会在 TextContent 之后再解一次。按首个非空白
字节分派后每次只解码一遍。新增的表驱动测试在改动前后的实现上都通过。

Refs MEMOH-247
@github-actions github-actions Bot added bug Fixes a bug or unexpected behavior change:server Changes backend code, configuration, or API contracts size:L PR size uses the larger of added or deleted lines, excluding generated files size:S PR size uses the larger of added or deleted lines, excluding generated files and removed size:L PR size uses the larger of added or deleted lines, excluding generated files labels Oct 29, 2026
@chen-ran

Copy link
Copy Markdown
Member

有一个阻塞问题:

阻塞:新的 MessageContentHash 让 run 内折叠以 stale_source 失败

新哈希直接对 content 的原始字节求值,但步骤提交和压缩读回拿到的字节并不相同:

  • stepProvenance(step_commit.go#L403-L410)用落库后返回的行求哈希,而 assistant/tool 行返回的 Content 是 createArg.Content,也就是 Go 写入的字节(service.go#L1684-L1698)。
  • 压缩比对的是从 jsonb 读回的行(service_view.go#L36)。jsonb 会重排 key、加空格,还会把 \u003c 还原成 <。

所以 parts 形式的消息,以及带 <>& 的文本回复,两边哈希必然不同。run 内折叠只要选中本 run 早先提交的行,就会以 stale_source 失败,最后以 context.budget_unsatisfied 结束整轮。旧哈希先转成 SDK 消息再编码,不受字节写法影响,所以没有这个问题。轮前压缩两边都从库里读,也不受影响。

复现:在真实 Postgres 上,用 backlogFixture 按 stepProvenance 的方式构造 View 后执行 fold,本 PR 上失败,base 上成功。现有测试的 View 都不带 ContentHash,所以没拦住。

测试 …

修法可以二选一:步骤提交改用 RETURNING content 的字节求哈希;或者步骤来源不带 ContentHash,由 claim 和 coverage 校验兜底。请顺便把这个测试加进仓库,并修正 commit message 里"步骤提交读的也是存储解码"的说法。

另外,这个 PR 救不回 MEMOH-247 里的那个 session:402 导致的积压和那两条 O(n²) 查询还在。建议单独跟进,MEMOH-247 先不要关。

MessageContentHash 原先把消息转换成 SDK 消息、重新编码后再求哈希,每行加载都
要付出一次完整转换。改为对 role、content 字节与 legacy 信封字段逐字段求
SHA-256:同一行的同一版本哈希相同,行被改写即不同。哈希只在进程内的请求视图
与压缩前校验之间流转,不落库。

字节哈希只对从库里读回的行成立:jsonb 会重排 key、加空格、还原 < 等转义,
写入方手里的字节不是存储版本。加载、discuss 历史条目和压缩候选行都从库里读,
两边一致;步骤提交只持有自己写入的字节,因此步骤来源不再携带版本哈希,run 内
折叠读回这些行时由 claim 与 coverage 校验兜底。新增 Postgres 回归测试按步骤
提交的方式构造请求视图后执行折叠,并加入 CI。

Refs MEMOH-247
resolve 把同一条历史消息反复转换成 SDK 消息(每次都要整段解码、规范化、
再编码):prefix 长度、整体规范化、两次索引 remap 各跑一遍 sanitize 与工具
闭合修复,修复内部每条消息又转换两三次;来源匹配对每条消息和每条记录各算
一次内容哈希,且做两遍;provenance 与最终请求又各转换一遍。8 MB 历史要分配
数 GB,生产负载下单轮准备耗时数分钟,定时任务超出执行预算。

- contextMessage 把消息与其 SDK 形式配对,sanitize 后统一转换一次;工具闭合
  修复、prefix 长度和索引 remap 都在这份结果上进行,只有被改写或合成的消息
  才再转换
- 修复保留未改动的消息原样,不再把已是 parts 形式的 tool 消息重新编码
- 来源按消息携带的行 ID 匹配记录,只有 summary 仍按内容匹配其 artifact;
  被修复或规范化改写过的消息因此仍能对应到来源行
- provenance 直接复用请求里的 SDK 消息

新增基准与 golden:provider 消息与 provenance 与改动前逐字节一致;fork 来源
行 ID 修好了被过滤、被决策投影搬移、做过图片规范化的三类消息;legacy
tool_calls 行的 token 估算不再取决于窗口里是否碰巧有已决的 ask_user 投影。

6 MB / 676 行历史:9.60 s / 4.46 GB → 0.72 s / 307 MB。

Refs MEMOH-247
历史准备在数据库调用之间全是 CPU 计算,从不检查 ctx:定时任务的执行预算
耗尽后,这一轮仍会把请求装配完,日志只有 context deadline exceeded,看不出
卡在哪一步。现在逐行解码历史、折叠压缩历史、规范化请求消息、编译上下文
之后都检查 ctx,以 context.Cause 结束并带上步骤名,例如
"decode history rows: schedule execution budget expired"。

Refs MEMOH-247
基准与 golden 是一次性的对照证据:结果已写入 PR 说明,文件留在验证证据里,
不再常驻仓库。保留的测试只守住两件事:请求结束时按步骤及时失败,以及被
工具闭合修复改写的消息仍对应其来源行。

Refs MEMOH-247
@ChrAlpha
ChrAlpha force-pushed the fix/memoh-247-history-prep-cpu branch from f4a2def to c6ace53 Compare November 1, 2026 01:04
@github-actions github-actions Bot added size:M PR size uses the larger of added or deleted lines, excluding generated files and removed size:S PR size uses the larger of added or deleted lines, excluding generated files labels Nov 1, 2026
@ChrAlpha

ChrAlpha commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

有一个阻塞问题:

阻塞:新的 MessageContentHash 让 run 内折叠以 stale_source 失败

新哈希直接对 content 的原始字节求值,但步骤提交和压缩读回拿到的字节并不相同:

  • stepProvenance(step_commit.go#L403-L410)用落库后返回的行求哈希,而 assistant/tool 行返回的 Content 是 createArg.Content,也就是 Go 写入的字节(service.go#L1684-L1698)。
  • 压缩比对的是从 jsonb 读回的行(service_view.go#L36)。jsonb 会重排 key、加空格,还会把 \u003c 还原成 <。

所以 parts 形式的消息,以及带 <>& 的文本回复,两边哈希必然不同。run 内折叠只要选中本 run 早先提交的行,就会以 stale_source 失败,最后以 context.budget_unsatisfied 结束整轮。旧哈希先转成 SDK 消息再编码,不受字节写法影响,所以没有这个问题。轮前压缩两边都从库里读,也不受影响。

复现:在真实 Postgres 上,用 backlogFixture 按 stepProvenance 的方式构造 View 后执行 fold,本 PR 上失败,base 上成功。现有测试的 View 都不带 ContentHash,所以没拦住。

测试
修法可以二选一:步骤提交改用 RETURNING content 的字节求哈希;或者步骤来源不带 ContentHash,由 claim 和 coverage 校验兜底。请顺便把这个测试加进仓库,并修正 commit message 里"步骤提交读的也是存储解码"的说法。

另外,这个 PR 救不回 MEMOH-247 里的那个 session:402 导致的积压和那两条 O(n²) 查询还在。建议单独跟进,MEMOH-247 先不要关。

已按照第二种修法修正。这个 PR 确实不关闭 MEMOH-247,后续工作已经在做:contextfrag 的 fragment / payload 哈希(resolve 尾部剩下的约 0.9 s)由 context identity 重构处理

Resolve conflicts with felinics#1697 and felinics#1700 (history checkpoint):

- tool_closure: keep converting each message once, and adopt main's
  sources: a closure renders from its call's row, a result read from a
  decision from the call, and moved results group by source.
- continuation: provenance names the repaired outgoing messages.
- Chat budget recovery and the compaction overlay fold are gone on main;
  drop the PR's edits to them and its "fold compacted history" check.
  Row decoding under the checkpoint also observes the request's end.
- Provenance no longer carries a content hash, and records are matched
  by row ID, so MessageContentHash has no caller left: remove it, the
  step-version note, and the PostgreSQL test that guarded that version.
@github-actions github-actions Bot added size:S PR size uses the larger of added or deleted lines, excluding generated files and removed size:M PR size uses the larger of added or deleted lines, excluding generated files labels Nov 1, 2026
ChrAlpha added a commit to ChrAlpha/Memoh that referenced this pull request Nov 1, 2026
felinics#1710 now carries origin/main with the history checkpoint redesign
(felinics#1700). Conflicts resolve to felinics#1710's side. The continuation's search
for the deferred tool call is dropped: the continuation now anchors on
the run's turn request IDs from SQL and no longer scans records for it.
The entry.go comment fix goes with the deleted file.
@chen-ran
chen-ran merged commit f2f46fe into felinics:main Nov 1, 2026
17 checks passed
ChrAlpha pushed a commit to ChrAlpha/Memoh that referenced this pull request Nov 2, 2026
ListHistoryWindow 每一步原来在 LATERAL 子查询里就算 octet_length(content::text)。
规划器只要不沿有序索引走、改成"扫一遍再排序取一行",每一步都会把窗口里每一行
解开转成文本,同样的数据从毫秒级变成十几秒(Memoh-Cloud felinics#697 的生产慢样本、
felinics#1710 开发环境的 2×8 s 都是这个量级)。

现在每一步只取主键和排序键,取到那一行后按主键量它的内容。返回的行与原来逐行
一致;合成会话(3,384 行 / 9.7 MB,预算 800 KB)上正常计划 4.6 ms → 3.5 ms,
禁用索引扫描时 6.3 s → 0.21 s。

Refs felinics/Memoh-Cloud#697

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Fixes a bug or unexpected behavior change:server Changes backend code, configuration, or API contracts size:S PR size uses the larger of added or deleted lines, excluding generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants