LLM 网页端聊天请求逆向观察 (Reverse Engineering an LLM Web Chat Request)
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_challengebody 是:
{
"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: xxxDeepSeek 返回长得像 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