第 4 章 网关:分流与守卫
第 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.pyissue #530)
翻译过来:模型一路思考到 max_tokens 上限,想完了,但没有答案。
docstring 给出了实测规模(测于 2026-10-08):
strata-a173 次、strata-b81 次;受害最重的是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。
两处已核实的代码与注释不一致(值得修)
核对代码时发现两处实现与其自己的说明矛盾,都记在这里,因为它们都是真实风险:
-
带会话时,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。 -
/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) |