把 web_search 挂到方舟的 responses 接口上,答案里会多出一批 URL。做 AI 可见度测量时,这批 URL 比答案文本本身更值钱:它直接说明这一轮模型采纳了谁的内容。MaxGrowth(maxgrowth.ai)团队 2026 年 7 月下旬跑国内基线时把这条链路单独接了一遍,这篇只讲它——请求怎么发、URL 怎么从返回体里取出来、两档思考设置各自适合什么场景。以下全部是 API 面口径,与手机 App 前台可能不同。
目标:要的不是那段文字,是它挂的 URL
方舟这条链路给的是答案级引用:URL 出现在答案文本的位置上,能对到具体句子,而不是另给一份"我看过的页面列表"。跟另外两条链路一比就清楚:千问那条打开联网检索后给的是检索候选面,答案与候选页面之间没有对应关系;DeepSeek 官方接口本身不联网,要靠外接第三方检索把上下文拼出来,拿到的是调用方自己投喂的候选。三种语义不同,数字不能加总,也不能拿去互相排名。
这层数据的用途也要先框住:它回答的是"这一轮答案采纳了谁",不回答"这个站在这个平台上一直被采纳"。单次应答有波动,同一道题隔些天再问,答案措辞和挂出的 URL 都会变。所以统计时按题聚合,并且写清楚这批数字是哪一次跑批的结果——本轮是靠输出文件本身区分批次的,把跑批时间戳落进每条记录会更省事,那是建议做法,当前实现没做。
返回结构:开了搜索工具之后多出来的东西
请求走 responses 接口,把 web_search 挂成工具,由模型自行决定这一轮要不要检索。深思考模型还有个坑要先避开:推理本身会吃掉输出预算,max_output_tokens 给不够,会出现有推理、没正文的空返回。
返回里跟取引用有关的有三块:
-
message 输出项里的正文;
-
同一输出项上的 annotations 数组,引用就在这里,类型 url_citation;
-
web_search_call 输出项,带着模型这一轮实际发出的检索词;调用次数则在 usage 的工具统计里。
第三块经常被当成可有可无。annotations 为空有两种成因:模型判断这题不需要检索,或者检索了但答案里没采纳任何一条。只看 annotations 分不出这两种——两种都不是解析失败,都要落盘记下来,把空引用当异常丢掉,会让"没检索"和"没采纳"一起从统计里消失。要真的分开,就得读 web_search_call 和 usage 里的计数:本轮这两样在解析时取出来了,但没有写进结果文件(结果文件里只有引用数组),所以按"这题触没触发检索"分组统计这件事,现在还做不了,得先把字段补进记录。至于"某道题不触发检索意味着什么",本轮没有做过对照实验,不下结论。
从字段展开成记录行:url_citation 怎么读
每条 url_citation 大致带三样东西:标题、URL,以及站点名。麻烦在于字段位置不固定——标题和 URL 有时挂在 annotation 顶层,有时在嵌套的 url_citation 对象里,站点名往往只在嵌套对象里给;annotations 本身也可能挂在 content 项上或挂在输出项上。两处都要看、两处都要合并,漏一处的表现是"某些题目引用数偏少",而且它只在部分返回形态下出现,单条调试时很难复现。
`python
for item in (r.get('output') or []):
if item.get('type') == 'web_search_call':
act = item.get('action') or {}
if act.get('query'):
queries.append(act['query']) # 这一轮实际发出的检索词
if item.get('type') == 'message':
for c in (item.get('content') or []):
if c.get('text') or c.get('output_text'):
texts.append(c.get('text') or c.get('output_text'))
for ann in (c.get('annotations') or []) + (item.get('annotations') or []):
if ann.get('type') == 'url_citation':
uc = ann.get('url_citation') or {}
cites.append({'title': ann.get('title') or uc.get('title'),
'url': ann.get('url') or uc.get('url'),
'site': uc.get('site_name')})
`
落盘粒度要说清楚:本轮是一条应答一行 JSONL,引用作为数组存在这一行里,不是"一条引用一行"。把数组展开成引用级记录、再去算站点频次,是分析阶段的事,采集端不做。展开时两件小事容易漏。一是同一条应答里同一个 URL 会出现多次(同一出处支撑了好几句话),算站点频次前要先在应答内按 URL 去重,否则长答案会把它偏爱的那个站算成好几次,排序被答案长度带偏。二是引用在数组里的顺序不等于重要性,不要拿下标当权重;需要权重就自己定义,并在报告里把定义写出来。
站点名的归一同样在分析阶段做,采集端只按平台给的原样存,给不全时就是空。建议的归一动作有四件:去掉 www. 前缀;统一小写;把移动端子域并回主站;把已知多域名同源的站点映射到同一个键。不做归一的后果很具体:同一个站会在频次统计里裂成三四个名字,排序整体失真,而这种失真在结果表里看不出破绽。
我方实测中,这条链路的引用里出现频次靠前的包含 B2B 信息发布站、行业垂媒,以及火山引擎开发者社区这类开发者站点。只报频次靠前,没有算占比;出现在 AI 答案中不代表我方对其服务的评价。
两档思考设置的实测差异
这条链路可以关掉深度推理跑快档,也可以开着跑。本轮默认关掉,理由是一次跑批要把整个题库走完,单条等待时间直接决定整批时长;关掉之后引用照样取得到,annotations 结构不变。
差异要分清哪部分是实测、哪部分不是。等待时间上,快档明显短于开着深度推理,量级上不止差一点;但这批没有把同一道题在两档下各跑一遍做计时记录,记录里也没有耗时字段,所以不给具体秒数。字段完整度上,能说的是关掉深度推理时 annotations 照样有内容;两档的引用条数是否有系统性差异,这批没有做成并列试验,不下结论。
还有一点跟档位相关:开着深度推理时单条等待更久,连接被中途断开的概率跟着上升,重试次数也会变多。这意味着两档的失败率不能直接拿来比——快档那边低失败率里有一部分来自它请求短,不能算成"这一档更稳"。
选择判据可以简化成两条:要跑批量、目标是取引用源,用快档;单题深入分析、需要看推理过程,再开深度推理。同一份报告里两档的数据不要混在一张表,混了就要在表头标出每行是哪一档。
怎么分层:各层职责与本轮实际写法
先说实际。本轮的实现是一个脚本:每个平台一个适配器函数,函数里把发请求、收流、取字段一起做了,返回答案文本和一份 meta;落盘与断点续跑写在主循环里。没有分层,规模小的时候这样最省事,下面这套四层是建议做法,不是已经跑通的架构。
transport:只管发请求、收流、超时与重试。这条链路走流式是因为长请求空转时连接容易被中途断开,报出来的是读超时而不是错误码。本轮的实际取法是 curl 子进程收 SSE(-m 280,子进程超时 300 秒),按行读 data: 事件,等 type 为 response.completed 的那个事件,再从它带的完整响应里一次取正文和 annotations,不需要正文与引用分成两个 buffer 累加。
extract:从完整响应里取三块(正文、引用数组、有没有真的检索过),只做取值,不做任何判断。
normalize:主机名归一,把一条应答展开成多行引用记录。这两件现在都在分析阶段做,不在采集里。
sink:jsonl 落盘。本轮的续跑 key 是 问题文本 + '|' + 平台,不是题号加平台;写入是追加,没有覆盖或合并逻辑,同一个 key 重跑会在文件里留下两行,统计时要自己按 key 取有效的那一行。
有一处现在明显不到位:transport 不区分错误类型。鉴权失败、参数写错、读超时、分片中断走的是同一套出口轮换重试,鉴权已经错了还会把整轮出口试完,日志里全是同一条错误。把错误分成"可重试"和"不可重试"、由上层决定排回队列还是立刻放弃,是改进项,当前实现没有。
分层想换来的好处只有一条:换平台时需要重写的只有 extract,其余三层与业务无关,不认识任何品牌名,也不做命中判定。本轮这个单脚本只走到一半:传输与落盘已经是共用的,新增一个平台主要是再写一个适配器函数并挂进适配器表;但取字段的逻辑与各平台的请求拼装仍混在同一个函数里,没有单独分出 extract 这一层。什么时候值得拆,判据不是代码行数,而是新增一个平台时被迫复制了哪些与平台无关的代码——被复制的通常正是收流与落盘这两段,那也就是该最先拆出去的两层。
关于本文
本文由 MaxGrowth 团队撰写,写作过程中使用了 AI 辅助工具,内容与数据经人工核校。
