背景
生产环境在 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:442、schema.sql:426、db/migration/h2|mysql|kingbase/V1__baseline_schema.sql
但有两处叠加导致长度不可控:
-
二级标题无长度约束(源头)。daily note 的 ## 标题由 LLM 在记忆摘要时生成,规则在 prompts/memory/summarize-system.txt:45:
daily_entry: 要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 `"## HH:mm ..."`)
只约束了"以时间戳开头",未限制标题长度。LLM 偶尔会把一整段事件细节/总结写成标题(例如 ## 08:30 用户要求设置每日财经早报定时任务,每天早上8点推送包含沪深指数、重要财经新闻和个股异动的汇总报告到指定群组...),单条轻松破 200 字符。
-
片段级召回追踪把整条标题 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-324 的 indexOf('#')、FactController.java:78 的 split("#",2)),说明 # 后内容本就不是关键定位信息,截断不影响召回/新鲜度计算。
建议方案(三层防护,治标+治本)
-
治本 · prompt 约束标题长度 — prompts/memory/summarize-system.txt:45,给 daily_entry 的 ## 标题增加约束:标题保持简短(如 ≤ 30 字),细节写进标题下方正文而非标题。源头标题短了,slug 自然不会超长。
-
硬兜底 · slug 化时截断 — MemoryRecallTracker.sanitizeSectionKey()(:129)返回值限制长度(如 ≤ 200 字符),为 文件路径(~20) + #(1) + slug 留足余量。不依赖 LLM 守规矩的硬保证。
-
最后防线 · 写库前截断 — MemoryRecallService.recordRecall() 入口(:41 之后)对 filename 做最终截断(如 ≤ 255),且截断必须在方法入口,保证同一 filename 在查询/更新/插入三个分支用同一个值,避免截断不一致导致的查询失配和并发兜底失效。
验证
- 单测:构造一个超长(> 256 字符)中文标题,走完
MemoryRecallTracker → recordRecall 全链路,断言落库 filename ≤ 256 且不抛异常。
- 回归:保留一条正常长度标题,断言其 filename/slug 不被误截断(含 CJK)。
背景
生产环境在 6 月 9 日前后出现
mate_memory_recall写入失败,数据库报字符串超长(MySQL:Data too long for column 'filename';H2/PG:value too long for type character varying(256))。实测 filename 形如:
即
文件路径+#+整条二级标题的中文 slug,标题部分可达上百字符,叠加后超过列长度。根因
mate_memory_recall.filename定义为VARCHAR(256) NOT NULL(三方言 baseline 一致):schema-mysql.sql:442、schema.sql:426、db/migration/h2|mysql|kingbase/V1__baseline_schema.sql但有两处叠加导致长度不可控:
二级标题无长度约束(源头)。daily note 的
##标题由 LLM 在记忆摘要时生成,规则在prompts/memory/summarize-system.txt:45:只约束了"以时间戳开头",未限制标题长度。LLM 偶尔会把一整段事件细节/总结写成标题(例如
## 08:30 用户要求设置每日财经早报定时任务,每天早上8点推送包含沪深指数、重要财经新闻和个股异动的汇总报告到指定群组...),单条轻松破 200 字符。片段级召回追踪把整条标题 slug 拼进 filename(触发点)。
MemoryRecallTracker把 daily note 按##标题拆成片段,每段 key 用文件名#标题slug:MemoryRecallTracker.java:121— `String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);`sanitizeSectionKey()(:129)只做 slug 化(小写 + 非字母数字/CJK 折叠成-),CJK 中文被原样保留,不做任何截断。MemoryRecallService.recordRecall()(:80)直接entity.setFilename(filename),写入前无长度截断。为什么 6 月 9 日前后才暴露
schema 的
VARCHAR(256)一直未变。变的是功能:daily note 片段级召回追踪上线后,filename 从文件级(memory/2026-06-05.md,很短)变为片段级(文件名#中文长标题)。在片段级追踪上线前,该列长度绰绰有余;上线后,一旦 LLM 写出超长标题即触发。暴露面 / 影响
catch (DuplicateKeyException)不生效(这是主键/唯一冲突兜底,不拦DataIntegrityViolation截断类错误),整条 recall 丢失,记录召回信号断档。recordRecall内部用 filename 做eq查询(:54、:102),若库里部分旧记录是全长的、新写入被 DB 硬截断,会导致"查不到旧记录 → 又插入 → 又被截断"的连锁,并发兜底DuplicateKeyException路径也可能失配。#锚点都是"截断丢弃"处理(MemoryRecallService.computeFreshness:321-324的indexOf('#')、FactController.java:78的split("#",2)),说明#后内容本就不是关键定位信息,截断不影响召回/新鲜度计算。建议方案(三层防护,治标+治本)
治本 · prompt 约束标题长度 —
prompts/memory/summarize-system.txt:45,给daily_entry的##标题增加约束:标题保持简短(如 ≤ 30 字),细节写进标题下方正文而非标题。源头标题短了,slug 自然不会超长。硬兜底 · slug 化时截断 —
MemoryRecallTracker.sanitizeSectionKey()(:129)返回值限制长度(如 ≤ 200 字符),为文件路径(~20) + #(1) + slug留足余量。不依赖 LLM 守规矩的硬保证。最后防线 · 写库前截断 —
MemoryRecallService.recordRecall()入口(:41之后)对 filename 做最终截断(如 ≤ 255),且截断必须在方法入口,保证同一 filename 在查询/更新/插入三个分支用同一个值,避免截断不一致导致的查询失配和并发兜底失效。验证
MemoryRecallTracker→recordRecall全链路,断言落库 filename ≤ 256 且不抛异常。