调试 OpenAI Responses API 的 Web Search 踩坑记录

最近在做一个投资研究 Agent,其中有个很具体的需求:监控腾讯控股 0700.HK 的估值指标,尤其是 trailing PE 或 TTM PE。

这个需求看起来不复杂,但实际做下来踩了几个坑。直接访问 Yahoo Finance 的接口,在当前运行环境里会返回 403 Forbidden;换用模型的 web search 又遇到了 Responses API 请求格式、流式响应解析和第三方 provider 兼容性问题。

这篇文章记录一下这次调试过程。重点不是“怎么让模型搜索网页”,而是:当你真的把 OpenAI 新版 Responses API 里的 web_search 接到工程代码里时,哪些地方最容易出错。

需求背景

腾讯 PE 监控任务需要拿到 0700.HK 当前的 trailing PE 或 TTM PE。

最开始的思路很直接:从常见财经数据源获取结构化数据。但是 Yahoo Finance 在当前环境里不稳定,接口返回 403 Forbidden。后来通过 web search 找到一个当前可访问、页面结构也比较容易解析的数据源:

https://stockanalysis.com/quote/hkg/0700/statistics/

所以最后的数据源策略变成了两层:

  1. 优先直接抓取 StockAnalysis 页面,解析 P/E Ratio
  2. 如果网页结构变化、抓取失败,才使用 Responses API 的 web_search 作为兜底。

这个顺序很重要。模型搜索适合发现信息和兜底,不适合作为首选的结构化行情接口。

官方写法和实际差异

OpenAI 官方文档里,Web Search 是 Responses API 的内置工具。官方示例大致是这样的:

{
  "model": "gpt-5.5",
  "tools": [
    { "type": "web_search" }
  ],
  "input": "What was a positive news story from today?"
}

官方文档还说明,web_search 是 Responses API 里通用可用的工具版本,同时也存在较早的 web_search_preview 版本。文档地址:

https://developers.openai.com/api/docs/guides/tools-web-search

如果只看官方示例,容易形成两个直觉:

  1. input 可以直接传字符串;
  2. 不开流式响应也可以正常拿到 response.output_text

但我这次使用的是 Codex 配置里的一个 provider:

[model_providers.codexzh]
base_url = "https://api.codexzh.com/v1"
wire_api = "responses"
web_search = "live"

这个 provider 的 /models 返回了这些可用模型:

gpt-5.5
gpt-5.4
gpt-5.4-mini
gpt-5.2
gpt-5.3-codex

实际测试里,gpt-5.5 可以用于 Responses API。但请求格式不能完全照搬官方最短示例。

第一个坑:字符串 input 直接 400

最开始我按常见 Responses 示例直接传字符串:

{
  "model": "gpt-5.5",
  "tools": [
    { "type": "web_search" }
  ],
  "input": "搜索腾讯控股 0700.HK 当前 trailing PE"
}

当前 provider 返回的是:

{
  "error": {
    "message": "openai_error",
    "type": "bad_response_status_code",
    "param": "",
    "code": "bad_response_status_code"
  }
}

为了排除是不是 web_search 工具导致的,我又测试了不带 tools、只传字符串 input 的情况,结果仍然是同样的 400

结论是:在这个 provider 上,字符串形式的 input 不可用。

可用的请求格式

最终跑通的格式有几个关键点:

  1. input 必须传 message 数组;
  2. stream 必须为 true
  3. 请求 header 里要设置 Accept: text/event-stream
  4. 搜索工具名使用 web_search

可用 payload 如下:

{
  "model": "gpt-5.5",
  "tools": [
    { "type": "web_search" }
  ],
  "stream": true,
  "input": [
    {
      "role": "user",
      "content": "搜索腾讯控股 0700.HK 当前 trailing PE 或 TTM PE。只返回 JSON。"
    }
  ]
}

请求 header:

Authorization: Bearer <OPENAI_API_KEY>
Content-Type: application/json
Accept: text/event-stream

这个地方需要特别注意:这不是说 OpenAI 官方 API 必须这样写。官方文档中,Responses API 的 stream 是可选参数;设置为 true 时,响应会通过 Server-Sent Events 返回。这里只是当前 provider 的兼容性表现。

也就是说,工程里最好不要把“官方 API 能接受的格式”和“当前 provider 实际能接受的格式”混为一谈。尤其是使用代理、聚合网关或自定义 provider 时,最好写一个最小请求脚本,把 payload、header、模型名、工具名分别验证一遍。

第二个坑:SSE 不是普通 JSON

开启 stream=true 之后,响应不再是一个普通 JSON body,而是 SSE 流。

官方 Responses API 文档也说明了:当 stream 设置为 true 时,模型响应会以 Server-Sent Events 的方式流式返回。

https://developers.openai.com/api/docs/api-reference/responses

所以解析时不能直接 response.json()。需要逐行读取 data:,再解析事件内容。

这次实现里,主要拼接这个事件:

response.output_text.delta

也就是每次有文本增量时,把 delta 追加到缓冲区。等流结束后,缓冲区就是最终输出文本。

同时还做了一个兜底:如果没有拿到 delta,就从 response.completed 事件里的完整 response 对象中再提取一次文本。这样可以兼容不同服务端对流式事件的实现差异。

实际工程里的解析逻辑放在:

src/research_agent/data_sources/valuation.py

第三个坑:工具名不要想当然

这次我在当前 provider 上测试了几个工具名:

web_search
web_search_2025_08_26
web_search_preview
web_search_preview_2025_03_11

结论很简单:

web_search:可用,但必须配合 message 数组 input 和 stream=true
web_search_2025_08_26:400
web_search_preview:400
web_search_preview_2025_03_11:400

所以当前项目里固定使用:

{ "type": "web_search" }

这里也能看出一个经验:不要根据网上旧示例或模型版本猜工具名。OpenAI 官方文档当前推荐的 Responses API 工具名是 web_search,但具体 provider 是否支持 preview 名称、日期后缀名称,仍然要实测。

为什么要求模型只返回 JSON

估值数据最终要进入监控链路,不是给人直接阅读。所以 prompt 里我会明确要求:

只返回 JSON。

更理想的输出类似:

{
  "symbol": "0700.HK",
  "metric": "trailing_pe",
  "value": 18.73,
  "source": "https://stockanalysis.com/quote/hkg/0700/statistics/"
}

当然,只要求“返回 JSON”并不等于模型永远会返回严格 JSON。工程代码里仍然要做几件事:

  1. 去掉可能出现的 Markdown code fence;
  2. 捕获 JSON 解析异常;
  3. value 做数值类型校验;
  4. 保留原始文本,方便调试;
  5. 数据异常时不要静默吞掉,要让监控链路知道本次估值不可用。

模型搜索可以提升兜底能力,但不能替代数据校验。

数据源设计:搜索不是第一选择

这次调试之后,我更明确了一个原则:不要把模型 web search 当作首选结构化数据源。

更稳的顺序应该是:

  1. 直接访问可解析网页或正式数据 API;
  2. 抓取失败时,用 web search 重新发现可用来源;
  3. 让模型只承担“发现”和“兜底提取”的角色;
  4. 最终仍然接入正式行情或估值 provider。

原因也很简单。

网页结构会变,搜索结果会变,模型输出格式也可能变。相比之下,正式数据接口、可控网页解析和明确的字段校验,才更适合作为监控系统的主链路。

在这个项目里,当前策略是:

StockAnalysis 直接解析
        ↓ 失败
Responses API + web_search 兜底
        ↓ 失败
返回不可用状态,并记录错误

这比“所有估值都问模型”稳定得多。

复现命令

只验证 OpenAI web search 数据源:

PYTHONPATH=src python - <<'PY'
from research_agent.config.settings import Settings
from research_agent.data_sources.valuation import OpenAIWebSearchValuationDataSource

settings = Settings()
snapshot = OpenAIWebSearchValuationDataSource(
    api_key=settings.openai_api_key,
    base_url=settings.openai_base_url,
    model=settings.openai_model,
).get_snapshot("0700.HK")

print(snapshot)
PY

验证完整监控链路:

PYTHONPATH=src python -m research_agent.cli monitor-once

小结

这次调试最有价值的地方,不是找到了某个 PE 数据源,而是把 Responses API + Web Search 接入工程时的几个边界摸清楚了:

  1. 官方示例是基准,但 provider 兼容性要单独验证;
  2. 当前 provider 下,字符串 input 不可用,message 数组可用;
  3. 当前 provider 下,必须使用 stream=true 并按 SSE 解析;
  4. 工具名固定使用 web_search,不要随意换 preview 或日期后缀;
  5. 模型搜索适合作为兜底,不适合作为结构化金融数据的主来源。

以后再接类似能力,我会先写一个最小化探针:只测模型名、请求体、工具名、stream 和解析逻辑。这个探针跑通之后,再把它接进正式业务链路。这样比直接在业务代码里边猜边改,成本低很多。

版权声明: 本文首发于 指尖魔法屋-调试 OpenAI Responses API 的 Web Search 踩坑记录https://blog.thinkmoon.cn/post/994_%E8%B0%83%E8%AF%95openai-responses-api%E7%9A%84web-search%E8%B8%A9%E5%9D%91%E8%AE%B0%E5%BD%95/) 转载或引用必须申明原指尖魔法屋来源及源地址!