Repository navigation
Description
你让模型搜一下网,搜索服务那边说"你的免费额度用完了,去申请个自己的 key"。这句话会被当成搜索结果交给模型。
模型看到的不是"搜索失败了",而是一段看起来像正常返回的文字。于是它会拿这段话当素材继续回答你 —— 可能编一个没根据的答案,也可能把"去 dashboard 申请 key"这种话当成网页内容讲给你。你不会知道搜索其实没发生。
这条路是出厂默认:不带自己的 key 时,本地搜网默认走 Exa 的免费额度。
不做会怎样:额度用尽之后,每一次搜网都在悄悄地把一句错误提示伪装成结果。这正是我们当初立"禁止伪装成功"那条规矩要防的事,只是这个形状从外面进来,落在既有收口之外。
给实现方
复杂度:S–M
来源:alpha-code#1433(VERIFY-1)实测命中,原始负载已入库:
docs/verification/2026-09-24-1433-four-account-states-websearch/results/exa-free-tier-rate-limit-as-success.json
实测事实
本轮约 13 次 exa 调用里命中 1 次,拿回 outcome=result / bytes=246:
You've hit Exa's free MCP rate limit. To continue using without limits, create your own Exa API key.
Fix: Create API key at https://dashboard.exa.ai/api-keys , and then update Exa MCP URL to …
为什么走成了成功路:HTTP 200 + MCP 响应未置 isError + 负载不是 {error:…} 结构
⇒ parseResponse 原样返回 content[].text ⇒ 叶子把它放进 output
(packages/opencode/src/tool/websearch.ts:186)。
为什么是默认路径:applyWebSearchSovereignty set-if-unset OPENCODE_ENABLE_EXA=1
⇒ selectWebSearchProvider 返回 "exa"。
命中是间歇的(后续 4+3 轮各 0 次)—— 但「后来没复现」不等于它不存在,原始负载在库里。
与既有规矩的关系
#489 / E7 立的是「web search 失败诚实:禁伪成功、任何非 2xx 一律 LOUD」。
这个形状不是回归 —— 它是 2xx 且结构上合法的响应,落在那条收口的辖区之外。
所以修法不是「再检查一次状态码」。
动手前要答的
怎么判别「这是一段错误提示」而不手写 Exa 的文法?
本仓明令禁止「手写别人文法的替身」(#123/#36,一夜十四轮返工)。按内容关键字匹配
("rate limit" / "create your own API key")正是那个形态,不要走。
先实读 Exa 的 MCP 响应里有没有结构化的信号(isError、error content type、
_meta、上游文档里声明的字段)。有 ⇒ 消费它。没有 ⇒ 那就明说没有,
并把可选方案与各自代价摆出来交裁决,不要退回关键字匹配当默认解。
AC
AC1 免费额度用尽的那种响应,不再以「成功的搜索结果」形状到达模型;模型收到的是一次明确的失败。
AC2 先证明手段能测出已知的坏:拿库里那份原始负载当输入,改前判「成功」、改后判「失败」,两份输出都给。
AC3 正常搜索结果一条都不受影响(给对照臂:真实结果仍然原样到达)。
边界
- 只碰失败判别,不碰主权闸、不碰选路、不碰出网围栏。
packages/core与packages/opencode两份传输之间没有可共用的依赖边(仓内既有约定),
若两处都要改,各写一份并加一条钉住两边不许漂的判据 —— 参考#1415的做法。
Out of scope
- 云腿(
cloud_web_search)的失败面。 - 给用户配置自己的 Exa key 的入口。
Activity
impharaon commentedon Sep 24, 2026
裁决:只落勘破,不落闸。本票保持 open,AC1 未满足。
勘破的结论是「今天没有可消费的结构化信号」,而这个结论本身就是交付物 ——
按
勘破先于闸门设计,这正是不该硬造闸门的那一格。不落
_meta闸的两条理由,任何一条单独成立都够:result+ 非isError)本轮一次都没复现出来;要拿它当闸,我得先自己拼一个不带
_meta的 envelope,再用「没有
_meta即失败」去判它 —— 期望值与被测对象同源,是本仓点名过的假闸门形态。
_meta是 vendor 的无契约字段。Exa 哪天不发它,这道闸拒载的就是真实结果,本地搜网整条断掉 —— 而 owner 的要求一直是
本地云端搜网都得可用。
本票的阻塞项,写明白
#1445卡在一个观测上,不是卡在实现上:需要一份 D 形态的真实 envelope(2xx +
result+ 非isError的限流响应)。拿到它之前,任何闸门都是在猜。今天 Exa 的限流走的是
429+ JSON-RPCerror -32000(43/43),是响亮失败,现有代码判得出来。
我的建议(是建议,不是已定的 AC)
在传输层加一条只记录、不判决的诊断:2xx +
result时记下isError/structuredContent/_meta的键名 / 限流响应头,不含正文。它不改变任何行为,却能在 D 再次出现时留下那一格。要不要做由 owner 裁;
我没有替它开票,也没有把它写进本票的 AC。
已经分出去的
packages/core那一侧的缺陷不在本票,已另开#1449—— 那不是伪成功,是连响亮失败都认不出来:今天真抓到的
isError:true负载喂进去,opencode判失败、core判成功并把错误原文当搜索结果交给模型。mcp-websearch.ts:154的注释自己写着「两侧都要收口」,只收了一侧。一个代价,记在这里
这轮勘破把本机出口 IP 的 Exa 免费额度跑满了(
retry-after≈ 12 小时)。这段时间内没有自带 key 的本地搜网会一直拿
429。是响亮失败不是静默,但功能确实不可用。取 D 形态没有别的办法,不过这个代价该在动手前说,没说。
fix(gate): alpha tip 是红的 —— 上游 sync 打红两道闸,每条 lane 的 push 都被拦 (#1450)
docs(#1445): Exa MCP 失败信号实读 —— 票面那一种响应没有可消费的结构化信号 (#1452)
verify(#1433): 四格 × 搜网实测 —— 零余额真账户的 402 wire body 第一次跑出来 (#1453)
fix(core): #1449 本地搜网的 V2 副本认得出失败了 —— isError 与非 2xx 不再当搜索结果交给模型 (#1455)
docs(#1445): 坏负载在手上之后重做勘破 —— 判据仍不存在,这次是逐条点名排除 (#1457)
impharaon commentedon Sep 26, 2026
处置:AC1 连续两轮判不到 ⇒ 重写 AC(不开第三轮)
两轮的结论一致,而第二轮是在坏负载在手上的前提下、逐条点名排除出来的
(十条候选 + 实测,勘破见
docs/architecture/2026-09-24-exa-mcp-failure-signal-recon.md第 134 行起《2026-09-26 补勘》,证据
docs/verification/2026-09-26-1445-exa-vendor-source-and-live-shapes/)。结论不是「没找到」,是「四条约束互斥」:
对只能看文本的判据,那句限流提示与下面三样属于同一个等价类 ——
① Exa 的真零命中(源码逐字
{content:[{type:"text",text:"No search results found. Please try a different query."}]},信封与它逐格相同);② 3.1.9 时代的真结果(
/context整段直出、无_meta、无Title:行);③ 托管端下一次换渲染之后的真结果。任何拒它的结构性判据都会拒掉整个类;
类内唯一能把它单独挑出来的特征是文案内容 = 关键字匹配。
所以「不押无契约字段 + 不匹配文案 + 拒它 + 放行零命中」这四条同时要求是矛盾的。
这不是搜索不够充分的问题,原 AC1 不可满足。
原 AC1(作废,留痕)
作废理由如上。不改票号、不新开需求 —— 用户可观察的结果没变,变的是达成它的路径。
重写后的 AC
方向从「从响应里认出它」(做不到)改为「别让这条路被用来回答用户」(做得到)。
支撑这个转向的三个实测数字:
isError:true⇒ 我们撞到的那个无信号形态不是开源代码发的,是托管端
mcp.exa.ai自己那一层的产物;-32000(43/43),现有代码认得出(
#1449已把packages/core那一侧也收口);2026-09-26 实测 0 次。
AC1(新) —— 没有自带 key 时,本地搜网不静默降级:用户面能看出「这次没真的搜网」。
判据:无 key 路径的每一种结局(429 限流 / 无信号文本提示 / 真零命中)在模型面都可区分,
且「没搜到」与「没搜」不是同一句话。已知的坏:把那份 246 字节的提示负载当成结果喂进去,
用户面必须看不到「我查了,没查到」这种说法。
AC2(新) —— 托管端行为漂移有绊线:若
mcp.exa.ai再次发出不带任何契约信号的失败(即那个形态复现),仓里有一处会记录下这次信封的结构字段(
isError/structuredContent/content[0]._meta的键名 / 限流响应头),只记录、不判决。判据:拿那份负载跑一遍,记录里出现该有的字段;且它不改变任何放行/拒绝行为(对照:同一负载在开关关闭时输出逐字节不变)。
AC3(新) —— 上面两条都不得引入文案匹配,也不得把致命前提押在无契约字段上。
判据:实现里不存在对响应正文的关键字/正则匹配;任何读
_meta之类字段的地方,该字段缺席时的行为是放行,不是拒绝。
为什么是这三条
它把不可观测变成可观测,而不需要现在就判。代价是几行日志,收益是那个形态再现时我们有据。
明确不做
Title:/URL:行、Parallel 的results[]判成功)——它是唯一能同时拒那个形态、放行今日真结果的做法,但要放弃零命中放行、
并且承担「托管端换渲染即本地搜网整条断掉」的代价。这是偏好裁决,
写在勘破《什么条件下判据会出现》里,没替 owner 做。
复杂度 S(AC2 是几行只记录的诊断;AC1 是判据与措辞)。
feat(websearch): #1445 重写后 AC —— 无 key 三种结局钉在模型面,信封结构记录只记录不判决 (#1459)
docs(#1445): 条目形状 × fail-open 勘破 —— 命题自洽但对模型面惰性,49 版渲染只换过两次,推荐不做 (#1460)
impharaon commentedon Sep 28, 2026
裁决:条目形状信号 —— 不做。这一条到此结束,不再开实现票。
勘破已合(
839ce4f08,docs/architecture/2026-09-27-exa-entry-shape-fail-open-recon.md)。这不是偏好,是查清之后的结论 —— 我上一版把它包装成「请 owner 在两个代价之间选」是错的,
理由见下面第 2、3 条:我递上去的那个取舍,一半是我自己的默认假设造出来的,一半是我只看了两个样本。
1. fail-open 的命题自洽,但对模型面是惰性的 —— 这是决定性的一条。
命题本身没问题:认出条目形状才附一句声称,认不出原样放行,所以「换渲染就断掉」结构上不存在。
但模型只读工具的
output(packages/opencode/src/tool/websearch.ts:186→session/message-v2.ts:299;core 侧
websearch.ts:407的toModelOutput同理),title/metadata不进模型。⇒ 声称与不声称两个分支交给模型的字节逐字相同,那个伪装负载到模型面一个字都不会变。
要让模型对「缺席」有反应,只能把「缺席 = 不可信」写进工具描述 —— 那是 fail-closed 搬进提示词,
真零命中与旧渲染的代价原样回来。所以它真正的消费者只剩 UI 与日志,买不到它本来要买的那件事。
2. 它与刚合入的 AC3 闸正面互斥。 E 的实现就是
startsWith("Title: "),而
#1445重写后 AC3 那道普查闸的原文是「startsWith只认{与data:,零includes/match/test」。要放行它,就得给那道闸开一个按函数名的白名单 —— 那正是「关键字匹配与结构匹配的边界开始糊」的地方。
为一个对模型面无效的信号去弱化一道刚立起来的闸,不划算。
3. 我那句「无底洞」是错的,已订正。 把 npm 上
exa-mcp-server全部 49 个发行版(2024-12-17 → 2026-08-18)按
web_search处理函数窗口比了一遍:三代渲染、609 天里只换过两次(顶层
results[]33 版 / 直出context12 版 /Title:系 4 版,第三代仍在,就是托管端今天的渲染)。我上一版只比了 3.1.9 与 3.2.1 两个样本就推出「要跟外部逐点保持同步 = 无底洞」,推得过头了。
诚实边界:
mcp.exa.ai不是 npm 包(前两轮已证那个伪装形态不是任何 npm 版发的),托管端自己的发版史拿不到 —— 所以「渲染稳定」这个结论只对开源代码成立。
4. 今天的实际风险已被罩住。 免费档限流走 429(实测 43/43,响亮);那个无信号形态历史观测 1 次;
而重写后的 AC1 已实测它到模型面是 vendor 自己那句 rate limit 原文、不是我们编的「没查到」;
AC2 的信封记录会在它再现时留下结构字段。
若将来要翻案,勘破文档末尾写了 AC-a/b/c(具名函数 + 按函数名白名单 + 变异臂进闸)。
翻案的前提应当是:出现一个模型面真的会变的消费路径,或者那个形态开始反复出现。