深蓝黑背景封面:右侧大标题「一个请求,拆开只有四段」,副标题「起始行、头部字段与报文正文的分工」;左侧原创示意图——一份竖放的报文被切成四段横条,自上而下标注起始行、头部、空行、正文,空行那段画得特别窄却用高亮描边;左上标注「面试经典 · 网络」,右上标注「Day 08 · 10-26」 图 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 推送——用的就是分块思路:正文没有一个总长度,服务器生成一段推一段。

左右对照结构图:左侧请求报文自上而下四段——起始行是「方法 + 路径 + 协议版本」,方法声明要干什么;头部字段区列出 Host、Content-Type、Content-Length 等键值对,负责声明元信息;一条空行把头部与正文严格分开;正文承载表单或 JSON,长度由头部里的 Content-Length 或分块编码声明。右侧响应报文结构对称——起始行换成「协议版本 + 状态码 + 原因短语」,状态码是对请求的裁决;头部给出 Content-Type、缓存控制等;空行之后是正文。下方提示三点:空行不是装饰而是唯一分界,解析器靠它知道头部读完;正文长度必须可从头部推出,否则收方不知道何时读完;流式响应用分块编码把正文切成带长度的小块连续推送。底部提示:报文只是约定好的文本格式,方法、状态码、头部各司其职——请求方写「要什么」,响应方回「办得怎样」 图 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 都能手敲;四是正文长度和头部声明对不上还指望对端「智能处理」——对端只会按规则读,读少了截断,读多了把下一份报文的开头吃掉。

看懂四段结构之后,再去看流式接口的超时、重试与断连处理,很多「灵异行为」都有了解释:无非是某一段没按约定来。你调试接口时翻过原始报文吗,当时抓到的是什么问题?欢迎分享。