【Bug已解决】Mustache list sections silently drop falsy items (`0`, `False`, `““`)
【Bug已解决】Mustache list sections silently drop falsy items (0,False,"")
一、现象长什么样
某些 LangChain 组件(如 prompt 模板把变量渲染成文本、或把结构化结果喂给支持 Mustache 的下游)用了 Mustache 风格的列表段(section)来渲染一个列表。当列表里包含假值(falsy)元素——0、False、""、None——这些元素会被静默丢弃,渲染结果里根本不出现它们。
比如模板:
{{#items}}- {{.}} {{/items}}数据items = [1, 0, 2, False, ""]期望渲染出五行,实际只渲染了1和2两行。0、False、""消失了。
如果这是把"工具返回的数值列表"渲染给用户,或者把"检索命中的分数列表"渲染进 prompt,丢掉的0/False会改变语义(比如一个布尔结果的False被丢,看起来像"没有结果"),造成推理错误,且没有任何报错。
二、背景
Mustache 的 section 语义是"真值才渲染":
- 当 section 的值是一个非空列表:迭代渲染每个元素。
- 但当列表里的单个元素是 falsy(
0/False/""/None)时,部分 Mustache 实现(以及不少"类 Mustache"的迷你模板器)对每个元素先做真值判断,falsy 元素直接跳过该次迭代。 - 更糟的是,当 section 的值本身是单个 falsy 标量(
0/False),整个 section 不渲染。
问题根源在"迭代每个元素时也套用了 section 的真值规则"。严格 Mustache 规范对列表元素其实是用上下文 push,但很多轻量实现为了求快,对元素做了if value:判断,于是0/False被当"假"跳过。
三、根因
根因两点:
- 迭代时对元素做真值判断:渲染列表段时,对每个元素执行
if item:,falsy 元素被跳过。规范本应只判断"是不是列表/要不要迭代",不该判断"元素真假"。 - 无数据保全意识:模板器把"渲染"当成了"过滤",把 falsy 当成"不应显示",但调用方要的是"原样列出所有元素"。
本质:把"section 是否渲染"的真值语义,错误地延续到了"列表里每个元素是否渲染",混淆了"段存在性"与"元素值"。
四、最小可运行复现
下面缩略逻辑复现静默丢弃:
def bad_render(template_items, items): # 类 Mustache:对每个元素做真值判断 out = [] for it in items: if it: # 错误:falsy 元素被跳过 out.append(f"- {it}") return "\n".join(out) print(bad_render(None, [1, 0, 2, False, ""])) # 只输出 "- 1" 和 "- 2",0/False/"" 丢失修复:列表段只判断"是否列表",元素原样渲染(falsy 也渲染)。
def good_render(items): out = [] for it in items: out.append(f"- {it}") # 不判断真假,原样列出 return "\n".join(out)五、解决方案(第一层:最小直接修复)
最小修法:在列表段渲染时,只对"值是否为可迭代列表"做判断,进入迭代后不对元素做真值过滤,falsy 元素也要渲染(空字符串渲染为空行,0/False 渲染成其字面量)。
def render_list_section(items): if not isinstance(items, (list, tuple)): # 非列表:按普通 section 真值规则 return None if items is False or items is None else "block" lines = [] for it in items: # 关键:不判断 it 真假,原样渲染 lines.append(f"- {it!r}" if it is False or it == 0 or it == "" else f"- {it}") return "\n".join(lines)这一层保证0/False/""都出现在输出里,语义不丢。
六、解决方案(第二层:结构化改进)
把"列表段渲染规则"固化成策略对象,作为单一事实来源,明确 falsy 元素是否保留。
from dataclasses import dataclass @dataclass(frozen=True) class LangChainMustacheListPolicy: """Mustache 列表段渲染策略的单一事实来源。""" keep_falsy_items: bool = True falsy_literal: dict = None # 如何呈现 falsy def render_items(self, items) -> str: if not isinstance(items, (list, tuple)): raise TypeError("list section needs a list") lines = [] for it in items: if it is False: lines.append("- false") elif it == 0 and not isinstance(it, bool): lines.append("- 0") elif it == "": lines.append("- (empty)") else: lines.append(f"- {it}") return "\n".join(lines) def validate(self) -> None: if not self.keep_falsy_items: raise AssertionError("must keep falsy items to preserve data")模板器用policy.render_items,falsy 保全成为硬规则。
七、解决方案(第三层:断言 / CI 守护)
用 pytest 锁死 falsy 保全:
import pytest from policy import LangChainMustacheListPolicy as P def test_falsy_preserved(): p = P() out = p.render_items([1, 0, 2, False, ""]) assert "0" in out and "false" in out and "(empty)" in out def test_count_matches(): p = P() out = p.render_items([1, 0, False, ""]) # 4 个元素都应有对应行 assert out.count("-") == 4 def test_requires_keep_falsy(): with pytest.raises(AssertionError): P(keep_falsy_items=False).validate() def test_non_list_rejected(): import pytest with pytest.raises(TypeError): P().render_items("not a list")CI 加一条:模板渲染单测必须覆盖含0/False/""的列表,断言元素数量不丢。
八、排查清单
- 渲染出的列表少了几个元素?→ 列表段对 falsy 元素做了真值跳过。
0/False/""消失了?→ 迭代时if item:把它们过滤了。- 是否只判断"是否列表"?→ 进入迭代后不应再判元素真假。
- 布尔结果
False被当"无结果"?→ 需原样渲染false。 - 用的是标准 Mustache 还是自制模板器?→ 自制的更易踩这个坑。
- 是否有"元素数不变"的测试?→ 必须有数量一致性断言。
九、小结
Mustache 列表段在部分实现里对每个元素套用真值判断,导致0/False/""被静默丢弃,改变渲染语义且无声无息。根因是混淆了"section 是否渲染"与"列表元素是否渲染",把过滤当成了保全。第一层改为只对"是否列表"判断、元素原样渲染;第二层用LangChainMustacheListPolicy把 falsy 保全固化成单一事实来源;第三层用 pytest 守护元素数量不丢。模板渲染的通用原则:迭代列表时只判断"是不是列表",绝不对元素做真值过滤,否则数据在渲染期就丢了。
