最近在整理 GEO 监测能力时,我重新看了一遍几种采集方式。表面上看,GEO 监测只是问大模型一个问题,然后判断回答里有没有出现目标品牌。真正做起来会发现,它比普通关键词排名监控麻烦得多:同一个问题在不同模型里会有不同检索策略,不同时间会引用不同网页,回答里还可能把品牌、公司、产品和竞品混在一起。

这篇只写我这次做技术接入时踩到的点。重点放在两类路线:一类是网页版监测,一类是基于厂商 API 去模拟模型的联网回答。后面用豆包的模拟实现举例,讲接口怎么调、返回怎么解析、哪些字段值得保存。业务提示词不在这里展开,示例代码里也不会放。

网页版监测更接近用户,但问题也更复杂

最直观的做法是打开各个大模型的网页版,像真实用户一样提问,再把回答抓回来。这个方式有一个明显好处:它看到的确实是用户正在使用的产品形态,包含网页端自己的搜索、推荐、账号状态和前端策略。

但它的问题也很明显。

首先是合规边界。网页版通常面向人机交互,不是稳定的数据采集接口。用自动化浏览器、账号池、Cookie 复用、绕过风控去批量采集,都很容易碰到服务条款、账号安全和访问频率问题。这个方向我一般只把它当成“现象观察”或“低频人工校验”,不把它写成可规模化的技术路径。

其次是稳定性。网页端页面结构会变,登录态会过期,验证码和限流也会突然出现。即使采到了回答,结果里哪些是答案正文、哪些是引用卡片、哪些是推荐追问,也需要跟着前端 DOM 改。它适合做接近真实体验的抽样,但不适合承担每天定时、批量、可复盘的监控任务。

还有一个容易被忽略的点:网页版监测虽然“真实”,但也不等于完全可解释。一次截图只能说明那一刻看到了什么,很难稳定回答这些问题:引用来源有哪些、品牌是否被正面推荐、竞品排在第几、同一个问题在五个模型里谁更容易提到你。

API 模拟不是简单把问题发给模型

另一条路是基于 API 模拟。很多团队会先写一个最小调用:把用户问题发给模型,拿回文本,判断里面有没有品牌词。这个 demo 很快,但离 GEO 监测还差一截。

GEO 监测要模拟的不是“模型能不能回答”,而是“模型在联网检索后会如何组织答案”。所以 API 调用至少要处理几个细节。

第一,必须让模型真的走搜索。不同厂商的开关不一样,有的是 enable_search,有的是工具调用,有的是专门的智能搜索接口。只开普通聊天接口,模型可能凭参数记忆回答,结果和真实联网问答差很多。

第二,要把搜索结果拿出来。只保存最终回答不够,因为 GEO 优化很关心引用来源。一个品牌被提到,可能是因为官网被引用,也可能是因为第三方媒体、百科、论坛或竞品文章里顺带出现。没有来源 URL,就很难判断后续该补哪类内容。

第三,要做归一化。厂商返回的字段差异很大,有的叫 site_name,有的叫 sitename,有的摘要在 summary,有的在 snippet。如果不先归一化,后面的“来源排行”“系统是否已收录”“引用域名覆盖”都会很乱。

第四,要接受波动。GEO 监测不是一次性判断题,更像采样统计。一次任务里品牌没有出现,不代表模型永远不会提;一次出现,也不代表可见性已经稳定。所以我更愿意把 API 模拟结果落成任务、来源、主体分析和报告,而不是只在页面上显示一个“命中 / 未命中”。

豆包模拟:我用的是 Responses API 加豆包助手

豆包这一支,我用的是火山方舟的 Responses API:

POST https://ark.cn-beijing.volces.com/api/v3/responses

模型用的是:

doubao-seed-2-0-pro-260215

关键不是这个接口能生成文本,而是它可以启用 doubao_app 工具里的 ai_search。这样返回里除了最终回答,还能拿到搜索过程里的结果卡片。请求头里除了常规的鉴权和 JSON 类型,还需要带上 beta 开关:

Authorization: Bearer <ARK_API_KEY>
Content-Type: application/json
ark-beta-doubao-app: true

下面是一个保留关键结构的 Node.js 示例。注意这里没有放业务提示词,也没有放真实的 role_description,我会把这部分放到配置里,避免代码示例被直接复制成线上策略。

const ENDPOINT = "https://ark.cn-beijing.volces.com/api/v3/responses";
const MODEL = "doubao-seed-2-0-pro-260215";

function buildDoubaoGeoRequest(question) {
  const roleDescription = process.env.DOUBAO_APP_ROLE_DESCRIPTION;
  if (!roleDescription) {
    throw new Error("Missing DOUBAO_APP_ROLE_DESCRIPTION");
  }

  return {
    model: MODEL,
    stream: true,
    input: [
      {
        type: "message",
        role: "user",
        content: [
          {
            type: "input_text",
            text: question,
          },
        ],
      },
    ],
    tools: [
      {
        type: "doubao_app",
        feature: {
          ai_search: {
            type: "enabled",
            role_description: roleDescription,
          },
          chat: { type: "disabled" },
          deep_chat: { type: "disabled" },
          reasoning_search: { type: "disabled" },
        },
      },
    ],
  };
}

async function callDoubaoGeo(question) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.ARK_API_KEY}`,
      "content-type": "application/json",
      "ark-beta-doubao-app": "true",
    },
    body: JSON.stringify(buildDoubaoGeoRequest(question)),
  });

  const rawText = await response.text();
  if (!response.ok) {
    throw new Error(`Doubao request failed: ${response.status} ${rawText}`);
  }

  return rawText.trimStart().startsWith("{")
    ? parseDoubaoJson(rawText)
    : parseDoubaoSse(rawText);
}

我这里把 chatdeep_chatreasoning_search 都关掉,只保留 ai_search。原因很简单:GEO 监测要的是尽量稳定的联网搜索回答,不希望一次任务里混入太多不可控的产品形态。真实项目里还要给请求加超时,比如 180 秒;否则上游卡住时,队列 worker 会被占住。

SSE 解析比请求本身更容易出错

豆包这个接口可以流式返回。流式响应里会出现多类事件:有的是回答增量,有的是最终完成事件,有的是搜索完成事件。我的处理方式是先把 SSE 拆成 payload,再从 payload 里分别抽取三类数据:

  • 回答文本:优先取完成事件里的完整文本,缺失时再用 delta 拼接。
  • 请求 ID 和 usage:通常在完成事件或 response 对象里。
  • 搜索结果:递归扫描 payload 中的 results,重点读 text_card

精简后的解析代码大概是这样:

function parseSsePayloads(rawText) {
  return rawText
    .replace(/\r\n/g, "\n")
    .split(/\n{2,}/)
    .flatMap((block) => {
      const event = block
        .split("\n")
        .find((line) => line.startsWith("event:"))
        ?.slice("event:".length)
        .trim();
      const data = block
        .split("\n")
        .filter((line) => line.startsWith("data:"))
        .map((line) => line.slice("data:".length).trimStart())
        .join("\n")
        .trim();

      if (!data || data === "[DONE]") return [];
      return [{ event, payload: JSON.parse(data) }];
    });
}

function collectSearchResults(value, output = []) {
  if (Array.isArray(value)) {
    for (const item of value) collectSearchResults(item, output);
    return output;
  }
  if (!value || typeof value !== "object") return output;

  if (Array.isArray(value.results)) {
    for (const item of value.results) {
      const card = item?.text_card || item;
      if (card?.url) {
        output.push({
          title: card.title || "",
          site_name: card.sitename || card.site_name || "",
          summary: card.summary || card.snippet || "",
          url: card.url,
          published_at:
            card.published_at || card.date || card.datePublished || "",
        });
      }
    }
  }

  for (const child of Object.values(value)) {
    collectSearchResults(child, output);
  }
  return output;
}

这里最容易踩坑的是“字段位置不固定”。有时搜索结果在 response.doubao_app_call_search.completed 一类事件里,有时会嵌在更深的对象中。如果解析代码只按一层路径取值,很容易在线上遇到空来源。我的习惯是对搜索结果做递归扫描,再按 url + title + site_name 去重。

最后落库时,我不会只存最终文本,而是把结果拆成几层:

  • assistant_content: 模型最终回答。
  • request_id: 上游请求 ID,方便排查。
  • usage: 上游 token 或计量信息。
  • search_results: 标准化后的引用来源。
  • matched: 回答正文或引用来源里是否命中目标品牌。
  • content_matched_citation_indexes: 品牌出现在回答里时,附近引用了哪些来源序号。

这一步做完,GEO 监测才开始有分析价值。否则只靠一个 includes(brandName),很容易把“被推荐”“被负面提到”“只在引用标题里出现”混成同一种结果。

我在系统里怎么把它变成可用的监测能力

在 Dcoding Max 盾码无界系统里,我没有把豆包模拟做成一个孤立按钮。它被放进“大模型监控任务”的统一模型里,和通义、DeepSeek、元宝、文心等入口放在同一套口径下比较。

这个设计里有两个概念比较重要。

channel 表示执行路线。web_platform 走外部网页版监测接口,适合做接近真实网页产品的抽样;api_platform 走系统内置的厂商 API runner,适合做更可控、可记录、可重试的模拟。mode 才表示具体模型入口,比如 doubaoqwendeepseek

这样拆开之后,同一个业务问题可以同时跑多种组合。任务成功后,系统会统一写入回答文本、来源列表和品牌命中结果,再异步做品牌主体分析。品牌主体分析不是简单看有没有目标品牌,而是从回答里识别企业、品牌、产品和竞品,给出排名、态度和标签。

实际看效果时,我最关注的不是“某一次豆包有没有提到我”,而是这些指标能不能稳定生成:

  • 品牌提及率:成功任务里有多少回答提到了目标品牌。
  • 最佳排名和平均排名:目标品牌在主体分析里排第几。
  • 情绪分布:正面、中性、负面回答占比。
  • 引用来源:哪些域名和 URL 更容易被模型引用。
  • 分模型表现:豆包、元宝、文心、DeepSeek、通义之间的差异。
  • 系统收录命中:模型引用的 URL 是否已经存在于自己的内容发布记录里。

品牌诊断报告会再往前走一步。系统会围绕品牌和关键词生成 3 个核心查询词,然后按默认的 5 个模型入口创建监控任务,也就是一次诊断形成最多 15 个采样点。等任务和品牌主体分析都完成后,再汇总成报告数据。

这个报告不是把回答堆在一起,而是按模型拆开看:每个模型的任务成功率、提及率、排名、态度、引用域名、引用 URL、竞品和代表样本都会单独列出来。评分也有明确拆分,提及率占 40 分,排名占 25 分,态度占 20 分,来源覆盖占 10 分,成功率占 5 分。这样看起来就很清楚:问题到底出在品牌没被提到,还是被提到了但排名靠后,或者模型引用的来源根本不是自己的内容。

我觉得这类系统真正有用的地方也在这里。它不能保证每一个用户在每一次提问时都看到同样答案,但它能把原来很虚的 GEO 问题变成可追踪的数据:哪类查询词弱,哪个模型弱,哪些引用来源缺,哪些竞品经常被推荐。后续做内容发布、媒体补充、官网结构调整时,就有了比较具体的依据。

小结

GEO 监测难在它不是传统搜索排名,也不是普通模型调用。网页版监测接近真实产品,但批量化和合规边界都要谨慎;API 模拟更适合工程化,但必须精巧构造请求,让模型进入联网搜索状态,并且把回答、引用、命中、排名和态度都拆开保存。

豆包这条路线的关键点是 Responses API、doubao_appai_search、SSE 解析和搜索结果归一化。真正放进业务系统时,代码的重点不只是调通接口,而是让每次监测都能被复盘、被聚合、被比较。只有做到这一步,GEO 监测才不只是“问一下 AI”,而是能变成后续内容优化和品牌诊断的工程基础。

参考资料

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐