Skip to content

搜网额度用尽时,那句「去申请自己的 key」被当成搜索结果交给了模型 #1445

Closed

Description

@jinjunnn

你让模型搜一下网,搜索服务那边说"你的免费额度用完了,去申请个自己的 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

  1. jinjunnn commented on Sep 24, 2026

    @jinjunnn
    OwnerAuthor

    裁决:只落勘破,不落闸。本票保持 open,AC1 未满足。

    勘破的结论是「今天没有可消费的结构化信号」,而这个结论本身就是交付物 ——
    按 勘破先于闸门设计,这正是不该硬造闸门的那一格。

    不落 _meta 闸的两条理由,任何一条单独成立都够:

    1. 失败臂从没被观测过。 票面那一种响应(2xx + result + 非 isError)本轮
      一次都没复现出来;要拿它当闸,我得先自己拼一个不带 _meta 的 envelope,
      再用「没有 _meta 即失败」去判它 —— 期望值与被测对象同源,是本仓点名过的
      假闸门形态。
    2. 前提为假的闸门比没有闸门更贵。 _meta 是 vendor 的无契约字段。Exa 哪天不发它,
      这道闸拒载的就是真实结果,本地搜网整条断掉 —— 而 owner 的要求一直是
      本地云端搜网都得可用。

    本票的阻塞项,写明白

    #1445 卡在一个观测上,不是卡在实现上:需要一份 D 形态的真实 envelope
    (2xx + result + 非 isError 的限流响应)。拿到它之前,任何闸门都是在猜。
    今天 Exa 的限流走的是 429 + JSON-RPC error -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 形态没有别的办法,不过这个代价该在动手前说,没说。

  2. added 3 commits that reference this issue on Sep 24, 2026
  3. self-assigned this
    on Sep 26, 2026
  4. added 2 commits that reference this issue on Sep 26, 2026
  5. jinjunnn commented on Sep 26, 2026

    @jinjunnn
    OwnerAuthor

    处置: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

    方向从「从响应里认出它」(做不到)改为「别让这条路被用来回答用户」(做得到)。
    支撑这个转向的三个实测数字:

    • 那句提示在开源代码的四个发行版(3.1.9 / 3.2.1 / 3.4.0 / 3.4.1)里都带 isError:true
      ⇒ 我们撞到的那个无信号形态不是开源代码发的,是托管端 mcp.exa.ai 自己那一层的产物;
    • 今天的免费档限流走 429 + JSON-RPC -32000(43/43),现有代码认得出
      (#1449 已把 packages/core 那一侧也收口);
    • 那个无信号形态的历史观测 = 1 次(2026-09-24T06:39:30Z);43 次 429 窗口里 0 次;
      2026-09-26 实测 0 次。

    AC1(新) —— 没有自带 key 时,本地搜网不静默降级:用户面能看出「这次没真的搜网」。
    判据:无 key 路径的每一种结局(429 限流 / 无信号文本提示 / 真零命中)在模型面都可区分,
    且「没搜到」与「没搜」不是同一句话。已知的坏:把那份 246 字节的提示负载当成结果喂进去,
    用户面必须看不到「我查了,没查到」这种说法。

    AC2(新) —— 托管端行为漂移有绊线:若 mcp.exa.ai 再次发出不带任何契约信号的失败
    (即那个形态复现),仓里有一处会记录下这次信封的结构字段(isError / structuredContent /
    content[0]._meta 的键名 / 限流响应头),只记录、不判决。判据:拿那份负载跑一遍,
    记录里出现该有的字段;且它不改变任何放行/拒绝行为(对照:同一负载在开关关闭时输出逐字节不变)。

    AC3(新) —— 上面两条都不得引入文案匹配,也不得把致命前提押在无契约字段上。
    判据:实现里不存在对响应正文的关键字/正则匹配;任何读 _meta 之类字段的地方,
    该字段缺席时的行为是放行,不是拒绝。

    为什么是这三条

    • AC1 把要保护的东西说准了:用户真正会被骗到的是「模型说我查过了」,不是「我们没识别出某个信封」。
    • AC2 就是上一轮我建议、这一轮仍然成立的那条「只记录不判决」的诊断 ——
      它把不可观测变成可观测,而不需要现在就判。代价是几行日志,收益是那个形态再现时我们有据。
    • AC3 把两条禁令固定成可判的东西,防止下一轮又绕回关键字匹配。

    明确不做

    • 条目形状闸(按 Exa 的 Title: / URL: 行、Parallel 的 results[] 判成功)——
      它是唯一能同时拒那个形态、放行今日真结果的做法,但要放弃零命中放行、
      并且承担「托管端换渲染即本地搜网整条断掉」的代价。这是偏好裁决,
      写在勘破《什么条件下判据会出现》里,没替 owner 做。
    • 关键字匹配:owner 已明令禁止。

    复杂度 S(AC2 是几行只记录的诊断;AC1 是判据与措辞)。

  6. added a commit that references this issue on Sep 28, 2026
  7. added a commit that references this issue on Sep 28, 2026
  8. jinjunnn commented on Sep 28, 2026

    @jinjunnn
    OwnerAuthor

    裁决:条目形状信号 —— 不做。这一条到此结束,不再开实现票。

    勘破已合(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 版 / 直出 context 12 版 / 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(具名函数 + 按函数名白名单 + 变异臂进闸)。
    翻案的前提应当是:出现一个模型面真的会变的消费路径,或者那个形态开始反复出现。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

type:bugSomething is incorrect or regressed

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions