0. 这次研究的对象是什么?

我想搞明白一个 LLM 网页端聊天页面是怎么工作的,尤其是:

用户点发送
    ↓
前端怎么发请求
    ↓
服务端怎么流式返回
    ↓
前端怎么把 token 一点点渲染到页面上

我一开始以为可能是:

fetch
WebSocket
普通 JSON

但最后发现,它实际更像:

XMLHttpRequest streaming
+ text/event-stream
+ 自定义 p/o/v patch 协议
+ POW challenge-response 风控机制

1. 第一个发现:真正的聊天主接口是 completion

一开始浏览器 Network / Console 里 API 很多,看起来很乱:

client/settings
users/current
chat_session/create
chat_session/fetch_page
chat/create_pow_challenge
chat/completion
gator.volces.com/list
static js/css
Cloudflare turnstile

后来筛了一下,发现真正跟模型回复相关的是:

POST /api/v0/chat/completion

其他很多是噪声:

client/settings           页面配置
users/current             当前用户信息
chat_session/fetch_page   会话列表
chat_session/create       创建/预创建会话
gator.volces.com/list     埋点统计
static js/css             前端资源
Cloudflare turnstile      风控/验证相关

第一个重要经验:

逆向时不要被一堆 API 吓到。
先找主链:哪个请求带 prompt,哪个请求返回 answer。

2. 第二个发现:它不是 fetch,也不是 WebSocket,而是 XHR streaming

我写了 hook 脚本监听:

fetch
XMLHttpRequest
EventSource
WebSocket
TextDecoder
Performance resource

最后发现主请求是:

XHR:request /api/v0/chat/completion
XHR:stream:chunk /api/v0/chat/completion
XHR:stream:chunk /api/v0/chat/completion
XHR:stream:chunk /api/v0/chat/completion
...
XHR:load /api/v0/chat/completion

这说明它不是普通一次性 JSON,也不是 WebSocket。

它更像:

const xhr = new XMLHttpRequest();

xhr.open("POST", "/api/v0/chat/completion");

let lastLen = 0;

xhr.onreadystatechange = () => {
  if (xhr.readyState >= 3) {
    const full = xhr.responseText;
    const delta = full.slice(lastLen);
    lastLen = full.length;

    // delta 就是新增的流式内容
  }
};

xhr.send(body);

也就是说,服务端不断往同一个 HTTP response 里 flush 文本,浏览器的 xhr.responseText 会不断变长,前端每次切出新增部分,然后更新 UI。

重要经验:

流式响应 ≠ WebSocket。
HTTP 本身也可以流式返回。
XHR responseText 变长就是一种实现方式。

3. 第三个发现:返回格式是 SSE-like,但不是标准 OpenAI delta

我抓到的返回内容大概是:

event: ready
data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}

event: update_session
data: {"updated_at":1778070438.170476}

data: {"v":{"response":{"message_id":2,"parent_id":1,"role":"ASSISTANT","status":"WIP","fragments":[{"type":"RESPONSE","content":"ST"}]}}}

data: {"p":"response/fragments/-1/content","o":"APPEND","v":"REAM"}

data: {"v":"_TEST"}

data: {"v":"_"}

data: {"v":"123"}

data: {"v":"\n"}

data: {"p":"response/status","o":"SET","v":"FINISHED"}

event: close
data: {"click_behavior":"none","auto_resume":false}

它长得像 SSE:

event: xxx
data: xxx

但它不是 OpenAI 那种:

{"choices":[{"delta":{"content":"..."}}]}

它是 DeepSeek 网页端自己的状态 patch 协议。


4. 第四个发现:p / o / v 是状态补丁协议

核心结构是:

{
  "p": "response/fragments/-1/content",
  "o": "APPEND",
  "v": "REAM"
}

可以理解成:

p = path,要修改哪里
o = operation,要做什么操作
v = value,操作值

比如:

{
  "p": "response/fragments/-1/content",
  "o": "APPEND",
  "v": "REAM"
}

意思是:

response.fragments.at(-1).content += "REAM";

一开始返回:

"content": "ST"

后面追加:

REAM
_TEST
_
123
\n

最终变成:

STREAM_TEST_123

这个设计很适合流式 UI,因为服务端不需要每次返回完整 message,只要返回“怎么修改当前状态”。

重要经验:

很多网页端 LLM 返回的不是最终答案,而是“状态树补丁”。
前端负责把补丁应用到本地状态,然后触发 UI 渲染。

5. 第五个发现:completion 请求 body 很直观

我进一步抓 request,发现 /api/v0/chat/completion 的 body 类似:

{
  "chat_session_id": "71773081-9cab-467f-930a-3f9cb5146e26",
  "parent_message_id": 2,
  "model_type": null,
  "prompt": "只回复我B",
  "ref_file_ids": [],
  "thinking_enabled": false,
  "search_enabled": false,
  "preempt": false
}

这里几个字段很好理解:

chat_session_id      当前聊天会话 ID
parent_message_id    上一条消息 ID,用来接上下文
model_type           模型类型,第一条可能是 default,后面可能 null
prompt               用户输入
ref_file_ids         引用文件
thinking_enabled     是否开启深度思考
search_enabled       是否联网搜索
preempt              是否抢占/中断类参数

所以业务层并不复杂。

真正复杂的是 headers。


6. 第六个发现:completion 请求带动态风控/证明 header

completion 请求不是裸 JSON。它有一些关键 header:

x-ds-pow-response
x-hif-leim
x-hif-dliq
authorization
x-client-platform
x-client-version
x-client-locale
x-client-timezone-offset
x-app-version
content-type
accept

其中:

authorization

是登录态。

x-client-*

是客户端信息。

真正值得研究的是:

x-ds-pow-response
x-hif-leim
x-hif-dliq

其中 x-ds-pow-response 后来证明是一个 POW challenge-response 结果。


7. 第七个发现:网页端有 create_pow_challenge

我抓到一个请求:

POST /api/v0/chat/create_pow_challenge

body 是:

{
  "target_path": "/api/v0/chat/completion"
}

返回是:

{
  "algorithm": "DeepSeekHashV1",
  "challenge": "570256b7ea51de7f8f371d2d31cde6b60bb076f9699c9e4205b5b87a3a1ddb74",
  "salt": "f6dbcc8f2af9ed1a650a",
  "signature": "d148d6d63ec351f7dddc007658403e67cdeacb128a168adea69b5ac114d1b5c6",
  "difficulty": 144000,
  "expire_at": 1778071864313,
  "expire_after": 300000,
  "target_path": "/api/v0/chat/completion"
}

这就说明:发 completion 前,网页需要先拿一道临时题。


8. POW 是什么?

POW 是 Proof of Work,工作量证明。

它不是证明“我是谁”,而是证明:

我为这次请求实际做过一点计算。

大概流程:

服务端:给浏览器一份 challenge
浏览器:本地尝试计算 answer
浏览器:把 answer 放到 x-ds-pow-response
服务端:验证 answer 是否正确
通过后:处理真正的 completion 请求

它防的不是“让你看不到 API”。

它防的是:

复制一次请求后无限重放
离开浏览器直接 curl
低成本批量自动化
伪造简单 challenge
绕开网页运行时

9. salt 是什么?

salt 可以理解成“加盐”。

在 hash 里,如果只做:

hash("password123")

同样输入永远得到同样输出。

如果加 salt:

hash("password123" + salt)

那么即使原始输入一样,只要 salt 不同,hash 结果也不同。

salt 的作用:

防止预计算
防止查表
防止同输入同输出
让每次 challenge 更独立

在这次 POW 里,可能类似:

hash(challenge + salt + answer)

浏览器不断尝试 answer,直到 hash 满足某种难度条件。


10. challenge / salt / signature / difficulty / answer 分别是什么?

challenge

服务端出的题。

它通常是随机的,或者和用户、时间、路径、服务端状态有关。

salt

额外扰动值。

让每次计算不一样,防复用、防预计算。

signature

服务端签名。

它很关键。因为服务端要防止客户端自己伪造一个很简单的 challenge。

有 signature 后,服务端可以检查:

这个 challenge 是不是我签发的?
difficulty 有没有被改低?
target_path 有没有被改?
expire_at 有没有被改?
salt 有没有被改?

difficulty

难度参数。

你抓到的是:

difficulty: 144000

它不一定表示“准确计算 144000 次”,但大概率和计算难度有关。

expire_after

你抓到的是:

expire_after: 300000

这里一般是毫秒,所以:

300000 ms = 300 s = 5 分钟

answer

浏览器本地算出的答案。

你后面解出了:

{
  "answer": 91131
}

这个 answer 就是浏览器尝试出来的 POW 解。


11. x-ds-pow-response 其实是 Base64 JSON

我后来把 x-ds-pow-response 解开,发现它不是完全黑盒。

它里面是:

{
  "algorithm": "DeepSeekHashV1",
  "challenge": "570256b7ea51de7f8f371d2d31cde6b60bb076f9699c9e4205b5b87a3a1ddb74",
  "salt": "f6dbcc8f2af9ed1a650a",
  "answer": 91131,
  "signature": "d148d6d63ec351f7dddc007658403e67cdeacb128a168adea69b5ac114d1b5c6",
  "target_path": "/api/v0/chat/completion"
}

也就是说,它大概是:

JSON.stringify(powResult)
    ↓
Base64 encode
    ↓
放进 x-ds-pow-response header

注意:Base64 不是加密,只是编码。

所以准确说:

x-ds-pow-response 不是神秘加密字符串。
它是编码后的 POW 结果包。

12. 它的 challenge 是预取的

我原本以为流程是:

用户点发送
    ↓
请求 challenge
    ↓
算 answer
    ↓
发 completion

但观察多次请求后发现更像:

页面空闲或上一轮结束
    ↓
提前请求 create_pow_challenge
    ↓
缓存 challenge
    ↓
用户下一次发送
    ↓
使用缓存 challenge 算 POW
    ↓
发 completion
    ↓
completion 结束后再预取下一次 challenge

证据是:

第 1 次 completion 用了 probe 之前已经缓存好的 challenge
第 1 次 completion 后,请求 challenge_A
第 2 次 completion 使用 challenge_A
第 2 次 completion 后,请求 challenge_B
第 3 次 completion 使用 challenge_B

这个设计很好理解:用户点发送时不想等 challenge 请求,所以页面提前准备好“下一张票”。

重要经验:

有些风控 token / challenge 不是发送时才生成,而是提前预取缓存。
看请求顺序时要注意:completion 后面的 challenge 可能是给下一次用的。

13. x-hif-leim / x-hif-dliq 是什么?

这两个我没有完全弄清楚,但观察到:

"hif": {
  "leim_len": 73,
  "dliq_len": 73,
  "leim_parts": [56, 16],
  "dliq_parts": [56, 16]
}

每次结构稳定:

56字符.16字符

这说明它们很可能是某种风控/环境校验头。

可能参与的因素有:

浏览器环境
时间
请求上下文
设备指纹
页面运行状态
风控 SDK 状态
随机数
签名摘要

它不像 x-ds-pow-response 那样能直接 Base64 解成 JSON,所以暂时只知道它是动态头,而且结构稳定。


14. 为什么前端透明,机制仍然有效?

我一开始会觉得:

既然前端 JS 能拿到 challenge,那这个机制不是没用吗?

后来想明白了:它不是靠“藏住 challenge”防你,而是靠:

一次性
短期有效
绑定路径
绑定登录态
绑定浏览器环境
绑定风控头
需要本地计算
可能不能重复使用

前端当然透明。

浏览器能发出去的东西,理论上都能被观察到。

但这套机制的作用不是“让你看不到”,而是让请求:

不能低成本复制
不能长期复用
不能脱离浏览器环境稳定调用
不能拿旧 header 无限重放

一句话:

前端透明,但不便携。

在页面里 hook、观察、借用官方前端逻辑是可行的。

离开页面,用 Python requests 或 curl 完全复刻就会麻烦很多。


15. “私有 API”怎么理解?

官方开放 API 通常有:

稳定文档
稳定 endpoint
明确鉴权方式
明确 request schema
明确 response schema
明确 rate limit
兼容承诺

网页端私有 API 则是前端自己用的内部协议。

特点是:

可能没有文档
可能随时变
依赖登录态
依赖页面运行时
依赖风控 SDK
依赖动态 header
依赖一次性 token / challenge
不保证第三方调用稳定

这次看到的 /api/v0/chat/completion 就更像网页端私有 API,而不是开放 API。


16. 为什么我不该只想着“复制 curl”?

因为这个请求不只是:

URL + body

而是完整生命周期:

页面加载
    ↓
登录态准备
    ↓
风控 SDK 初始化
    ↓
预取 challenge
    ↓
本地求解 POW
    ↓
生成 x-ds-pow-response
    ↓
生成 x-hif-* header
    ↓
带 chat_session_id / parent_message_id / prompt 发 completion
    ↓
服务端返回 text/event-stream
    ↓
前端解析 data/event
    ↓
应用 p/o/v patch
    ↓
UI 增量渲染
    ↓
结束后预取下一次 challenge

这比单个 curl 复杂得多。


17. 如果做 userscript,最稳的路线是什么?

最稳的不是复刻私有 API,而是:

让官方前端自己发请求。
userscript 只做 UI 自动化和结果监听。

也就是:

1. userscript 填输入框
2. userscript 点击发送按钮
3. 官方前端生成 POW / hif / auth / body
4. 官方前端发 completion
5. userscript hook XHR stream
6. userscript 拼接 content
7. 自动复制 JSON

这个路线的好处是:

不需要复刻 POW
不需要理解 hif
不需要自己管理登录态
不需要自己维护 session / parent_message_id
网页改动时也相对容易修

缺点是:

依赖页面 UI
不如直接 API 干净
页面结构变了脚本要改

但对逆向小白来说,这条路线最实用、最安全、也最容易学到东西。


18. 我这次学到的逆向方法论

方法 1:先观察,不要急着复刻

一开始不要想着:

我要马上写 requests 调接口

先看:

谁发请求?
什么时候发?
body 有什么?
headers 有什么?
response 怎么返回?
UI 怎么更新?

方法 2:从“唯一特征词”定位主请求

比如让模型输出:

STREAM_TEST_123

然后在日志里搜这个词。

这样可以快速区分:

哪个请求真返回了模型内容
哪个请求只是配置/埋点/静态资源

方法 3:hook 多种通信方式

常见通信方式:

fetch
XMLHttpRequest
EventSource
WebSocket
ReadableStream
TextDecoder

不要只盯 fetch。

方法 4:看 request 和 response 两边

只看 response 不够。

要同时看:

request body
request headers
response headers
response body
调用栈 stack
请求顺序

方法 5:按生命周期理解,而不是按单个请求理解

现代网页很多机制是链式的:

challenge 请求
    ↓
本地计算
    ↓
completion 请求
    ↓
流式返回
    ↓
结束后预取下一次 challenge

单看一个请求容易误判。


19. 概念速查

XHR

XMLHttpRequest,老牌浏览器 HTTP 请求 API。

这次 DeepSeek 网页端聊天主请求用的是 XHR。

Streaming

服务端不一次性返回完整结果,而是一点点返回。

LLM 输出 token 时常用这种方式。

SSE

Server-Sent Events,服务端通过 HTTP 持续向客户端推送文本事件。

格式通常是:

event: xxx
data: xxx

DeepSeek 返回长得像 SSE。

Patch

补丁。

不是返回完整状态,而是返回“如何修改状态”。

比如:

{"p":"response/fragments/-1/content","o":"APPEND","v":"hello"}

Hash

哈希。把任意输入变成固定长度摘要。

特点是:

同输入同输出
难以反推
输入稍变输出大变

Salt

盐。加入 hash 输入里的额外随机值。

作用:

防止同输入同输出
防预计算
防查表

Challenge

服务端给客户端的一次性题目。

POW

Proof of Work,工作量证明。

证明你为这次请求做过计算。

Signature

签名。服务端用来证明 challenge 没被伪造或篡改。

Nonce / Answer

客户端尝试出来的答案。

通常要不断尝试,直到满足难度条件。


20. 最终总结

这次研究可以总结成一句话:

DeepSeek 网页端聊天不是一个简单的 fetch API。
它是:
XHR streaming
+ text/event-stream
+ 自定义 p/o/v patch 状态更新协议
+ 预取式 POW challenge-response
+ 动态风控 header
+ 会话状态管理
组成的完整请求生命周期。

最重要的学习点不是“找到了某个接口”,而是理解了:

现代网页端 LLM 产品为什么不是复制一个 URL 就能复刻;
为什么前端透明但请求仍然不便携;
为什么流式返回通常是协议层、状态层、UI 层一起配合;
为什么 salt / challenge / signature / difficulty 这些东西会出现在真实产品里。

你现在作为逆向小白,已经迈过了很关键的一步:

从“看 Network 一堆请求很懵”
变成了
“能分辨主请求、流式协议、风控挑战、状态补丁和会话生命周期”。

这就是入门逆向最核心的能力。


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/General