第 4 章 网关:分流与守卫
MoE 大模型低成本部署框架第 4 章 网关:分流与守卫
CHAPTER 04

第 4 章 网关:分流与守卫

19 分钟阅读 · 5,391 字

第 3 部第一章。~/rag-proxy/rag_proxy.py 实测 2206 行,是整个系统唯一的公网面。
本章讲它的分流与守卫;检索组装留给 05。

关于行号:本章行号取自 2026-10-19 的 ~/rag-proxy/rag_proxy.py。
本章写作过程中核对出两份早期调研稿的行号与实际文件不符(它们把文件记成 3178 行),
已按实际文件全部更正。这也是本书反复强调「行号会漂移、以函数名为准」的原因。


4.1 网关在链路里的位置

公网 https://ai.ssemarket.cn / ssemarket.cn:23034
   │
frps 中继(TLS 在这里终结)
   │
Pod :8080  ──  rag_proxy.py(listen_host 0.0.0.0,listen_port 8080)
   │
   ├──► 127.0.0.1:8091  快道(lane_labels 里就叫「快道」)
   └──► 127.0.0.1:8092  普道

文件开头自己写了这句话(rag_proxy.py:4-5):

放在 Strata 服务之前(客户端 → :8080 → 引擎回环),当对话超过模型窗口时,
把「更早的历史」换成「检索出来的片段」,让上下文始终落在原生 262144 以内;短对话原样透传(零改动)。

引擎只绑 127.0.0.1(~/Strata/strata-iq3_xxs-a.json 的 "host": "127.0.0.1"),
外部没有任何路径能直接打到引擎。

这个形状由约束 5(只有一个公网端口)逼出来,顺带带来两个实际好处:

  • 所有跨用户的公共逻辑集中在一个文件里,改它只需 supervisorctl restart rag-proxy,引擎不动、不停服。
  • 安全面只有一份代码要审计。

代价同样明确:网关状态全在内存(约束 11),重启即清空会话与缓存。


4.2 分流:模型名决定去哪条 lane

分流由 pick_upstream(rag_proxy.py:171)与 route_upstream(:179)完成,
映射表在 config.json 的 model_routes:

客户端写的模型名 实际去向
qwen3.8-flash(default_upstream) http://127.0.0.1:8092 → 普道
qwen3.8-flash-fast http://127.0.0.1:8091 → 快道
strata-a http://127.0.0.1:8091
strata-b http://127.0.0.1:8092

lane_labels 把两个上游直接标成「快道」「普道」,lane_poll_s: 2 表示每 2 秒探一次上游健康状态。

两条 lane 跑同一个模型、同一份权重、同一套引擎参数(对比
~/Strata/strata-iq3_xxs-a.json 与 -b.json,除了 port、gpu、log 三个字段,其余完全一致)。
所以「快道 / 普道」区分的是服务等级,模型能力完全相同。这个区别很容易被误解,
第 15 章讲进程拓扑时会再确认一次。


4.3 窗口守卫:「永不 400」的实现点

引擎侧的行为是硬性的:原生窗口固定 262144,超窗直接 400,从不截断。
网关必须在请求到达引擎之前把它处理掉。这里有三层,层层都是被真实事故逼出来的。

⚠️ 本节(4.3)写于 2026-10-19 那次修复之前,三层结构与行号是修复前的口径。
当天事故 6 之后加了**第四层「窗口预检」**并改了第二层的行为 —— 见 §4.10;
修复后的行号在附录 A.3。本节保留原样,是为了让"为什么会长成这样"的顺序可读。

第一层:估算与钳制(clamp_max_tokens,:243)

网关自己估一遍 prompt token 数,然后把客户端要的 max_tokens 压进剩余空间。
docstring 直接写明了为什么需要余量:

2026-10-08:余量里必须再减 count_margin()。生产实测网关按「精确计数 219021」放行了
max_tokens 32000,引擎自己数到 231775 → 400(差 5.8%)。

5.8% 的计数残差就是这条代码的由来。残差的来源在 est_prompt_tokens(:231)的注释里也写了:

低估提示词——Claude Code 这类客户端的工具定义就有上万 token,曾导致「网关以为装得下、引擎 400 拒绝」。

估算参数在 config.json 里逐项标定:chars_per_token 2.5、tok_per_char_cjk 1.05、
tok_per_char_digit 1.05、tok_per_char_other 0.7、msg_overhead_tokens 8、image_tokens 1024。
clamp_margin_tokens 512 / clamp_margin_div 64 给出残差余量。

代码里有一处细节值得学:

req["max_tokens"] = room
req.pop("max_completion_tokens", None)   # 只留一个口径,别让另一个字段绕过闸门

OpenAI 新协议用 max_completion_tokens、旧协议用 max_tokens。如果只改一个字段,
另一个就会绕过闸门。这种「两个口径同时存在」的坑在协议层代码里反复出现。

第二层:照引擎的答复重发(overfit_room :320 + patch_max_tokens :333)

如果网关估准了、引擎还是报 400,网关不把这个 400 交回客户端。
引擎的超窗错误里直接写了「这里最多还能给多少 max_tokens」(引擎侧 issue #545):

照这个数压一次,比网关再猜一遍计数准——它是引擎自己数到的真实 prompt 长度。

于是网关解析错误消息、取出那个数、改写请求体、重发一次。
计数器 overfit_retries / overfit_failures 记录这条路径的命中率。
config.json 里 retry_on_overfit: True 控制它。

这是一个很干净的设计模式:让权威方告诉你它的真实约束,比自己在外面猜更准。

第三层:收敛保证(assemble 内,:1301)

最后一道保险:丢块之后仍超预算(估算偏差 / 计数不可用 / 图片展开)时继续砍,必须收敛到装得下。

注释还强调降级也不能放弃守卫(:1874):

降级不等于放弃窗口守卫:原样转发一个必然超窗的请求,就是把 400 交回客户端。

「永不 400」的准确边界要记住:它承诺的是「不会因为太长而拒收」,
它没有承诺「你贴的每一个字都会被模型看到」。后半句由检索质量决定,见第 05 章的失败数据。


4.4 思考预算守卫(thinking_guard,:264)

这道守卫解决一个非常具体的生产故障,引擎自己的诊断原文是:

the reply reached max tokens while still thinking, so it has no answer(serve/server.py issue #530)

翻译过来:模型一路思考到 max_tokens 上限,想完了,但没有答案。
docstring 给出了实测规模(测于 2026-10-08):

strata-a 173 次、strata-b 81 次;受害最重的是 max_tokens 小的客户端
(strata-a 上 2112 一档占 149 次、strata-b 上 512 一档占 49 次)。

处理方式:客户端没有表达过思考偏好时,网关补一个 reasoning_budget_tokens,
给答复留出预算。引擎支持按请求给这个参数(issue #123),到达预算时它走 REASONING_WRAP_UP
——一句「想够了,给答案」加 </think>,然后接着生成正文。

注释里有一句关键的性能判断:

已经读进去的前缀不重读,所以代价只是「思考短一点」,不是「再来一遍 prefill」。

边界克制同样写在 docstring 里,值得抄进任何网关代码:

只管「没说话」的请求:客户端一旦自己给了 thinking / output_config / reasoning_effort /
reasoning_budget_tokens,一律不碰(那是它的选择,改它就是越权)。

还有一条容易漏的连锁:refit_thinking_budget(:297)。窗口守卫可能把 max_tokens 从 32000
压到约 26k,此时先前补的思考预算反而大于等于新的 max_tokens——等于没有预算。所以要按同一套规则重算,
而且只重算网关自己补的那个。


4.5 会话状态与并发隔离

  • Session 类在 :790,每个会话自带 threading.RLock()(:797)。
  • get_session(:849)按 key 取会话,过期回收在 :854:now - s.updated > session_ttl_s。
  • session_ttl_s = 86400(24 小时),max_sessions = 64。
  • 会话 key 由 session_key(:867)从请求里推出来。

同一会话内部的组装状态必须串行改写,否则会出现「组装块 A 配历史 B」的错配。
这把 per-session 锁就是网关侧的上下文隔离。引擎侧的隔离靠批槽与停车(第 12 章),
两层各管各的粒度。

代价:同一会话内不能并行。对真人几乎无感,对压测脚本要注意——
压测必须用不同的 session key,否则测的是串行。


4.6 鉴权:会话与 key 的分工

网关的鉴权由三个方法完成:

_needs_credential(path)   # :1497  「唯一要凭据的就是『会跑模型』的那两个端点」
_gate(path)               # :1508  放行 / 401
_upstream_key(path)       # :1518  转发时要不要代持引擎 key

_INFER_PATHS 只有两个:/v1/chat/completions、/v1/messages(:2031)。
_ALWAYS_OPEN(:1479)与 _READ_OPEN(:1483)里是监控与状态端点:
/health、/rag/health、/rag/status、/metrics、/props、/slots、/status、
/v1/status、/mcp、/settings、/v1/models、/concurrency。

_upstream_key 的 docstring 把策略写得很清楚:

· 读类端点(监控页要用)→ 一律代持,这样不登录也能看监控;
· 对话端点 → 只有带会话才代持 —— 这正是本次改动的目的:不再把 key 分发出去;
· 其它路径(/load、/unload 这类)→ 不补,交给引擎自己按客户端的 key 判。

「不再把 key 分发出去」是这一层的核心目标:客户端只需要有集市登录会话,
引擎 key 由网关代持,用户手里没有引擎凭据。引擎 key 存在
api_key_file: /home/cloud/logs/strata-api-key.txt,配置文件里只有路径、没有 key 本身。

登录走 OAuth(ssecho_auth.py,306 行),config.json 的 oauth 段配置
app_id: ssestrabed、redirect_uri: https://ai.ssemarket.cn/oauth/callback、
secret_file: /home/cloud/logs/ssestrabed-oauth.json。

两处已核实的代码与注释不一致(值得修)

核对代码时发现两处实现与其自己的说明矛盾,都记在这里,因为它们都是真实风险:

  1. 带会话时,key 被代持到所有路径。 _upstream_key(:1518)第一条分支是
    if OAUTH is not None and self._session() is not None: return API_KEY,
    它先于「其它路径不补」的判断生效。而 /load、/unload、/settings、/mcp
    不在 _INFER_PATHS 里,_gate 不要求凭据就放行。结果:任何已登录用户都能用网关的 key
    驱动引擎管理端点。修法是把会话分支也限制在 _READ_OPEN ∪ _INFER_PATHS。

  2. /concurrency 无鉴权,而代码注释声称它已被鉴权。 监控端点刻意开放(docstring 说
    「这样不登录也能看监控」),这是设计选择;但附近注释写成「鉴权不在这里再判一次:上面 _gate
    已经保证了『有会话或有 key』」,与实际不符。开放监控面本身可以接受,错误的注释不能留——
    下一个人会基于它做安全假设。

引擎侧(serve/server.py)另有 Host 头校验,git log 里三条连在一起看很清楚:
e1a0f979 每个请求校验 Host(防 DNS rebinding)→ 9d9b92f6 无 key 的跨站浏览器请求直接拒
→ 7df6cbcf 带 API key 时跳过 Host 校验。第三条是前两条的修正:加了 Host 校验后,
合法的命令行客户端会被误拒,折中是「有 key 就放行」。这三条是一个很好的样本,
说明安全加固在真实服务上通常需要两到三轮才能收敛。


4.7 流式转发:逐块透传,不重新编码

_proxy(:1593)对 SSE 采取逐块透传:上游每个块原样 self.wfile.write(chunk) 加 flush(),
不解析、不重新封装。转发同时做三件轻活(:1660-1680):

  • 记录首块时间(TTFT 的来源);
  • 把块 memcpy 进推理记录缓冲(infer_store.capture,解析留给后台线程);
  • 计数用于并发监控。

计数方式值得注意,注释给了标定:

流式:一个 data: 事件 ≈ 一个 token(实测 28 块 ≈ 24 token 的回答),
比按字节估准得多(后者把 SSE 的 JSON 开销也算成内容,短回答能差百倍)。

还有一条 keep-alive 陷阱被明确记下(:1749):

HTTP/1.1 keep-alive 下同一个 Handler 实例会连着服务多个请求,而 self._rec 是实例属性

所以每个请求开头要显式重置记录上下文。这类「实例复用导致的串状态」是 HTTP 服务层最常见的错误来源之一。

本书核对说明:早期调研稿提到「上游 15 秒无产出时网关发心跳」。
在 rag_proxy.py 里检索 heartbeat / ping / 注释行均无命中,该说法不成立,本书不采。
长 prefill 期间连接之所以不断,靠的是逐块透传与中继侧的超时配置,网关本身没有主动心跳。


4.8 降级:RAG 失败绝不变成请求失败

文件开头把这条写成承诺(:23):

降级:嵌入/重排不可用时退化为「只保留最近窗口」,绝不因为 RAG 失败而让请求失败。

代码里的降级点分散但一致:

情况 行为 位置
嵌入不可用 向量缺失,检索退化为按角色与近因打分 embedding_worker :703,embed_errors 计数 :725
重排不可用 置 0 而不是拿余弦顶替 :1116
计数不可用 退化为上界估算,宁可多砍 :1271、:1971
上游不可达 记 degraded 计数,仍走窗口守卫 :1872-1874

:1116 那条特别值得停一下:重排器挂了,代码故意不用嵌入余弦顶替,
而是把重排分置 0。原因是这两种分数量纲不同、不可互换,顶替会产生一个「看起来在工作、
实际排序已经变形」的系统。宁可显式退化,也不要隐式替换。

副作用也要写清楚:重排分置 0 后,排序实际退化为「只按近因与角色」,
而 COUNTERS 里没有 rerank_unavailable 这个计数器——这条降级发生了运维并不知道。
这是第 19 章观测盲区清单里的一条。


4.9 观测面:/health 与 /concurrency

  • health()(:2154):计数器、会话数、组装块重建计数、嵌入缓存状态、两条上游可用性
    (ok / 部分不可用 / down,:2165)。
  • concurrency()(:2115):每一个在途请求的阶段、已用时、TTFT、已输出量、实时速度,
    加两条 lane 的引擎状态与 GPU 读数。它是前端并发监控台的数据源。

计数器集合(:141 起):requests、passthrough、assembled、degraded、
assembly_reused、assembly_rebuilt、block_rebuilds、embed_errors、embeds_done、
embeds_queued、rerank_cache_hits、rerank_calls、overfit_retries、overfit_failures、
upstream_errors。

必须记住的局限:/concurrency 是代理视角——它知道「有一个请求在飞」,
它不知道「这个请求占了引擎哪个批槽、那个槽现在在 prefill 还是在 decode」。
引擎的状态模型是单路的(第 19 章盲区 #1)。这个视角差是本项目可观测性缺口的核心。


4.10 2026-10-19 增补:窗口预检与 400 改写

素材与口径以 docs/reports/CONTEXT-COUNT-BILLING-2026-10-19.md §落地记录为准;
事故经过见 18.7,四层计数口径见 14 章。

为什么要加这一层

4.3 的三层守卫有一个共同前提:先放行,出错再补救。事故 6 暴露了这个前提的代价——
一个必然超窗的请求会真的打到引擎、真的花掉一次上游来回、然后把引擎的内部错误码 #545
原样交给用户,并且网关还会把 max_tokens 压到 1 再试一次。

新增的第四层把判断挪到转发之前:

层 时机 手段 结果
1 估算与钳制 转发前 字符估算 + clamp_max_tokens 输出预算压进剩余空间
2 照引擎答复重发 400 之后 读引擎给的余量,压 max_tokens 重发 只用于"输出超余量"
4 窗口预检(新) 转发前 调 serve 的 count_tokens 精确计数(只 tokenize,不跑模型) 真装不下 → 本地 400,不进引擎
3 强制收敛 组装内部 丢块后仍超预算继续砍 必须收敛到装得下

改了什么(rag-proxy 侧,当日已生效)

改动 效果
count_tokens() 加方言参数 OpenAI 请求走 serve 新路由 /v1/chat/completions/count_tokens,与 anthropic 方言同一渲染管线计数
新增 overfit_info() / overfit_error() 解析引擎超窗 400 里的实测 prompt / 窗口 / 余量,改写成 Anthropic 原生口径的干净错误;#545 内部文案不再透传
_proxy 的 400 分支改写 提示词本身超窗(room<1 或 prompt+slack ≥ 窗口)不再压 max_tokens=1 盲重试,直接回一次干净错误(省两个上游来回);只有"输出超余量"仍按引擎余量重试一次
no_rag 透传分支加窗口预检 上界估算说"可能装不下" → 先向 serve 精确计数;真装不下就地 400(计入 window_guard_rejects);装得下但输出超余量按实测压 max_tokens;serve 未升级返回 404 时自动降级为原样透传(兼容旧 serve)
留档口径 infer_store 仍存引擎原始错误正文(raw_payload),发给客户端的是改写后的 —— 排障看原始,用户看改写

serve 侧同步改了 _count_tokens 的 dialect="openai" 与新路由分发,并在三份 lane 配置里加
"fit_max_tokens": true("输出超余量"由 400 改为自动截断输出)。这部分要重启 lane 才生效,
18:52–18:55 经用户点名批准后完成。

端到端探针(30 万字「龘」)

观测点 数字
输入 300,000 个「龘」
引擎实测 prompt 600,052 token
:8080 返回 prompt is too long: 600052 tokens > 262136 maximum + prompt_tokens / max_context: 262144 / max_output_tokens: 0 + 中文 hint
上游来回次数 第一次重启后 1 次(serve 计数路由还没上线,预检降级)→ 第二次重启后 0 次(日志"本地拒绝(不进引擎、不浪费 GPU)")
事故当时的对照 2 次来回,客户端看到 #545 英文原文

注意 262136 这个数:它是 262144 - ctx_slack(8),不是笔误。窗口判定用带残差的边界,
错误文案里报的也是这个边界。

验证与未做完的部分

项 状态
rag-proxy/test_window_guard.py ALL PASS(6 s);顺带修了两处脱节的脚手架(A6 的 build_memory_block stub 4→5 元组;B/C 组强制 rag_enabled=True,因为生产自 10-19 起是 false)
两条方言计数一致性 8091 / 8092 上同一句话都是 56 token
serve 单测 test_server.py 131 条过 130(唯一失败是 Vue 迁移遗留的前端资源断言,与本次无关)
fit_max_tokens 生效证据只有"配置有效 + server.py 加载 + 进程已重启";行为级验证需要 ~230k token 的窗口边缘请求(70 s+ prefill),未在现网跑,留待观察 overfit_retries 是否趋零
中继窗口 256,000 vs 引擎 262,144 待转给中继维护方,本 Pod 够不着
待观察 1–2 天 infer 记录里 window_guard / 透传(no_rag·精确计数) 的分布与 window_guard_rejects 计数

时间口径冲突(按 README 约定 2 不静默选):报告写"rag-proxy 已重启,pid 3426352,15:33 北京",
而 supervisord.log 记录该 pid 生成于 08:41:42 UTC = 16:41 北京(当天没有任何 15:33 前后的进程事件)。
进程事件以 supervisord 为准,15:33 更像"改代码完成"的时刻(rag_proxy.py mtime = 15:28 北京)。

修复后的行号(当场 grep -n 核实,文件 2382 行 / mtime 2026-10-19 15:28 北京)

函数 4.3 里写的(修复前) 现在
clamp_max_tokens :243 :265
thinking_guard :264 :286(refit_thinking_budget :319)
overfit_room / patch_max_tokens :320 / :333 :342 / :355
overfit_info / overfit_error(新) — :376 / :397
count_tokens — :456–488
assemble :1301 :1255
no_rag 透传分支的窗口预检 — :2061–2069
盲重试调用点 — :1768(旧引用 :1715-1720 已漂)

"永不 400"这句话在 10-19 之后需要重新表述:它现在承诺的是
“不会因为太长而把 400 甩回你”——超长请求会被本地拒绝并给出实测数字,
而不是被硬塞进引擎再撞回来。承诺的边界从"不拒收"变成了"拒收得清楚"。


4.11 本章小结

  • 网关 2206 行,唯一公网面;改它只需重启 rag-proxy,引擎不动。
  • 分流靠模型名,两条 lane 跑完全相同的模型与参数,区别在服务等级。
  • 窗口守卫分三层:估算钳制(5.8% 残差逼出余量)→ 照引擎的 400 答复重发 → 强制收敛。
    2026-10-19 加了第四层:转发前的窗口预检(§4.10),并把"400 后压 max_tokens=1 盲重试"改成
    “提示词本身超窗就本地拒绝、给实测数字”。
  • 思考守卫补 reasoning_budget_tokens,代价只是「思考短一点」;客户端自己给过就不碰。
  • 会话 24h TTL、上限 64 个、per-session 锁;压测要用不同 session key。
  • 鉴权目标是「不再把 key 分发出去」:对话端点带会话才代持,监控端点开放。
    两处代码与注释不一致已核实并记录。
  • 流式逐块透传,data: 事件计数比字节估算准得多。
  • 降级承诺明确:RAG 失败绝不变成请求失败;重排不可用时置 0 而不用余弦顶替。
  • 早期调研稿的「15 秒心跳」「Host 白名单在网关」两处说法经核对不成立,已更正。

下一章:05 网关:检索组装与漂移重建 —— 1M 能力的全部机制,以及它在哪里失败。

本章出处

内容 出处
文件行数 2206 wc -l rag-proxy/rag_proxy.py,2026-10-19(当日修复后为 2382 行,mtime 15:28 北京)
分流与 lane 标签 rag_proxy.py:171,179;config.json model_routes / lane_labels
5.8% 计数残差 rag_proxy.py:243 docstring,测于 2026-10-08
工具定义导致低估 rag_proxy.py:231,391
照引擎 400 重发 rag_proxy.py:320,333(引擎 issue #545)
思考守卫实测次数 173 / 81 rag_proxy.py:264 docstring,测于 2026-10-08(引擎 issue #530 / #123)
会话 TTL 86400、上限 64 config.json session_ttl_s / max_sessions
鉴权三方法 rag_proxy.py:1497,1508,1518
两处代码与注释不一致 本次核对,rag_proxy.py:1518-1529 与 :1761-1767
引擎侧 Host 三轮加固 git log e1a0f979 / 9d9b92f6 / 7df6cbcf
降级承诺与置 0 决定 rag_proxy.py:23,1116,1271,1872-1874

上表除最后一行外,行号是 2026-10-19 修复前的锚点(当日改动让 rag_proxy.py 从 2206 行涨到 2382 行)。
修复后的锚点见 §4.10 末表与附录 A.3;引用前一律当场 grep -n。

§4.10 的出处:

内容 出处
窗口预检 / 400 改写 / 不再盲重试 的改动清单 docs/reports/CONTEXT-COUNT-BILLING-2026-10-19.md §落地记录
300,000「龘」→ 引擎 600,052;262136 边界 同上 §验证记录(第一次重启后 1 次上游来回);第二次重启后"本地拒绝"见 §TODO 2
两条方言计数一致(同一句话 56 token) 同上 §TODO 2,2026-10-19 18:55 后实测
test_window_guard.py ALL PASS(6 s)与两处脚手架修正 同上 §测试
serve 单测 131 过 130 同上 §TODO 2
fit_max_tokens 未做行为级验证 同上 §TODO 2(需 ~230k token 边缘请求,70 s+ prefill)
重启时间口径冲突(15:33 vs 16:41) 报告 §落地记录 vs ~/deploy/logs/supervisord.log(UTC,+8)
修复后的函数锚点 本书 2026-10-20 当场 grep -n(附录 A.3)
全部章节