🛡️ ARK 智能体诊断报告

langchain-ai/langchain#39152 · DictPromptTemplate 静默删除列表里的非 str/dict 元素 · 2026-07-31 · W32 诊断 9/5(超额 180%)

📋

问题摘要

🔴

DictPromptTemplate.format() 的契约是「替换模板变量,其余原样返回」。实际行为是:任何嵌在 list / tuple 里、且不是 strdict 的元素,会被无声删除—— intfloatboolNone、嵌套列表,全部消失。

不抛异常、不打日志、不降级警告。同一个 1, 写成 {"scalar": 1} 就保留, 写成 {"nums": [1]} 就蒸发。 决定数据生死的不是数据本身,而是它被放在哪种容器里。

静默数据销毁 F8 族 · 第 3 例 新亚型:无声删除 langchain-core 1.5.3 实机复现 公开 API 可达
🔍

根因定位(P2 Probe)

缺陷表面是「少写了一个 else 分支」,真正的结构问题是同一函数内对相同数据采用了两套互相矛盾的默认策略

  1. _insert_input_variables() 的顶层 else 采用白名单外放行(不认识的类型原样保留);
  2. 而其 list/tuple 内层循环采用白名单外丢弃(不认识的类型直接不 append);
  3. 两套策略相隔 4 行代码,对同一个值给出完全相反的处置。这不是设计取舍,是遗漏——因为没有任何注释、文档或类型签名声明列表是有损的;
  4. 同样的遗漏在 _get_input_variables()再次出现:它同样只递归 str/dict。后果是嵌套列表里的 {var} 永远不会被登记为 input variable,缺参保护 KeyError 在结构上无法触发
# langchain_core/prompts/dict.py :: _insert_input_variables elif isinstance(v, (list, tuple)): formatted_v: list[str | dict[str, Any]] = [] for x in v: if isinstance(x, str): formatted_v.append(formatter(x, **inputs)) elif isinstance(x, dict): formatted_v.append(_insert_input_variables(x, inputs, template_format)) # ← 没有 else。int / float / bool / None / list 在此静默消失 formatted[k] = type(v)(formatted_v) else: formatted[k] = v # ← 列表外的同一个标量,在这里被完整保留

ARK 离线确定性复现(无 API key、无网络): 六臂对照锁死根因与边界—— A(列表内标量)与 B(列表外同款标量)构成最小差分:唯一变量是容器类型; C(变量藏在嵌套列表)证明 input_variables 返回 []format() 不缺参报错, D 对照组(同一变量浅一层)正常登记并抛 KeyError; E 证明经 ChatPromptTemplate.from_messages() 公开 API 同样中招; F 证明模板完全没有变量时数据照样被销毁——说明这不是渲染副作用,是无条件的结构性丢弃。 6/6 与预测完全一致。
脚本:scripts/repros/repro_39152_dict_prompt_list_scalar_drop.py · 验证于 langchain-core 1.5.3

# case result verdict A {"nums":[1,2,3], "flags":[True,None]} -> {"nums":[], "flags":[]} BUG: 数据被删除 B {"scalar":1, "nested":{"scalar":2}} -> 原样保留 OK (控制臂) C {"payload":[["prefix","{q}"]]} input_variables=[] format()不报错 BUG: 保护机制失效 D {"payload":["{q}"]} input_variables=['q'] format()KeyError OK (控制臂) E ChatPromptTemplate.from_messages(...) -> {"nums":[]} BUG: 公开 API 可达 F {"nums":[1,2]} (零变量 · mustache) -> {"nums":[]} BUG: 与渲染无关
💥

后果分析

💊

ARK 处方(P3 Prescribe)

组件机制
上游修复建议 dict.py 两个递归函数 list/tuple 分支改为按项递归(而非 str/dict 特例化),未知类型走 else: append(x) 与顶层策略对齐;_get_input_variables 同步递归,让嵌套变量重新可见。回归需覆盖嵌套列表、混合列表、tuple 保型、零变量模板四种形状(issue 报告人已指出 test_dict.py 目前一个含 list 值的用例都没有
ARK 防线 1 OutputValidator 结构守恒 对「变换前后应保持结构」的操作声明守恒不变式:渲染前后逐路径比对容器基数,len(before) != len(after) 且未声明为过滤操作 → 契约违规。不依赖开发者预先知道哪些类型会被吃掉
ARK 防线 2 InputGuard 模板契约 模板注册时抽取完整变量集(含任意深度容器),与运行时实际提供的 inputs 对账;出现「模板里有占位符但未被登记」立即告警——把 C 臂那种「保护机制被静默拆除」变为显式失败
ARK 防线 3 OTel 留痕 新增 ark.payload.silent_drop 事件,携带丢失路径与被删类型,把「不可见的数据蒸发」升级为可告警指标

为什么这不是「再补一个 else 就完了」: 上游补丁修的是这一个函数的这一个分支。但同样的「白名单外丢弃 vs 白名单外放行」不一致,在 _get_input_variables 里已经复制了一份——同一个 bug 在同一个文件里出现了两次, 这正是「靠人记住约定」的失效证据(与 F9 族 #39100/#39113 跨 provider 镜像遗漏同构)。 ARK 的结构守恒不变式不关心是谁、在哪个分支、忘了写什么:只要一次变换让容器基数无故减少,就会被运行时抓住。

🧬

缺陷族谱 · F8「语义反转 / 契约漂移」第 3 例

Issue亚型机制用户看到的
#39052语义反转MMR lambda_mult 传参时含义被反向解释合法但相反的结果
#39047契约漂移官方弃用警告推荐的 encoder 产出非 UUID 文档 ID崩溃或静默去重失效
#39152无声删除(新)list 内非 str/dict 元素被丢弃,且保护机制同步失效合法但残缺的 payload

前两例的共性是「值被改错」,本例把 F8 族推到了另一个极端:值不是被改错,是被取消存在。 从可靠性工程视角看,这类缺陷对传统防御的免疫力最强——类型系统看到的是合法 list[Any], schema 校验看到的是合法空数组,APM 看到的是一次成功调用,单元测试看不到(因为 test_dict.py 里根本没有含 list 的用例)。 唯一能捕获它的是「变换前后结构守恒」这一类行为不变式探针——这正是 ARK OutputValidator 的设计前提。

跨族观察:本例与 F9 族(#39100 / #39113 出站元数据泄漏)互为反向镜像—— F9 是「不该出现的字段出去了」,F8 本例是「该出现的字段没出去」。两者根因完全同构: 边界上缺一道强制对账,且遗漏概率随分支数量线性放大。 这也是 ARK 把出站契约与结构守恒收敛到同一个 OutputValidator 单点的原因。

ARK — Agent Reliability Kit · github.com/wzg0911/ark

4P Framework: Pinpoint → Probe → Prescribe → Publish · 生成于 2026-07-31 17:40 CST