🛡️ ARK 智能体诊断报告

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 静默删除。

F10 族首例 · 可选属性读取的半函数守护 同一函数内两套相反的 null 策略,相隔六行 ARK 实机离线复现 ✅ 8 臂对照 🔁 处方已回流 ARK 自身(v0.8.3)

影响范围

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)

🔍

根因定位(P2 Probe)

读取一个「可选属性」有两个彼此独立的失败模式,而几乎所有手写守护都只覆盖其中一个:

失败模式触发条件后果
ABSENCE(缺席) 属性不存在于对象上 AttributeError,当场崩溃
NULLITY(空值) 属性存在但值为 None 下游 TypeError,崩在离根因很远的地方
# langchain_exa/retrievers.py::_get_metadata metadata = { "title": result.title, "url": result.url, "id": result.id, "score": result.score, "published_date": result.published_date, "author": result.author, # ← 必填块:falsy 值一律保留 } if getattr(result, "highlights"): # ← 缺陷1:两参 getattr,等于 result.highlights metadata["highlights"] = result.highlights if getattr(result, "highlight_scores"): metadata["highlight_scores"] = result.highlight_scores if getattr(result, "summary"): # ← 缺陷2:真值当存在性,[] / "" / 0.0 被删 metadata["summary"] = result.summary
  1. 缺陷 1 · 装饰性防御。getattr(x, "y") 不带第三参数时就是 x.y。作者写下了 Python 里「容忍缺席」的通用惯用法,却漏掉了提供这份容忍的那个参数。第三个参数才是守护本身
  2. 缺陷 2 · 真值冒充存在性。即使属性存在,if <value>: 也会丢掉 []""0.0。于是「Exa 搜过了,没有 highlights」与「从没请求过 highlights」坍缩成同一个输出——键都不存在,下游永久失去区分能力
  3. 同函数内的策略自相矛盾。上方 dict 字面量无条件保留 title=Nonescore=0.0author=None;下方六行却把同样 falsy 的可选值删掉。两套相反的 null 策略,共存于一个七行函数
  4. 这不是 mock 才有的幻觉。报告人用了 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
  5. 类型注解掩盖了风险面。_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/2getattr/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

# arm verdict A Result(optionals=None) control(完整 exa_py)/ DEFECT(旧版 in-range pin) B ResultWithText 官方声明返回类型 AttributeError C _Result subpages 元素类型 AttributeError D ResultWithTextAndHighlights 有 highlights 缺 summary AttributeError(按属性,非按类) E getattr(x,'y') vs getattr(x,'y',None) raised=True / None → 第三参数才是守护 F highlights=[] · highlight_scores=[] · summary="" 3 个全部 DROPPED G 必填块 score=0.0 title=None author=None 全部 KEPT(策略相反) H 真实 exa-py==1.0.8 的 Result(无 mock) AttributeError
💥

后果分析

值得注意的分歧点: 三名贡献者的方案都聚焦缺陷 1(补上 None 默认值), alisatwat3 明确表示要「preserving the existing behavior of omitting empty values」—— 即有意保留缺陷 2。这意味着上游修复后,highlights=[] 被静默删除的信息损失依然存在。 ARK 的处方覆盖两半,这正是「打补丁」与「立不变式」的分野。

💊

ARK 处方(P3 Prescribe)

组件机制
上游修复建议 _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 族的结论完全同构:逐点修补追不上组合面,唯有单点强制可收敛。

🔁

处方回流自身代码库(第 3 次 · 2026-08-01)

按本例处方对 ARK 自审,当场命中同型缺陷

# ARK 自身(修复前)· src/ark/langchain.py getattr(response, 'llm_output', {}).get(...) # ↑ 覆盖 ABSENCE ✅ ↑ 但 llm_output 的声明默认值就是 None # 当属性存在且为 None 时,默认值 {} 永不触发,.get() 在下一帧炸开 # langchain-exa(缺陷源) getattr(result, "summary") # ↑ 两参,ABSENCE 未覆盖 ❌

形状相同,缺的是相反的那一半。 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)。 三次的共同结构是:我们诊断出的缺陷模式,在我们自己的代码里也存在一份变体。 这既是对「缺陷族是真实模式而非个案叙事」的最强证据,也说明 诊断能力与产品能力是同一件事的两面—— 能识别它,才能在自己身上找到它;能在自己身上修掉它,处方才不是空头支票。

🧬

缺陷族谱 · F10 可选属性读取的半函数守护(新族)

载体覆盖了漏掉了表象
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

ARK 首页 · Agent 崩溃风险自检清单 · 技术品牌页