图 1:本文封面,一份报文拆开只有四段。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题目问法很朴素:一个 HTTP 请求发出去,线上跑的到底是什么?很多人能说出「有 header 有 body」,但让他说清每一段的职责和边界规则,就开始含糊。
先把结论立住:HTTP/1.1 的报文就是一段约定好格式的文本,规范里写成「起始行 + 若干头部字段行 + 一个空行 + 可选正文」四段。请求和响应的结构完全对称,区别只在起始行:请求写「我要干什么」,响应回「办得怎么样」。
**第一段,起始行。**请求的起始行叫请求行,三个字段用空格分开:方法、请求目标、协议版本,比如 POST /v1/chat HTTP/1.1,方法声明的是「要干什么」这个语义动作。响应的起始行叫状态行,把方法换成状态码和原因短语:HTTP/1.1 200 OK,状态码就是对这次请求的裁决。两种报文在语法上只差这一行。
**第二段,头部字段区。**一行一个键值对,职责是声明元信息:Host 说明目标主机(HTTP/1.1 里客户端必须携带 Host 头,一台服务器托管多个站点就靠它分流),Content-Type 说明正文是什么格式,Content-Length 说明正文有多少字节。头部读到哪为止?靠下一段。
**第三段,空行。**就一个 CRLF,却是头部与正文唯一的、严格的分界。解析器的标准动作是:读起始行,逐行读头部直到遇见空行,再根据头部里的信息决定正文怎么读。空行不是排版装饰,删了它,整个报文就无法解析。
**第四段,正文。**表单、JSON、文件内容都在这里。关键是收方怎么知道正文何时读完:要么头部里的 Content-Length 给出确切字节数,要么用分块编码(chunked)把正文切成一串自带长度的小块、以零长度块收尾。流式响应——包括调用模型 API 时的 SSE 推送——用的就是分块思路:正文没有一个总长度,服务器生成一段推一段。
图 2:请求与响应四段对称,空行是唯一分界。
**眼见为实:用原始 TCP 亲手构造一份报文。**不用任何 HTTP 库,直接用 TCP socket 手写请求、手读响应,报文的真面目就摆在眼前。用 Node.js 22.15.0 直接运行(node demo-http.mjs):
// demo-http.mjs — 绕过 HTTP 库,用原始 TCP 看一眼报文的真面目
import net from 'node:net';
const server = net.createServer((socket) => {
let raw = '';
socket.on('data', (chunk) => {
raw += chunk.toString('utf8');
if (!raw.includes('\r\n\r\n')) return; // 空行是头部与正文的分界
console.log('—— 服务器收到的原始请求报文 ——');
console.log(raw.trimEnd());
const body = JSON.stringify({ ok: true, msg: '你好' });
socket.end(
'HTTP/1.1 200 OK\r\n' +
'Content-Type: application/json; charset=utf-8\r\n' +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
'Connection: close\r\n' +
'\r\n' +
body,
);
});
});
server.listen(18080, '127.0.0.1', () => {
const reqBody = '{"prompt":"你好"}';
const client = net.createConnection(18080, '127.0.0.1', () => {
client.write(
'POST /v1/chat HTTP/1.1\r\n' +
'Host: 127.0.0.1:18080\r\n' +
'Content-Type: application/json\r\n' +
`Content-Length: ${Buffer.byteLength(reqBody)}\r\n` +
'\r\n' +
reqBody,
);
});
let resp = '';
client.on('data', (c) => { resp += c.toString('utf8'); });
client.on('end', () => {
console.log('—— 客户端收到的原始响应报文 ——');
console.log(resp.trimEnd());
server.close();
});
});
实测输出:
—— 服务器收到的原始请求报文 ——
POST /v1/chat HTTP/1.1
Host: 127.0.0.1:18080
Content-Type: application/json
Content-Length: 19
{"prompt":"你好"}
—— 客户端收到的原始响应报文 ——
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 26
Connection: close
{"ok":true,"msg":"你好"}
注意一个细节:{"prompt":"你好"} 是 15 个字符,Content-Length 却是 19——长度按字节算,UTF-8 里一个汉字占 3 字节。代码里用 Buffer.byteLength 而不是字符串的 length,就是无数手写客户端踩过的坑。
**常见误区。**一是把空行当成分段空行、可有可无,它是唯一的硬分界;二是 Content-Length 按字符数填,中文正文直接截断或粘包;三是以为 HTTP/1.1 报文是某种二进制结构,其实它就是纯文本,telnet 都能手敲;四是正文长度和头部声明对不上还指望对端「智能处理」——对端只会按规则读,读少了截断,读多了把下一份报文的开头吃掉。
看懂四段结构之后,再去看流式接口的超时、重试与断连处理,很多「灵异行为」都有了解释:无非是某一段没按约定来。你调试接口时翻过原始报文吗,当时抓到的是什么问题?欢迎分享。