跳到主要内容

压缩管线:XML 骨架 Prompt、Modes、解析与落库

30 秒导读: 上一章(02)讲了 worker 守护进程如何攒起一批"刚发生的工具调用"。这一章讲语义:这批原始工具调用,怎么被拼成一段 prompt、喂给一个持续在线的"观察者(observer)"模型、让它吐出一段纯文本 XML,再被正则解析成一条条结构化记忆存进库。这里是记忆质量的源头——prompt 写得好不好、解析容不容错,直接决定了未来会话能召回到什么。


1. 这一章讲什么(先建立直觉)

claude-mem 的核心动作只有一句话:把"一次工具调用"压成"一条可检索的记忆"

这个"压"字,拆开是三步:

步骤干的事本章文件
拼 prompt把工具名、参数、输出,塞进一段带 XML 骨架的指令src/sdk/prompts.ts
定口吻这条记忆分几类、用什么语言、多啰嗦src/services/domain/ModeManager.ts + plugin/modes/*.json
解析落库把模型返回的纯文本 XML 解析成对象,去重、存库、广播src/sdk/parser.ts + src/services/worker/agents/ResponseProcessor.ts

一句话直觉: 把 observer 模型当成一个只会说 XML 的速记员——你给它看一段"刚才发生了什么"(工具调用),它按你给的模板填一张卡片(<observation>),你再把卡片抄进档案柜(SQLite,见 04)。

本章讲这批工具调用从哪来(缓冲/批处理见 02),也讲存进什么表结构(见 04)。本章只讲中间这段语义变换


2. 顶层全景:一次工具调用 → 一条记忆

先看这段管线怎么流。从上到下是时间顺序,左边是数据、右边是负责的符号:

[一批工具调用] (来自 02 章的缓冲/批处理)


┌─────────────────────────────┐
│ 1. 拼 Prompt │ prompts.ts
│ buildObservationPrompt() │ · 每条工具调用 → 一段 <observed_from_primary_session>
│ observationSkeleton(mode) │ · 首轮先发 buildInitPrompt(身份+骨架)
│ truncateObservationField │ · 巨大字段 head/tail 截断 + <elided>
└─────────────────────────────┘
│ (纯文本 prompt,含 XML 骨架当"填空模板")

┌─────────────────────────────┐
│ 2. observer 模型 │ ClaudeProvider / OpenAICompatibleProvider
│ 持久 SDK 会话 (见 02) │ · 口吻/分类/语言由 mode 决定
└─────────────────────────────┘
│ (模型回一坨纯文本,理想情况是 <observation>...</observation>)

┌─────────────────────────────┐
│ 3. 解析 │ parser.ts
│ parseAgentXml() │ · stripCodeFences 去围栏
│ parseObservationBlocks() │ · 无效 type 回退、空观察丢弃
└─────────────────────────────┘
│ (ParsedObservation[] / ParsedSummary)

┌─────────────────────────────┐
│ 4. 落库 + 去重 + 广播 │ ResponseProcessor.ts
│ storeObservations() │ · content_hash 去重
│ broadcastObservation() │ · 同步 Chroma、推 UI/Telegram
└─────────────────────────────┘


[SQLite 一行 memory_item] (表结构见 04)

部件一句话职责:

部件干什么符号 / 文件
Prompt 拼装把工具调用 + mode 模板拼成指令buildObservationPrompt · prompts.ts:121
骨架生成生成 <observation> 填空模板observationSkeleton · prompts.ts:24
字段截断保护 observer 上下文窗口不被撑爆truncateObservationField · prompts.ts:107
Mode 管理决定分类维度 + 语言 + 啰嗦程度ModeManager.loadMode · ModeManager.ts:93
解析纯文本 XML → 结构化对象parseAgentXml · parser.ts:41
落库广播存库、去重、同步向量库、广播processAgentResponse · ResponseProcessor.ts:22

3. 一个务实的取舍:为什么是文本 XML,不是结构化 JSON

这是理解本章一切设计的总纲,先讲清楚。

现代模型 API 大多支持"结构化输出 / tool-use":你给个 JSON schema,模型保证吐合法 JSON。那 claude-mem 为什么偏要让模型吐纯文本 XML,然后自己写正则去啃?

源码里留了一句诚实的自白:

// TODO(#2233): migrate to Anthropic tool-use API for deterministic JSON output.
// This text-XML path is the bridge.

—— src/sdk/parser.ts:5

所以官方立场是:文本 XML 是"过渡桥梁",终点是 tool-use JSON。 但这座桥现在承载着全部流量,它的取舍值得说透:

  • 可移植 > 确定性。 observer 会话可能跑在 Claude(ClaudeProvider)、也可能跑在任意 OpenAI 兼容端点(OpenAICompatibleProvider)。不是每个端点都可靠支持结构化输出;而"请你吐 XML"是任何会说话的模型都能做的最小公分母。用 XML,一套 prompt 打天下。
  • 代价是容错。 模型会时不时不听话——多说一句"Skipping...",或把 XML 包进 ```xml 围栏,或填了个不存在的 type。所以解析层(§7)几乎每一行都在擦屁股:去围栏、回退无效值、丢空观察。JSON schema 本可以让这些不可能发生,但那要牺牲可移植性。
  • prompt 里疯狂强调"只准说 XML"。 因为没有 schema 兜底,只能靠嘴皮子约束。footer 里反复写"XML 之外的一切输出都会被系统丢弃"(code.jsonfooter,plugin/modes/code.json:111),summary prompt 甚至加了三条 ⚠️ CRITICAL 警告(prompts.ts:166-169)。

一句话记住: claude-mem 选了"能跑在任何模型上"而不是"永远格式正确",于是把复杂度从 prompt 端推到了解析端。


4. Prompt 拼装:骨架即模板

prompts.ts 导出四个 build* 函数,对应 observer 会话生命周期里的四种消息。它们共享同一个核心:observationSkeleton

4.1 骨架 = 一张给模型填的"XML 填空题"

observationSkeleton(mode)(prompts.ts:24-62)不是让模型自由发挥,而是直接把期望的 XML 结构当模板贴给它,每个字段里塞的是 mode 提供的"占位说明":

<observation>
<type>[ bugfix | feature | refactor | change | discovery | decision | ... ]</type>
<title>[**title**: 捕捉核心动作或主题的短标题]</title>
<subtitle>[**subtitle**: 一句话说明(≤24 词)]</subtitle>
<facts>
<fact>[简洁、自足的陈述]</fact>
...
</facts>
<narrative>[完整上下文:做了什么、怎么运作、为什么重要]</narrative>
<concepts>
<concept>[知识类型分类]</concept>
</concepts>
<files_read> ... </files_read>
<files_modified> ... </files_modified>
</observation>

注意 <type> 那行:mode.observation_types.map(t => t.id).join(' | ')(prompts.ts:28)——可选类型是从 mode 动态拼进去的,不是写死的。这就是 §5 里"mode 决定分类维度"的入口。骨架里那些 xml_*_placeholdertype_guidanceconcept_guidance 全部来自 mode.prompts(类型见 types.ts:16-54)。

4.2 四个 build 函数各管一段生命周期

函数何时用关键内容位置
buildInitPromptobserver 会话首轮系统身份 + 用户原始请求 + 骨架prompts.ts:64
buildObservationPrompt每条工具调用把这一条 <observed_from_primary_session> 递给模型prompts.ts:121
buildSummaryPrompt阶段性进度总结换成 <summary> 骨架,强制不许用 <observation>prompts.ts:157
buildContinuationPrompt会话重启/续接重申身份 + 重贴骨架(会话历史丢了要重新灌)prompts.ts:191

调用点在两个 provider 里(见 ClaudeProvider.ts:467-522OpenAICompatibleProvider.ts:97-249):首轮发 init,之后每条工具调用发 observation,该总结时发 summary。

buildObservationPrompt 生成的单条消息长这样(prompts.ts:143-148):

<observed_from_primary_session>
<what_happened>Read</what_happened>
<occurred_at>2026-07-14T...Z</occurred_at>
<working_directory>/path/to/project</working_directory>
<parameters>{ ...工具输入的 JSON... }</parameters>
<outcome>{ ...工具输出的 JSON... }</outcome>
</observed_from_primary_session>

tool_input / tool_output 会先尝试 JSON.parse,失败就当纯字符串原样用(prompts.ts:126-141)——又一处容错:上游存的可能是 JSON,也可能就是一段裸文本。

4.3 summary 模式:同一个会话,换一套骨架

buildSummaryPrompt 是同一个 observer 会话里的模式切换。它开头喊一句 --- MODE SWITCH: PROGRESS SUMMARY ---(常量 SUMMARY_MODE_MARKER,prompts.ts:5),然后换成六字段的 <summary> 骨架(request / investigated / learned / completed / next_steps / notes,prompts.ts:178-185)。

它为什么要三条 ⚠️ CRITICAL 死命强调"根标签必须是 <summary> 不是 <observation>"?因为这是同一个持续会话,模型刚才一直在吐 <observation>,惯性极强——一旦吐错根标签,§7 的解析会把它当观察处理,总结就丢了。没有 schema 兜底,只能靠喊。


5. 保护 observer 的眼睛:巨大字段截断

这是本章第一个"精华级"设计,起因是一个真实事故:issue #2468 报了一个 130k 字符的文件被 Read

5.1 问题:一个巨大的 Read 结果能撑爆整个上下文窗口

observer 是个有限上下文的模型(注释里按 128k token 估算)。如果某次工具调用的 outcome 是一整个巨型文件,原样塞进 prompt,会把 observer 的上下文顶爆,SDK 会话直接以 "prompt is too long" 崩掉——一条毒记录能毒死整个观察会话

5.2 方案:head/tail 截断 + 显式 <elided> 标记

truncateObservationField(prompts.ts:107-119)给每个字段设了 16k 字符预算(OBS_PROMPT_FIELD_MAX_CHARS = 16_000,prompts.ts:103)。超了就只留头尾、挖掉中间:

字段长度 ≤ 16k ? ── 是 ──▶ 原样用



┌────────────┬───────── 挖掉中间 ─────────┬────────────┐
│ 头 60% │ <elided chars=... /> │ 尾 30% │
└────────────┴───────────────────────────┴────────────┘
(16k * 0.6) (显式标记) (16k * 0.3)
  • 头 60% / 尾 30% 的比例是有讲究的(prompts.ts:104-105 + 注释):头部通常是工具的"金字招牌"(文件路径、错误标题、命令头),尾部通常是错误信息 / 最后几行上下文,中间才是可丢的水
  • 剩下 10% 留给那句 <elided chars="N" original_size_chars="M" reason="oversize" /> 标记(prompts.ts:118)。

5.3 妙在哪:让模型"知道自己没看全"

关键不是"截断",而是那个显式标记。prompt 里专门追加一句(prompts.ts:150):

如果 <parameters><outcome> 里有 <elided .../> 标记,说明该字段被截断了。只描述你能看到的部分,不要臆测被挖掉那段的细节

如果只是默默砍掉中间,模型会幻觉填补缺失内容,记忆里就多了假事实。给它一个看得见的"这里被删了 N 字"标记,等于给模型划了一条诚实的边界。这是"防幻觉"的一个小而漂亮的手法。

顺带两个健壮性细节:JSON.stringifyundefined/函数返回 undefined,这里 ?? '' 兜底(prompts.ts:111);headChars/tailChars 都用 Math.max(0, ...) 防负数(prompts.ts:113-114)。


6. Modes:一个 env 变量,同时拨动"分类"和"语言"两个旋钮

到目前为止,骨架里所有"填什么、怎么填、用什么语言"的文字都来自 mode。这个 mode 是谁、怎么选出来的?

6.1 入口:CLAUDE_MEM_MODE

worker 后台初始化时,读一个设置项决定用哪个 mode(worker-service.ts:450-451):

const modeId = settings.CLAUDE_MEM_MODE; // 默认 'code'
ModeManager.getInstance().loadMode(modeId);

默认值 'code'SettingsDefaultsManager.ts:112。一个字符串,就同时决定了两件看似无关的事:

  1. 分类维度 —— observation_types(bugfix/feature/... )和 observation_concepts 是哪几个;
  2. 输出语言 + 口吻 —— prompt 里那些占位文字和 footer 用中文/日文/英文,记录得多认真还是"chill"。

6.2 单例 + 骨架里到处引用

ModeManager 是单例(ModeManager.getInstance(),ModeManager.ts:26),loadMode 后把结果缓存在 activeMode。§4 的骨架、§7 解析里的"合法 type 列表",全都通过 getActiveMode() 拿同一份 mode(例如 parser.ts:105)。

6.3 继承:parent--override 两段式合并

mode 文件名支持一层继承,靠 -- 分隔(parseInheritance,ModeManager.ts:33-55)。看 plugin/modes/ 目录:code.json 是完整父 mode,code--ja.jsoncode--chill.json只写差异的 override。

loadMode 的合并流程(ModeManager.ts:93-163):

loadMode("code--ja")

├─ 拆成 parentId="code", overrideId="code--ja" (parseInheritance)

├─ 先加载父 code.json ──────────────▶ parentMode(完整)

├─ 再加载 code--ja.json ────────────▶ overrideConfig(只有差异)

└─ deepMerge(parentMode, overrideConfig) ──▶ 合并后的完整 mode

(递归深合并:嵌套对象逐键覆盖,
数组/标量整体替换 —— ModeManager.ts:65-80)
  • 只写差异:code--ja.json 只覆盖了 footer、各 xml_*_placeholdercontinuation_instructionsummary_footer——也就是"露给模型看的文字"和末尾那句 LANGUAGE REQUIREMENTS: Please write ... in 日本語(plugin/modes/code--ja.json:4,20,22)。observation_types 这类结构完全继承父 mode。这就是"改语言不改分类"的实现方式。
  • code--chill.json 走另一条路:它只覆盖 recording_focusskip_guidance(plugin/modes/code--chill.json),把"记录一切"改成"只记录重新发现会很痛的东西,拿不准就跳过"。同一套分类,不同的记录密度。

6.4 层层 fallback:永远有个 mode 能用

loadMode 的容错很密(ModeManager.ts:96-150):

情况兜底
无父 mode,文件缺失回退加载 'code'(ModeManager.ts:114)
code.json 都没了抛致命错 Critical: code.json mode file missing(ModeManager.ts:112)
父 mode 缺失父也回退到 'code'(ModeManager.ts:129)
override 文件缺失/无效只用父 mode(ModeManager.ts:142-149)
继承超过一层(a--b--c)直接抛错(ModeManager.ts:44-47)

code 是整个系统的保底 mode,唯一"不可缺失"的文件。


7. 解析:把一坨纯文本变回结构

模型回了一坨文本,parseAgentXml(parser.ts:41-85)负责把它变回对象。因为没有 schema 兜底(见 §3),这一层的每个分支几乎都是在处理"模型没完全听话"的情况。

7.1 主流程:先探根标签,再分派

parseAgentXml(raw)

├─ raw 空 / 非字符串 ─────────────▶ { valid: false }

├─ stripCodeFences(raw) 去掉整体包裹的 ```xml 围栏

├─ 匹配 <skip_summary/> ? ────────▶ 返回一个 skipped=true 的空 summary

├─ 找第一个根标签 <observation|summary>
│ │
│ ├─ 没找到 ─────────────────▶ { valid: false }
│ ├─ 是 observation ─────────▶ parseObservationBlocks()
│ └─ 是 summary ─────────────▶ parseSummaryBlock()

raw 用第一个出现的根标签决定走哪条路(parser.ts:66-84)——这也是为什么 summary prompt 要死命强调根标签必须对(§4.3)。

7.2 去围栏:只在"整体是一个围栏"时才剥

stripCodeFences(parser.ts:10-13)只匹配"整个 payload 就是一个 ``` 块"的情况:

/^\s*```(?:xml)?\s*\n([\s\S]*?)\n```\s*$/i

注释(parser.ts:6-9)解释了为什么不"剥掉第一个开围栏 + 最后一个闭围栏":那样会误伤内部本身含代码示例、或围栏外还有正文的情况(CodeRabbit 在 PR #2282 的评审)。只在最保守的情况下动手。

7.3 观察块解析:三道容错闸门

parseObservationBlocks(parser.ts:87-151)用正则逐个抠 <observation>...</observation>,每块抽字段后,过三道闸:

  1. 无效 type → 回退,不丢弃。 拿当前 mode 的合法 type 列表(parser.ts:106),若模型填的 type 不在列表里,用列表第一个当兜底并记 error 日志(parser.ts:108-117)——一条记忆不会因为分类填错就整条丢掉。
  2. type 混进 concepts → 剔除。 模型常把 type 又抄进 <concepts>,这里过滤掉(cleanedConcepts,parser.ts:119),因为 type 和 concept 是两个正交维度(prompt 里也反复强调)。
  3. 全空 → 丢弃。 若 title/narrative/facts/concepts 全空,这块是噪音,continue 跳过(parser.ts:130-136)。

若一个 <observation> 都没抠出来,整体判 { valid: false }(parser.ts:74-76)。

字段抽取靠两个小工具:extractField(单标签,parser.ts:182-189,空串归一成 null)和 extractArrayElements(先抠外层数组再抠 <fact>/<file> 等子项,parser.ts:191-213)。

7.4 summary 解析:防"假阳性"

parseSummaryBlock(parser.ts:153-180)抠六个字段,并加一道防线:如果 <summary> 标签虽在、但五个主字段全空,判为误报直接返回 null(parser.ts:167-170),避免存进一条空壳总结。

<skip_summary reason="..."/> 是模型主动说"这轮没啥可总结"的信号,解析成一个 skipped: true 的 summary(parser.ts:48-64)——它是有效解析,只是下游会决定不落库(见 §8)。


8. 落库、去重、广播

解析出的 ParsedObservation[] / ParsedSummary 交给 processAgentResponse(ResponseProcessor.ts:22)。它是"从内存对象到持久记忆"的最后一棒。

8.1 无效解析不是静默丢弃,而是分类记账

!parsed.valid 时并不假装没事(ResponseProcessor.ts:50-90):

  • 先看是不是配额限制的散文(isQuotaLimitedObserverOutput):是的话暂停 generator、保留这批待处理消息、abort 会话(ResponseProcessor.ts:51-69)——别把没配额当成"没记忆"。
  • 否则调 classifyObserverOutput 给这段非 XML 输出分类记日志,让"被丢掉的一批"可见而非静默,然后确认这批消息(不重排队)。故意不重试:纯文本 skip 若重排队,会形成"同一批低信号内容反复重试"的观察者死循环(ResponseProcessor.ts:84-89)。

8.2 存储:内容哈希去重

有效观察打上 agent 标签后交给 storeObservations(ResponseProcessor.ts:126,实现在 SessionStore.ts:1955)。去重发生在 SQL 层:

INSERT INTO observations (... content_hash ...)
VALUES (...)
ON CONFLICT(memory_session_id, content_hash) DO NOTHING

—— SessionStore.ts:1996

content_hashcomputeObservationContentHash(memorySessionId, title, narrative) 算出(SessionStore.ts:2004)。同一会话里 title+narrative 相同的观察只存一次——这让 observer 偶尔重复吐同一条时不会灌重。表结构细节见 04

因为去重会把多条 parsed 观察塌成同一行,返回的 observationIds 可能有重复。广播前用 [...new Set(...)] 去重(uniqueObservationIds,ResponseProcessor.ts:281),否则会触发 Chroma "IDs already exist" 反复对账(issue #2240)。

8.3 summary 落库:skip 变 null

normalizeSummaryForStorage(ResponseProcessor.ts:242-261)在存储前把 summary 归一:skipped 的直接返回 null(ResponseProcessor.ts:251)——§7.4 里那个"这轮没啥可总结"的信号,到这里被兑现为不写库

8.4 广播:同步向量库 + 推 UI/Telegram

存完之后,syncAndBroadcastObservations(ResponseProcessor.ts:263)对每条唯一观察做两件事:

  • 异步同步到 Chroma 向量库(syncObservation,失败只记 error、不阻断——"没有向量搜索也能继续",ResponseProcessor.ts:296-318);
  • 广播给订阅者(broadcastObservation,ResponseProcessor.ts:320),把 facts/concepts/files 序列化成 JSON 推给 UI。

summary 同理走 syncAndBroadcastSummary(ResponseProcessor.ts:364)。检索侧怎么用这些落库结果,见 05


9. 边界与坑(诚实清单)

  • 格式全靠嘴约束,没有硬保证。 没 schema,模型跑偏时只能靠 §7 的容错兜。官方也知道,所以有 TODO(#2233) 要迁到 tool-use JSON(parser.ts:5)。
  • 无效 type 是"回退"而非"报错纠正"。 回退到列表第一个 type(§7.3)保住了记忆,但也意味着分类可能悄悄失真——模型填错分类不会有人拦。
  • 截断按字符数,不按 token。 16k 字符 ≈ 4k token 是粗估(prompts.ts:91-97 注释自陈 "4 chars/token rough estimate"),对 CJK / 特殊 token 化的文本会不准。
  • 头尾截断可能切断结构。 60/30 挖中间对"头尾是信号"的工具输出很合理,但若关键信息恰在中段就会丢——靠 <elided> 标记让模型别幻觉来缓解,不能保证不丢信息。
  • 继承只允许一层。 a--b--c 直接抛错(ModeManager.ts:44),想要多层组合(如"日语 + chill")当前做不到。
  • 解析是正则而非真 XML parser。 观察内容里若出现未转义的 </> 或嵌套同名标签,正则可能抠错。这是"文本 XML"取舍的固有脆弱点。

10. 代码地图(导航索引)

主题文件符号
XML 骨架模板src/sdk/prompts.tsobservationSkeleton
首轮 / 续接 / 观察 / 总结 promptsrc/sdk/prompts.tsbuildInitPrompt · buildContinuationPrompt · buildObservationPrompt · buildSummaryPrompt
巨大字段截断 + elided 标记src/sdk/prompts.tstruncateObservationField · OBS_PROMPT_FIELD_MAX_CHARS
summary 模式切换标记src/sdk/prompts.tsSUMMARY_MODE_MARKER
mode 加载与继承合并src/services/domain/ModeManager.tsloadMode · parseInheritance · deepMerge
当前激活 mode / 合法 typesrc/services/domain/ModeManager.tsgetActiveMode · getObservationTypes
mode 配置类型src/services/domain/types.tsModeConfig · ModePrompts · ObservationType
父 mode / override 示例plugin/modes/*.jsoncode.json · code--chill.json · code--ja.json
mode 选择入口src/services/worker-service.tsinitializeBackground(读 CLAUDE_MEM_MODE)
文本 XML 解析主流程src/sdk/parser.tsparseAgentXml
去围栏 / 观察块 / 总结块src/sdk/parser.tsstripCodeFences · parseObservationBlocks · parseSummaryBlock
字段 / 数组抽取src/sdk/parser.tsextractField · extractArrayElements
落库 · 去重 · 广播src/services/worker/agents/ResponseProcessor.tsprocessAgentResponse · normalizeSummaryForStorage · syncAndBroadcastObservations
内容哈希去重(存储层)src/services/sqlite/SessionStore.tsstoreObservations · computeObservationContentHash

相邻章节: 工具调用从哪来(缓冲/批处理)见 02;解析结果存进什么表、Chroma 镜像见 04;落库后怎么被检索回来见 05;全局阅读地图见 index