Repository navigation
fix(agent): 超大 session 准备上下文时每条历史消息只转换一次 - #1710
Conversation
- 内容以 [ 或 { 开头时不再先按字符串解码:json.Unmarshal 要扫完整段才报类型不符
- parts 数组由已紧凑的各 part 直接拼接,原样保留的 part 单独 compact,不再整体重新编码
- 交给 sdk.Message 解码的信封直接拼字节,它只被读一次
输出字节与之前一致,messageconv 测试与历史 golden 均不变。
Refs MEMOH-247
TextContent、ContentParts、HasContent 原先先把整段内容当字符串解码,失败后 再按 parts 解码一遍;HasContent 还会在 TextContent 之后再解一次。按首个非空白 字节分派后每次只解码一遍。新增的表驱动测试在改动前后的实现上都通过。 Refs 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
f4a2def to
c6ace53
Compare
已按照第二种修法修正。这个 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.
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.
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>
Author
Type
Summary
线上一个每 5 分钟触发的定时任务 session(3,574 条未压缩消息,约 106 MB),每轮准备上下文要 1–5 分钟。其中 121 个 run 在 300 s 预算内没完成,以
context deadline exceeded失败;每轮还要分配约 3 GB 内存,拖慢整个 pod。原因是 resolve 把同一条历史消息反复转换成 SDK 消息,每次都要整段 JSON 解码、规范化、再编码,一条消息每轮要转换约 15 次:
MessageContentHash,内部先做一次完整转换),而且要做两遍;这个 PR 只处理进程内的重复计算,作为止血:
contextMessage把消息和它的 SDK 形式配对,sanitize 之后统一转换一次。工具闭合修复、prefix 长度、索引 remap、provenance 和最终请求都复用这份结果,只有被修复改写或新合成的消息才重新转换。工具闭合与决策结果搬移沿用 main 的来源规则:补上的结果和从决策读出的结果算在调用行名下,搬移的结果保留自己的来源行。FromDBMessage写入的ContextSource)匹配记录,只有 summary 还按内容匹配它的 compaction log。按内容匹配用的MessageContentHash因此没有调用方了,一并删除(main 的 provenance 已不再携带内容哈希)。messageconv去掉几次多余的整段 compact,turn.ModelMessage的TextContent/HasContent改为按首字节分派,避免对 parts 数组先按字符串完整解码一遍。两处输出都和之前一致。context.Cause失败,错误文本带上停在哪一步,例如decode history rows: schedule execution budget expired。行为上有两处有意的变化:
tool_calls行的 token 估算,之前会因为窗口里碰巧有一个已决的 ask_user 投影而变化,现在不再受它影响。不在这个 PR 的范围内:
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):bbedfb5f5RefreshContextFrag和 applier,6 MB耗时仍与字节数成线性,没有新增整段历史遍历。
与 main 的一致性(合并前):用一份由 main 生成的 golden 对照,覆盖孤立调用和孤立结果、部分过滤的多结果 tool 消息、决策投影、legacy
tool_calls、字节数组图片、中断 reasoning、空失败行、重复内容的提问。provider 消息和 provenance 逐字节一致,差异只有上面说明的 source ID 和一处估算。基准和 golden 只用于这次对照,没有放进仓库。测试(当前 head)
TestPrepareHistoryContextStopsWhenTheRequestEnds:请求在加载后结束时,以原始 cause 和步骤名失败。TestHistorySourceMessageIDsSurviveToolClosureRepair:被修复过滤过的 tool 消息仍对应其来源行。TestNormalizeContextMessagesRemapsCurrentAndMemory增加断言:sent()等于转换models()的结果。normalizeContext,全部通过。go test ./internal/agent/... ./internal/messageconv/... ./internal/chat/...全部通过;golangci-lint run ./internal/agent/... ./internal/messageconv/...无告警。TestPostgres*:internal/agent/application、cmd/agent各 27 个通过、0 跳过;internal/chat/checkpoint、internal/chat/message、internal/agent/context/compaction、internal/runtimefence通过。开发环境(当前 head,memoh-stack,docker compose + air,源码挂载)
history_checkpoints),server 健康。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 发送后收到回复。

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

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

Human QA