Skip to content

memory: mate_memory_recall.filename 因拼入整条 H2 标题 slug 突破 VARCHAR(256)(写入报 Data too long / 字符串超长) #461

Description

@ncw1992120

背景

生产环境在 6 月 9 日前后出现 mate_memory_recall 写入失败,数据库报字符串超长(MySQL:Data too long for column 'filename';H2/PG:value too long for type character varying(256))。

实测 filename 形如:

memory/2026-06-05.md#08-30-用户设置每日财经早报定时任务...

文件路径 + # + 整条二级标题的中文 slug,标题部分可达上百字符,叠加后超过列长度。

根因

mate_memory_recall.filename 定义为 VARCHAR(256) NOT NULL(三方言 baseline 一致):

  • schema-mysql.sql:442schema.sql:426db/migration/h2|mysql|kingbase/V1__baseline_schema.sql

但有两处叠加导致长度不可控:

  1. 二级标题无长度约束(源头)。daily note 的 ## 标题由 LLM 在记忆摘要时生成,规则在 prompts/memory/summarize-system.txt:45

    daily_entry: 要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 `"## HH:mm ..."`

    只约束了"以时间戳开头",未限制标题长度。LLM 偶尔会把一整段事件细节/总结写成标题(例如 ## 08:30 用户要求设置每日财经早报定时任务,每天早上8点推送包含沪深指数、重要财经新闻和个股异动的汇总报告到指定群组...),单条轻松破 200 字符。

  2. 片段级召回追踪把整条标题 slug 拼进 filename(触发点)MemoryRecallTracker 把 daily note 按 ## 标题拆成片段,每段 key 用 文件名#标题slug

    • MemoryRecallTracker.java:121 — `String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);`
    • sanitizeSectionKey():129)只做 slug 化(小写 + 非字母数字/CJK 折叠成 -),CJK 中文被原样保留,不做任何截断。
    • 该 key 经 MemoryRecallService.recordRecall():80)直接 entity.setFilename(filename)写入前无长度截断

为什么 6 月 9 日前后才暴露

schema 的 VARCHAR(256) 一直未变。变的是功能:daily note 片段级召回追踪上线后,filename 从文件级(memory/2026-06-05.md,很短)变为片段级(文件名#中文长标题)。在片段级追踪上线前,该列长度绰绰有余;上线后,一旦 LLM 写出超长标题即触发。

暴露面 / 影响

  • 写入失败:超长片段的 recall 记录插不进去,对应 catch (DuplicateKeyException) 不生效(这是主键/唯一冲突兜底,不拦 DataIntegrityViolation 截断类错误),整条 recall 丢失,记录召回信号断档。
  • 召回链路行为不一致recordRecall 内部用 filename 做 eq 查询(:54:102),若库里部分旧记录是全长的、新写入被 DB 硬截断,会导致"查不到旧记录 → 又插入 → 又被截断"的连锁,并发兜底 DuplicateKeyException 路径也可能失配。
  • 系统其他读取方# 锚点都是"截断丢弃"处理(MemoryRecallService.computeFreshness :321-324indexOf('#')FactController.java:78split("#",2)),说明 # 后内容本就不是关键定位信息,截断不影响召回/新鲜度计算。

建议方案(三层防护,治标+治本)

  1. 治本 · prompt 约束标题长度prompts/memory/summarize-system.txt:45,给 daily_entry## 标题增加约束:标题保持简短(如 ≤ 30 字),细节写进标题下方正文而非标题。源头标题短了,slug 自然不会超长。

  2. 硬兜底 · slug 化时截断MemoryRecallTracker.sanitizeSectionKey():129)返回值限制长度(如 ≤ 200 字符),为 文件路径(~20) + #(1) + slug 留足余量。不依赖 LLM 守规矩的硬保证。

  3. 最后防线 · 写库前截断MemoryRecallService.recordRecall() 入口(:41 之后)对 filename 做最终截断(如 ≤ 255),且截断必须在方法入口,保证同一 filename 在查询/更新/插入三个分支用同一个值,避免截断不一致导致的查询失配和并发兜底失效。

验证

  • 单测:构造一个超长(> 256 字符)中文标题,走完 MemoryRecallTrackerrecordRecall 全链路,断言落库 filename ≤ 256 且不抛异常。
  • 回归:保留一条正常长度标题,断言其 filename/slug 不被误截断(含 CJK)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions