langchain-ai/langchain#39167 · langchain-exa `_get_metadata()` 的伪守护与真值删除 · 2026-08-01 · 第 10 份诊断(W32)
langchain_exa/retrievers.py::_get_metadata()
只有七行,却同时装着两个方向相反的缺陷:
它用 getattr(result, "summary")——两参数、无默认值——
这在语义上精确等价于 result.summary,
读起来像防御,行为上零防御,属性缺席即 AttributeError;
同时它用真值判断代替存在性判断,把 [] / "" / 0.0 静默删除。
影响范围
langchain-exa 1.1.0 · 所有 ExaSearchRetriever / Exa 工具调用路径
框架现状
⚠️ open(2026-07-31 10:58 UTC)· 无 assignee · labels: bug/exa/external · 3 人竞领
ARK 方案
✅ ark.attrs 属性访问不变式(v0.8.3)
读取一个「可选属性」有两个彼此独立的失败模式,而几乎所有手写守护都只覆盖其中一个:
| 失败模式 | 触发条件 | 后果 |
|---|---|---|
| ABSENCE(缺席) | 属性不存在于对象上 | AttributeError,当场崩溃 |
| NULLITY(空值) | 属性存在但值为 None |
下游 TypeError,崩在离根因很远的地方 |
getattr(x, "y") 不带第三参数时就是 x.y。作者写下了 Python 里「容忍缺席」的通用惯用法,却漏掉了提供这份容忍的那个参数。第三个参数才是守护本身;if <value>: 也会丢掉 []、""、0.0。于是「Exa 搜过了,没有 highlights」与「从没请求过 highlights」坍缩成同一个输出——键都不存在,下游永久失去区分能力;title=None、score=0.0、author=None;下方六行却把同样 falsy 的可选值删掉。两套相反的 null 策略,共存于一个七行函数;MagicMock(spec=...),但 langchain-exa 1.1.0 自己声明 exa-py>=1.0.8,<2.0.0,而 exa_py 1.0.8 的 Result 根本没有 summary 字段——pip install langchain-exa "exa-py==1.0.8"(依赖声明本身授权的解析结果)就能让真实库对象崩溃,无需任何 mock;_get_metadata 注解为 result: Any,实际只对 Result 安全。而 search_and_contents() 的重载声明会返回 ResultWithText / ResultWithTextAndHighlights——它们恰恰缺这些字段;Result.subpages 的元素类型 _Result 四个字段全缺。ARK 离线确定性复现(无 API key、无网络):
8 臂对照逐个锁死结论——
B(ResultWithText,官方声明的返回类型)、C(_Result,subpages 元素类型)、D(有 highlights 缺 summary)全部 AttributeError;
D 特别证明崩溃是按属性发生,不是按类——前两个守护通过了,第三个炸了;
E 直接给出 getattr/2 与 getattr/3 的语义差;
F 显示 3 个 present-but-falsy 值被静默删除;G 对照必填块 falsy 值全部存活;
H 用真实 exa-py==1.0.8 对象复现崩溃,零 mock。
脚本:scripts/repros/repro_39167_exa_fake_getattr_guard.py · 实跑验证记录见 scripts/repros/VERIFICATION.md
_get_metadata 崩了整条链就断了。而触发条件是「Exa 这次没返回 summary」——一个由远端服务决定、调用方无法控制的偶发条件;getattr 会自动认为这里已经处理过缺席。看起来被守护的代码,比明显没守护的代码更难被发现——因为它主动关闭了审查者的怀疑;exa-py>=1.0.8,<2.0.0 横跨了「有 summary」与「无 summary」两个时代。包自己授权的解析结果就能让自己崩溃——这是依赖区间与属性假设脱钩的典型;getattr(result, attr, None))。这是本季度第 3 次「ARK 诊断 → 社区多人竞领」模式复现(前两次:#39106、#39113)。值得注意的分歧点:
三名贡献者的方案都聚焦缺陷 1(补上 None 默认值),
alisatwat3 明确表示要「preserving the existing behavior of omitting empty values」——
即有意保留缺陷 2。这意味着上游修复后,highlights=[] 被静默删除的信息损失依然存在。
ARK 的处方覆盖两半,这正是「打补丁」与「立不变式」的分野。
| 层 | 组件 | 机制 |
|---|---|---|
| 上游修复建议 | _get_metadata() |
两处都要改:① getattr(result, attr, None) 补默认值;② if value is not None: 取代真值判断,让 [] 与「未请求」可区分。只改第一处等于修一半 |
| ARK 防线 1 | ark.attrs.attr() |
可选属性读取必为全函数:缺席与 None 皆归一到显式哨兵,绝不误删 falsy。两个失败模式一次覆盖,不给「只守一半」留出空间 |
| ARK 防线 2 | is_present() / prune_absent() |
把「存在性」与「真值」在 API 层面强制分离:存在性判断唯一入口是 is not None,删除动作必须显式调用 prune_absent(),杜绝 if x: 的隐式语义漂移 |
| ARK 防线 3 | attr_mapping() / attr_text() |
跨类型的安全投影:对 Any 注解的外来对象,声明期望形状后按形状读取,把「注解 Any、实际只对一个类安全」的隐患暴露在调用点 |
为什么 ARK 的方案不是「同一个补丁再打一遍」:
上游修复是逐点式的——这次把三个 getattr 补上默认值,
下一个可选字段、下一个 provider 适配器仍要靠开发者记得。
ARK 的属性访问不变式是结构式的:只要走 ark.attrs,
「缺席」与「空值」在类型层面就不可能只覆盖一半。
这与 F9 族的结论完全同构:逐点修补追不上组合面,唯有单点强制可收敛。
按本例处方对 ARK 自审,当场命中同型缺陷:
形状相同,缺的是相反的那一半。
langchain-exa 漏掉 ABSENCE,ARK 漏掉 NULLITY——
而 LLMResult.llm_output 的声明默认值恰恰就是 None,
意味着 ARK 这条路径在最常见的情况下失效。两段代码乍看都像被守护了。
已修复为 v0.8.3 属性访问不变式:新增 ark.attrs 模块
(attr / attr_mapping / attr_text / is_present / prune_absent),
确立不变式:「可选属性读取必为全函数:缺席或 None 皆归哨兵,绝不误删 falsy」。
连带修复 crewai/langchain 相对导入越界与 langchain 注解 PEP563 问题。
新增 tests/test_v0_8_3_attr_access_invariant.py,
回归 300 passed / 3 skipped 全绿。
连续第 3 次回流命中(v0.8.1 → v0.8.2 → v0.8.3)。 三次的共同结构是:我们诊断出的缺陷模式,在我们自己的代码里也存在一份变体。 这既是对「缺陷族是真实模式而非个案叙事」的最强证据,也说明 诊断能力与产品能力是同一件事的两面—— 能识别它,才能在自己身上找到它;能在自己身上修掉它,处方才不是空头支票。
| 载体 | 覆盖了 | 漏掉了 | 表象 |
|---|---|---|---|
| langchain-exa#39167 | — | ABSENCE(两参 getattr) | 写着 getattr,读起来像防御 |
| langchain-exa#39167(第二重) | ABSENCE 后仍存在 | 真值 ≠ 存在性 | falsy 值静默蒸发 |
| ARK 自身(回流命中) | ABSENCE ✅ | NULLITY(默认值永不触发) | 三参 getattr,看起来完全正确 |
F10 与既有族的关系:它与 F8「无声删除」共享「静默丢失信息」的后果,
但根因完全不同——F8 是契约层的结构不守恒(Schema 过滤未留痕),
F10 是语言层的守护半覆盖(getattr 惯用法的两个陷阱)。
F10 的独特性在于:它伪装成已修复的代码。
F1–F9 的缺陷在代码里长得像缺陷,F10 长得像修复——
这使它在 code review 中的存活率显著更高,也使「靠人眼审查」这条防线在此族上系统性失效。
ARK — Agent Reliability Kit · github.com/wzg0911/ark
4P Framework: Pinpoint → Probe → Prescribe → Publish · 生成于 2026-08-01 15:45 CST