利用 Codex 调试通义智文 RAG 实现的一次逆向经历

发布时间:

最近在做 RAG 系统的文档高亮功能,想参考一下通义智文的实现方案。本来打算自己 F12 慢慢看,但因为涉及登录态失效和大量请求过滤,比较繁琐。于是尝试用 agent-browser 让 AI 帮我跑了一遍流程。过程中踩了几个坑(主要是模拟登录失败和拿不到 Response Body),最后通过劫持本地 Cookie 和注入 JS 解决了,顺便把他们后端返回的坐标数据结构摸清楚了。记录一下这个全自动化的分析过程。。


理解 agent-browser

首先 codex主要使用了 skills.sh 生态中的 agent-browser 技能(这个我是提前安装好的,是参考 skills.sh 这个网站下载的工具)。把它当成“可脚本化的 DevTools”。

你平时在 DevTools 里干的事agent-browser 对应的命令作用
F12 -> Network 面板agent-browser network requests抓包、看流量、过滤 XHR/Fetch
F12 -> Console 面板agent-browser eval在页面上下文跑 JS(提取数据、fetch、甚至改 DOM)
地址栏输 URLagent-browser open打开网页
点按钮/输入框/滚动agent-browser click / fill / scroll模拟交互
Elements/Accessibility 树agent-browser snapshot拉一份当前页面结构快照(拿到元素 ref)

它的边界也很清楚:验证码、扫码登录这种,靠“点点点”硬冲只会浪费时间。得换思路。

第一坑:自动下载 Chromium 失败

一开始 agent-browser 自己拉一份 Chromium 跑,但环境里下载二进制经常失败(TLS reset 这事国内太常见了)。这一步如果死磕会把整个流程卡死在“下载浏览器”上。

处理很简单:不用它下载,直接用本机的 Google Chrome。

第二坑:登录就是自动化的天敌

通义这边的阅读页(/efficiency/doc/read?taskId=...)没登录会直接跳登录。让 agent 去填手机号、等验证码、扫二维码……理论上能做,实际很痛苦,而且不稳定。最后用的是“复用本地登录态”:

  1. 把我本机 Chrome Profile 里跟登录相关的少量文件复制到临时目录(Cookies/Local Storage/Local State/Preferences 这几样)。
  2. 用这个临时目录启动一个 headless Chrome,并打开 CDP 端口(--remote-debugging-port=9222)。
  3. 让 agent-browser 通过 connect 9222 附着上去。站点看到的是“一个带着登录态的 Chrome”,就直接放行了。

这就是“会话劫持”,只是复用我自己电脑上、我自己的登录态。

第三坑:Network 里能看到请求,但看不到 Response Body

agent-browser network requests 很好用,但它默认更像“请求索引”:能看到 URL、方法、类型(xhr/fetch),不保证直接把响应体也给你。

要确认“接口里到底有没有 x/y 坐标”,最终还是得在 Console 语境里自己 fetch(url),把文本截一段出来看。这也是后面很多关键证据的来源。


先搞清楚坐标从哪来:不是前端切出来的

在阅读页清空抓包后重载,然后只看 xhr/fetch,再按关键词(list/doc/text/coordinate/json)筛了一轮。

最可疑的不是某个“coordinate API”,而是一串对象存储上的页面 JSON:

GET https://darwin-controller-pro*.oss-cn-hangzhou.aliyuncs.com/<docId>/pages/<pageId>.json?...

直接在页面里 fetch 了其中一个 pages/<pageId>.json,截前 500 个字符,里面就把坐标摊开了(字段名都懒得伪装):

[
  {
    "alignment": "left",
    "blocks": [
      {
        "pos": [{"x":153,"y":51},{"x":338,"y":51},{"x":338,"y":92},{"x":153,"y":92}],
        "text": "华创证券",
        "wordInfo": [
          {"pos":[{"x":165,"y":51},{"x":202,"y":51},{"x":202,"y":92},{"x":165,"y":92}],"text":"华"},
          {"pos":[{"x":212,"y":51},{"x":249,"y":51},{"x":249,"y":92},{"x":212,"y":92}],"text":"创"}
        ]
      }
    ]
  }
]

这基本说明:

  • 坐标不是“前端渲染后再切分”算出来的。
  • 后端已经把 块级blocks[].pos)和 字/词级wordInfo[].pos)的四点框都算好,前端只是叠一层渲染。

这份 JSON 里有 fontName/fontSize/bold 这种信息,像是文本层解析;扫描件没试,估计大概率还是走渲染+OCR,再统一产出同一套结构。它们最终都落到 “pages/*.json + 坐标” 这套格式上,前端不关心来源。


再看 RAG:引用、chunk_id、高亮,其实都在一个 stop event 里

真正想看的,是“检索切片怎么做”和“高亮怎么对齐”。在阅读页里点发送之后,抓包里终于出现了一个更像主流程的请求:

POST https://www.qianwen.com/zhiwen/api/doc_chat
Accept: text/event-stream

踩坑:它是 fetch + SSEnetwork requests 里能看到 URL,但不太好直接拿到请求体。想复现 stop event 的结构,就必须知道后端到底吃了哪些字段。最省事的方法反而是“在页面里埋点”:给 window.fetch 包一层,把 url/method/body 记录下来,然后再点一次发送。这样就把 payload 里的关键字段抓出来了,比如 currentDocId/docIdList/taskId/prompt/pluginIdList/sseId/source

这是 SSE。它不是一次性返回 JSON,而是一段段 data:{...}\n\n 往外吐。前面会先冒出 finish_reason="function_call",而且能看到插件信息:

  • plugin_content.function_name = "doc-search"

真正有价值的是最后那条 stop event。里面同时返回了两套东西:

  1. references[]:给“引用/溯源”用的文本和 chunk id
  2. highlight[]:给“高亮/定位”用的页码和坐标

把 stop event 的头部截了 500 字符,大概长这样:

{
  "code": 200,
  "data": {
    "finish_reason": "stop",
    "references": [
      {
        "doc_id": 1469...,
        "chunk_ids": ["64c0c1c3...","f0fcb618...", "..."],
        "text": "..."
      }
    ],
    "highlight": [
      {
        "docId": "1469...",
        "posList": [
          {
            "pageNum": 39,
            "posList": [
              [{"x":87,"y":48},{"x":338,"y":48},{"x":338,"y":114},{"x":87,"y":114}],
              ...
            ]
          }
        ],
        "uniqueIdList": ["64c0c1c3...","f0fcb618...", "..."],
        "text": "..."
      }
    ]
  }
}

当时有个小插曲:doc_id 是超大整数,JS 里直接 JSON.parse 会有精度丢失风险。后面干脆把它当字符串处理,省心。

“chunk_ids 对应的是行/段/块”怎么验证?

计算统计。

同一次 stop event 里,把每条 reference 做了四个统计:

  • chunk_ids.length
  • text.length
  • text\n\n 分段的段落数量
  • 同一条高亮里 uniqueIdList.length 与矩形框数量

跑出来的一组典型数字是:

  • chunk_ids_len = 28
  • text_len = 1412
  • 按空行切段:paragraphs ≈ 27
  • uniqueIdList_len = 28(和 chunk_ids_len 对齐)
  • 一个页上的矩形框:rects_total = 46
  • 平均每个 chunk 覆盖 46/28 ≈ 1.64 个矩形框

这组关系太像“段落块”了:

  • chunk_ids 和“段落数”基本同量级,明显不像 512 字符硬切(那通常只会有几块)。
  • 一个 chunk 对应 1~2 个矩形框,很符合“一个段落折成两行”的排版现实。
  • references[i].text === highlight[i].text(我额外校验过),说明引用文本和高亮文本是同一个拼接结果。

结论:通义这套 RAG 的 chunk 粒度更接近“段落块”,不是固定长度切片。


他们的“溯源 + 高亮”大概是怎么拼起来的

把上面的证据串起来,通义这套实现看起来像这样:

  1. 文档入库时先做解析,拆成很多“段落块/版面块”。每块有:
    • chunk_id(32 位 hex,看起来像 hash)
    • pageNum + 坐标矩形列表
    • text
  2. 检索时按块召回,但返回给前端时会把相邻块做合并,变成“引用片段”:
    • references[].text 是拼接后的片段
    • references[].chunk_ids[] 记录这个片段由哪些块组成
  3. 高亮不靠前端再算:
    • 后端直接给 highlight[].posList[{pageNum, posList:[rect...]}]
    • 一条引用可以跨页:posList 本身就是数组,天然支持多页
  4. 高亮是“块级”而不是“句子级”:
    • 我没在响应里看到 offset/token 对齐信息
    • 看到的是“若干矩形框”,更像把整块框出来

附录:Codex用过的命令

供你参考:

# 0) (可选) 克隆本机 Chrome Profile 到临时目录(只复制必要文件)
# 说明:
# - 下面的 Profile 路径以 macOS + Chrome 的 Default profile 为例,你本地可能是 Profile 1/2。
# - 只建议在“自己的电脑、自己的账号”场景这么做。
Ran mkdir -p /tmp/agent-qwen-profile/Default
Ran cp "$HOME/Library/Application Support/Google/Chrome/Local State" /tmp/agent-qwen-profile/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Preferences" /tmp/agent-qwen-profile/Default/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Secure Preferences" /tmp/agent-qwen-profile/Default/
Ran cp "$HOME/Library/Application Support/Google/Chrome/Default/Cookies"* /tmp/agent-qwen-profile/Default/
Ran cp -R "$HOME/Library/Application Support/Google/Chrome/Default/Local Storage" /tmp/agent-qwen-profile/Default/

# 1) 启动带 CDP 的本机 Chrome(复用临时 profile)
Ran /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\
    --remote-debugging-port=9222 \\
    --user-data-dir=/tmp/agent-qwen-profile \\
    --headless=new --disable-gpu \\
    --no-first-run --no-default-browser-check \\
    about:blank

# 2) agent-browser 附着到 CDP
Ran agent-browser --session qwen-cdp connect 9222

# 3) 打开阅读页
Ran agent-browser --session qwen-cdp open 'https://www.qianwen.com/efficiency/doc/read?taskId=<TASK_ID>'
Ran agent-browser --session qwen-cdp wait --load networkidle

# 4) 清空并导出请求列表
Ran agent-browser --session qwen-cdp network requests --clear
Ran agent-browser --session qwen-cdp network requests --json > /tmp/qwen_requests.json

# 5) 用脚本过滤出 xhr/fetch + 关键词命中的 URL(示例:用 uv 跑 python)
Ran uv run python - <<'PY'
import json, re
obj=json.load(open("/tmp/qwen_requests.json"))
reqs=(obj.get("data") or {}).get("requests") or []
kw=re.compile(r"(list|doc|text|coordinate|json)", re.I)
for r in reqs:
    if (r.get("resourceType") or "").lower() not in ("xhr","fetch"):
        continue
    if not kw.search(r.get("url","")):
        continue
    print(r.get("resourceType"), r.get("method"), r.get("url"))
PY

# 6) 在页面里 fetch pages/*.json,看坐标字段(只看前 500 字符)
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
(async () => {
  const url = 'https://darwin-controller-pro.oss-cn-hangzhou.aliyuncs.com/<docId>/pages/<pageId>.json?...';
  const text = await fetch(url).then(r => r.text());
  return text.slice(0, 500);
})()
JS

# 6.5) (可选) 注入 fetch hook,抓 doc_chat 的请求体
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
(() => {
  if (window.__FETCH_HOOKED__) return;
  window.__FETCH_HOOKED__ = true;
  window.__FETCH_CALLS__ = [];
  const origFetch = window.fetch.bind(window);
  window.fetch = async (...args) => {
    try {
      const [input, init] = args;
      const url = typeof input === "string" ? input : input?.url;
      const method = init?.method || input?.method || "GET";
      const body = typeof init?.body === "string" ? init.body.slice(0, 2000) : init?.body;
      window.__FETCH_CALLS__.push({ t: Date.now(), url, method, body });
    } catch {}
    return origFetch(...args);
  };
})();
JS

# 7) 触发 doc_chat,然后在页面里读取 SSE stop event,提取 references/highlight
Ran agent-browser --session qwen-cdp eval --stdin <<'JS'
// 这里省略了完整 JS:核心是 fetch('.../doc_chat') 后 reader.read() 读 SSE,
// 找到 finish_reason === 'stop' 的那条 data 事件,解析 data.references/data.highlight。
JS