Skip to content

[bug] 召回内容含 {{ }} 模板字面量时,DSH 整轮 prompt 渲染失败(malformed prompt variable reference) #79

Description

@meyaomiao

环境

  • 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_]*$/)

间歇性发作:只有语义召回恰好命中污染节点的轮次才触发,极难排查。

根因链路(四步)

  1. 入库(无问题):提取/整合管线忠实保存会话中出现的代码。一个塔罗牌占卜前端会话的 Vue 模板片段被原样写入 gm_nodes.contentgm_messages.content,其中包含合法的 Vue 插值语法 {{ cardReading.positionMeaning }} 等。

  2. 注入(dsh.ts L533–547):system-prompt/assemble 钩子把召回节点 + 溯源消息拼成 text,assembly.contexts.push({ name: "graph-memory:recall", text })——没有任何转义

  3. 宿主渲染器校验:DSH 的动态 context 渲染是严格插值。@deepseek-ai/dsh-system-prompt/lib/index.js L105–129 的 interpolate() 扫描 context 文本中每个完整 {{name}} 组,按 /^[a-z][a-z0-9_]*$/ 校验变量名;非法名(驼峰/带点/带空格)在 L118 直接 throw。

  4. 爆炸面: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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
 }

临时缓解(存储侧,治标)

对已污染库可直接 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 列

注意清洗后仍会被新会话再次污染,务必配合注入侧修复。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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