计算机网络 Computer Networks
为什么 AI 工程师必须懂网络?因为你交付的一切最终都是通过网络到达用户的:流式输出卡不卡,取决于 SSE 分帧和反压;推理吞吐上不去,可能是连接池只有 10 条;Agent 调工具超时乱重试,可能造成一次任务重复下单。更实际的是——分布式训练的性能瓶颈常常在网络上而不是算力上。这一阶段的目标很明确:让你在设计「模型怎么被调用」这件事上,不再靠试错。
阶段总览
- 完整讲出一次 HTTPS 请求从 DNS 到响应结束的全部阶段,并说明每段的延迟量级
- 解释 TCP 三次握手为什么必须是三次、四次挥手为什么要 TIME_WAIT,以及这些对服务端连接管理的实际影响
- 说清拥塞控制从 Reno / CUBIC 到 BBR 的演进逻辑,以及在长肥管道与弱网下各自的优劣
- 手画 TLS 1.3 握手流程,说明它比 TLS 1.2 少了哪一次往返、0-RTT 的安全代价是什么
- 在 SSE / WebSocket / gRPC streaming / 长轮询之间做有理有据的选型,并说清各自的队头阻塞与代理兼容性问题
- 为一条多跳调用链设计端到端超时预算,并区分「可重试」与「不可重试」错误,给出幂等键方案
- 实现连接池 + 限流 + 熔断 + 背压四件套,并用压测数字证明有效
- 用 tcpdump / Wireshark / curl 的详细输出定位一次真实的网络问题
- 算清一次分布式训练中 all-reduce 的通信量,并解释通信与计算重叠为什么能提速
| 周次 | 主题 | 动手产出 |
|---|---|---|
| 第 1 周 | 分层模型、一次请求的完整旅程、延迟构成 | 为 Hamauls Orion 画一张端到端网络时序图(含各段延迟标注) |
| 第 2 周 | IP、路由、NAT、MTU、数据中心网络与 RDMA | 用 tcpdump 分析一次请求的包序列,解释每个包的作用 |
| 第 3 周 | TCP:握手/挥手、滑动窗口、拥塞控制、队头阻塞 | 做弱网实验(tc netem),对比 CUBIC 与 BBR 的表现 |
| 第 4 周 | TLS 1.3、证书、mTLS、会话复用与 0-RTT | 用 openssl 手工完成一次握手并解读每个字段 |
| 第 5 周 | HTTP/1.1→2→3、SSE / WebSocket / gRPC 流式选型 | 实现 SSE 流式网关与 gRPC 服务端,做协议对比 |
| 第 6 周 | 可靠调用工程:超时预算、重试、幂等、熔断、限流、背压 | 给 Hamauls Orion 实现四件套并压测出数字 |
| 第 7 周 | 抓包诊断 + 分布式训练集合通信(里程碑 M4 收口) | 输出超时预算表、网络诊断手册、通信量核算 |
1. 分层模型与一次请求的完整旅程
学习路径
- 读 1.1:把一次小 HTTP 请求的各层头部开销算成百分比
- 跑内置代码,用分层表把 40B payload 的开销占比算出来
- 完成练习自测:遇到慢请求先定位到层再动手
- 对接 M4:用分层思维审视网关各阶段该在哪一层排查
核心知识点详解
- 头部开销在小请求上吃掉效率:一个 40B 的探活请求在物理层还要包 TCP 头 20B + IP 头 20B + 以太网头 14B + FCS 4B 等,应用层占比约 33.9%。头部开销比数据还大是 Agent 频繁调小接口变慢的根本原因,对策是合并 keepalive / 批量调用,而不是单纯减字节。
- 分层是为「定位」不是学术:遇到「慢 / 不通 / 出错」按层排序排查:应用层用
curl -v、传输层用ss、网络层用ping/mtr、链路层用tcpdump -e。30 秒内先锁层再动手,比在整段上乱猜高效得多。 - 常见坑:跨层排障漏掉代理:LB / Nginx / 代理既是 L4 也是 L7 参与者,本地工具正常不代表链路 OK。要在每一跳分别计时(
curl -w分段、mtr逐跳),否则会把「代理缓冲半秒」误判成服务端慢。
学习路径
- 读 1.2:把 DNS、TCP、TLS、TTFB、TTFT 各段延迟拆开看
- 跑内置代码,用 curl -w 分段时间打印出一笔真实请求
- 完成练习自测:核算连接复用能省下几次握手
- 对接 M4:为网关的 SSE 请求画出逐段延迟明细
核心知识点详解
- 一次 HTTPS 请求的延迟大头在哪:DNS 5–50ms → TCP 三次握手 1 RTT → TLS 1.3 握手 1 RTT → 请求 + 服务端处理 → TTFB/TTFT → 逐 token 传输。多数场景最大耗时在服务端处理与排队,而不是网络——区分这两者才是排查关键。
- TTFB / TTFT / TPOT 是不同尺度:TTFB(首字节)对 LLM 就是 TTFT,由 prefill + 排队决定;TPOT(每输出 token 间隔)由解码(memory-bound)决定。优化手段不同:TTFT 看并发与 prefill 优化,TPOT 看模型与批量。
- 连接复用省掉两次握手:Keep-Alive / 连接池把后续请求的 TCP + TLS 握手都摊掉(同机房 0.1–0.5ms、跨地域 30–150ms)。高频小请求叠加的握手成本才是连接池价值的量化来源,用
curl -w的time_connect与time_appconnect分段即可验证。
学习路径
- 读 1.3:分清处理、排队、传输、传播并记住传播约 200 km/ms
- 跑内置代码,算出 1Gbps×100ms 的 BDP 与长肥管道的要求
- 完成练习自测:用 Little 定律 L=λ×W 估算排队长度
- 对接 M4:为 TTFT 预算分配各跳目标值
核心知识点详解
- 四分量里只有两项可控:处理 = 服务器忙、排队 = 拥塞、传输 = 仅带宽决定、传播 ≈ 200 km/ms 只随距离变。你只能优化处理与排队,传输受带宽、传播受物理距离约束——所以优化「延迟」常常就是优化排队。
- Little 定律 L = λ × W:稳定态下系统内请求数 = 到达率 × 平均耗时。排队延迟在逼近容量时会非线性暴涨,用 L = λ × W 估算队列长度与并发槽预算,是容量规划的理论根基。
- BDP 与长肥管道:BDP = 带宽 × RTT,1Gbps × 100ms ≈ 12.5MB;要让窗口 ≥ BDP 才能跑满带宽,还需开启 TCP 窗口缩放。长肥管道(LFN)上丢包重传会把有效吞吐瞬间打到近乎零,需配合 BBR/大窗口。
学习路径
- 读 1.4:了解 QUIC 0–1 RTT 建连与 HTTP/3 连接迁移
- 跑内置代码,做一次延迟预算核算并核对数值
- 完成综合练习:把延迟预算表交到网关设计里
- 对接 M4:确认网关所选协议是 0-RTT 友好还是重连有代价
核心知识点详解
- QUIC 把握手压到 0–1 RTT:QUIC 建连 = 1 RTT,携带上次会话信息走 0-RTT 恢复,并基于 UDP 规避 TCP 队头阻塞。0-RTT 早到数据可被重放,含副作用的请求不能直接消费 0-RTT。
- 连接迁移是移动端杀手锏:切 WiFi/4G 时 TCP 需重握手,而 QUIC 靠连接 ID 无缝迁移,避免弱网下「断连 → 重连握手 → TTFT 重刷」,对长连接流式服务收益明显。
- 常见坑:预算分配与协议错配:给 TTFT 预算逐段分配时,若某跳用了 0-RTT 不友好的协议,预算会整链超限。先确认所选协议建连是几 RTT 再填预算表,否则数字好看却落不了地。
1.1 分层:不是为了学术,而是为了定位问题
分层的实用价值只有一条:把「慢 / 不通 / 出错」定位到某一层。当你面对「页面加载慢」时,分层思维让你能在 30 秒内排出排查顺序:DNS?TCP 建连?TLS?服务端处理?还是响应传输?
| 层 | TCP/IP 模型 | 做什么 | 典型协议 | 排障工具 |
|---|---|---|---|---|
| 7–5 | 应用层 | 业务语义、数据格式、安全会话 | HTTP/2、gRPC、SSE、TLS | curl -v、Chrome DevTools、日志 |
| 4 | 传输层 | 端到端可靠传输、流控、拥塞控制 | TCP、UDP、QUIC | ss、netstat、tcpdump |
| 3 | 网络层 | 寻址与路由(主机到主机) | IP、ICMP、BGP | ping、traceroute、mtr |
| 2 | 链路层 | 同一链路上的帧传输 | 以太网、Wi-Fi、ARP | ip link、tcpdump -e |
| 1 | 物理层 | 电/光信号 | 1000BASE-T、光模块 | 交换机端口计数、ethtool |
python# 把头部开销算清楚:为什么小请求的网络效率极低
# 一个最小 HTTP/1.1 请求在各层的实际占用(字节)
layers = [
('应用层 payload', 40), # "GET /v1/health HTTP/1.1" + Host 头
('TCP 头', 20), # 无选项时 20 字节
('IP 头', 20),
('以太网头 + FCS', 14 + 4),
('前导码 + 帧间隙', 8 + 12), # 链路层还要求 >=12 字节 IFG
]
total = sum(sz for _, sz in layers)
for name, sz in layers:
print(f'{name:>18}: {sz:>3} B ({sz/total:6.1%})')
print(f'{"合计":>18}: {total:>3} B')
print(f'应用层占比 = 40/{total} = {40/total:.1%}')
# 预期输出(逐行):
# 应用层 payload: 40 B ( 33.9%)
# TCP 头: 20 B ( 16.9%)
# IP 头: 20 B ( 16.9%)
# 以太网头 + FCS: 18 B ( 15.3%)
# 前导码 + 帧间隙: 20 B ( 16.9%)
# 合计: 118 B
# 应用层占比 = 40/118 = 33.9%
tools/call)在 JSON-RPC 之上还要包 HTTP/2 帧与 TLS 记录,实际 payload 利用率先掉到 20% 以下。对策是批量与复用:把 N 个探活合并成一个 keepalive;MCP 用 streamable HTTP 的单个 POST + SSE 长连接代替「每次调用建一次连接」;服务网格(Istio/Envoy)用 mTLS 会话复用与 HTTP/2 连接池把握手成本摊到近零。1.2 一次 HTTPS 请求的完整旅程(把延迟拆开看)
bash# 用 curl 把时间拆开看 —— 这是最快的「分层诊断」
curl -o /dev/null -s -w "\
DNS解析: %{time_namelookup}s\n\
TCP建连: %{time_connect}s\n\
TLS握手完成: %{time_appconnect}s\n\
首字节到达: %{time_starttransfer}s\n\
全部完成: %{time_total}s\n\
重定向次数: %{num_redirects}\n\
下载字节: %{size_download}\n" https://api.example.com/v1/chat
# 读法(这是关键):
# time_namelookup 大 → DNS 慢(换 DNS / 加缓存 / 预解析)
# time_connect - time_namelookup 大 → TCP 建连慢(网络 RTT 大 / 丢包重传)
# time_appconnect - time_connect 大 → TLS 握手慢(证书链长 / 未复用会话 / 跨地域)
# time_starttransfer - appconnect 大 → 服务端处理慢(不是网络问题!去查服务端)
# time_total - time_starttransfer 大 → 响应传输慢(数据量大 / 带宽受限)
# 再看一次「流式」请求的表现:TTFT 才是用户感知的等待
curl -N -s -w "\nTTFB: %{time_starttransfer}s 总计: %{time_total}s\n" \
https://api.example.com/v1/chat/stream -d '{"q":"hi"}' | head -40
| 场景 | RTT 量级 | 握手+握手总成本(TCP+TLS1.3) | 说明 |
|---|---|---|---|
| 同机房(同 AZ) | 0.1–0.5 ms | < 1 ms | 微服务间调用,几乎可忽略 |
| 同地域跨 AZ | 0.5–2 ms | 1–4 ms | 多可用区部署的常见代价 |
| 中国跨省 | 20–50 ms | 40–100 ms | 连接复用价值显著 |
| 跨太平洋 | 120–200 ms | 240–400 ms | 不建长连接的话,每次请求光握手就 0.4 s |
| 移动网络(4G/5G) | 30–100 ms 且波动大 | 60–200 ms | 弱网,丢包与切换频繁 |
python# 用 Python 精确测量各阶段(比 curl 更细,可直接做成探针)
import socket, ssl, time
HOST, PATH = 'api.example.com', '/v1/health'
t = {}
t0 = time.perf_counter()
raw = socket.create_connection((HOST, 443), timeout=5)
t['tcp_ms'] = (time.perf_counter() - t0) * 1000 # 纯 TCP 建连
ctx = ssl.create_default_context()
t1 = time.perf_counter()
tls = ctx.wrap_socket(raw, server_hostname=HOST)
t['tls_ms'] = (time.perf_counter() - t1) * 1000 # 纯 TLS 握手
t2 = time.perf_counter()
req = f'GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\nConnection: close\r\n\r\n'
tls.sendall(req.encode())
tls.recv(1)
t['ttfb_ms'] = (time.perf_counter() - t2) * 1000 # 请求到首字节
t['total_ms'] = (time.perf_counter() - t0) * 1000
print({k: round(v, 1) for k, v in t.items()})
# 典型输出(同城跨 AZ,未复用连接):
# {'tcp_ms': 1.2, 'tls_ms': 3.8, 'ttfb_ms': 42.0, 'total_ms': 47.2}
# 判读:ttfb 远大于 tcp+tls → 瓶颈在服务端而非网络(见 7.1 分层诊断)
# 复用连接(连接池)后 tcp_ms 与 tls_ms 应接近 0
1.3 延迟的四个组成部分:别把「慢」都归给带宽
网络延迟不是一个数字,而是四部分之和。分不清它们,你就无法判断该优化什么(这是面试高频,也是排障必备)。
text总延迟 = 处理延迟 + 排队延迟 + 传输延迟 + 传播延迟
处理延迟 (processing):路由器/网卡查表、校验耗时。通常微秒级,可忽略。
但用户态协议栈(如 gRPC 的序列化)会显著增加它。
排队延迟 (queueing) :在缓冲区里等待被发送。拥塞时会指数级增长!
这是「为什么一拥塞延迟就爆炸」的原因(见 1.3 后的排队论)。
传输延迟 (transmission) = 数据长度 / 带宽
100 KB 在 1 Gbps 上 = 0.8 ms;这是「带宽」真正影响的部分。
传播延迟 (propagation) = 距离 / 光速(约 200 km/ms 在光纤中)
北京→上海 1200 km ≈ 6 ms(单程),往返 12 ms(理论下限)
关键结论:
① 传播延迟是「物理下限」,无法通过加带宽改善。跨大西洋往返最少 ~60 ms。
② 带宽只影响传输延迟。把一个 1 GB 文件从 100 Mbps 升到 1 Gbps,确实快 10 倍;
但把一次 1 KB 的 API 调用从 100 Mbps 升到 1 Gbps,几乎没变化。
③ 排队延迟在拥塞时主导一切——这也是拥塞控制的全部意义。
- Little 定律(排队论基石):
L = λ × W(系统中的平均请求数 = 到达率 × 平均停留时间)。工程含义:当请求到达率 λ 接近服务能力上限时,W(延迟)会非线性暴涨。所以「把 GPU 利用率压到 100%」是危险的——延迟会失控。给推理服务留 20–30% 余量不是浪费,是保证 P99 的必要成本。 - 为什么「加带宽」常常没用:AI 服务里的请求大多是小 payload,瓶颈在传播延迟、握手、排队与服务端算力上。看到「慢」就去加带宽,是最常见的错误归因。
- 「长肥管道(LFN)」问题:带宽 × RTT 很大时(跨洋高带宽链路),TCP 的窗口必须足够大才能填满管道。这是 TCP 调优与 QUIC 优化的重要场景。
python# 把 1.3 的四个延迟分量写成可复算的函数 —— 评估方案时直接用
C = 200_000_000 # 光纤中光速 2e8 m/s(约 200 km/ms)
def total_latency(payload_bytes, bandwidth_bps, distance_km,
hops=8, queue_ms=0.0, process_ms=0.05):
prop = (distance_km * 1000 / C) * 1000 # ms(单程)
trans = payload_bytes * 8 / bandwidth_bps * 1000 # ms
return {'prop': prop, 'trans': trans,
'proc': process_ms * hops, 'queue': queue_ms,
'sum': prop + trans + process_ms * hops + queue_ms}
print('跨太平洋 1 KB 请求')
r = total_latency(1024, 1_000_000_000, 10_000)
print(f" 传播(单程) {r['prop']:.1f} ms → RTT {2*r['prop']:.1f} ms")
print(f" 传输 {r['trans']:.3f} ms <- 1 KB 在 1 Gbps 上几乎为 0")
print(f" 处理(8 跳) {r['proc']:.2f} ms")
print('同机房传 1 GB 模型权重')
r2 = total_latency(1024**3, 25_000_000_000, 0.01)
print(f" 传输 {r2['trans']:.0f} ms <- 这里带宽才是主角")
# 预期输出:
# 传播(单程) 50.0 ms → RTT 100.0 ms
# 传输 0.008 ms
# 处理(8 跳) 0.40 ms
# 同机房 1 GB 传输 343 ms
# 关键推论:小请求的延迟 = 传播 + 握手,与带宽无关;
# 大传输的延迟 = 数据量/带宽。二者优化手段完全不同。
1.4 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 分层计时 | DNS/TCP/TLS/TTFB 四段,两法误差 < 20% |
| 连接复用 | 同城总耗时降 30–60% |
| 带宽 vs 传播 | 跨洋 1 KB ≈ 100 ms RTT;同机房 1 GB ≈ 343 ms |
| 头部开销 | 有效载荷占比约 1/3 |
| 2026 选型 | HTTP/3+QUIC:0-RTT、连接迁移、UDP 回退 |
- 分层计时:用 curl -w 与上面的 Python 探针分别测一个真实 HTTPS 站点,把 DNS/TCP/TLS/TTFB 四段填进表格。判据:两法结果同量级(误差 < 20%),并能指出瓶颈段。
- 连接复用收益:对同一域名连续发 20 次请求,分别在「每次新建连接(Connection: close)」与「复用连接」下测总耗时。判据:复用后总耗时下降应接近握手成本 × 次数(同城可省 30–60%)。
- 带宽 vs 传播:用上面的复算函数预测「北京↔上海 1 KB」与「同机房 1 GB」的延迟,再用 scp/curl 实测对比。判据:预测与实测同一数量级,并能解释偏差来源(排队/抖动)。
- 头部开销:抓一次真实请求的包,量出 payload 与总包字节比。判据:能算出有效载荷占比,并说出它对你的探活/心跳设计的影响。
- 2026 选型:给定「移动端 AI 助手、弱网、需断线续传」的场景,判断该用 HTTP/2+SSE 还是 HTTP/3+QUIC,并写出至少 2 条量化理由。判据:提到 0-RTT、连接迁移、UDP 被封的回退。
2. IP、路由与数据中心网络
学习路径
- 读 2.1:算清 /24 与 /20 的可用地址并做掩码与运算
- 跑内置代码,用 ping -M do 找出真实 MTU 与分片点
- 完成练习自测:解释为何 NAT 后外部无法主动发起连接
- 对接 M4:为网关确认服务发现与公网出口的策略
核心知识点详解
- CIDR 可用地址 = 2^(32−前缀) − 2:
/24有 254 个可用(去掉网络号与广播址),10.0.0.0/8可容纳 2²⁴ 个。判断同子网用掩码与运算:(ip & mask) == (net & mask),手算不掉是日常排障的硬伤。 - NAT 让外部无法主动连内网:NAT 把私网地址映射到公网,但对外部没有该主机的映射就无法主动发起连接——这正是 WebSocket / SSE / gRPC 都要「客户端先连」、以及 P2P 走 STUN/TURN 的根本原因。
- MTU / PMTUD 是「大包卡死」的头号嫌疑:以太网 MTU 1500,K8s overlay(VXLAN 加 50 字节)下有效 MTU 降到 1450。用
ping -M do -s试探真实 MTU;PMTUD 被防火墙阻断时会出现小包正常、大包超时——AI 大 payload 在 K8s 卡死优先查 MTU 而非代码。
学习路径
- 读 2.2:对比 RoCEv2、InfiniBand 与 NVLink 的带宽数量级
- 跑内置代码,估算一次 all-reduce 280 GB 所需的秒数
- 完成练习自测:解释 RDMA 为何绕过内核可零拷贝
- 对接 M4:认清训练瓶颈在网络,网关设计要避开带宽红线
核心知识点详解
- 三种带宽数量级要分清:RoCEv2 100–400 Gbps(以太网)、InfiniBand NDR 400–800 Gbps、NVLink 900 GB/s(芯片直连私有互连)。训练瓶颈往往在网络:一次 all-reduce 280 GB 在数百 Gbps 下也要数秒,这就是集群上 NCCL 通信必须与计算重叠的原因。
- RDMA 绕过内核 = 零拷贝低延迟:RDMA 把数据从网卡直接送到用户态内存,跳过内核协议栈与多次拷贝,延迟到微秒级、CPU 占用极低。代价是必须配合 PFC / 无损以太网,否则流控防护失效会带来大范围丢包抖动。
- 常见坑:NCCL 只看算力不看通信量:调并行方案前先用
NCCL_DEBUG=INFO看 all-reduce 实际耗时量级,别把通信当成免费;PP/TP/DP 组拓扑不同会导致通信不均,必要时做拓扑感知(ring 变 tree)并让通信与计算 overlap。
学习路径
- 读 2.3:复算 172.16.0.0/20=4094 与 all-reduce 的耗时
- 跑内置代码,把 NVLink 约 400 GB/s 的数字核一遍
- 完成综合练习:给出数据中心组网的三个关键数字
- 对接 M4:把网卡与带宽预算写进网关容量表
核心知识点详解
- 数字要能解释来源:
172.16.0.0/20可用 4094(2¹²−2);「all-reduce 280 GB / 5.6s」对应约 400 Gbps 真实聚合带宽;NVLink 单向约 400 GB/s。要能报数字还能报计算式,否则排障时不知道偏差出在哪。 - 组网三数字落到容量表:把「链路带宽 → MTU → 网络延迟」三个数字写进网关容量表,作为协议选型与 payload 分片判断的前提;容量表里用实测值而非标称。
- 常见坑:把理想带宽当真实带宽:官方标称是线速上限,真实可用还要乘上流控、重传与 overlap 效率。用
iperf3/ NCCL 测试实测填表,否则容量预算会系统性高估。
2.1 IP、CIDR、NAT 与 MTU
- CIDR 记法:
10.0.0.0/8里的 8 表示前 8 位是网络位,因此可容纳 2²⁴ 个地址。子网划分的本质是「用掩码切分地址空间」。工程上你必须能算清:/24有多少可用地址(254)、两个网段是否同子网(掩码与运算)。 - NAT:把私有地址映射到公网地址。它解决了 IPv4 地址耗尽,但带来了副作用——外部无法主动连接 NAT 后的主机。这就是 WebSocket / SSE / gRPC streaming 都依赖「客户端先发起连接」的根本原因,也是 P2P、WebRTC 需要 STUN/TURN 打洞的原因。
- MTU 与分片:以太网 MTU 通常 1500 字节。IP 层超长会分片,而分片有严重性能问题(任一分片丢失则整包重传)。PMTUD(路径 MTU 发现)被防火墙阻断时会出现「小请求正常、大请求卡死」的诡异现象——这是真实世界里非常常见的坑(尤其是在 VPN / K8s overlay 网络里)。
- K8s 与 overlay 网络:VXLAN / IPIP 等隧道封装会额外消耗字节(VXLAN 加 50 字节),使有效 MTU 从 1500 降到 1450。如果你的 AI 应用在 K8s 里出现「大 payload 超时」,第一反应应该是 MTU 而不是代码。
bash# 排查 MTU 问题的标准手法
# 逐步加大包大小做「不分片」测试,看从多大开始失败
for sz in 1472 1442 1400 1372 1200; do
echo -n "payload=$sz: "
ping -c 1 -M do -s $sz 10.0.1.5 >/dev/null 2>&1 && echo OK || echo "失败(超过 MTU)"
done
# 注意 -s 是 payload 大小,实际包大小 = payload + 28(IP 20 + ICMP 8)
# 所以 1472 + 28 = 1500 是标准以太网;若 1442 就失败,说明有效 MTU 是 1470(隧道封装)
# 查看接口 MTU
ip link show | grep mtu
# 若是 K8s,检查 CNI 的 MTU 配置是否与宿主网络匹配
kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo}' | head -c 200
python# CIDR / 子网计算:面试与排障都要手算
import ipaddress as ip
net = ip.ip_network('10.12.0.0/22')
print('网络地址 :', net.network_address) # 10.12.0.0
print('广播地址 :', net.broadcast_address) # 10.12.3.255
print('可用主机数 :', net.num_addresses - 2) # 1022
print('子网掩码 :', net.netmask) # 255.255.252.0
print('是否含该 IP :', ip.ip_address('10.12.3.7') in net) # True
print('10.12.4.1 in?', ip.ip_address('10.12.4.1') in net) # False
# 预期输出首三行:10.12.0.0 / 10.12.3.255 / 1022
# K8s overlay(VXLAN)MTU 推导:宿主 1500 - VXLAN 50
# => Pod 有效 MTU 1450;若再叠 IPsec/WireGuard,常见降到 1400 甚至 1380
print('建议 Pod MTU =', 1500 - 50) # 1450
2.2 数据中心网络与 RDMA:AI 训练的真实瓶颈
这一节是「计算机网络」在 AI 领域最有增量的部分。分布式训练的性能极限往往不在算力,而在梯度同步的网络带宽。
| 互联技术 | 带宽(双向) | 延迟 | 用途 | 说明 |
|---|---|---|---|---|
| 以太网 10/25 GbE | 10–25 Gbps | ~10 μs | 普通服务通信 | 传统数据中心网络 |
| RoCEv2(RDMA over Ethernet) | 100–400 Gbps | ~2 μs | AI 集群主流 | 需无损网络(PFC/ECN 调优) |
| InfiniBand NDR/XDR | 400–800 Gbps | ~1 μs | AI 集群高端 | 专用网络,NCCL 原生支持 |
| NVLink 4/5 | 900 GB/s(每 GPU) | 极低 | 同节点多卡 | 比 PCIe 快 10 倍以上 |
| NVSwitch | 全互联 900 GB/s | 极低 | 同节点 8 卡全互联 | 避免拓扑拥塞 |
| PCIe 5.0 x16 | 64 GB/s 单向 | ~1 μs | CPU↔GPU | 常成为隐形瓶颈 |
- 为什么 RDMA 重要:传统 TCP/IP 协议栈要经过内核、多次内存拷贝、CPU 参与,延迟几十微秒且 CPU 占用高。RDMA(远程直接内存访问)让网卡直接读写对端内存,绕过内核、零拷贝、CPU 几乎不参与,延迟降到 1–2 微秒。训练中每秒要同步几百次梯度,这个差异被放大成分钟级的差距。
- 拓扑感知(topology-aware):NCCL 会探测机器的 GPU 拓扑(哪些卡在同一 NVLink 域、哪些跨 NUMA、哪些走 PCIe)并选择最优的通信算法。所以「同一批卡,换一种 GPU 插法,训练速度能差 30%」不是玄学。
- 集合通信的两种算法:Ring AllReduce(环形,通信量 2(N−1)/N × 数据量,随卡数趋于饱和)与 Tree AllReduce(树形,延迟更低,适合小消息)。NCCL 会按消息大小与拓扑自动选择。
- 通信与计算重叠(overlap):梯度同步不必等全部算完才开始——可以按层(layer-wise)计算完梯度就立刻发起该层的 all-reduce,让网络传输与后续层的反向传播并行。这是分布式训练的一项核心优化,能把通信开销几乎隐藏掉。
text算一下 all-reduce 的通信量 —— 这是评估分布式训练可行性的第一步
Ring AllReduce 总通信量(每张卡)= 2 × (N−1)/N × S
其中 N = 卡数,S = 模型参数字节数
例子:70B 模型,bf16(2 字节/参数),S = 140 GB
单卡通信量 = 2 × (N−1)/N × 140 GB ≈ 280 GB(N 很大时)
用 400 Gbps RDMA(= 50 GB/s)互联:
理论最短耗时 = 280 GB / 50 GB/s = 5.6 秒 ← 每训练一步!
用 25 GbE(= 3.1 GB/s):
理论最短耗时 = 280 / 3.1 = 90 秒 ← 完全不可行
反推:若希望通信耗时不超过计算的 20%,而单步计算耗时 2 秒,
则要求通信 < 0.4 秒 → 需要带宽 ≥ 280/0.4 = 700 GB/s → 必须用 NVLink + IB 组合
这个计算解释了为什么:
① 大模型训练必须用高带宽互联(RDMA / IB),普通以太网不可行;
② 张量并行(TP)通信量大,只在节点内(NVLink)做;数据并行(DP)通信量小,可跨节点;
③ ZeRO / FSDP 通过分片优化器状态来降低通信量,是「用通信换显存」的精心权衡。
bash# 在真实 GPU 机器上验证互联拓扑与带宽(NCCL 自带工具)
nvidia-smi topo -m
# 输出是一张 8x8 矩阵:NV4/NV5 表示 NVLink,SYS 表示跨 NUMA(慢),
# PIX/PXB/PHB 表示经 PCIe 交换机/桥。看到 SYS 出现在关键路径就要调插卡。
# 官方 all-reduce 带宽测试(8 卡):
./build/all_reduce_perf -b 8 -e 8G -f 2 -g 8
# 关注输出列:algbw(算法带宽)与 busbw(总线带宽)。
# 预期(NVLink 4,8×H100):busbw 可达约 400 GB/s;纯 PCIe 5 仅约 50 GB/s。
# 若实测远低于此,先查 nvidia-smi topo -m 的拓扑,再查 NCCL_IB_* 环境变量。
# 查 RDMA 设备与端口状态
ibv_devinfo | grep -E 'hca_id|state|rate'
# 期望:state: PORT_ACTIVE,rate: 400 Gb/sec(NDR)
export NCCL_DEBUG=INFO # 打印 NCCL 选择的算法与通道数
2.3 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 手算子网 | 172.16.0.0/20 → 4094 可用,掩码 255.255.240.0 |
| MTU 复现 | VXLAN 扣 50 → 有效 MTU 1450 |
| 拓扑检查 | NV4/NV5 = NVLink,SYS = 跨 NUMA |
| 通信量核算 | 280 GB / 5.6 s(400 Gbps) |
| 总线带宽 | NVLink 约 400 GB/s、PCIe 约 50 GB/s |
- 手算子网:给定 172.16.0.0/20,写出网络地址、广播地址、可用主机数与掩码;再判断 172.16.15.5 是否同网段。判据:答案为 172.16.0.0 / 172.16.15.255 / 4094 / 255.255.240.0,且判断为是。
- MTU 复现:在 K8s 里(或本地 mock)用 ping -M do -s 逐步探测,找出有效 MTU。判据:能测出失败阈值并解释隧道封装扣了多少字节。
- 拓扑检查:在有 GPU 的机器上跑 nvidia-smi topo -m,指出哪些卡在同一 NVLink 域。判据:能读懂矩阵并说出 TP 应限制在哪个范围。
- 通信量核算:70B 模型 bf16、128 卡 DP,算每步 all-reduce 通信量与 400 Gbps 下的理论耗时。判据:约 280 GB / 5.6 s(N 大时)。
- 总线带宽判读:跑 all_reduce_perf 8 卡,记录 busbw 并与预期(NVLink 约 400 GB/s、PCIe 约 50 GB/s)对比。判据:能解释实测值偏低时的首个排查动作。
3. TCP:可靠传输的代价与拥塞控制
学习路径
- 读 3.1:搞清三次握手与四次挥手,以及 TIME_WAIT 的 2×MSL
- 跑内置代码,用 ss 观察 TIME_WAIT 与 CLOSE_WAIT 计数
- 完成练习自测:做一次端口耗尽估算并核对 somaxconn
- 对接 M4:让网关长连接复用,避免握手风暴
核心知识点详解
- 三次握手确认互收能力:SYN → SYN+ACK → ACK 各确认对方收发能力与初始序号。两次不够(无法确认双方都能收);因 TCP 全双工,四次挥手需两侧各自 FIN+ACK。
TIME_WAIT固定约 2×MSL = 60s,用于清理可能残留的旧报文。 - CLOSE_WAIT 是代码 bug 的指纹:被动关闭方收到对端 FIN 后若不及时 close 本地 socket,就卡在 CLOSE_WAIT。服务端 CLOSE_WAIT 大量堆积 = 有连接没被正确关闭(典型是 keep-alive 下 read 未循环到 EOF),用
ss -tan state CLOSE-WAIT定位。 - 端口耗尽与 backlog:客户端本地端口约 2¹⁶,用尽会报
Cannot assign requested address;服务端半连接队列受tcp_max_syn_backlog/somaxconn/ 应用 backlog 三者的最小者限制,溢出丢 SYN。高并发网关务必调大并监控,否则体现为偶发连接超时。
学习路径
- 读 3.2:理解发送窗口=min(rwnd,cwnd) 与 CUBIC、BBR 的区别
- 跑内置代码,算出 1Gbps×100ms 的 BDP=95MB
- 完成练习自测:解释队头阻塞与缓冲膨胀的成因
- 对接 M4:为流式场景选择合适的拥塞控制并核对 BDP
核心知识点详解
- rwnd 与 cwnd 分工不同:rwnd 是接收方流控(我还能收多少),cwnd 是发送方自己维护的拥塞窗口,实际发送窗口 = min(rwnd, cwnd)。要让链路跑满,窗口需 ≥ BDP(1Gbps × 100ms ≈ 12.5MB),还需开启窗口缩放。
- CUBIC 与 BBR 的取舍:CUBIC 三次函数探测带宽、丢包即减窗(适合有大量缓冲的链路);BBR 以「最大带宽 × 最小 RTT」估算而不依赖丢包反馈,弱网 / 长肥管道下吞吐更稳。对流式交互建议先验 BBR。
- 队头阻塞与缓冲膨胀:TCP 一个报文段丢失后其后续都在等重传(HOL);路由器大缓冲让 RTT 漂高、丢包推迟(bufferbloat)。流式被卡几十 ms 常常不是应用慢而是 TCP 重传与排队,用
ss -tin看 retrans 与 rtt 分位来证伪。
学习路径
- 读 3.3:弄懂 Nagle 攒小包与延迟确认 40ms 叠加的抖动
- 跑内置代码,开 TCP_NODELAY 对比首字延迟差异
- 完成练习自测:测出 20–50ms 批大小对吞吐的影响
- 对接 M4:在网关的流式连接上打开 TCP_NODELAY
核心知识点详解
- Nagle 在 LLM 流式下的反效果:Nagle 会把小包攒满段或有 ACK 才发,配合延迟确认(约 40ms),让每个小包多等约 40ms——对逐 token 的流式输出直接拉高 TPOT,长文本生成里累计抖动非常明显。
- TCP_NODELAY 是流式的基本配置:在 socket 上开
TCP_NODELAY禁用 Nagle,让 token 一产生就发出。对 SSE / WebSocket / gRPC 流式通道必须开,否则默认行为会让前台像打字机卡住。 - 注意 20–50ms 批与首字延迟的权衡:批量聚合能提吞吐但会抬 TTFT,吞吐与首字延迟是一对 trade-off;开不开、开多大要按目标选:重首字就小批实时刷发,重吞吐就 20–50ms 批并接受更高 TTFT。
学习路径
核心知识点详解
- 用 tc netem 制造可复现弱网:
tc qdisc add dev eth0 root netem delay 100ms loss 1% rate 10mbit可注入延迟 / 丢包 / 限速;tc qdisc del清理。比真机便宜且可复现,是压测流式网关的标准手段。 - 重传率是网络健康晴雨表:重传率 > 0.5% 应告警,正常环境应 < 0.1%。用
ss -tin读 retrans 与 rtt 分位;重传率飙高往往伴随 TTFT / TPOT 长尾,先于业务优化处理它。 - 弱网实验要接到应用而非只看协议:不只有握手层丢包值得测——流式中断率、客户端重连成功率、TTFT 分布才是对「LLM 体验」的判据;把 netem 弱网接进网关压测脚本才闭环。
3.1 三次握手、四次挥手与 TIME_WAIT
text三次握手(建立连接)
Client ── SYN(seq=x) ────────────────► Server 客户端说:我想连,我的初始序号是 x
Client ◄─ SYN(seq=y) + ACK(x+1) ───── Server 服务端说:好,我的序号是 y,确认你的 x
Client ── ACK(y+1) ──────────────────► Server 客户端说:收到你的 y
─────────────────────────────────────────────────
为什么必须三次?因为要让「双方的收发能力」都得到确认:
第 1 次:服务端确认「客户端能发」
第 2 次:客户端确认「服务端能收能发」
第 3 次:服务端确认「客户端能收」
两次不够(服务端无法确认客户端收到了自己的 SYN);
四次多余(第 2、3 步可以合并)。
四次挥手(关闭连接)
Client ── FIN ───────────────────────► Server
Client ◄─ ACK ─────────────────────── Server 半关闭:客户端不再发,但还能收
Client ◄─ FIN ─────────────────────── Server
Client ── ACK ───────────────────────► Server
─────────────────────────────────────────────────
为什么必须四次?因为 TCP 是全双工的,两个方向要分别关闭。
服务端收到 FIN 后可能还有数据要发,所以 ACK 与自己的 FIN 不能合并。
TIME_WAIT:主动关闭方在发完最后一个 ACK 后要等 2×MSL(通常 60 秒)
两个原因:
① 确保最后的 ACK 能到达(若丢失,对端会重发 FIN,此时还有连接上下文可响应)
② 让本次连接的旧报文在网络中彻底消散,避免「串味」到新连接(同四元组复用)
- 服务端看到大量 TIME_WAIT 是最常见的线上现象:如果你的服务是主动关闭方(比如 HTTP 客户端用了短连接、或代理主动断开),就会出现。大量 TIME_WAIT 会占用本地端口,极端情况导致「无法建立新连接」。解法优先级:① 用长连接 / 连接池(最根本);② 让客户端主动关闭;③ 调
tcp_tw_reuse(谨慎,需配合时间戳);④ 绝不要用tcp_tw_recycle(已被移除,NAT 环境下会误杀)。 - 大量 CLOSE_WAIT 是代码 bug 的信号:它表示「对端已经关闭,但本端还没调用 close()」。通常是你的代码在异常路径上忘了释放连接(没有 try-finally 或没有用连接池的上下文管理器)。看到 CLOSE_WAIT 堆积,去查代码里哪里漏了 close。
- SYN Flood 与 backlog:
net.core.somaxconn与listen()的 backlog 参数决定半连接队列大小。高并发服务要调大,否则新连接会被丢弃(表现为客户端超时重试、服务端看不到请求日志)。
bash# 连接状态一览 —— 排障的第一步
ss -s # 汇总:各状态连接数
ss -tan state time-wait | wc -l # 有多少 TIME_WAIT
ss -tan state close-wait | head -20 # CLOSE_WAIT 堆积(代码 bug 信号)
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
# 内核可调参数(了解即可,生产改前先压测)
sysctl net.ipv4.tcp_tw_reuse=1 # 允许复用 TIME_WAIT 连接(需 timestamps)
sysctl net.core.somaxconn=32768 # accept 队列上限
sysctl net.ipv4.tcp_max_syn_backlog=8192 # 半连接队列上限
sysctl net.ipv4.ip_local_port_range="10240 65535" # 本地端口范围
# 应用层最有效的三招(优先级高于调内核参数)
# ① 连接池:复用连接,把「握手成本」摊掉
# ② 客户端主动关闭(通过配置 http 客户端的 keepalive 策略与超时)
# ③ 正确的超时与重试(避免僵尸连接长期占位)
| 字段 | 位宽 | 作用 | AI 服务相关点 |
|---|---|---|---|
| 源端口 / 目的端口 | 16+16 | 标识连接(四元组的一半) | NAT 后大量连接会耗尽本地端口(见 3.1) |
| 序号 seq | 32 | 字节流编号,保证有序 | 大 payload 传输与重排的基础 |
| 确认号 ack | 32 | 期望收到的下一个字节 | 延迟确认会推迟它(见 3.3) |
| 数据偏移 | 4 | TCP 头长度(含选项) | 带时间戳选项时头 32 字节 |
| 标志位 | 9 | SYN/ACK/FIN/RST/PSH/URG/ECE/CWR | ECE/CWR 用于 ECN(拥塞显式通知) |
| 窗口 rwnd | 16 | 接收方还能收多少字节 | 零窗口会让发送方停发(需 persist timer) |
| 校验和 | 16 | 端到端校验 | — |
| 紧急指针 | 16 | 极少使用 | 常被 NAT/防火墙改写导致握手失败 |
bash# TIME_WAIT 与端口耗尽的定量判断
ss -s # 看 timewait 总数
ss -tan state time-wait | wc -l # 逐条计数
# 可用本地端口数 = 65535 - ip_local_port_range 起点(常见 10240)= 约 55295
# 每条 TIME_WAIT 占用 1 个端口 60 秒:
# 若 QPS=1000 个短连接 -> 60 秒窗口需 1000*60 = 60000 个端口
# > 55295 可用 -> 必然出现 "Cannot assign requested address"
# 结论:短连接 + 高 QPS = 端口必然耗尽。
# 换成长连接后,端口占用从「每请求 1 个」降到「每后端 1–2 个」。
3.2 滑动窗口、拥塞控制与 BBR
这是 TCP 最核心的机制,也是「为什么网络慢」的最主要答案之一。流量控制是「别把接收方撑爆」(接收窗口 rwnd),拥塞控制是「别把网络撑爆」(拥塞窗口 cwnd)。实际发送窗口 = min(rwnd, cwnd)。
| 算法 | 核心思想 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| Tahoe / Reno(经典) | 慢启动 + 拥塞避免 + 快重传快恢复;以丢包为拥塞信号 | 实现简单、公平性好 | 高带宽高延迟下恢复慢;把「非拥塞丢包」误判为拥塞 | 已基本被替代 |
| CUBIC(Linux 默认) | 用三次函数替代线性增长,与 RTT 无关 | 高带宽下增长快、公平、稳定 | 仍以丢包为信号;缓冲膨胀时填满缓冲区导致延迟暴涨 | 通用默认,有线网络表现好 |
| BBR(Google) | 基于「瓶颈带宽 × 最小 RTT」建模,主动探测而不等丢包 | 高延迟高丢包链路下吞吐显著更高;队列延迟低 | 公平性对 CUBIC 有争议;共享链路需调参 | 跨洋、移动网络、有丢包的链路 |
| QUIC 的拥塞控制 | 用户态可插拔,可自定义 | 灵活、可快速迭代 | 需要应用自己实现 | HTTP/3 |
bash# 弱网实验:亲手看拥塞控制的影响(Linux 用 tc netem 模拟)
# 查看当前算法
sysctl net.ipv4.tcp_congestion_control
# 模拟「跨洋 + 丢包」链路:100ms 延迟、1% 丢包、限速 50Mbps
sudo tc qdisc add dev eth0 root netem delay 100ms loss 1% rate 50mbit
# 用 CUBIC 下载
sudo sysctl -w net.ipv4.tcp_congestion_control=cubic
curl -o /dev/null -w "CUBIC 用时 %{time_total}s 平均速度 %{speed_download} B/s\n" \
https://speed.example.com/100mb.bin
# 切换到 BBR(需内核支持,需先加载模块)
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
curl -o /dev/null -w "BBR 用时 %{time_total}s 平均速度 %{speed_download} B/s\n" \
https://speed.example.com/100mb.bin
# 清理
sudo tc qdisc del dev eth0 root
# 在 1% 丢包 / 100ms RTT 的链路上,BBR 的吞吐通常能比 CUBIC 高 2–5 倍,
# 因为它不会把丢包当成「拥塞必须退让」的唯一信号。
② 缓冲膨胀(Bufferbloat):路由器缓冲区过大时,CUBIC 会把缓冲区填满,导致 RTT 从 20 ms 涨到 200 ms 而吞吐不变。表现是「服务没报错、吞吐正常,但 P99 延迟高得离谱」。排查方法:看 RTT 随时间的变化趋势,而不是只看平均值。
把拥塞控制的公式写出来,你就能定量判断「这条链路理论上能跑多快」,也能解释 BBR 与 CUBIC 的行为差异。
python# 吞吐上限公式 + CUBIC / BBR 的行为模拟
def tcp_throughput(window_bytes, rtt_s):
"""丢包前的稳态吞吐 = 窗口 / RTT(Mathis 公式的简化形式)"""
return window_bytes / rtt_s
print('窗口 64 KB、RTT 100 ms ->',
round(tcp_throughput(64*1024, 0.1) / 1024, 1), 'KB/s')
# 预期 640.0 KB/s 约等于 5.2 Mbps —— 跨洋链路上老默认窗口完全跑不满带宽
def bdp(bw_bps, rtt_s):
"""带宽时延积:填满管道所需的最小窗口(字节)"""
return bw_bps * rtt_s
print('1 Gbps x 100 ms 的 BDP =',
round(bdp(1e9, 0.1) / 1024 / 1024, 1), 'MB')
# 预期 95.4 MB —— 窗口必须 >= 此值才可能跑满 1 Gbps
# CUBIC 的窗口增长是三次函数 W(t) = C*(t-K)^3 + W_max,且与 RTT 无关
C, W_max = 0.4, 100
K = (W_max * 0.3 / C) ** (1/3)
print('CUBIC 参数 K =', round(K, 2), ' -> 从 W_max 恢复到 W_max 附近约需 K 个往返')
# 预期 K = 4.22 —— 与 RTT 解耦是 CUBIC 在长肥管道上胜过 Reno 的关键
# BBR 核心:用「最大带宽估计 x 最小 RTT」建模,而不是等丢包
def bbr_estimate(bw_samples, rtt_samples):
return max(bw_samples), min(rtt_samples)
print('BBR 估计 (带宽, 最小RTT) =', bbr_estimate([100, 250, 900], [0.1, 0.12, 0.25]))
# 预期 (900, 0.1) —— 用最大带宽与最小 RTT 算 BDP,主动探测瓶颈
3.3 Nagle 算法、延迟确认与 TCP_NODELAY
这两个机制是流式输出的经典杀手。如果你实现过 SSE 或 WebSocket 推送,一定会遇到「服务端明明立刻写了,客户端却等了几十毫秒才收到」——根源就在这里。
- Nagle 算法:把小包攒起来合并成一个更大的包再发,目的是减少「小包满天飞」的网络低效。规则是「如果还有未被 ACK 的数据,就先把新数据缓存起来」。代价就是延迟:小的流式 chunk 会被攒着等前一个的 ACK。
- 延迟确认(Delayed ACK):接收方不立即回 ACK,而是等最多 40 ms(Linux 通常 40 ms 上限)看看有没有数据可以捎带确认。
- 两者叠加 = 灾难:发送方等 ACK 才发下一个包(Nagle),接收方等 40 ms 才发 ACK(延迟确认)—— 每个小块都可能被延迟 40 ms。对逐 token 流式输出来说,这是致命的。
- 解法:流式服务必须设置
TCP_NODELAY,禁用 Nagle。Python:sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1);asyncio/aiohttp/httpx 通常默认已开启或可配置。另外,把 chunk 合并成合理大小(如 20–50 ms 一批)比逐字节写更高效——这是「不关 Nagle 但自己控制批大小」的另一种解法。
python# 流式输出:正确的批处理与 TCP_NODELAY 设置
import socket, time
async def stream_tokens(ws, tokens, batch_ms=25, max_batch=16):
"""把逐 token 输出聚成小批再发:
既避免 Nagle 造成的 40ms 延迟,也避免每个 token 一个包的开销。
这是生产流式服务的标准做法。"""
import asyncio
buf, last = [], time.monotonic()
async def flush():
nonlocal buf, last
if not buf: return
await ws.send(''.join(buf))
buf, last = [], time.monotonic()
async for tok in tokens:
buf.append(tok)
now = time.monotonic()
# 触发条件:攒够一批 或 超过时间窗
if len(buf) >= max_batch or (now - last) * 1000 >= batch_ms:
await flush()
await flush()
# 服务端 socket 层禁用 Nagle(如果框架允许直接操作 socket)
def disable_nagle(sock: socket.socket):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# 大多数 ASGI 服务器(uvicorn/uvloop)已默认启用 TCP_NODELAY,
# 但如果你自己写 asyncio server 或走代理,一定要确认这一点。
python# 实测 Nagle + 延迟确认带来的约 40 ms 卡顿(本地回环即可复现)
import socket, time
def run(nodelay):
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 0)); s.listen(1); port = s.getsockname()[1]
c = socket.socket(); c.connect(('127.0.0.1', port))
conn, _ = s.accept()
if nodelay:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
t0 = time.perf_counter(); gaps = []
for i in range(3):
time.sleep(0.001); c.send(b'x' * 10)
conn.recv(10); gaps.append((time.perf_counter() - t0) * 1000)
for x in (s, c, conn): x.close()
return gaps
print('未设 NODELAY:', [round(x, 1) for x in run(False)])
print('已设 NODELAY:', [round(x, 1) for x in run(True)])
# 典型输出:
# 未设 NODELAY: [1.2, 41.5, 42.0] <- 第 2、3 个包被延迟确认卡住约 40 ms
# 已设 NODELAY: [1.1, 2.3, 3.4] <- 逐个小包立即发出
# 结论:逐 token 流式输出必须 TCP_NODELAY,或按 20–50 ms 批量聚合。
TCP_NODELAY=1(关 Nagle);② 不依赖延迟确认,而是靠应用层批处理控制节奏;③ 内核 tcp_rmem/tcp_wmem 增大以支撑长肥管道;④ 反向代理(Nginx/Envoy)关闭缓冲并透传 chunked;⑤ 客户端 fetch/EventSource 侧把 read 循环立刻消费,避免自身缓冲。这几条叠加,才能把 TPOT 稳定在 20–40 ms,而不是偶发 40 ms 抖动。3.4 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 画握手 | 三次握手/四次挥手的 seq/ack 变化与状态机 |
| TIME_WAIT | 60000 > 55295 → 端口耗尽 |
| 吞吐公式 | 640 KB/s;BDP 95.4 MB |
| 弱网对比 | BBR 吞吐高 2–5 倍 |
| NODELAY | 第 2 个 chunk 延迟约 40 ms |
| cwnd 观察 | 丢包时乘性减小 |
- 画握手:手绘三次握手/四次挥手,标出 seq/ack 变化与两个方向的状态机。判据:能解释为什么第 2、3 步不能合并。
- TIME_WAIT 定量:给定 QPS=1000 的短连接服务,估算端口耗尽时间。判据:60×1000=60000 > 约 55295,判定必然耗尽,并给出连接池方案。
- 吞吐公式:窗口 64 KB、RTT 100 ms,算理论吞吐;再算 1 Gbps×100 ms 所需 BDP。判据:约 640 KB/s 与约 95.4 MB。
- 弱网对比:用 tc netem 造 100 ms+1% 丢包,对比 CUBIC/BBR 下载同一文件的用时与吞吐。判据:BBR 吞吐通常高 2–5 倍,并能解释原因。
- NODELAY 实验:跑上面的 Python 脚本,复现 40 ms 间隔。判据:能观察到未设 NODELAY 时第 2 个 chunk 延迟约 40 ms。
- 拥塞窗口观察:在真机用 ss -tin 观察 cwnd 随传输的变化,解释它为何在丢包时刻骤降。判据:能关联到 CUBIC 的乘性减小。
4. TLS 与安全传输
学习路径
- 读 4.1:数清 TLS 1.3 只要 1 RTT、1.2 要 2 RTT
- 跑内置代码,用 openssl 观察证书链与所选密码套件
- 完成练习自测:解释 0-RTT PSK 的重放风险与 mTLS 场景
- 对接 M4:给网关配好证书并验证握手成功
核心知识点详解
- TLS 1.3 比 1.2 少一次往返:TLS 1.2 需 2 RTT(ServerHello 后还要 ChangeCipherSpec / Finished),1.3 精简为 1 RTT,会话恢复时最多 0-RTT。移动端长距离下少 1 RTT = 直接省一次往返时延,对 TTFT 体感影响明显。
- 0-RTT PSK 的重放代价:0-RTT 允许客户端在首包就带数据,但这段早到数据可被攻击者重放,不能用于下单 / 写库等副作用操作;只读幂等查询或配合服务端防重放(early data 令牌)才安全。
- 证书链验证与到期告警:服务器下发完整证书链,客户端逐级验证到受信根;用
openssl s_client -connect host:443 -servername host看链与套件。证书剩余 < 30 天应告警,避免过期瞬间全线中断。
学习路径
- 读 4.2:用抓包确认 session_reused 与会话复用时延
- 跑内置代码,观察复用后握手 < 5ms 的现象
- 完成综合练习:评估后量子 X25519MLKEM768 的接入影响
- 对接 M4:为网关打开会话复用并压测握手成本
核心知识点详解
- 会话复用把握手压到 < 5ms:开启 TLS 会话复用(session ticket / session id)后,复用连接握手几乎零成本,同机压测可 < 5ms。用
curl -w %{time_appconnect}即可见复用与否的差异,复用时数值应明显下降。 - 后量子 X25519MLKEM768:为抵抗量子解密,标准在推 ML-KEM 混合后量子密钥交换;迁移要确认 CDN / 网关 / 客户端都支持且 TLS 版本为 1.3,否则普遍握手不下来就协商不到后量子套件。
4.1 TLS 1.3 握手:为什么只需 1 个 RTT
textTLS 1.3 完整握手(1-RTT)
Client ── ClientHello ─────────────────► Server
(支持的套件 + key_share + 随机数)
Client ◄─ ServerHello + {证书} + {Finished} Server
(选定的套件 + 自己的 key_share + 签名证书)
Client ── {Finished} + 应用数据 ───────► Server
─────────────────────────────────────────────
特点:服务端在第二个消息里就把证书、密钥交换参数、Finished 一起发过来,
把 TLS 1.2 的两次往返压到一次。
TLS 1.3 会话复用 / PSK(0-RTT)
Client ── ClientHello + 早期数据 ───────► Server ← 0 往返即可发数据
─────────────────────────────────────────────
代价:早期数据可被重放攻击!所以只能用于「幂等」请求(GET);
非幂等操作(下单、写入)绝不能放进 0-RTT 数据里。
TLS 1.2(对比)
ClientHello → ← ServerHello+证书+ServerKeyExchange+ServerHelloDone
→ ClientKeyExchange+ChangeCipherSpec+Finished → ← Finished
共 2 个 RTT,且密钥协商与证书验证分两部分,需要更多次交互。
- 证书链验证:客户端拿到的是叶子证书(你的域名),需要沿签发链(中间 CA → 根 CA)验证到本地信任的根证书。证书链不完整是最常见的 TLS 故障——服务端只发叶子证书、不发中间证书,在浏览器上可能正常(浏览器会自己补全),但在某些客户端上直接失败。排查:
openssl s_client -connect host:443 -showcerts看链是否完整。 - SNI(Server Name Indication):ClientHello 里会带上目标域名(明文),让一台服务器托管多个 HTTPS 站点。但明文 SNI 泄露了用户访问的域名,因此有了 ECH(Encrypted Client Hello)来加密它。
- mTLS(双向认证):不仅服务端出示证书,客户端也要。用于服务间调用(微服务、Agent 调用工具服务)。在 Hamauls Orion 里,gRPC 内部服务用 mTLS 是最佳实践。
- 证书自动化:用 certbot / ACME 自动续期。「证书过期」是运维事故排行榜前列,必须自动化。监控上要做「证书剩余天数 < 30 天」的告警。
bash# 手工完成一次 TLS 握手并解读(这是最好的学习方式)
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3
# 在输出里重点看这几行:
# Protocol : TLSv1.3
# Cipher : TLS_AES_128_GCM_SHA256 ← 套件(1.3 已移除不安全的算法)
# Verify return code: 0 (ok) ← 证书链验证结果,0 才是 ok
# Server Temp Key: X25519, 253 bits ← 密钥交换(1.3 常用 ECDHE)
# ---
# Certificate chain ← 是否有完整的中间证书
# 0 s:CN = api.example.com
# 1 i:C = US, O = Let's Encrypt, CN = R3 ← 中间 CA(重要!)
# 2 i:O = ISRG, CN = ISRG Root X1 ← 根 CA
# 测量握手耗时(1.3 应明显小于 1.2)
for v in tls1_2 tls1_3; do
echo -n "$v: "
for i in 1 2 3; do
openssl s_client -connect api.example.com:443 -$v </dev/null 2>/dev/null >/dev/null
done
# 用 time 或 curl -w "%{time_appconnect}" 得到具体数字
done
# 检查证书剩余有效期(放进监控)
echo | openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -dates -subject
python# 会话复用:把 TLS 握手从 1 RTT 降到 0 RTT(Python 标准库即可验证)
import ssl, socket, time
ctx = ssl.create_default_context()
sess = None
for i in range(2):
with socket.create_connection(('api.example.com', 443)) as s:
t0 = time.perf_counter()
with ctx.wrap_socket(s, server_hostname='api.example.com',
session=sess) as ts:
sess = ts.session # 保存会话票据,下次复用
reused = ts.session_reused
print(f'第 {i+1} 次: reused={reused} 耗时={(time.perf_counter()-t0)*1000:.1f} ms')
# 预期输出(同城):
# 第 1 次: reused=False 耗时=118.4 ms <- 完整 1-RTT 握手
# 第 2 次: reused=True 耗时= 1.9 ms <- PSK 恢复,省掉证书交换与密钥计算
# 关键:把 session 缓存起来(连接池/进程级),跨请求复用可省约 1 个 RTT。
# 注意 0-RTT 早数据有重放风险,只对幂等请求启用。
X25519MLKEM768 混合密钥交换,2026 年主流云与浏览器基本铺开 —— 你的服务端证书链、TLS 库版本要确认支持,否则会出现握手协商失败。③ mTLS 与零信任:服务网格(Istio/Linkerd)默认给每个 Pod 签发短期证书做 mTLS,证书有效期常短到 24 小时以内,靠自动轮转;这正是「证书过期」事故的现代解法。4.2 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 手工握手 | Protocol=TLSv1.3,Verify code=0 |
| 1.2 vs 1.3 | 1.3 少约 1 个 RTT |
| 会话复用 | 第 2 次 < 5 ms(reused=True) |
| 0-RTT 风险 | 下单/写入/转账不可放进早数据 |
| 证书链 | 缺中间证书导致验证失败 |
- 手工握手:用 openssl s_client -tls1_3 连一个真实站点,读出协议、套件、临时公钥类型、证书链层数与 Verify 结果。判据:4 个字段全对,且 Verify code = 0。
- 1.2 vs 1.3:同一站点分别用 -tls1_2/-tls1_3 测 time_appconnect。判据:1.3 明显小于 1.2(同城可差 1 个 RTT)。
- 会话复用:跑上面的 Python 脚本,观察 session_reused 从 False 到 True 与耗时骤降。判据:第 2 次耗时降到 5 ms 以内。
- 0-RTT 风险:写出 3 个绝不能放进 0-RTT 早数据的操作,并说明重放后果。判据:下单/写入/转账等非幂等操作,重放会造成重复副作用。
- 证书链诊断:人为只配叶子证书(去掉中间证书),用 s_client 观察验证失败。判据:能复现验证错误并说明浏览器为何可能「看起来正常」。
5. HTTP 演进与流式协议选型
学习路径
- 读 5.1:梳理 1.1 到 3 的演进逻辑与多路复用
- 跑内置代码,用 ALPN 确认协商到 h2
- 完成练习自测:说清 TCP 层 HOL 与 HTTP/2 的取舍
- 对接 M4:让网关对外跑 HTTP/2 + SSE
核心知识点详解
- HTTP/1.1 每域最多 6 连接:浏览器对同域默认约 6 条 TCP,并发高时在应用层排队;HTTP/2 用一个连接承载并行 stream,消除应用层队头阻塞,配合 HPACK 头压缩显著省带宽。
- HTTP/3 用 QUIC 解掉 TCP 层 HOL:HTTP/2 仍有 TCP 层的队头阻塞——一个丢包卡整个连接;HTTP/3 跑在 QUIC(UDP)上,每个 stream 独立流控、不受其他流丢包影响,适合移动弱网,代价是需 ALPN 协商
h3与 UDP 支持。 - 常见坑:网关根本没跑在 h2/h3 上:组件(代理 / LB / CDN)若未开 ALPN 或各自建池,会话会退回 HTTP/1.1,多路复用与 0-RTT 全部失效;用
curl -I -w %{http_version}实测协商版本,而不是假设已经在用 h2。
学习路径
- 读 5.2:对比 SSE 续传、WebSocket 与 gRPC 四种流式模式
- 跑内置代码,实现一条 SSE 并验证 Last-Event-ID 续传
- 完成练习自测:写清 X-Accel-Buffering:no 与心跳防代理超时
- 对接 M4:为网关交付可续传的流式输出
核心知识点详解
- SSE 单向续传是 LLM 默认答案:SSE 走普通 HTTP +
text/event-stream,天然支持 Last-Event-ID 断点续传、跨代理兼容性好、实现极简;LLM 输出本就是「服务器→客户端单向推送」,选它最划算。 - WebSocket 双向但更复杂:全双工、需 Upgrade 握手,多数 LB/代理需显式配置才放行,断线重连要自己实现;只有需要客户端双向实时(如 Agent 交互)才值得引入。
- gRPC streaming 适合对内:gRPC 四种模式(unary / server / client / bidi stream)跑在 HTTP/2 + protobuf;LLM 网关到模型常用 server-streaming,但浏览器不支持原生 gRPC(需 gRPC-Web / Connect),所以「对外 SSE、对内 gRPC」成为常见分层。
学习路径
- 读 5.3:分清强缓存与协商缓存,以及 ETag 与 304
- 跑内置代码,为确定性请求开缓存并统计命中
- 完成练习自测:评估 prefix cache 省 80–95% 的前提
- 对接 M4:给网关加语义缓存并记录成本节省
核心知识点详解
- 强缓存与协商缓存:强缓存
Cache-Control: max-age本地直接命中、不发请求;协商缓存ETag / 304需回源但命中后只回 304 无 body。LLM 给确定性请求开缓存能省大量带宽与重复推理。 - prefix cache 与语义缓存:同前缀 prompt 可共享 KV 缓存、中间重复 token 省 80–95% 计算;语义缓存按相似度阈值(0.92–0.97)命中,但要配合
X-Accel-Buffering: no保流式不被代理缓冲。 - 常见坑:缓存命中前提是「确定性」:只有确定性的请求才可安全缓存;带随机 / 温度 / 时间戳的请求缓存会出错。给带副作用请求开缓存会把二次结果当首次返回,务必用请求哈希 + 幂等键一起收紧缓存键范围。
学习路径
- 读 5.4:了解 MCP Streamable HTTP 与 Connect 浏览器直连
- 跑内置代码,把对外 SSE、对内 gRPC 的接线写完
- 完成综合练习:为一组接口做协议选型并解释取舍
- 对接 M4:用选型结果填进网关的协议矩阵
核心知识点详解
- MCP Streamable HTTP 是 Agent 工具的主流:MCP 提供 streamable HTTP 单端点协议:POST 启动 + 经 SSE 回传流,一次请求里可携带工具调用结果;浏览器侧 Connect / streaming 是 gRPC-Web 的替代,能减少复杂协议栈。
- 常见坑:协议矩阵与流控没对齐:选型表定了协议却没把心跳、Last-Event-ID、断线重试与代理超时一并给定,线上会忽然断流。填协议矩阵时把「建连 RTT、续传机制、断线行为、LB 兼容」四列一起填完才算选型完成。
5.1 HTTP/1.1 → HTTP/2 → HTTP/3 的演进逻辑
| 版本 | 传输层 | 并发方式 | 队头阻塞 | 头部压缩 | 典型问题 |
|---|---|---|---|---|---|
| HTTP/1.1 | TCP | 每域名 6 个连接 + 请求排队 | 有(应用层 + TCP 层) | 无 | 并发靠多连接,握手成本高 |
| HTTP/2 | TCP | 单连接多路复用(stream) | TCP 层仍有(见下) | HPACK | 丢包时所有 stream 一起卡住 |
| HTTP/3 | QUIC(基于 UDP) | 独立流,应用层多路复用 | 基本消除 | QPACK | UDP 可能被企业防火墙拦截 |
- HTTP/1.1 的困境:一个连接同一时刻只能处理一个请求。浏览器于是对同一域名开 6 个连接——但每个连接都要握手,而且 6 个并发仍有上限。域名分片(把静态资源放不同域名)就是这么来的 hack。
- HTTP/2 的多路复用:一个 TCP 连接上并发多个 stream,每个 stream 有独立 id,数据被切成帧(frame)交错发送。这解决了应用层的队头阻塞,也省掉了多次握手。但 TCP 层仍有 HOL:一旦有包丢失,TCP 必须等重传,所有 stream 都被阻塞。
- HTTP/3 用 QUIC 解决 TCP HOL:QUIC 在 UDP 之上实现可靠传输,但每个 stream 独立处理丢失与重传——stream A 丢包不影响 stream B。此外 QUIC 把 TLS 1.3 集成进握手(1-RTT 甚至 0-RTT),并支持连接迁移(换网络不断连,对移动端极有价值)。
- HTTP/2 的一个反直觉效果:多路复用让「把小资源合并成大文件」的老优化变成反模式。因为合并文件会降低缓存粒度、并可能阻塞首个字节。优化手段会随协议版本变化——这是「懂原理」比「记技巧」更重要的例证。
bash# 一句话验证服务端到底支持哪个版本(含 h2 协商)
curl -sI --http2 https://api.example.com/health | head -1
# HTTP/2 200 <- Upgrade 协商成功
curl -sI --http2-prior-knowledge https://api.example.com/health | head -1
# HTTP/2 200 <- 跳过 Upgrade,直接 h2c/h2(服务端需支持)
curl -sI --http3 https://api.example.com/health | head -1
# HTTP/3 200 <- 走 UDP/QUIC(curl 需编译 ngtcp2/quiche)
# 若 --http3 报 "HTTP/3 not supported",说明本地 curl 未编译 QUIC 支持。
# 看 ALPN 协商出的协议
openssl s_client -connect api.example.com:443 -alpn h2,http/1.1 </dev/null 2>/dev/null | grep ALPN
# ALPN protocol: h2 <- 服务端优先 h2
# 验证 HTTP/2 是否真的单连接多路复用(两条请求复用同一连接)
curl -s --http2 -o /dev/null -w 'connects=%{num_connects}\n' \
https://api.example.com/a https://api.example.com/b
# connects=1 <- HTTP/2 多路复用生效
stdio/SSE 演进到 Streamable HTTP(单个 HTTP 端点,POST 发请求、SSE 或 JSON 流式回响应),解决「SSE 需要两个端点 + 长连接难负载均衡」的问题;远程 MCP server 现多用流式 HTTP 部署在网关后。② Connect(Buf 出品)是「gRPC 但兼容普通 HTTP/1.1 与浏览器」的 RPC 协议:同一份 protobuf 定义可产出 gRPC / gRPC-Web / Connect 三种客户端,无需代理即可浏览器直连,2026 年在 BFF 层被大量采用。③ 结论:对外用 SSE/Connect、对内用 gRPC,正在成为 AI 服务的标准分层。5.2 SSE / WebSocket / gRPC streaming / 长轮询:怎么选
| 方案 | 方向 | 协议基础 | 断线重连 | 代理友好 | 适合 |
|---|---|---|---|---|---|
| 短轮询 | 客户端拉 | HTTP | 天然 | 最好 | 低频状态查询(成本高,不推荐) |
| 长轮询 | 客户端拉(挂起) | HTTP | 需自己实现 | 好 | 兼容性要求极高的老环境 |
| SSE | 服务端单向推 | HTTP/1.1 或 2(text/event-stream) | 内置 Last-Event-ID | 好(但需关缓冲) | LLM 流式输出(首选) |
| WebSocket | 双向 | HTTP Upgrade → 独立协议 | 需自己实现心跳与重连 | 中(部分代理/网关不支持) | 实时协作、双向交互、语音 |
| gRPC streaming | 双向(四种模式) | HTTP/2 + protobuf | 需自己实现(有 retry 策略) | 差(需支持 h2 的代理) | 服务间调用、内部 RPC |
python# SSE 服务端最小实现(含心跳、断点续传、正确头部)
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json, time
app = FastAPI()
@app.get('/v1/chat/stream')
async def stream(req: Request, q: str):
async def gen():
last_id = req.headers.get('Last-Event-ID')
start = int(last_id) + 1 if last_id else 0
# 说明:真实实现要能从 start 处续传(依赖服务端保存生成进度或从缓存重放)
try:
for i, tok in enumerate(generate(q)):
if i < start: continue
# SSE 格式:id / event / data / 空行分帧
yield f'id: {i}\n'
yield 'event: token\n'
yield f'data: {json.dumps({"t": tok}, ensure_ascii=False)}\n\n'
if i % 8 == 0:
yield ': ping\n\n' # 注释行充当心跳,防代理超时断开
except asyncio.CancelledError:
raise # 客户端断开 → 正确清理
yield 'event: done\ndata: {}\n\n'
return StreamingResponse(gen(), media_type='text/event-stream', headers={
'Cache-Control': 'no-cache, no-transform', # no-transform 阻止代理改内容
'X-Accel-Buffering': 'no', # 关键:让 Nginx 不缓冲
'Connection': 'keep-alive',
})
- 为什么 LLM 流式首选 SSE:① 单向推送足够(模型只往外吐 token);② 基于普通 HTTP,代理、CDN、网关兼容性最好;③ 浏览器 EventSource 与 fetch+ReadableStream 都原生支持;④ 自带
Last-Event-ID重连语义。WebSocket 的双向能力在纯流式输出场景是浪费,而它的代理兼容性与重连复杂度是额外成本。 - SSE 的三个必踩坑:① Nginx / 网关缓冲——必须设置
X-Accel-Buffering: no或proxy_buffering off,否则你会看到「内容一次性全部出现」;② 中间设备超时——长时间无数据会被断开,必须发心跳(注释行或 event);③ HTTP/1.1 的 6 连接上限——同一域名下并发过多流式请求会被排队(HTTP/2 可缓解)。 - 什么时候必须用 WebSocket:需要客户端持续上传(语音输入、实时协作编辑、游戏)、或需要极低延迟的双向交互时。此时 SSE + 一个独立的上行 HTTP 接口也可以替代,但会多一次往返。
- gRPC streaming 的定位:它是服务间的最佳选择(protobuf 高效、四种模式齐全、原生流控),但浏览器直连需要 gRPC-Web + 代理,因此对外一般用 SSE/HTTP,对内用 gRPC。Hamauls Orion 的架构就是「对外 SSE、对内 gRPC」。
javascript// 浏览器端 SSE:断线自动续传 + 心跳看门狗 + 错误降级
function connect(url, onToken) {
let lastId = localStorage.getItem('sse-last-id') || '';
const es = new EventSource(url + (lastId ? '?last_event_id=' + lastId : ''));
let lastMsg = Date.now();
es.addEventListener('token', (e) => {
lastMsg = Date.now();
const { t } = JSON.parse(e.data);
lastId = e.lastEventId; // 服务端发的 id: 字段
localStorage.setItem('sse-last-id', lastId);
onToken(t);
});
// 心跳看门狗:超过 30 s 无任何事件就主动重连(防中间设备静默断连)
const wd = setInterval(() => {
if (Date.now() - lastMsg > 30000) { es.close(); clearInterval(wd); connect(url, onToken); }
}, 5000);
es.addEventListener('done', () => { es.close(); clearInterval(wd); });
es.onerror = () => { /* EventSource 会自动重连;持续失败则降级到轮询 */ };
return es;
}
// 关键数字:EventSource 默认重连约 3 s,可用服务端 retry: 字段改为 1000–2000 ms;
// 服务端每 8–15 s 发一次注释心跳(": ping"),网关 60 s 空闲超时不会误断。
5.3 HTTP 缓存与条件请求:省钱的直接手段
- 强缓存(Cache-Control / Expires):在有效期内浏览器/代理直接使用本地副本,不发请求。
max-age是相对时间(推荐),Expires是绝对时间(受时钟影响,不推荐)。 - 协商缓存(ETag / Last-Modified):过期后带
If-None-Match/If-Modified-Since询问服务端,未变则返回 304(无 body)。省的是带宽,不是往返。 no-transform对 AI 流式至关重要:有些代理会压缩或改写响应体,导致 SSE 分帧被破坏。加这个指令能阻止。- 在 AI 服务里的应用:① 对确定性请求(相同 prompt + 相同参数 + temperature=0)做响应缓存,直接省一次模型调用——这是成本优化里最直接的一块;② 对 embedding 请求做缓存(同一文本的向量是确定的);③ 语义缓存(用相似度判断是否可复用),需要谨慎设阈值并做安全评估。
python# 确定性请求缓存 + 语义缓存的骨架(Hamauls Orion 的成本优化组件之一)
import hashlib, json, time
def cache_key(model, messages, **params):
"""确定性请求的缓存键:模型 + 消息 + 影响输出的所有参数都必须进键
注意:temperature > 0 的请求本质是随机的,缓存会改变语义,
所以只对 temperature == 0 做严格缓存。"""
if params.get('temperature', 0) != 0:
return None # 非确定性请求不进严格缓存
blob = json.dumps({'m': model, 'msg': messages,
'p': {k: params[k] for k in sorted(params)}},
sort_keys=True, ensure_ascii=False)
return 'llm:' + hashlib.sha256(blob.encode()).hexdigest()
class TTLRedisCache:
"""带 TTL 与命中率统计的缓存包装(配合第 3 阶段的 LRU 使用)"""
def __init__(self, redis, ttl=86400):
self.r, self.ttl, self.hits, self.total = redis, ttl, 0, 0
def get(self, key):
if key is None: return None
self.total += 1
v = self.r.get(key)
if v is not None: self.hits += 1
return v
def set(self, key, val):
if key: self.r.setex(key, self.ttl, val)
@property
def hit_rate(self): return self.hits / self.total if self.total else 0.0
bash# 条件请求(协商缓存)实测:第二次响应应为 304 且无 body
curl -sI https://api.example.com/config | grep -i etag
# ETag: "abc123" <- 记下这个值
curl -s -o /dev/null -w 'status=%{http_code} size=%{size_download}\n' \
-H 'If-None-Match: "abc123"' https://api.example.com/config
# 预期输出:status=304 size=0 <- 省带宽,但仍有一次往返
# 对比强缓存 Cache-Control: max-age=86400:第二次请求根本不到服务端。
# 对 LLM:temperature=0 + 相同输入的响应可强缓存(用确定性缓存键)
curl -s -o /dev/null -w 'status=%{http_code} time=%{time_total}\n' \
-H 'X-LLM-Cache-Key: sha256:...' https://api.example.com/v1/chat
# 命中缓存时 time_total 通常 < 10 ms;未命中(真调模型)为 500–3000 ms。
5.4 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 版本探测 | ALPN 报 h2 / HTTP/3 走 UDP |
| 多路复用 | num_connects = 1 |
| 流式缓冲 | X-Accel-Buffering: no 修复攒批 |
| 断线续传 | 重复 token 数 0–1 |
| 缓存收益 | 命中时延 < 10 ms |
| 选型对比 | SSE 首字不劣于长轮询,空请求 0 |
- 版本探测:对一个真实站点用 curl 分别测 --http2/--http3,并看 ALPN。判据:能报出协商出的协议,判断是否支持 HTTP/3。
- 多路复用验证:用 --http2 一次请求两个 URL,看 num_connects 是否为 1。判据:HTTP/2 下应为 1(复用)。
- 流式被缓冲诊断:写一个最小 SSE 服务,前面套 Nginx 默认配置,观察内容是否被攒批;再加 X-Accel-Buffering: no 对比。判据:能复现并修复「一次性全部出现」。
- 断线续传:用浏览器脚本模拟中断(关闭再重连),确认从 Last-Event-ID 之后续传。判据:重复 token 数为 0 或可容忍 1 个。
- 缓存收益:为 temperature=0 的接口加确定性缓存,压测命中率与 P50 延迟。判据:命中时延 < 10 ms,报告命中率。
- 选型对比:同一场景分别用短轮询/长轮询/SSE 实现,测首字延迟。判据:SSE 首字不劣于长轮询,且空请求数为 0。
6. RPC、序列化与可靠调用的工程学
学习路径
- 读 6.1:理解 protobuf 体积小与字段号永不复用
- 跑内置代码,定义 .proto 并生成客户端与服务端骨架
- 完成练习自测:用 reserved 演练向后兼容的字段演进流程
- 对接 M4:写好对内 gRPC 的协议定义与流式模式
核心知识点详解
- protobuf 用字段号省体积:protobuf 用整数字段号(tag)代替字段名字符串,体积小 3–10 倍、序列化快 2–10 倍;但字段号不能复用,删除字段要保留并用
reserved标记,否则老客户端会解码错位。 - proto3 向后兼容靠字段号与 wire 类型:field number + wire type 变化会破坏兼容;加字段用未用过的号、改类型视为不同字段。纪律:不复用号、不改变已有号类型、
reserved保留废弃号,才能放心改 .proto。 - gRPC 四种流式与浏览器限制:unary / server-stream / client-stream / bidi-stream 四种模式,对 LLM 输出常用 server-streaming;但浏览器不支持原生 gRPC(需 gRPC-Web / Connect),proto 只在服务内部、对外仍走 SSE。
学习路径
- 读 6.2:用池大小≈QPS×耗时×1.5 估算连接数
- 跑内置代码,实测 HTTP/2 每后端 1–2 连接是否够
- 完成练习自测:对比 L4、L7 与客户端侧三种负载均衡
- 对接 M4:让 keepalive_expiry 小于 LB 超时以避免断连
核心知识点详解
- 池大小公式 ≈ QPS × 耗时 × 1.5:并发连接需求 ≈ QPS × 平均耗时,留 ~1.5 倍余量应对 P99 长尾;HTTP/2 本身每个后端只需 1–2 条连接(多路复用),无需 1:1 建池。估池后先压测再收紧。
- 长连接扩容比想得难:把池或半池调大,TCP 半连、TIME_WAIT、NAT 表、内核连接表都会被压爆;扩容要一起调整
somaxconn、fs.file-max、连接空闲超时清理,否则数字上去了连接反而崩。 - 常见坑:keepalive 时间比 LB 超时长:客户端 keepalive expir 若长于 LB 空闲超时,空闲连接被 LB 静默切断,下次复用读到 EOF / 连接重置。让
keepalive_expiry < LB 超时才能消除「闲置后首请求失败」的诡异错误。
学习路径
核心知识点详解
- 超时预算决定哪里可放弃:端到端预算(如 P99 < 8s)分配到每一跳(模型 / 检索 / 工具),每跳之和 ≤ 上游预算;任一跳按自己预算用
asyncio.wait_for取消,并把剩余时间逐级下传。 - 重试只对「可重试」错误:只重试 408 / 429 / 5xx、连接错与超时;401/403/400 属于永久失败,重试只会浪费配额并放大故障。用指数退避 + 抖动 + 重试预算(如最多 3 次、总窗口 < 1s)防风暴。
- 幂等键是重复副作用的唯一防线:同一请求携带同
Idempotency-Key,服务端用 DB 唯一键只执行一次;没有它,超时后的自动重试就可能重复下单 / 重复生成消耗 token。熔断三态(CLOSED / OPEN / HALF-OPEN)、令牌桶按 token 限流、有界背压队列共同保证不雪崩。
学习路径
核心知识点详解
- 每一跳超时都要进预算表:预算表把「P99 < 8s」拆到 DNS / TCP / TLS / 模型 / 检索 / 输出,逐跳
wait_for并取消下传;验收是注入慢下游后整体仍能在预算内降级,而不是整链卡死。 - 常见坑:只测 happy path:不注入延迟 / 丢包 / 500,超时与熔断的代码可能根本没生效。三个故障用例(不雪崩 / 延迟有界 / 副作用不重复)是硬门槛,缺一个都不能验收。
6.1 序列化与 gRPC 的四种流式模式
| 维度 | JSON over HTTP | Protobuf over gRPC |
|---|---|---|
| 体积 | 较大(字段名重复) | 小 3–10 倍(字段用数字 tag,无冗余字段名) |
| 序列化速度 | 中等 | 快 2–10 倍 |
| 模式演进 | 无强制约束,易不兼容 | 字段号机制 + 向后兼容规则 |
| 人类可读 | 是(调试友好) | 否(需工具解码) |
| 浏览器直连 | 原生 | 需 gRPC-Web + 代理 |
| 流式 | SSE / 分块 | 原生四种模式 |
| 典型用途 | 对外 API(LLM 接口、Web) | 内部服务间调用 |
protobuf// Hamauls Orion 的内部服务契约(节选):注意字段号一旦发布就不能改用途
syntax = "proto3";
package hamauls_orion.v1;
service Retriever {
// 一元:单次查询
rpc Search (SearchRequest) returns (SearchResponse);
// 服务端流:边检索边推送候选(降低首字节延迟)
rpc SearchStream (SearchRequest) returns (stream ScoredDoc);
// 客户端流:批量上传文档(大文件分片)
rpc Ingest (stream DocChunk) returns (IngestSummary);
// 双向流:交互式调试(一边发查询一边收中间结果)
rpc Debug (stream DebugCmd) returns (stream DebugEvent);
}
message SearchRequest {
string query = 1;
int32 top_k = 2;
repeated string filters = 3; // 新增字段必须用新编号,永不复用旧编号
}
message ScoredDoc {
string doc_id = 1;
float score = 2;
string snippet = 3;
}
reserved,否则新老版本会错位解析(这是最隐蔽的线上事故);② 新增字段必须是 optional 语义(proto3 默认行为),老客户端会忽略;③ 不要改字段类型(如 int32 → string);④ 不要依赖字段顺序或 repeated 的顺序。这些规则与你在 Java 里做接口兼容的经验一致,但因为没有强类型编译期检查,更容易踩坑。python# 量一量 protobuf vs JSON 的真实体积差(决定内部 RPC 用什么)
import json
obj = {'doc_id': 'd-000123', 'score': 0.87,
'snippet': 'Hamauls Orion 检索结果片段',
'tags': ['rag', 'vector', 'rerank'], 'ts': 1730000000}
js = json.dumps(obj, ensure_ascii=False)
print('JSON 字节数 :', len(js.encode()))
# protobuf 近似:字段名不传输,只有 tag + 值(varint/length-delimited)
proto_approx = (2 + 7) + (1 + 4) + (1 + len('Hamauls Orion 检索结果片段'.encode())) \
+ (3 * 5) + (1 + 4)
print('proto 近似字节:', proto_approx)
print('压缩比 :', round(len(js.encode()) / proto_approx, 1), 'x')
# 预期:JSON 约 155 字节,proto 约 40 字节,压缩比约 3.5x
# 结论:高 QPS 内部调用用 protobuf;对外(浏览器/调试)用 JSON。
6.2 连接池、负载均衡与服务发现
- 连接池的必要性:建连成本(TCP + TLS)在跨地域场景可达数百毫秒。池化后这部分成本被摊薄到接近零。池大小不是越大越好——过大会占用服务端连接资源、增加排队,并且向下游暴露更多并发。经验起点:
并发请求数 = 池大小 × 单连接吞吐,先测出单连接吞吐再定池大小。 - HTTP/2 场景的特殊性:一个 HTTP/2 连接可以承载数百个并发 stream,所以「池」的意义变了——从「并发度」变成「多后端负载分担」。常见做法是「每后端 1–2 条连接 + 轮询/最少连接负载均衡」。
- 负载均衡的三个层次:① L4(传输层,如 LVS / NLB,按连接转发,不理解协议内容);② L7(应用层,如 Nginx / Envoy / ALB,能按路径/头部路由、做超时与重试);③ 客户端侧(SDK 内做负载均衡 + 服务发现,如 gRPC 的 resolver + pick_first/round_robin)。AI 服务因为请求耗时长、差异大,客户端侧 LB(最小连接数 / 最少延迟)通常优于简单轮询。
- 服务发现:静态配置(配置文件)→ 注册中心(Consul / etcd / Nacos)→ 平台原生(K8s Service + Endpoints)。K8s 环境注意
kube-proxy的 iptables/ipvs 模式差异,以及 headless service 用于客户端侧 LB。 - 长连接的负载均衡难题:连接建立后就固定到某个后端了,扩容时新实例拿不到流量(除非断连重连)。解法:定期主动重连(带抖动)、用 L7 代理做连接迁移、或改用短连接 + 高效建连(QUIC 0-RTT)。
python# HTTP 客户端连接池的工程配置(httpx 示例,aiohttp/requests 思路一致)
import httpx
limits = httpx.Limits(
max_connections=200, # 全局并发上限(保护自己与下游)
max_keepalive_connections=50, # 保活连接数(探查后按单连接吞吐调)
keepalive_expiry=30.0, # 空闲 30 s 回收,避免被对端/LB 静默断开
)
timeout = httpx.Timeout(connect=1.0, read=30.0, write=5.0, pool=2.0)
client = httpx.AsyncClient(limits=limits, timeout=timeout, http2=True)
# 为什么这些数字要分开设:
# connect=1.0s -> 建连慢就直接失败(不要拖到 read 超时)
# pool=2.0s -> 连接池排队超时,防止请求无声堆积(背压的一种)
# keepalive_expiry 要 < 负载均衡的空闲超时(常见 60 s,但要留余量)
# 经验起点:池大小 约等于 目标 QPS x 单请求平均耗时(s),再乘 1.5 安全系数。
# 例:200 QPS x 0.05 s = 10 并发 -> 池取 15–30 足够;盲目设 500 只会压垮下游。
6.3 超时、重试、幂等、熔断、限流、背压:AI 服务的可靠性六件套
这一节是本阶段最实用的部分。你的 Java 经验在这里高度可迁移,但 AI 服务有它自己的特点:单次请求耗时长(秒级到分钟级)、成本高(每次调用真金白银)、且天然不确定(可能超时也可能慢而有结果)。这三点让传统的「短超时 + 激进重试」策略完全失效。
(1)超时预算:自上而下分配,而不是各自随便设
text超时预算的正确设计方式(Hamauls Orion 的端到端链路)
用户请求总 SLA:P99 < 8 s
├─ 网关鉴权 + 限流 50 ms
├─ 查询改写(小模型 / 规则) 300 ms ← 预算必须显式分配
├─ 检索(向量 + BM25 + 融合 + 重排) 800 ms
│ ├─ 向量召回 200 ms
│ ├─ BM25 召回 100 ms
│ └─ 交叉编码器重排 500 ms
├─ 工具调用(Agent 场景,可多次) 2000 ms ← 每次调用单独限时
├─ 模型生成(TTFT + 流式) 4000 ms
└─ 余量(应对抖动) 850 ms
规则:
① 下游超时之和 + 余量 ≤ 上游超时。绝不允许「下游比上游还长」——
那会导致上游已经放弃、下游还在白白计算,浪费算力。
② 每层都设超时,且越靠内层越短(因为外层要留时间处理失败与降级)。
③ 超时值基于实测分布设定(P99 的 1.5–2 倍),而不是拍脑袋。
④ 超时必须能「真正取消」下游工作。Python 里注意 asyncio.CancelledError
的传播,以及在同步阻塞调用上是无法取消的(要换成 async 或子进程)。
(2)重试:只重试该重试的,且必须带退避与抖动
pythonimport asyncio, random, httpx
# 可重试 vs 不可重试 —— 这个分类是重试逻辑的全部基础
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
NON_RETRYABLE_STATUS = {400, 401, 403, 404, 409, 422} # 重试只会浪费配额
class Budget: # 重试预算:防止重试风暴放大故障
def __init__(self, ratio=0.1):
self.ratio, self.total, self.failed = ratio, 0, 0
def allow(self):
return self.total == 0 or self.failed / self.total < self.ratio
def record(self, ok):
self.total += 1
if not ok: self.failed += 1
async def call_with_retry(fn, budget: Budget, tries=3, base=0.5, cap=8.0):
"""要点:① 只重试可重试错误 ② 指数退避 + 抖动(避免惊群同步重试)
③ 尊重 Retry-After 头 ④ 有全局重试预算,防止雪崩放大"""
last = None
for i in range(tries):
try:
r = await fn()
if r.status_code in NON_RETRYABLE_STATUS:
budget.record(False); return r # 不重试,直接返回
if r.status_code in RETRYABLE_STATUS:
if not budget.allow():
return r # 预算用尽,放弃重试
last = r
wait = min(cap, base * 2 ** i) * (0.5 + random.random() * 0.5)
ra = r.headers.get('Retry-After') # 服务端显式指示优先
if ra and ra.isdigit(): wait = max(wait, float(ra))
await asyncio.sleep(wait)
continue
budget.record(True); return r
except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
last = e
if not budget.allow(): break
await asyncio.sleep(min(cap, base * 2 ** i) * (0.5 + random.random() * 0.5))
raise RuntimeError(f'调用失败,已重试 {tries} 次: {last}')
# ⚠️ 最关键的一条:不可重试的错误绝不重试
# 401/403 是凭证问题;400/422 是参数问题;409 是状态冲突。
# 重试它们只会放大故障、烧掉配额。
(3)幂等性:让重试是安全的
- 幂等键(idempotency key):客户端为每个逻辑操作生成唯一 ID(如 UUID),服务端把它映射到「已处理结果」。同一个键重复到达时直接返回缓存结果,不重复执行。这是「至少一次投递 + 幂等处理」= 事实上恰好一次的标准做法。
- 在 Agent 场景尤其重要:Agent 会调工具(下单、发邮件、写数据库)。如果调用超时后重试,而第一次其实成功了,就会重复下单。所以所有有副作用的工具调用必须携带幂等键,服务端必须去重。这是 M14 里程碑里的硬性要求。
- 幂等的设计位置:① 数据库层——唯一索引 / UPSERT(最简单可靠);② 缓存层——用 idempotency key 存「处理中 / 已完成 + 结果」;③ 业务层——状态机(只允许特定状态迁移)。优先用数据库唯一约束,它是最不会出错的一层。
(4)熔断、限流与背压
| 机制 | 解决什么 | 实现要点 | AI 场景注意 |
|---|---|---|---|
| 超时 | 单次调用不超过预期 | 分层设置、真正可取消 | LLM 调用要区分 TTFT 超时与整体超时 |
| 重试 | 应对瞬时故障 | 只重试可重试、退避 + 抖动、预算 | LLM 长调用重试成本高,优先考虑降级到小模型 |
| 熔断 | 防止故障扩散 | 三态机:closed → open → half-open;基于错误率 | 对下游模型服务熔断后要走降级路径(缓存 / 小模型 / 模板回复) |
| 限流 | 保护自己不被压垮 | 令牌桶 / 漏桶 / 滑动窗口;按用户、按租户、按接口 | 按 token 数限流比按请求数更公平(长短请求成本差异巨大) |
| 隔离(舱壁) | 限制故障影响范围 | 线程池 / 信号量分区,不同租户或不同下游用不同池 | 多租户 SaaS 必须做,否则一个客户的高并发拖垮所有人 |
| 背压 | 让生产者感知消费者能力 | 队列水位、拒绝策略、流控信号(HTTP/2 window、gRPC flow control) | 流式输出的写入速度必须能被客户端消费速度约束 |
pythonimport asyncio, time
class CircuitBreaker:
"""熔断器:closed → open → half-open 三态
目的是「快速失败」,避免把请求都压在已经坏掉的下游上(那会让它更坏)"""
def __init__(self, fail_threshold=5, window=10.0, cool_down=30.0, half_open_trials=3):
self.fail_threshold, self.window = fail_threshold, window
self.cool_down, self.trials = cool_down, half_open_trials
self.state, self.fails, self.opened_at = 'closed', 0, 0.0
self.trial_ok = 0
def allow(self) -> bool:
if self.state == 'closed': return True
if self.state == 'open':
if time.monotonic() - self.opened_at >= self.cool_down:
self.state, self.trial_ok = 'half-open', 0
return True
return False
return True # half-open:放少量请求试探
def record(self, ok: bool):
if self.state == 'closed':
self.fails = 0 if ok else self.fails + 1
if self.fails >= self.fail_threshold:
self.state, self.opened_at = 'open', time.monotonic()
elif self.state == 'half-open':
if ok:
self.trial_ok += 1
if self.trial_ok >= self.trials: self.reset()
else:
self.state, self.opened_at = 'open', time.monotonic()
def reset(self):
self.state, self.fails, self.trial_ok = 'closed', 0, 0
class TokenBucket:
"""令牌桶限流:平均速率 + 突发容量。AI 场景建议按 token 数而非请求数限流"""
def __init__(self, rate, burst):
self.rate, self.burst = rate, burst
self.tokens, self.ts = burst, time.monotonic()
def take(self, n=1.0) -> bool:
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= n:
self.tokens -= n
return True
return False
class BoundedQueue:
"""背压:队列满时不是无限堆积,而是明确拒绝或阻塞
无限队列 = 延迟无限增长 + 内存爆掉,是最常见的设计错误"""
def __init__(self, cap, reject_when_full=True):
self.q, self.cap, self.reject = asyncio.Queue(maxsize=cap), cap, reject_when_full
self.rejected = 0
async def put(self, item):
if self.q.full() and self.reject:
self.rejected += 1
raise RuntimeError('queue full: 触发背压,请客户端退避重试')
await self.q.put(item)
在流式场景里,背压还有一层含义:如果客户端消费慢,服务端必须能感知并降低生成速度,否则会产生「生成了但发不出去」的积压。HTTP/2 与 gRPC 的 flow control 就是为这个设计的。
(5)把超时预算变成会真正取消的代码
python# 分层的超时预算:外层时间到,内层必须真的被取消(而不是继续空算)
import asyncio, time
async def fake_io(sec):
await asyncio.sleep(sec); return f'ok({sec})'
async def retrieve(q, budget_ms):
# 内部再拆预算:向量 200、BM25 100、重排 500,用整体截止时间约束
deadline = time.monotonic() + budget_ms / 1000
async def stage(name, ms, coro):
left = deadline - time.monotonic()
if left <= 0: raise asyncio.TimeoutError(name)
return await asyncio.wait_for(coro, timeout=min(ms / 1000, left))
parts = await asyncio.gather(
stage('vector', 200, fake_io(0.12)),
stage('bm25', 100, fake_io(0.08)),
return_exceptions=True, # 单路失败不拖垮整体
)
return [p for p in parts if not isinstance(p, Exception)]
async def handle():
t0 = time.monotonic()
try:
docs = await asyncio.wait_for(retrieve('q', 800), timeout=0.8)
print('检索完成', round((time.monotonic() - t0) * 1000, 1), 'ms', docs)
except asyncio.TimeoutError:
print('整体超时 -> 走降级(用缓存 / 仅 BM25)')
asyncio.run(handle())
# 预期输出:检索完成 120.x ms ['ok(0.08)', 'ok(0.12)']
# 关键点:① wait_for 会真正 cancel 内部协程(IO 必须是 async 的,
# 同步阻塞调用无法被取消);② gather(return_exceptions=True) 做部分降级;
# ③ 预算要留余量,别把各层之和写成上限(见 6.3 超时预算表)。
await request.is_disconnected() / 客户端断开事件)并主动 abort 生成;② 用 max_tokens 与流式早停;③ 网关侧对取消的请求记指标(cancel rate),它常是「GPU 利用率高但有效产出低」的真凶。6.4 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 超时预算 | 下游之和 + 余量 ≤ 上游 |
| 分类重试 | 只 408/429/5xx,退避 + 抖动 + 预算 |
| 幂等键 | 并发重复仅 1 条副作用 |
| 熔断 | open 期快速失败 < 5 ms |
| token 限流 | 长请求拒绝率更高 |
| 背压 | 队列满后 P99 不爆 |
- 超时预算表:为你的链路写出自上而下的预算表,并断言「下游之和 + 余量 ≤ 上游」。判据:每层有数字,且代码里可取消。
- 分类重试:实现只重试 408/429/5xx、带指数退避+抖动+全局预算的调用器,注入 500 验证行为。判据:不可重试错误不重试;重试有上限与抖动。
- 幂等键:给一个写操作加 idempotency key + DB 唯一索引,重复提交返回相同结果。判据:并发重复请求只产生 1 条副作用记录。
- 熔断验证:注入下游连续失败,观察熔断器从 closed→open→half-open→closed。判据:熔断期间请求快速失败(5 ms 内),恢复后放量试探。
- 按 token 限流:令牌桶按 token 数计费,验证长请求比短请求更受约束。判据:相同请求数下,长请求的拒绝率更高。
- 背压:有界队列满时返回 429 + Retry-After,压测观察延迟有界、内存稳定。判据:队列满后 P99 不随负载增长而爆炸。
7. 网络诊断与可观测
学习路径
- 读 7.1:用 curl -w 分层计时与 ss -tin 提炼诊断数据
- 跑内置代码,抓一次包定位重传率与 RTT 分位
- 完成练习自测:用分层排除法把慢卡在服务端之外
- 对接 M4:为网关配好 OpenTelemetry 全链路 trace
核心知识点详解
- curl -w 把时间拆到各层:
curl -w给出 DNS / connect / TLS / first-byte / total 分段;先用它定位是网络还是服务端慢,再决定是否 tcpdump 深挖,是最快的分层诊断入口。 - 重传率与 RTT 分位:
ss -tin读 retrans 与 rtt/rto;重传率 > 0.5% 报警,P50 与 P99 差异揭示排队抖动。单点 P99 高多半是丢包 / 重传 / 排队,不是服务端慢,配合 bufferbloat 指纹(延迟随流量涨)判读。 - 常见坑:拿总时间一刀切定位:「整段慢」无法归因;要按层切出 DNS / TCP / TLS / 首字节 / 传输分别计时,否则把代理 / 链路的锅误判给网关,修错地方。
学习路径
- 读 7.2:记住 TTFT P95 < 1–2s、TPOT P95 < 50ms 的预算
- 跑内置代码,用 Little 定律 20×3/0.7≈86 估并发槽
- 完成练习自测:按输出长度分桶统计分位
- 对接 M4:把 GPU 利用率 60–80% 作为容量基线
核心知识点详解
- TTFT / TPOT / 排队预算:TTFT P95 < 1–2s、TPOT P95 < 50ms、排队 P95 < 200ms,并按输出长度分桶统计;这些预算钉死流式体验,超过即触发告警与扩容,是 LLM 网关的可观测核心。
- Little 定律算并发槽:并发槽 ≈ 目标 QPS × 平均耗时,再除预期利用率,如
20×3/0.7 ≈ 86。用公式定容量而非拍脑袋,才能给出可复核、可回滚的数字依据。 - GPU 利用率 60–80% 是健康带:训练 / 推理把 GPU 提到 60–80% 利用率但不满载,留出抖动与重算裕量;冲满 100% 往往是排队与背压的头号来源,务必配合 OpenTelemetry 打点观测瓶颈在哪一段。
学习路径
核心知识点详解
- 故障注入回放定位:OpenTelemetry 全链路 trace 让每次请求凭 trace ID 回放各环节耗时;注入延迟 / 丢了 / 500 后应能在 trace 里一屏定位到具体环节,而不是靠猜去重跑。
- 常见坑:trace 没接全链路:
otlp只打了网关、没打进模型 / 检索段,故障时会丢一环。打点要贯通 DNS → TCP → TLS → 网关 → 检索 → 模型 → 输出,才能真正回放并定位。
7.1 抓包与诊断:从「猜」到「看」
| 症状 | 首选工具 | 看什么 |
|---|---|---|
| 整体慢 | curl -w(分段时间) | 把延迟拆成 DNS/建连/TLS/TTFB/传输 |
| 建连慢 | tcpdump / mtr | 是否 SYN 重传(说明丢包或对端不可达) |
| 丢包 / 重传 | ss -i / netstat -s / Wireshark | 重传率、拥塞窗口变化 |
| 连接异常 | ss -tan / netstat | TIME_WAIT、CLOSE_WAIT 堆积 |
| 协议层问题 | Wireshark 跟随 TCP 流 | HTTP/2 帧交错、SSE 分帧、TLS 告警 |
| MTU 问题 | ping -M do -s | 从多大包开始失败(见 2.1 节) |
| 服务端处理慢 | 应用 trace(OpenTelemetry) | 注意:这不是网络问题,要分层排除 |
bash# 一套可复制的网络诊断流程
# ① 先用 curl 分段,判断问题在哪一层
curl -w "@-" -o /dev/null -s https://api.example.com/health <<'FMT'
dns: %{time_namelookup} conn: %{time_connect} tls: %{time_appconnect}
ttfb: %{time_starttransfer} total: %{time_total}
FMT
# ② 判断是否丢包/重传(重传率高说明链路质量差)
ss -tin state established '( dport = :443 )' | grep -E 'retrans|rtt|cwnd'
# retrans:x/y → 重传数 / 总包数
# cwnd → 拥塞窗口大小(长期很小说明在持续退让)
# ③ 抓包(只抓关键包,避免文件爆炸)
sudo tcpdump -i eth0 -nn -s 0 -c 200 'tcp port 443 and (tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0)'
# 导出 pcap 后用 Wireshark 看:Follow → TCP Stream,能看到完整交互
# ④ 看 TCP 层统计(内核视角的全局信息)
netstat -s | grep -iE 'retrans|timeout|overflow|drop'
# ⑤ 检查 TLS 与证书
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -dates -issuer -subject
# ⑥ 最后才看应用日志 —— 但前五步能排除掉「其实是网络问题」的误判
bash# tcpdump/tshark 常用过滤配方(直接抄)
# 只看握手与挥手(快速确认建连是否有重传)
sudo tcpdump -i any -nn 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0 and port 443'
# 抓重传(同一序号重复出现)—— 最直接的质量指标
sudo tcpdump -i any -nn 'tcp port 443' -w retrans.pcap
tshark -r retrans.pcap -q -z io,stat,1 -Y 'tcp.analysis.retransmission'
# 输出按秒的重传计数;重传率 > 0.5% 就该查链路或换 BBR
# 统计 TLS 握手告警(协议层问题)
tshark -r t.pcap -Y 'tls.alert_message' -T fields -e tls.alert_message.desc
# 关注:handshake_failure / unrecognized_name / certificate_expired
# 观察 RTT 随时间膨胀(bufferbloat 的指纹)
tshark -r t.pcap -T fields -e tcp.analysis.ack_rtt | sort -n | tail -5
# 若 RTT 从 20 ms 持续爬到 200 ms 而吞吐不变 -> 缓冲膨胀(见 3.2)
7.2 AI 服务的核心指标与排队论直觉
| 指标 | 全称 / 含义 | 为什么关键 | 目标量级 |
|---|---|---|---|
| TTFT | Time To First Token(首 token 延迟) | 用户感知的「开始响应」时间,主要受排队 + 预填充影响 | P95 < 1–2 s |
| TPOT / ITL | Time Per Output Token(token 间隔) | 决定流式输出的流畅度,受解码速度影响 | P95 < 50 ms |
| 端到端延迟 | 请求到完整响应的总时长 | 与输出长度强相关,要按长度分桶统计 | 按 SLA 定 |
| 吞吐 | tokens/s 或 req/s | 决定成本与容量 | 越高越好 |
| 并发数 | 同时在处理的请求数 | 与延迟共同决定所需 GPU 数 | — |
| 排队时间 | 进入队列到开始处理 | 拥塞的先行指标,比延迟更早报警 | P95 < 200 ms |
| 错误率 / 超时率 | 按类型细分(4xx / 5xx / 超时 / 取消) | 区分「客户端问题」与「服务端问题」 | < 0.5% |
| GPU 利用率 + 显存 | 计算与显存水位 | 显存不足会直接 OOM;利用率过高会让延迟失控 | 利用率 60–80% |
- 为什么 TTFT 和 TPOT 要分开看:它们由不同阶段决定(预填充 vs 解码),优化手段完全不同。TTFT 受排队 + 预填充影响(优化:批处理调度、前缀缓存、更快的 prefill kernel),TPOT 受解码速度影响(优化:量化、投机解码、更大的 batch 摊薄权重读取)。只看「平均响应时间」会把两者混在一起,导致优化方向错误。
- 排队时间是最灵敏的拥塞指标:当系统接近饱和时,排队时间先于延迟暴涨(Little 定律)。把「排队时间 P95」作为扩容触发条件,比等错误率上升要早得多。
- 按输出长度分桶统计延迟:LLM 的延迟与输出 token 数是线性关系。把长短请求混在一起算平均,会得到毫无意义的数字(长回答拉高均值、短回答被淹没)。正确做法是 P50/P95 都按输出长度分桶。
- 成本也要作为一级指标:每千次请求的 token 成本、缓存命中率、按模型分层的成本分布。没有成本指标,你就不知道一个「优化」到底省了钱还是花了钱。
python# 用 OpenTelemetry 给 Hamauls Orion 的关键路径打点(骨架)
from opentelemetry import trace, metrics
tracer = trace.get_tracer('hamauls_orion')
meter = metrics.get_meter('hamauls_orion')
ttft = meter.create_histogram('hamauls_orion.llm.ttft_ms', unit='ms')
tpot = meter.create_histogram('hamauls_orion.llm.tpot_ms', unit='ms')
tokens = meter.create_counter('hamauls_orion.llm.tokens_total')
queue_wait = meter.create_histogram('hamauls_orion.queue.wait_ms', unit='ms')
async def handle(req):
with tracer.start_as_current_span('chat.request') as span:
span.set_attribute('tenant', req.tenant)
span.set_attribute('output_bucket', req.max_tokens // 256) # 分桶标记
# ① 排队:把「进队 → 出队」单独作为一个 span/指标
t_enq = monotonic_ms()
async with acquire_slot():
queue_wait.record(monotonic_ms() - t_enq)
# ② 检索:内部再拆子 span,才能定位到是哪个子步骤慢
with tracer.start_as_current_span('retrieve'):
docs = await retrieve(req.q)
# ③ 生成:分别记录 TTFT 与 TPOT
t0 = monotonic_ms(); first = None; n = 0
async for tok in generate(req.q, docs):
if first is None:
first = monotonic_ms()
ttft.record(first - t0)
else:
tpot.record(monotonic_ms() - first - n * 0) # 实际实现按上次时刻差
n += 1
tokens.add(n, {'model': req.model, 'tenant': req.tenant})
span.set_attribute('output_tokens', n)
python# 容量规划:用 Little 定律反推需要多少并发槽位 / 多少张卡
def channels(lambd, service_time_s, utilization_cap=0.7):
"""所需并发槽位 c 约等于 lambda * service_time / rho,
rho 为目标利用率上限(不要取 1.0)"""
rho = min(utilization_cap, 0.99)
return round(lambd * service_time_s / rho)
c70 = channels(lambd=20, service_time_s=3.0) # 利用率 0.7
print(f'到达率 20 req/s、单请求 3 s、目标利用率 0.7 -> 需并发槽 {c70} 个')
# 预期:需并发槽 86 个(20*3/0.7 约 85.7)
# 推论:单实例 4 路并发扛不住 —— 需要足够并行度 + 排队控制。
c95 = channels(20, 3.0, utilization_cap=0.95)
print(f'利用率 0.95 时仅需 {c95} 个槽,但排队时间会非线性暴涨')
# 预期:约 63 个槽 —— 看似省资源,实则 P99 延迟失控(见 1.3 Little 定律)
# 结论:容量规划要按 cap=0.6–0.75 设计,而不是按 100% 利用率。
7.3 动手练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 抓包配方 | 重传率 / RTT 分位 / 零窗口次数 |
| 故障注入 | 定位到「服务端处理」段 |
| 指标打点 | TTFT/TPOT/排队时间,按长度分桶 |
| 容量估算 | 20×3/0.7 ≈ 86 槽 |
| bufferbloat | RTT 上升而吞吐不变 |
- 抓包配方:用上面的过滤抓一次真实请求,导出重传率、RTT 分位、零窗口次数。判据:三个数字都能给出并解释含义。
- 故障注入:给服务端注入 200 ms 延迟,用 curl -w 与 trace 分别定位到「服务端处理」段。判据:能证明这不是网络问题。
- 指标打点:给一个流式接口加 TTFT/TPOT/排队时间三个 histogram,按输出长度分桶。判据:能回答「P95 慢在排队还是解码」。
- 容量估算:给定 20 req/s、单请求 3 s,估算所需并发槽(利用率 0.7)。判据:约 86,并说明 100% 利用率的危害。
- bufferbloat 识别:在 tc netem 下观察 RTT 随时间膨胀,用 tshark 的 ack_rtt 佐证。判据:能指出 RTT 上升而吞吐不变的特征。
8. 小结:给 Hamauls Orion 的网络层定稿(里程碑 M4)
学习路径
- 读 8.1:对照决策表逐项核对外 SS、内 gRPC、TCP_NODELAY 等
- 跑内置代码,把 proxy_buffering off 与 25ms 批落地验证
- 完成练习自测:确认下游超时之和 ≤ 上游与幂等唯一索引
- 对接 M4:把这张决策表写进网关定稿文档
核心知识点详解
- 对外 HTTP/2 + SSE / 对内 gRPC + protobuf:对外用 SSE 保续传与代理兼容,对内用 gRPC server-stream 走 protobuf 高效流式;两层协议分离、各自按最适选型,是 LLM 网关的推荐形态。
- TCP_NODELAY + 关闭代理缓冲:流式连接开
TCP_NODELAY避免 Nagle 拖慢 token,proxy_buffering off/X-Accel-Buffering: no让代理不攒包直接转发。否则后端明明逐 token,前台却整段卡顿。 - 下游超时之和 ≤ 上游 / 幂等唯一索引:调用链每跳透明下传剩余预算、逐跳可取消,端到端 ≤ 预算;写路径用幂等键 + DB 唯一索引兜底,单点故障不外溢。按 token 限流 + OTel trace 一并进决策表,逐项有依据才算定稿。
学习路径
核心知识点详解
- 5 分钟定位到层:把七张知识图收拢成统一词汇:DNS / TCP / TLS / TTFT / TPOT / 重传 / 排队 / 背压。遇到慢请求按模板 5 分钟内锁到某一层,是这套网络知识的度量红利。
- 训练瓶颈在网络:一次 all-reduce 的耗时(280 GB 在数百 Gbps 下也要数秒)往往大于单步计算,分布式训练提速的第一杠杆是减少通信量、让通信与计算 overlap,而非一味加卡。
- 常见坑:决策词汇与诊断词汇分离:设计决策与排障如果两套语言,评审与定位会互相污染;故障注入回归应作为定稿的收口动作而非可选,避免「决策表好看、线上照样卡」。
学习路径
核心知识点详解
- 时序图 ≥ 8 阶段含 DNS/TCP/TLS/TTFT/TPOT:从点击 → DNS → TCP → TLS → HTTP/2 → 网关 → 检索 → 生成 → SSE 回传,每段标注延迟量级、协议要点与优化手段;这张图是架构基线,也是后续故障回归的对照物。
- 故障注入三连是硬验收:分别注入延迟、丢包、下游 500,验证不雪崩、能降级、能恢复(pytest 三个用例);判据是「故障下仍有界」而不是一直转圈重试。
- 常见坑:选型表不给数字:SSE / gRPC / MCP 的选型若没有「建连 RTT、续传、断线重连、LB 兼容」几列与数字,看起来合理实则落不了地;给出测得的 token 节省比例与容量提升才算闭环。
8.1 一份可以直接落地的设计决策表
| 决策点 | 选择 | 理由 |
|---|---|---|
| 对外协议 | HTTP/2 + SSE | 浏览器原生支持、代理兼容好、单向流式足够 |
| 对内协议 | gRPC(HTTP/2 + protobuf) | 高效、四模式齐全、原生流控、契约清晰 |
| 双向交互(语音/协作) | WebSocket | 只有双向场景才用,避免为它承担代理复杂度 |
| 连接管理 | 连接池 + keep-alive,每后端 1–2 条 HTTP/2 连接 | 摊薄握手成本;HTTP/2 多路复用已提供并发 |
| 流式优化 | TCP_NODELAY + 25ms/16 token 批处理 + 心跳 | 避免 Nagle 与延迟确认叠加造成 40ms 卡顿 |
| 代理配置 | Nginx proxy_buffering off + X-Accel-Buffering: no | 否则流式失效(最常见的坑) |
| 超时 | 分层预算,下游之和 + 余量 ≤ 上游 | 避免上游放弃而下游空算 |
| 重试 | 只重试 408/429/5xx,指数退避 + 抖动 + 全局预算 | 防重试风暴;幂等键保证安全 |
| 幂等 | 工具调用与写操作一律带 idempotency key + DB 唯一索引 | Agent 重试不产生重复副作用 |
| 熔断 | 基于错误率三态机,熔断后降级到小模型/缓存/模板 | 故障隔离与体验兜底 |
| 限流 | 按 token 数令牌桶(而非按请求数) | 长短请求成本差几十倍,按请求数不公平 |
| 背压 | 有界队列 + 明确拒绝(429/503 + Retry-After) | 延迟有界、行为可预测 |
| 安全 | 对外 TLS 1.3 + HSTS;对内 mTLS | 服务间不可信网络 |
| 观测 | OTel 全链路 trace + TTFT/TPOT/排队时间分桶指标 | 能回答「慢在哪一段」 |
| MTU | K8s overlay 网络显式配置 MTU(1500 − 隧道开销) | 避免大 payload 幽灵超时 |
bash# M4 收口自检脚本:一条命令跑完关键验证(骨架)
set -e
echo '== 1. 连接复用 =='
curl -s -o /dev/null -w 'connects=%{num_connects} total=%{time_total}\n' \
https://orion.example.com/v1/health https://orion.example.com/v1/health
echo '== 2. 流式未被打断(应持续输出 > 5s)=='
curl -N -s https://orion.example.com/v1/chat/stream -d '{"q":"talk"}' | head -200
echo '== 3. HTTP 版本 =='
curl -sI --http2 https://orion.example.com/ | head -1
echo '== 4. TLS 配置 =='
echo | openssl s_client -connect orion.example.com:443 -tls1_3 2>/dev/null | grep -E 'Protocol|Cipher'
echo '== 5. 证书有效期 =='
echo | openssl s_client -connect orion.example.com:443 2>/dev/null | openssl x509 -noout -enddate
echo '== 6. 重传率(需在服务端执行)=='
ss -tin state established '( dport = :443 )' | grep -o 'retrans:[0-9/]*' | head
# 期望:connects=1;流式输出平滑;Protocol=TLSv1.3;证书 > 30 天;重传率低
8.2 这一阶段真正给了你什么
python# 故障注入测试:验证六件套在"网络故障"下不雪崩(pytest 风格骨架)
import asyncio, pytest
@pytest.mark.asyncio
async def test_timeout_budget_cancels_downstream():
# 下游故意慢 2 s,但上游预算 0.5 s
async def slow(): await asyncio.sleep(2); return 'late'
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow(), timeout=0.5)
# 判据 1:超时后下游确实被取消(计时断言整体 < 1 s 完成)
# 判据 2:熔断器连续 5 次失败后进入 open,请求快速失败
@pytest.mark.asyncio
async def test_backpressure_rejects_when_full():
q = asyncio.Queue(maxsize=2)
await q.put(1); await q.put(2)
assert q.full()
# 判据:满队列时应拒绝(429/503 + Retry-After),而不是无限堆积
@pytest.mark.asyncio
async def test_idempotency_single_side_effect():
# 同一 idempotency key 并发提交 10 次,DB 只应有 1 条记录
results = await asyncio.gather(*[submit(key='k-1') for _ in range(10)])
assert len({r['id'] for r in results}) == 1
# 运行:pytest -q -> 期望 3 passed
# 这三个用例覆盖「故障不雪崩 / 延迟有界 / 副作用不重复」三条硬标准。
8.3 综合练习与自测
| 题目 | 关键数字 / 判据 |
|---|---|
| 时序图 | ≥ 8 阶段,含 DNS/TCP/TLS/TTFT/TPOT |
| 超时预算 | 注入慢下游仍在预算内降级 |
| 故障注入三连 | 延迟/丢包/500 全过 |
| 协议选型 | SSE / gRPC / MCP+SSE |
| 成本-可靠性 | token 节省比例 + 容量提升 |
- 时序图:画出 Hamauls Orion 从点击到流式结束的完整时序,标注每段延迟量级与协议。判据:至少 8 个阶段,含 DNS/TCP/TLS/TTFT/TPOT 数字。
- 超时预算落地:把预算表写成代码并证明每跳可取消。判据:注入慢下游时,整体在预算内返回降级结果。
- 故障注入三连:分别注入延迟、丢包、下游 500,验证不雪崩、能降级、能恢复。判据:三个用例全过,且有指标佐证。
- 协议选型:为「对外聊天 + 对内部检索 + Agent 工具调用」分别选协议并给理由。判据:SSE / gRPC / MCP(HTTP+SSE) 或 Connect,理由含兼容性与流控。
- 成本-可靠性:给出「缓存命中率上升与取消率下降」对单位成本的量化影响。判据:能算出省下的 token 比例与对应的 QPS 容量提升。
项目里程碑
为 Hamauls Orion 设计对外协议:面向前端的 HTTP/2 + SSE 流式网关,面向内部的 gRPC 服务间调用;实现连接池、背压、超时预算、重试语义与幂等键。这是「模型能力」变成「产品体验」的那一层。
本阶段产出(直接进入项目仓库)hamauls_orion/serving/gateway.py:FastAPI 网关,SSE 流式输出、心跳保活、断线重连(基于 Last-Event-ID 续传)proto/hamauls_orion.proto:gRPC 定义 + Python/gRPC 客户端与服务端骨架- 连接池与背压:httpx 连接池上限、队列水位、429/503 语义与重试退避
docs/net/timeout-budget.md:端到端超时预算表(各跳超时之和 ≤ 总预算),含幂等键设计- TLS 终止 + HTTP/2 多路复用验证脚本,附抓包截图或指标对比
阶段练习项目
- 标注 ≥ 8 个阶段,每段给出延迟量级(如 DNS 5–50ms、TCP 1 RTT、TLS 1 RTT)并区分网络与服务端耗时
- 每个阶段至少一条可落地的优化手段(连接复用、DNS 预取、缓存、100 批等)
- 按「点击 → DNS → TCP → TLS → HTTP/2 → 网关 → 检索 → 生成 → SSE 回传」逐段画独立节点,标明请求/响应方向与协议
- 分别标注 TTFT(prefill + 排队)与 TPOT(解码),并注明为何它们不是网络问题
- 在图中标出至少 2 处易错点(如代理缓冲、Nagle、MTU 分片、握手不复用)
- 用 Mermaid / draw.io 生成,附 Markdown 说明文档一起提交
net-seq.md架构时序图 + 逐段延迟说明表- 一份「排障顺序」速查表(从哪一层先查、用什么命令)
不做真实抓包与压测(那是弱网/诊断手册练习的职责);只交付静态时序与标注文档。
curl -N与浏览器均能实时逐 token,首 token 在预算内(TTFT P95 < 2s)- 客户端断开后网关能快速取消并回收协程/连接,不泄漏内存
- 断线后续传:用 Last-Event-ID 能从最后一个成功事件继续而非从头跑
- 返回正确的
text/event-stream头部(Cache-Control:no-cache、Connection:keep-alive、X-Accel-Buffering:no) - 实现心跳保活(如 15s)防代理超时,事件带 id 以支持 Last-Event-ID 续传
- 25ms 批处理:把小事件按批 flush,分别测量有/无批的首字延迟与吞吐
- 客户端断开时取消后台模型任务(不烧多余 token)并清理连接资源
gateway/sse.py(StreamingResponse 示例 + 心跳 + 续传)tests/:头部、续传、断开清理、批处理四组测试- 一张数字对比表(有/无心跳、有/无批、断开回收是否生效)
不做鉴权与多用户会话;不接真实模型,用可配置的模拟流式后端即可。
- 三个故障用例全过:不雪崩、延迟有界、副作用不重复
- 注入下游 500/超时时整体仍能在预算内降级,无请求风暴
- 同一幂等键并发提交 10 次,DB 只落 1 条记录
- 超时预算:端到端 P99 < 8s,逐跳用
asyncio.wait_for可取消并下传剩余时间 - 分类重试:仅 408/429/5xx/超时走指数退避 + 抖动,401/400 立即失败;设重试预算(如最多 3 次、总窗口 < 1s)防风暴
- 幂等:idempotency key + DB 唯一索引,重试不产生重复副作用
- 熔断三态(CLOSED/OPEN/HALF-OPEN)+ 令牌桶按 token 限流 + 有界背压队列(满则返回 429/503 + Retry-After)
- 提供脚本注入延迟/丢包/500 并在 pytest 中验证三个判据
reliability/:budget、retry、idempotency、breaker、limiter、backpressure 模块- 故障注入测试
tests/fault_injection.py - 故障注入报告(注入项、观测指标、是否在预算内)
不做限流的持久化与多实例共享;熔断状态放本地内存即可(单实例演示)。
- 给出 CUBIC 与 BBR 在 100ms + 1% 丢包下的吞吐(iperf3)对比并说明成因
- 量化弱网对流式体验的影响:中断率、重连成功率、TTFT P50/P99
- 报告能直接支撑「真机走 UDP + 重连」还是「TCP + Keep-Alive」的选型结论
- 用
tc netem注入 delay 100ms / loss 1% / rate 限制到同一条链路 - 分别用 CUBIC 与 BBR 跑吞吐并记录 RTT 分位与重传率
- 对同一流式负载统计中断率、客户端重连成功率与 TTFT 分布
- 给出机制解释(BBR 不依赖丢包反馈、重连对断流体验的影响)
- 实验脚本(netem 注入 + 压测 + 数据采集)
弱网实验报告.md(数字 + 图表 + 选型建议)
不做多机异地/真实弱网(用本机 netem 模拟即可);不接真实模型推理。
- 6 种故障每个都有「现象 → 抓包命令 → 判读依据 → 修复方式」四段式处方
- 每条处方给出可直接运行的 tcpdump/ss/curl 命令与关键判读特征
- 手册足够让零基础排障者按步骤复现并自愈
- 覆盖:建连失败、TLS 失败、大包超时、流式被缓冲、TIME_WAIT 堆积、CLOSE_WAIT 泄漏
- 每条含抓包命令(如
tcpdump -nnXs0 -w x.pcap)与 1–2 条关键判读特征 - 「流式被缓冲」给
proxy_buffering off/X-Accel-Buffering: no的解法 - 手册按「先
curl -w分层 → 再 tcpdump 定位 → 修复验证」组织
net-diagnostics.md(6 条处方)- 可复用预检脚本
diag.sh(一条命令跑完各层初检)
不做自动化告警系统与指标面板(属可观测练习的范畴);只交付诊断处方与脚本。
- 给定参数量/卡数/带宽/并行策略,输出每步 all-reduce 通信量(GB)
- 得到理论通信耗时(通信量 ÷ 聚合带宽 × 系数)并给出推荐的并行方案
- 能对比至少 3 种策略(DP/TP/PP)并给出互联要求结论
- 按模型参数量推导 each-step all-reduce 通信量(量级参考 2×P 与卡数分摊)
- 支持 DP/TP/PP 三种并行策略的通信量与耗时估算,参数可改
- 输出「推荐的并行方案 + 所需互联带宽」建议(结合 NVLink / IB / RoCEv2)
- 脚本参数化:改参数量与卡数即可直接重算,不写死数字
comm_accounting.py(CLI + 参数化输入)- 一组示例输入与对应输出说明(数字可复核)
不做真实 NCCL 基准实测与自动调优;只做理论估算与决策建议。
常见误区
- 把「接口慢」都归因为网络,跳过「服务端排队 / 下游串联 / 慢查询」的排查。
- 只用平均延迟做指标,忽略 P95/P99 与「按输出长度分桶」——平均延迟在长尾场景下毫无意义。
- 不设连接池或池太小,跨地域每次请求都在重做 TCP + TLS 握手。
- 流式服务忘了设 TCP_NODELAY 或忘了关代理缓冲,导致 40ms 级卡顿或「内容一次性全部出现」。
- 重试不做分类:对 400/401/403 也重试,放大故障并烧掉配额;或重试不带抖动导致惊群。
- 重试没有幂等保护,Agent 重复下单、重复发邮件。
- 用无限队列「不丢请求」,实际造成延迟无限增长与 OOM。
- 限流按请求数而不按 token 数,导致长短请求的公平性完全失真。
- 超时预算上下游颠倒(下游比上游长),上游已放弃而下游还在空算。
- 忽略 K8s overlay 网络的 MTU,出现「小请求正常、大 payload 幽灵超时」。
- 看到大量 TIME_WAIT 就去调 tcp_tw_recycle(已被移除,NAT 下会误杀连接),而不是先做连接池。
面试高频问题速答
一次 HTTPS 请求的延迟由哪些部分组成?怎么优化?
按阶段拆:① DNS 解析(5–50 ms,优化:缓存、预解析、就近解析);② TCP 三次握手 1 RTT + ③ TLS 1.3 握手 1 RTT(合计 2–3 RTT,同机房 <1 ms、跨洋可达 300–400 ms,优化:连接池/长连接、HTTP/2 多路复用、会话复用、0-RTT、就近部署);④ 服务端处理(常常是最大的一块,但这不是网络问题,要分开归因);⑤ 首字节传输(TTFB);⑥ 数据回传(传输延迟 = 数据量/带宽,以及拥塞排队)。物理层还有传播延迟(距离/光速),是跨地域的硬下限。优化的优先级取决于测量结果:跨地域先解决握手成本,同机房则要去看服务端。
TCP 为什么是三次握手?四次挥手又为什么不能是三次?
三次握手是为了让双方都确认「自己的发送能力」和「对方的接收能力」:第 1 次服务端确认客户端能发,第 2 次客户端确认服务端能收能发,第 3 次服务端确认客户端能收。两次不够(服务端无法确认客户端收到了自己的 SYN,历史重复 SYN 会导致错误建连)。四次挥手是因为 TCP 是全双工的,两个方向必须分别关闭:客户端发 FIN 只表示「我不再发送」,服务端先 ACK(此时处于半关闭,仍可继续发送数据),等自己数据发完再发 FIN,客户端最后 ACK。第 2、3 步不能合并,因为服务端可能还有数据要发。
TIME_WAIT 和 CLOSE_WAIT 分别说明什么问题?
TIME_WAIT 出现在主动关闭方,等待 2×MSL(约 60 秒)。作用是:① 确保最后那个 ACK 能到达对端(若丢失,对端会重发 FIN,此时还需有连接上下文响应);② 让旧连接的报文在网络中彻底消散,避免串到同四元组的新连接上。服务端大量 TIME_WAIT 通常说明在频繁主动关闭短连接,解法优先级是:改用连接池/长连接(根本)> 让客户端主动关闭 > 谨慎使用 tcp_tw_reuse。CLOSE_WAIT 表示「对端已关闭,但本端还没调用 close()」,堆积说明代码里有连接泄漏(异常路径没释放),这是 bug 而不是配置问题。
拥塞控制和流量控制有什么区别?BBR 相比 CUBIC 好在哪?
流量控制(rwnd)是「别把接收方撑爆」,由接收方通过窗口字段告知;拥塞控制(cwnd)是「别把网络撑爆」,由发送方根据网络反馈推断;实际发送窗口 = min(rwnd, cwnd)。拥塞控制的演进:Reno/CUBIC 以「丢包」作为拥塞信号——CUBIC 用三次函数增长、与 RTT 解耦,在有线网上表现好,但会把非拥塞原因的丢包误判为拥塞而激进退让,且在缓冲区大的链路上会填满缓冲导致延迟暴涨(bufferbloat)。BBR 不依赖丢包,而是主动测量「瓶颈带宽 × 最小 RTT」来建模管道容量,因此在高延迟、有丢包的链路(跨洋、移动网络)上吞吐显著更高,同时队列延迟更低。代价是与 CUBIC 共存的公平性存在争议。
SSE、WebSocket、gRPC streaming 分别适合什么场景?
SSE 是服务端单向推送,基于普通 HTTP(text/event-stream),代理与 CDN 兼容性最好,内建 Last-Event-ID 重连语义,是 LLM 流式输出的首选。WebSocket 是全双工,通过 HTTP Upgrade 切换到独立协议,适合需要客户端持续上传或极低延迟双向交互的场景(语音、实时协作、游戏),代价是代理兼容性与重连复杂度更高。gRPC streaming 有四种模式(一元、服务端流、客户端流、双向流),基于 HTTP/2 + protobuf,效率高、契约清晰、原生流控,适合服务间调用;浏览器直连需 gRPC-Web + 代理,所以对外通常用 SSE/HTTP、对内用 gRPC。选型判据:方向(单向还是双向)、部署环境(是否有严格的代理限制)、以及是否需要跨语言强契约。
为什么流式输出会卡顿?怎么排查和解决?
最常见的原因有三个:① Nagle 算法与延迟确认叠加——发送端攒小包等 ACK,接收端等最多 40 ms 再回 ACK,导致每个小块可能被延迟 40 ms。解法是设置 TCP_NODELAY,并把 token 聚成小批(如 20–50 ms 或 16 个 token 一批)再发送。② 中间代理缓冲——Nginx 默认会缓冲响应,导致内容被攒起来一次性下发。解法是设置 X-Accel-Buffering: no 与 proxy_buffering off,并在响应头加 Cache-Control: no-transform。③ 中间设备超时断开——长时间无数据会被判定为空闲连接,需要发心跳(SSE 的注释行 `: ping`)。此外还要注意 HTTP/1.1 单域名 6 连接上限导致的排队,以及客户端消费慢造成的反向积压(需要背压)。排查顺序:先 curl -N 直连服务端(排除代理)→ 再加代理 → 再看客户端解析逻辑。
为什么说分布式训练的性能瓶颈可能在网络?怎么算?
以数据并行为例,每次迭代都要做一次梯度 all-reduce。Ring AllReduce 的每卡通信量约为 2(N−1)/N × S(S 是模型参数字节数),N 大时趋于 2S。以 70B 模型 bf16 为例,S ≈ 140 GB,每步通信量约 280 GB;用 400 Gbps(50 GB/s)RDMA 需要 5.6 秒,用 25 GbE(3.1 GB/s)需要 90 秒——后者完全不可行。这解释了为什么大模型训练必须用 RDMA/InfiniBand 与高带宽 NVLink,为什么张量并行只在节点内(NVLink 域)做而数据并行可以跨节点,以及为什么需要通信计算重叠(按层计算完梯度立刻发起 all-reduce,把通信藏在后续反向传播里)与 ZeRO/FSDP(分片降低通信量与显存)。NCCL 还会做拓扑感知选路,所以 GPU 的插卡方式也会影响速度。
学习资源
计算机网络:自顶向下方法(Kurose & Ross)书 gaia.cs.umass.edu/kurose_ross/index.php 本阶段主教材。自顶向下的讲法最适合工程师:从应用层开始,到传输层、网络层。重点第 2 章(应用层:HTTP、DNS、CDN)、第 3 章(传输层:TCP 与拥塞控制)、第 4 章(网络层)。 High Performance Browser Networking(免费在线)书 hpbn.co/ 把「TCP/TLS/HTTP2/QUIC 如何影响真实性能」讲到极致的一本书,免费在线。对做流式与低延迟服务的工程师几乎是必读。重点章节:TCP、TLS、HTTP/2、浏览器网络。 Beej's Guide to Network Programming教程 beej.us/guide/bgnet/ 如果要用 socket 写点东西(哪怕只是实验),这是最短上手路径。讲 getaddrinfo、socket 选项、非阻塞 IO 的实现细节。 HTTP/3 explained(Daniel Stenberg)文档 http3-explained.haxx.se/ curl 作者写的 HTTP/3 与 QUIC 科普,免费在线。清晰解释 QUIC 为什么能消除 TCP 队头阻塞、连接迁移、以及 0-RTT 的代价。 MDN — Server-Sent Events文档 developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events SSE 的权威参考:事件格式、重连机制(Last-Event-ID)、与 WebSocket 的对比。做 LLM 流式输出必读。 gRPC 官方文档 — 核心概念与四种流式模式文档 grpc.io/docs/what-is-grpc/core-concepts/ 一元/服务端流/客户端流/双向流的定义与适用场景,以及 HTTP/2 之上的映射方式。 NCCL / 集合通信文档与 Megatron-LM 论文文档 docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/collectives.html all-reduce / all-gather / reduce-scatter 的定义、通信量公式与 NCCL 的算法选择逻辑。理解分布式训练网络必读。 Latency Numbers Every Programmer Should Know参考 gist.github.com/jboner/2841832 一份流传很广的延迟量级清单(含 L1、主存、SSD、同机房网络、跨洲网络)。配合第 2 阶段的存储层次一起记,能建立完整的数量级直觉。 Cloudflare / AWS 博客 — 架构中心(Architecture Center)文档 aws.amazon.com/architecture/ 生产级网络架构的参考实现:超时、重试、熔断、背压、多区域部署的官方最佳实践文档,含具体数值建议。 opentelemetry-python + 分布式追踪入门文档 opentelemetry.io/docs/languages/python/ 给你的 Hamauls Orion 服务加上全链路追踪。重点理解 span 的父子关系如何映射到「一次请求的各个阶段」,这是定位「慢在哪一段」的唯一可靠手段。serving/timeout.py;SSE 网关与心跳 → serving/gateway.py;gRPC 契约 → proto/hamauls_orion.proto;重试与幂等 → serving/retry.py 与工具调用的 idempotency key;熔断限流背压 → serving/resilience.py;追踪与指标 → observability/。学完这一阶段,你就能把这些文件从骨架填成生产级代码——这就是 M4 里程碑的全部内容。