环境
- graph-memory
1.6.0-beta.8(main @ 83ffb27)
- 宿主:DeepSeek Harness(DSH),
@deepseek-ai/dsh-system-prompt 渲染器
- 触发会话内容:前端(Vue)模板代码片段被提取/整合进记忆库
现象
用户侧表现为整轮请求直接失败:
本轮运行失败
malformed prompt variable reference "{{ cardReading.positionIndex }}" in context "graph-memory:recall" (variable names match /^[a-z][a-z0-9_]*$/)
且间歇性发作:只有语义召回恰好命中污染节点的轮次才触发,极难排查。
根因链路(四步)
-
入库(无问题):提取/整合管线忠实保存会话中出现的代码。一个塔罗牌占卜前端会话的 Vue 模板片段被原样写入 gm_nodes.content 与 gm_messages.content,其中包含合法的 Vue 插值语法 {{ cardReading.positionMeaning }} 等。
-
注入(dsh.ts L533–547):system-prompt/assemble 钩子把召回节点 + 溯源消息拼成 text,assembly.contexts.push({ name: "graph-memory:recall", text })——没有任何转义。
-
宿主渲染器校验:DSH 的动态 context 渲染是严格插值。@deepseek-ai/dsh-system-prompt/lib/index.js L105–129 的 interpolate() 扫描 context 文本中每个完整 {{name}} 组,按 /^[a-z][a-z0-9_]*$/ 校验变量名;非法名(驼峰/带点/带空格)在 L118 直接 throw。
-
爆炸面:assembleContext 三个输出全部经过这条通道——<knowledge_graph> XML 节点内容、systemPrompt 引导文字、<episodic_context> 溯源消息(escapeXml 只转义 <>&",不处理花括号)。任何包含合法模板语法的会话(Vue / Angular / Handlebars / Mustache / Liquid…)都是潜在地雷。
关键放大因素:自复制污染
讨论这个 bug 的会话本身也会被摄取管线记录,引用的触发字符串 {{ ... }} 会再次入库。我实测确认:清洗完数据库几分钟后 gm_messages 又出现新的含 {{ 行(正是排查会话自己的消息)。只在存储侧清洗无法断根,必须在注入侧转义。
复现最小路径
// 用真实 DSH 渲染器验证:
import { renderContextSections } from "@deepseek-ai/dsh-system-prompt";
const assembly = {
contexts: [{
name: "graph-memory:recall",
text: `[ASSISTANT] fixed: <p class="x">{{ cardReading.positionMeaning }}</p>`,
}],
variables: { query: "test" },
};
renderContextSections(assembly);
// → Error: malformed prompt variable reference "{{ cardReading.positionMeaning }}"
// in context "graph-memory:recall" (variable names match /^[a-z][a-z0-9_]*$/)
修复建议
召回内容是数据而不是模板。建议在 assembleContext 出口统一中性化,一处覆盖 DSH(dsh.ts)与 OpenClaw(index.ts)两条宿主路径:
/**
* 打散文本中的 {{ 开括号对,让宿主提示词插值器把它们当普通文本放行。
* 渲染器的规则(@deepseek-ai/dsh-system-prompt lib/index.js L109–116):
* 找不到配对 }} 的孤立 {{ 按普通文本原样放行,
* 因此只需打散开括号对即可完全惰性,几乎不影响可读性。
*/
function neutralizeTemplateBraces(text: string): string {
return text.replace(/\{\{/g, "{ {");
}
应用于 assembleContext 返回前的三个输出(xml / systemPrompt / episodicXml)。
已验证(本地补丁)
- 新增回归测试 ×2:节点内容路径 + episodic 溯源路径
- 全量测试:16 files / 129 tests 全部通过
- 用真实
@deepseek-ai/dsh-system-prompt 渲染器端到端验证:未转义精确复现上述报错原文;转义后渲染成功、内容原样可读({ { cardReading.positionIndex }})
核心补丁 diff(src/format/assemble.ts)
diff --git a/src/format/assemble.ts b/src/format/assemble.ts
index 71a3437..b45b545 100755
--- a/src/format/assemble.ts
+++ b/src/format/assemble.ts
@@ -179,13 +179,22 @@ export function assembleContext(
? `<episodic_context>\n${episodicParts.join("\n")}\n</episodic_context>`
: "";
- const fullContent = systemPrompt + "\n\n" + xml + (episodicXml ? "\n\n" + episodicXml : "");
+ // 召回内容是数据而不是模板:进入宿主提示词通道前必须让 {{ }} 模板字面量失活。
+ // DSH 会把动态 context 里的每个 {{name}} 组按提示词变量校验(名字必须匹配
+ // /^[a-z][a-z0-9_]*$/),会话代码里合法的 Vue/Angular/Handlebars 插值一旦被
+ // 原样召回注入,会让整轮 prompt 渲染直接抛错。渲染器对没有配对 {{ 的孤立
+ // 文本按原样放行,因此只需打散开括号对即可,内容保持可读。
+ const safeSystemPrompt = neutralizeTemplateBraces(systemPrompt);
+ const safeXml = xml === null ? null : neutralizeTemplateBraces(xml);
+ const safeEpisodicXml = neutralizeTemplateBraces(episodicXml);
+
+ const fullContent = safeSystemPrompt + "\n\n" + safeXml + (safeEpisodicXml ? "\n\n" + safeEpisodicXml : "");
return {
- xml,
- systemPrompt,
+ xml: safeXml,
+ systemPrompt: safeSystemPrompt,
tokens: Math.ceil(fullContent.length / CHARS_PER_TOKEN),
- episodicXml,
- episodicTokens: Math.ceil(episodicXml.length / CHARS_PER_TOKEN),
+ episodicXml: safeEpisodicXml,
+ episodicTokens: Math.ceil(safeEpisodicXml.length / CHARS_PER_TOKEN),
};
}
@@ -245,6 +254,21 @@ function renderNode(node: GmNode & { src: "active" | "recalled" }, indent: strin
return `${indent}<${tag} name="${node.name}" desc="${escapeXml(node.description)}"${source}${updated}>\n${node.content.trim()}\n${indent}</${tag}>`;
}
+/**
+ * 打散文本中的 `{{` 开括号对,让宿主提示词插值器把它们当普通文本放行。
+ *
+ * DSH 的 system-prompt 渲染器(@deepseek-ai/dsh-system-prompt)会扫描动态
+ * context 中的每个完整 `{{name}}` 组并按 /^[a-z][a-z0-9_]*$/ 校验变量名,
+ * 非法或未注册的名字会让整轮请求渲染失败。记忆库忠实保存会话中出现的
+ * 前端模板代码,这些插值被召回注入后等价于往提示词里注入了伪变量语法。
+ * 渲染器的规则是:找不到配对 `}}` 的孤立 `{{` 按普通文本处理,因此在两个
+ * 花括号之间补一个空格即可让内容完全惰性,同时几乎不影响可读性
+ * (`{{ cardReading.x }}` → `{ { cardReading.x }}`)。
+ */
+function neutralizeTemplateBraces(text: string): string {
+ return text.replace(/\{\{/g, "{ {");
+}
+
function escapeXml(s: string): string {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
临时缓解(存储侧,治标)
对已污染库可直接 SQL 清洗(备份后执行):
UPDATE gm_nodes SET content = replace(content, '{{', '{ {') WHERE content LIKE '%{{%';
UPDATE gm_messages SET content = replace(content, '{{', '{ {') WHERE content LIKE '%{{%';
-- 同理处理 name / description / summary / instruction / condition 列
注意清洗后仍会被新会话再次污染,务必配合注入侧修复。
环境
1.6.0-beta.8(main @83ffb27)@deepseek-ai/dsh-system-prompt渲染器现象
用户侧表现为整轮请求直接失败:
且间歇性发作:只有语义召回恰好命中污染节点的轮次才触发,极难排查。
根因链路(四步)
入库(无问题):提取/整合管线忠实保存会话中出现的代码。一个塔罗牌占卜前端会话的 Vue 模板片段被原样写入
gm_nodes.content与gm_messages.content,其中包含合法的 Vue 插值语法{{ cardReading.positionMeaning }}等。注入(
dsh.tsL533–547):system-prompt/assemble钩子把召回节点 + 溯源消息拼成text,assembly.contexts.push({ name: "graph-memory:recall", text })——没有任何转义。宿主渲染器校验:DSH 的动态 context 渲染是严格插值。
@deepseek-ai/dsh-system-prompt/lib/index.jsL105–129 的interpolate()扫描 context 文本中每个完整{{name}}组,按/^[a-z][a-z0-9_]*$/校验变量名;非法名(驼峰/带点/带空格)在 L118 直接 throw。爆炸面:
assembleContext三个输出全部经过这条通道——<knowledge_graph>XML 节点内容、systemPrompt 引导文字、<episodic_context>溯源消息(escapeXml只转义<>&",不处理花括号)。任何包含合法模板语法的会话(Vue / Angular / Handlebars / Mustache / Liquid…)都是潜在地雷。关键放大因素:自复制污染
讨论这个 bug 的会话本身也会被摄取管线记录,引用的触发字符串
{{ ... }}会再次入库。我实测确认:清洗完数据库几分钟后gm_messages又出现新的含{{行(正是排查会话自己的消息)。只在存储侧清洗无法断根,必须在注入侧转义。复现最小路径
修复建议
召回内容是数据而不是模板。建议在
assembleContext出口统一中性化,一处覆盖 DSH(dsh.ts)与 OpenClaw(index.ts)两条宿主路径:应用于
assembleContext返回前的三个输出(xml/systemPrompt/episodicXml)。已验证(本地补丁)
@deepseek-ai/dsh-system-prompt渲染器端到端验证:未转义精确复现上述报错原文;转义后渲染成功、内容原样可读({ { cardReading.positionIndex }})核心补丁 diff(src/format/assemble.ts)
临时缓解(存储侧,治标)
对已污染库可直接 SQL 清洗(备份后执行):
注意清洗后仍会被新会话再次污染,务必配合注入侧修复。