流式接口发出第一段内容之后,失败重试就不再只是重新请求一次。用户可能已经看到半句,模型端可能已经产生全部用量,网关却只记录了几个片段。取消、计费与重试必须共享同一个请求状态,而不能由三个互不相干的中间件分别处理。

本篇设计多租户 LLM 服务网关,范围包含流式转发、有限并发、准入配额、显式取消和用量核对;排除真实模型推理、供应商账单、工具调用与训练。实验启动真实回环 HTTP 服务和客户端,但“模型”仅按序生成数字片段,每段不等于真实 tokenizer 的 token,结果不能用来估算任何模型价格或 GPU 吞吐。

把首段、完成和取消分别建模

业务不变量是一个请求不能同时占用多个有效执行槽,取消后其本地执行应释放资源,已发送部分输出的请求不能被透明重试成另一段独立回答。目标拟定为准入后首 token p99 两秒以内、显式取消在一秒内停止本地任务;完整回答延迟取决于输出长度,不能拿首 token 指标代替。

接口候选为 POST /generations,携带租户身份、请求 ID、输入与最大输出预算,返回流;POST /generations/{id}/cancel 请求取消;GET /generations/{id} 查询终态与用量。实验为了简化 HTTP 客户端,使用 GET /stream/{id} 和 POST /cancel/{id},这只是本地测试路由,生产生成接口不应借此宣称 GET 具有生成副作用的合适语义。

状态模型为 QUEUED、RUNNING、STREAMING、COMPLETED、CANCELLED、FAILED。记录 request_id, tenant, admitted_at, started_at, first_output_at, ended_at, reserved_units, generated_units, delivered_units。客户端收到多少与上游生成多少不同:中间网络失败或客户端断开,仍可能产生未送达内容;供应商账单又可能包含输入、缓存或其他单位,必须由供应商实际用量核对。

HTML 的 Server-sent events 规范定义 text/event-stream、事件字段和重连相关行为。SSE 的 event ID 能帮助应用标记位置,但不自动让模型从指定 token 继续生成。浏览器 EventSource 自动重连与生成请求的业务重试是两种行为,应由接口明确是否允许重放已持久保存的片段。

长连接容量和生成预算分开算

教学输入平均每秒十个新请求,平均生成持续二十秒,稳定时平均在途约 10×20=200 个。若峰值五十请求/s 持续足够久且服务能力足够,在途约一千;若只有二百个执行槽,输入持续超过完成能力就会形成积压,不能继续使用稳态公式掩盖排队。

每条活跃连接缓冲预算 64 KiB,一千条约 62.5 MiB;每秒每请求五十个片段、每片段平均 8 B 有效文本,二百个请求约 80 KB/s。协议、TLS、tokenizer 和实际文本分布另计,且网络字节少不代表推理便宜。假设每请求输入 1000 token、输出上限 1000 token,一秒十请求意味着每秒最多预留两万 token 预算,实际核销需要分开记录输入和输出。

若平均输出长度翻倍,执行槽占用时长可能从二十秒变四十秒,固定到达率下并发需求翻倍。此时仅增加网关连接数无法增加模型吞吐,应降低最大输出、准入速率或等待目标,或者增加实际推理能力。队列长度上限由可接受等待时间与完成速率推导,不能设置一个足够大的整数便称为“削峰”。

flowchart LR
    C[客户端] -->|身份 请求 ID 最大输出| A[准入与预算预留]
    A -->|有限排队或拒绝| Q[执行槽]
    Q -->|模型请求| M[上游模型或本地模拟器]
    M -->|流片段与用量| G[流式转发]
    G -->|SSE 数据| C
    C -->|显式取消| X[请求状态表]
    X -->|取消信号| M
    M -->|终态核销与释放| Q
    M -->|生成量记录| U[用量账本]

初版可以没有等待队列,槽满即 429。这样峰值成功率降低但等待有界,行为容易解释。若加入队列,应限制每租户待处理量,并允许排队时取消释放预留。全局 FIFO 容易让长请求租户占满资源;可以在业务需要时引入分租户公平调度,但先确认问题来自执行时间差异而非下游总容量不足。

取消不是关闭一个浏览器标签页

关闭连接只是一个信号。服务端可能在下一次写入时才发现断开,上游模型还在生成。显式取消接口根据请求 ID 找到执行任务,设置取消信号,并在任务退出的 finally 中释放槽位;取消响应可以表示已接受取消请求,而真正释放时间应另有观测。不能在发送取消命令时就把整个预算当作未发生。

本地实验每十五毫秒生成一个数字片段,正常共二十段。客户端收到三段后调用取消,任务观察事件后退出。仅有一个执行槽,第一条运行期间另一个请求返回 429;取消完成后完整请求可以进入并收到二十段,证明槽位被重新利用。这里使用单进程锁和信号量;跨进程网关需要外部租约或一致的配额来源,实验没有覆盖它们。

sequenceDiagram
    participant C as 流客户端
    participant G as 网关模拟器
    participant M as 本地生成循环
    C->>G: stream R1
    G->>M: 占一个执行槽
    M-->>C: 片段 0、1、2
    C->>G: cancel R1
    G->>M: 设置取消事件
    M->>M: 停止循环并释放槽
    C->>G: 重新提交相同 R1
    G-->>C: 409,禁止部分流透明重试
    C->>G: 新请求 R2
    G-->>C: 正常完成二十段

失败路径还包括客户端停止读取但不断开。若写缓冲无上限,网关会积累文本;若无限阻塞,上游槽位会被慢客户端占用。生产实现需要有限缓冲、写超时和上下游取消传播,完成条件也要区分“上游生成结束”与“客户端已接收全部内容”。实验流量很小,没有制造慢读背压,所以不宣称验证了该机制。

重试边界由可见输出决定

尚未向客户端发送内容、且能确认上游没有受理时,可以在剩余总预算内尝试另一执行节点。上游是否已经开始无法确认时,重试可能产生第二次费用;需要供应商幂等支持或明确承担重复成本。第一片段已经可见后,透明换模型或重跑可能产生重复、矛盾或不同风格内容,应终止并暴露失败,或使用持久结果流的显式续传协议。

实验把已经出现过的请求 ID 永久记在本次进程字典,第二次使用返回 409。这是最小的“禁止重跑”机制,不是生产幂等实现:进程重启会忘记记录,不同输入同 ID 的载荷冲突也没有持久验证。真实实现需保存请求指纹、终态和保留期,并决定重复请求返回原结果、继续等待还是拒绝。

流结束后核销预留。取消不意味着 generated_units 为零,也不能拿 delivered_units 直接当供应商费用;计费事实应单独保留。对供应商超时无终态的请求,暂记未知并异步对账,避免既退款又让账本永远缺项。任何真实价格必须按合同与官方计量核验,本篇只用片段数演示差异。

一次真实本地 HTTP 观察

运行 python3 examples/system-design/labs/E04/run.py,服务仅绑定 127.0.0.1 的随机端口,结束后自动关闭。记录中的 Python 为 3.14.4;此次首片段约 20.288 ms,取消到释放约 0.459 ms,取消时生成三段、客户端收到三段,完整请求收到二十段,过载为 429、重复部分流为 409。原始输出及源码 SHA256 在 examples/system-design/evidence/E04/;时间受本机调度影响,重跑不会固定相同毫秒数。

这些是单次实测值,不能写成 p99,也不能外推远程模型延迟。实验确实使用 HTTP 服务与客户端,不是直接调用内存函数;模型生成、预算单位和并发上限则是明确的本地替身。显式取消路径已覆盖,断网取消、供应商取消失败、跨节点配额与慢读背压仍属于本实验之外。

面试追问:用户收到半句后网关重启怎么办;上游生成了内容却未传到客户端应如何记账;配额满时排队与立即拒绝哪种更符合等待目标。每个回答都应同时说明状态、资源和用量,而不是仅说“重试一次”。

[PATTERN] 用请求状态连接可见输出、取消资源和用量核销。第一段内容是重试语义的边界,取消确认与执行终止是两个观测点。

实验附件与导航

可运行实验源码 · 本次原始结果

系列导读;容量和数据承诺分别沿用系列的方法,本文数字为独立教学假设。