跳到主要内容

数据截至 (上游 commit 7fb95fe9048f)

AI 网关:一个 Go 拦截器链上的治理层

30 秒导读: services/aigateway 是 LangWatch 的 Go 数据面,坐在「你的 agent」和「OpenAI / Anthropic / Bedrock…」中间。它拿一把叫虚拟 key 的假凭据换出真凭据,顺手在同一条拦截器链上做 spend 登记、鉴权、限流、策略、模型解析、缓存改写、预算和护栏,再把这次调用写成一条客户自己项目里能看到的 OTel span,并把账记进一条异步的 spend 命令流。全链自身开销约 0.24 μs,慢的部分要么缓存掉了,要么甩给了异步旁路。

先澄清一个容易误会的边界: 网关不是一支单独编译的二进制。它和 langyagent / nlpgo 一起被编进同一支 mono-binary,靠第一个命令行参数分派(cmd/service/main.go:46services map,三支服务各注册一个 ServiceBoot)。所以「独立」指的是它跑在自己的进程里、有自己的一套依赖组装,不是「仓库里唯一的 Go 产物」。

本章只讲这一支服务。span 落库成 tracetrace 处理管线 的事,评估器怎么算分评估层 的事——本章只讲和它们的对接边界(§7.4、§7.5)。


1. 这是什么(零基础也能懂)

1.1 一句话定义

AI 网关 = 一个 OpenAI / Anthropic 协议兼容的反向代理,它比普通反向代理多懂一件事:请求体里那段 JSON 的含义。

普通代理只认 URL 和 header。这支网关会拆开 body 看你要调哪个模型、带了哪些工具、要不要流式,然后据此决定放行、改写还是拒绝。

1.2 先补五个名词

名词一句话解释
数据面 / 控制面数据面(data plane)是每个请求都要穿过的那条路(这支 Go 服务);控制面(control plane)是管配置和账本的地方(LangWatch 的 Next.js 应用)。数据面要快,控制面可以慢
虚拟 key(virtual key,VK)发给使用者的假凭据,形如 vk-lw-…。真正的 sk-… 只存在控制面,使用者从头到尾看不到
Bundle一把虚拟 key 解析出来的全部东西:真凭据链、预算、限流、护栏、策略、模型别名。见 services/aigateway/domain/bundle.goBundle
拦截器(interceptor)一个「包在下一层外面」的函数。多个拦截器套起来像洋葱:请求由外向内穿进去,响应由内向外穿回来
护栏(guardrail)对请求或响应内容做安全 / 合规判定的检查器,判定结果只有放行 / 拦截 / 改写三种

1.3 要解决的问题(场景化)

假设你在一家公司里管着 40 个工程师的 Claude Code。你面对四件互不相关的麻烦:

  • 钥匙——你不想把 Anthropic 的 sk-ant-… 发给 40 个人;一旦谁离职,你得换钥匙、通知全员。
  • ——月底账单是一个总数,你不知道是谁烧掉的、烧在哪个模型上。
  • 红线——你不想让某人从 agent 里调用一个连着生产数据库的 MCP 工具。
  • 可见性——出事时你想看到「那一次调用发生了什么」,而不是只有一张账单。

这支网关就是这四件事的单点答案:把 base URL 从 api.anthropic.com 换成网关地址,其它什么都不用改。

1.4 用起来什么样

对使用者,它就是一个换了域名的 OpenAI / Anthropic 端点:

# 完全是 OpenAI SDK 的老样子,只有 base URL 和 key 变了
curl http://localhost:5563/v1/chat/completions \
-H "Authorization: Bearer vk-lw-xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"hi"}]}'

有意思的是回来的响应头——治理结果是通过 header 侧信道告诉你的:

响应头含义
X-LangWatch-Gateway-Request-Id这次调用的唯一 id,同时是记账幂等键
X-LangWatch-Fallback-Count主凭据挂了几次、换了几把才成功
X-LangWatch-Budget-Warning预算快到顶了(但没拦你)
X-LangWatch-Cache-Mode缓存规则命中后的动作(respect/disable/force)
Traceparent网关给这次调用建的那条 span 的 W3C id

依据:services/aigateway/adapters/httpapi/router.gosetMetaHeaders(在响应写出路径 :215:225 被调用)。

1.5 一句话直觉

把它当成 nginx,只不过这台 nginx 会读 JSON body、会算钱、会打 OTel span。

nginx 的所有配置来自静态文件;这台网关的所有配置来自「用户这把 key 解析出来的 Bundle」——每把 key 一份策略,所以配置是每请求动态取的,于是「怎么把取配置这件事变得不慢」成了整个设计的主轴(§6)。


2. 顶层全景(它大概怎么转)

2.1 进程边界

怎么读这张图:实线是每个请求都要走的同步路径,虚线是异步 / 旁路。① 是本章讲的那个进程,② 是它进程内的供应商路由库(不是独立服务),③④ 是两个外部端点。

agent / SDK / claude-code
│ Bearer vk-lw-…

┌──────────────────────────────────────────────┐
│ ① AI 网关(Go,:5563) │
│ chi 路由 → 鉴权中间件 → 八层拦截器洋葱 │
└───┬───────────────┬──────────────────┬───────┘
│ 同步 ┊ 冷缓存才走 ┊ 异步
▼ ┊ ┊
┌─────────────┐ ┊ ┊
│ ② Bifrost │ ┊ HMAC 签名 ┊ 本地磁盘 spool 的
│ 多供应商路由 │ ┊ ┊ spend 命令流(admit/
└──────┬──────┘ ▼ ▼ confirm/fail)
│ ┌───────────────┐ ┌──────────────────┐
▼ │ ③ 控制面(TS) │ │ ④ 客户自己的项目 │
OpenAI / │ 发 JWT / 配置 │ │ 的 OTLP 端点 │
Anthropic / │ spend 落账 │◄─┘ (trace 落库)
Bedrock … └───────────────┘ (span 批量异步)

关键在于慢的东西都不在实线上:控制面调用只在鉴权缓存冷的时候发生,trace 导出是批量异步的,spend 记账走磁盘 spool 再由 drainer 批量外送,预算扣减压根不在网关里做(§4.5、§7.4)。

2.2 部件一句话职责

部件干什么在哪
mono-binary 分派按第一个命令行参数在 aigateway / langyagent / nlpgo 之间选一个启动cmd/service/main.go:46services map
cmd/root.go本服务的 ServiceBoot:读配置 → 造依赖 → 组 App → 起服务services/aigateway/cmd/root.go:15
config.go环境变量到结构体,含遗留变量名兼容层config.go:209 LoadConfig
deps.goDI 组装根:所有基础设施适配器在这里 new 出来deps.go:70 NewDeps
serve.gochi handler + pkg/lifecycle 优雅启停serve.go:23 Serve
domain/纯类型,零依赖:Bundle/ResolvedModel/三种判定domain/bundle.godomain/verdict.go
app/编排层:拦截器链的构造顺序、协议入口、核心 dispatchapp/app.go:77 buildInterceptors
app/pipeline/拦截器框架本身 + 八个具体拦截器app/pipeline/pipeline.go:159 Interceptor
adapters/基础设施实现:鉴权缓存、控制面客户端、Bifrost、spend spool、OTel 桥adapters/*/
dispatcher/给进程内调用者(services/nlpgo)的窄入口,绕过全部治理dispatcher/dispatcher.go

2.3 主线走一遍(不进代码)

① HTTP 层 读全 body → 从 body 里 peek 出 model 和 stream

② 鉴权中间件 Bearer/x-api-key/x-goog-api-key → Bundle(三级缓存)

③ 八层洋葱 spend登记 → 限流 → 策略 → 模型解析 → 缓存 → 预算 → 护栏 → trace

④ 核心 dispatch 按解析出的模型裁剪凭据链 → retry.Walk 逐个试

⑤ Bifrost 翻译成供应商原生协议 → 真实 HTTP 调用

⑥ 回程 护栏 post → trace 收尾 → 响应头 → JSON 或 SSE → spend 结算入 spool

2.4 四层目录就是六边形架构

规则例子
domain/不 import 任何本项目之外的东西(除 pkg/herr)domain.Bundledomain.BudgetDecision
app/ports.go消费者定义的接口(端口)AuthResolverProviderRouterBudgetChecker(:30Precheck)
app/ + app/pipeline/只依赖端口和函数类型,不认识任何具体实现pipeline.RateLimit(allow AllowFunc)
adapters/实现端口,认识 Redis / HTTP / OTel / Bifrostadapters/ratelimit.Limiter

有个细节值得单独点出:拦截器连端口接口都不认识,只认函数类型app/app.go:81 传进去的是 a.ratelimit.Allow 这个方法值,而不是 a.ratelimit 这个接口。这让 app/pipeline 包对 app 包零依赖——它是一个可以整包搬走的框架。


3. 洋葱:拦截器链是怎么搭起来的

3.1 它要解决的小问题

八个治理动作,每个的「插入时机」都不一样:

  • spend 登记:最先,连被后面某层拒掉的请求也要登记(账要完整)。
  • 限流、策略:只在请求前卡一刀,卡住就不用往下走。
  • 模型解析、缓存改写:在请求前,但要改写 body
  • 预算:请求前判定。
  • 护栏:请求前响应后流式的每个 chunk,三个时机。
  • trace:请求前开 span,响应后(或流关闭后)收尾。

如果写成一长串顺序调用,「响应后」和「流关闭后」这两类逻辑就得散落在 dispatch 之后的各个分支里。洋葱模型的价值就是让「前」和「后」写在同一个函数里

3.2 Interceptor 的形状:sync 与 stream 各一份

type Interceptor struct {
Name string
Sync func(next DispatchFunc) DispatchFunc
Stream func(next StreamFunc) StreamFunc
}

依据:services/aigateway/app/pipeline/pipeline.go:159(DispatchFunc/StreamFunc:19:22)。

为什么必须是两份而不是一份? 因为两条路的返回类型根本不同:同步返回 *domain.Response(一坨完整的字节),流式返回 domain.StreamIterator(一个还没开始吐字节的迭代器)。护栏在同步路上可以直接看响应体,在流式路上却只能包一层迭代器,等 chunk 一个个出来再看(§4.7)。这个差异藏不掉,索性摊开在类型上。

3.3 Build 的方向:切片第一个是最外层

sync := syncTerminal
for i := len(interceptors) - 1; i >= 0; i-- {
if interceptors[i].Sync != nil {
sync = interceptors[i].Sync(sync)
}
}

依据:services/aigateway/app/pipeline/pipeline.go:174Build倒着包,所以切片里排第一的最后被包上,成为最外层。

配合 app/app.go:77-118buildInterceptors 追加顺序,实际洋葱长这样:

请求 ──►┌ spend(最外:登记一切进入的请求)────────┐
│ ┌ ratelimit ──────────────────────────┐│
│ │ ┌ policy ──────────────────────────┐││
│ │ │ ┌ model_resolve ────────────────┐│││
│ │ │ │ ┌ cache ─────────────────────┐││││
│ │ │ │ │ ┌ budget ─────────────────┐│││││
│ │ │ │ │ │ ┌ guardrails ─────────┐ ││││││
│ │ │ │ │ │ │ ┌ traces ────────┐ │ ││││││
│ │ │ │ │ │ │ │ coreDispatch │ │ │ │││││
│ │ │ │ │ │ │ │ (凭据链+Bifrost)│ │ │ │││││
│ │ │ │ │ │ │ └─────────────────┘ │ │ │││││
│ │ │ │ │ │ └─────────────────────┘ │ │││││
│ │ │ │ │ └─────────────────────────┘ │││││
│ │ │ │ └─────────────────────────────┘││││
│ │ │ └────────────────────────────────┘│││
│ │ └────────────────────────────────────┘││
│ └────────────────────────────────────────┘│
└────────────────────────────────────────────┘ ◄── 响应

两个要点:

  • spend 是最外层,这是刻意的设计(注释原文:"Spend is OUTERMOST so every request that reaches the pipeline admits a spend record, including ones the chain itself rejects further down")——连被限流拒掉的请求也要在账上留下「来过」的痕迹。
  • traces 是最内层。 README 里 Auth → RateLimit → … → Dispatch → Trace 那行(services/aigateway/README.md:109)读起来像 trace 排在 dispatch 之后,代码里它是包在 dispatch 外面的最内一层——所以 span 的时长只覆盖 dispatch,不覆盖护栏和限流。这是有意的:客户 trace 里那条 span 应该是「模型调用花了多久」,不是「网关处理花了多久」。

3.4 PreOnly:只卡一刀的语法糖

多数层(限流、策略、模型解析、缓存)只需要「在前面判一下,不过就返回错误」。PreOnly 把这件事的 sync/stream 两份样板一次写完:

func PreOnly(name string, gate func(ctx context.Context, call *Call) error) Interceptor

依据:services/aigateway/app/pipeline/pipeline.go:222。传一个 gate 函数进去,它同时生成 Sync 和 Stream 两个包装器,逻辑完全一样。

3.5 Call 与 Meta:链上唯一的可变状态

type Call struct {
Bundle *domain.Bundle
Request *domain.Request
Meta *Meta
}

依据:pipeline.go:25(Meta:41)。

Call 在整条链上是同一个指针——所以 model_resolve 改写的 body、cache 记下的 CacheModecoreDispatch 数出来的 FallbackCount,最后都能被 HTTP 层一次读走。Meta 的字段就是 §1.4 那些响应头的来源。链跑完之后 Meta 会以值拷贝的形式塞进结果,HTTP 层拿到的是快照,不会和还在跑的流式 goroutine 打架。

3.6 MaterializeBody:懒读 body 的省钱设计

domain.Request 同时有 Body []byteBodyReader io.Reader 两个字段。谁需要看 body,谁自己调一次:

func (c *Call) MaterializeBody() error {
if c.Request.Body != nil {
return nil // 已经读过了,直接用
}
...
}

依据:pipeline.go:246

幂等是关键:policymodel_resolvecacheguardrailcoreDispatch 都可能调它,但真正的 io.ReadAll 只发生一次。而且每一层调用之前都先判「这层根本不需要吗」——没规则就不碰 body。

有个反差值得知道:/v1/chat/completions 这条路上 HTTP 层其实已经把 body 整个读完了(router.go:203readFullBody),再用 reader 交给 App。懒读机制留着,是给 dispatcher/ 这类别的入口和未来的真流式 body 用的。

3.7 关键细节:nil 依赖 = 不装那一层

buildInterceptors 每一层前面都有 if a.xxx != nil(app/app.go:79 起)。少注入一个 Option,那一层就整个不存在——不是「存在但空转」。测试里可以只装一层来测;生产里 Option 全给上。


4. 逐层拆拦截器

4.0 八层速查

#层名文件时机失败怎么办
0spendapp/pipeline/spend.go最前 + 结算不失败(本地 spool)
1ratelimitapp/pipeline/ratelimit.go:14直接 429
2policyapp/pipeline/policy.go:20直接 403
3model_resolveapp/pipeline/resolve.go:26前 + 改写 body400
4cacheapp/pipeline/cache.go:17前 + 改写 body不失败
5budgetapp/pipeline/budget.go:26拦 402 / 警告不拦
6guardrailsapp/pipeline/guardrail.go:25前 + 后 + 每 chunk拦 403;评估出错则放行
7tracesapp/pipeline/trace.go:64包住 dispatch不失败

4.1 ratelimit —— 每把 key 两个令牌桶

拦截器本身只有一行有效逻辑:把 Bundle.VirtualKeyIDBundle.Config.RateLimits 交给 allow,错了就包成限流错误(app/pipeline/ratelimit.go:14-18)。

真正的实现在 adapters/ratelimit/limiter.go:64Allow。三个设计点:

  • 两个桶,一次预约:RPM 和 RPD 各一个 golang.org/x/time/rate 限流器。RPD 桶被拒时,要把已经预约成功的 RPM 名额退回去(limiter.go:90rpmRes.CancelAt(now))。少了这一步,一个被日限拦下的请求还是会消耗掉一个分钟配额。
  • LRU 兜底:key 太多时老的被淘汰,等于放行——这是刻意的宽松
  • 配额变了自动重建:sameCeilings 比对桶的 burst 和当前配额,不一致就在锁里重建。所以管理员在控制面调高 RPM,不需要网关重启。

局限(诚实): 这是单进程内存限流。多副本部署时,每个 pod 各自持有一份配额——实际总放行量是 副本数 × RPM。代码里没有任何分布式协调。

4.2 policy —— 从 body 里挖出工具名、MCP 名和 URL

规则的形状是「正则 + 类型(deny/allow)+ 目标」。目标有四种:tool / mcp / url / model

难点不在匹配,在**「从一坨供应商方言 JSON 里把候选串挖出来」**:extractToolNames 认 OpenAI 的 tools[].function.name 和 Anthropic 的 tools[].name;extractURLs 全文扫描不管 URL 出现在哪个字段;model 读顶层 model 字段(全在 adapters/policy/matcher.go)。

URL 那条是最粗暴也最实用的:逐字节找 http:// / https://,再把结尾的 ,.;:)]}\ 当垃圾剥掉。为什么不解析 JSON 结构?因为模型生成的工具参数里 URL 可以嵌在任意深度的任意字段,结构化解析反而漏得更多

deny 与 allow 的语义不对称,这点容易踩:

  • deny:任一候选命中任一 deny 规则 → 拒。
  • allow:某个 target 一旦有了 allow 规则,该 target 的每个候选都必须至少命中一条,否则拒。

编译过的正则缓存在 sync.Map 里,所以热路径上不重复编译。

还有一个新接线值得注意:模型规则由 model resolver 执行——policy 拿不到解析后的模型 id 就没法执行模型规则,Policy(a.policy.Check, a.models != nil) 的第二个参数就是这个能力声明;带模型规则却没配 resolver 的 Bundle 会被拒绝服务而不是静默放行(app/app.go:83-90 的注释:"an unenforced deny rule is the one failure mode that looks exactly like a working one")。

4.3 model_resolve —— 别名 → 显式 → 隐式

三级下降,命中即停:

请求体里的 model 字符串

├─① 在 Bundle 的 ModelAliases 里? ──► 别名给出 provider+model,收工
│ ("fast" → {anthropic, claude-haiku-4-5})

├─② 含 "/" ? ──► 显式:切成 provider/model,provider 名归一化
│ ("bedrock/anthropic.claude-…")

└─③ 都不是 ──► 隐式:provider 留空,留给凭据选择阶段去猜
("gpt-5-mini")

依据:adapters/modelresolver/resolver.go:94Resolve;归一化表 normalizeProvider 只认 azure_openai|azurevertexbedrockgemini 等少数写法。

解析完如果规范模型名和原始不同,就地改写 body(app/pipeline/resolve.go):rewriteModel 用全量 unmarshal/marshal——比后面缓存那层的 sjson 外科手术贵,但只在别名或显式前缀被用到时才触发,不是每请求。

4.4 cache + cachecontrol —— 替客户改 Anthropic 的 cache_control

Anthropic 的 prompt cache 靠 body 里的 cache_control: {"type":"ephemeral"} 标记打开。问题是:这个标记是客户端 SDK 写死的,运维想统一管却够不着。这一层就是替运维伸手进去改。

规则匹配在 adapters/cacherules/evaluator.go:22Evaluate:按 Priority 升序排(数字小的优先),第一条匹配上的规则给出动作。匹配器之间是 AND,匹配器内部的值列表是 OR(matchesRule,:47)。

其中一个决定值得单独说:「匹配器为空 = 不过滤」,但「匹配器非空却拿不到数据 = 不匹配」。理由是接线缺失必须 fail-safe——否则一条本该只管一小撮 VK 的规则会悄悄套到全量流量上。

动作有三种,落到 body 上是 app/pipeline/cachecontrol.goapplyCacheControl:

动作做什么
respect什么都不做,原样返回
disable删掉 body 里所有 cache_control
force给最后一个 system 块和最后一条消息的最后一个 content 块注入 ephemeral

disable 的实现(cachecontrol.go:106stripCacheControl)有两处巧思:

  1. bytes.Contains 探一下再干活(:86)——不含这个字符串就直接返回,把绝大多数请求挡在昂贵路径外。
  2. 只删对象的键,不删数组元素——先收集路径、后批量删除时,数组下标不会因为前面的删除而漂移。

force 只对 Anthropic Messages 协议生效,因为这是 Anthropic 特有的协议特性。

4.5 budget —— 只前置放行,不事后扣减

这一层是全章最容易被误解的地方,所以先把结论摆出来:

网关不扣钱。 包注释(adapters/budget/budget.go:1-9)明写「Debits are NOT sent from the gateway hot path」:网关通过 spool 发 spend 命令,控制面的 process manager(platform/app/ee/governance/process-manager/gatewayDebits.process.ts)才是写 ClickHouse 账本的那一方——单一事实源,没有 PG 双写。

Precheck(budget.go:85)在已缓存的 Bundle 快照上做纯算术:limit 减 spent,on_breach=block 且余额 ≤ 0 就拦,warn 档在 90% 时告警。零 IO、纳秒级。

拦截器侧(app/pipeline/budget.go:26)把判定翻成三种行为:allow 什么都不做、block 返回 402、warn 往 Meta.BudgetWarnings 塞一条请求照常走。

precheck 自己出错怎么办? 记一条 warn 日志然后放行。这是全链最重要的一次取舍:预算是软约束,宁可少收一点钱也不能因为账本抖动就把所有人的 agent 打死。

那钱到底在哪扣?答案在 §7.4——扣在 Node 侧的 spend 结算管线里。

提示语的措辞也是设计。 预算超限的消息(app/pipeline/budget.go:121BudgetBreachError)刻意避开 "credit"、"billing" 这类词。原因:Claude Code 这类包装客户端会模式匹配 credit 信号,然后覆盖一层自己的充值 UI——而一个被组织托管的用户根本不拥有那个供应商账号,看到充值链接只会更困惑。

4.6 spend —— 每个请求两条腿:admit 与 outcome

最新的一层(app/pipeline/spend.go),也是记账正确性的基石。每个进入管线的请求先记一条 admission(SpendAdmission:谁、哪个项目、哪把 VK、请求的模型、trace id),结局(成功/失败)再补一条 outcome。admission 里带 TraceID,注释明说这是为了让 spend 记录不依赖 span 管线送达与否就能和 trace 关联。

它只往本地内存队列写,永不阻塞热路径(§7.4 讲它怎么落地)。

4.7 guardrails —— 三个时机、两种包装、一条 fail-open 底线

先看时机表:

时机检查对象出错怎么办
pre请求 body记 warn 日志,放行
post同步响应 body记 warn 日志,放行
stream_chunk流式的每一个 chunk静默 fail-open

同步路径(app/pipeline/guardrail.go:25)就是标准的洋葱前后夹:pre → next → post,两侧任一给出拦截判定就返回 403。

流式路径没法这么写——next 返回时一个字节都还没产生。于是它包一层迭代器(guardrailStreamWrapper,:159):每次 Next 先让内层前进,再评估刚拿到的 chunk,判拦就把内层关掉并让迭代提前结束。注意这意味着被拦的那一刻之前的内容已经发给客户端了——流式护栏只能止血,不能回收。

还有个容易漏的细节:包装器必须转发 RawFraming(),否则 Gemini 直通流经过这层包装后,写出端就认不出「这些字节已经是成品 SSE 帧了」,会再套一层 data: … \n\n 把响应打坏。traceStreamWrapper 也做了同样的转发。

fail-open 的执行位置有讲究:pre/post 在拦截器里判错误就只记日志,而 chunk 的 fail-open 下沉到了适配器里——因为 chunk 路径上「超时」是常态而非异常,不该每次都惊动上层。

曾经是 stub,现在是真判定。 旧版本这里有个诚实的边界说明:控制面 /guardrail/check 曾是恒返回 allow 的 stub,且 TS/Go 两侧字段名对不上。本 commit 里两端都已经补齐——TS 侧真跑 GatewayGuardrailEvaluationService.check(...)(platform/app/src/server/routes/gateway-internal.ts:707-739),Go 侧的响应结构体注释明确要求与控制面线格式字节兼容、并记录了旧版读 action 字段读不到的历史(adapters/controlplane/guardrails.go:84-89)。

4.8 traces —— 把 dispatch 括起来,也把失败括起来

这一层给客户开一条 span,然后在 dispatch 返回后收尾(app/pipeline/trace.go:64)。

最值得学的是错误路径也要收尾:失败时会先跑 classifyUpstream(trace.go:27)把供应商状态码翻成短标签,再连同状态码一起盖到 span 上,于是 trace 里那一行是红的而不是消失的。

流式版的收尾更麻烦,traceStreamWrapper(:163)要同时解决三件事:

① 累积响应体,还要有上限。 流式响应的完整文本只有把 chunk 拼起来才有,所以包装器边转发边攒,上限 8 MiB(:218responseBodyCap 注释)。超限只丢 trace 里的副本,客户端照收不误。

② 只收尾一次。 Next 走到头和客户端主动 Close 都会触发收尾,靠 sync.Once 保证只跑一次。

③ 用对 context。 收尾时用的不是调用方传进来的 ctx——HTTP 请求 context 在流结束时可能已经被取消了,pkg/forkedcontext.ForkWithTimeout 剥掉取消信号、留下值,再给几秒去把 span 送出去。


5. 核心 dispatch:模型感知的凭据链

洋葱的最里层是 coreDispatch(app/dispatch.go)。它做三件事:裁剪凭据链 → 逐个试 → 改写治理消息。

5.1 先裁剪:别拿 OpenAI 的 key 去调 Claude

一把个人 VK 可能同时绑着 Anthropic + OpenAI + Gemini 三家的凭据。用户请求 "claude-3-5-sonnet"(隐式,没有 provider 前缀),如果 OpenAI 排在链子前面,就会先白白打一次 OpenAI 再 fallback——一次多余的 RTT 加一条噪音日志。

eligibleCredentials(app/eligible.go:150)解决这个:

resolved.ProviderID 非空?
├─ 是 ──► 只留 ProviderID 相同的凭据
└─ 否 ──► inferProviderFromModel(模型名前缀) 猜一个
├─ 猜到 ──► 只留匹配的
└─ 猜不到 ──► 原样返回,别乱动
过滤后空了? ──► 原样返回原链(安全网)

inferProviderFromModel(:158)是一张刻意很短的前缀表:claude- → Anthropic,gpt-/o1-/o3-/o4-/… → OpenAI,gemini- → Gemini。Bedrock 和 Vertex 故意不在表里:用户裸写 claude-3-5-sonnet 时,友善的答案是走 Anthropic 官方 API;要走 Bedrock 就该显式写 bedrock/anthropic.claude-…

那个「过滤后为空就还原」的安全网写得很克制:错的 provider 至少能从 Bifrost 拿到一句清楚的报错,而空链只会给调用方一个含糊的 internal error。

5.2 retry.Walk:一个通用的链式走法

resp, el, err := retry.Walk(ctx, a.retryOpts(call.Bundle), credentialIDs(creds),
func(ctx context.Context, slotID string) (*domain.Response, error) {
return a.providers.Dispatch(ctx, call.Request, findCredential(creds, slotID))
}, classifyProviderError)

依据:app/dispatch.go:32(同步)与 :63(流式);引擎在 pkg/retry/retry.go:165Walk(泛型,同时服务同步和流式两条路)。

引擎的设计点:EventLog 走对象池、默认触发集是包级只读 map、断路器是可选口(BreakerChecker,retry.go:46)。

断路器已经接线。 retryOpts(app/dispatch.go:297-302)现在会把注入的 breaker 传给 Walk,注释写明意图:「一个一直在失败的凭据被直接跳过,别让每个请求都为它再付一次死往返」。旧版本「存在但没接」的边界已经消除。仍有一个遗留:控制面下发的 FallbackConfig 触发条件列表(config_wire.go:225)只翻译了 MaxAttempts,没有映射成 retry.Options.Triggers,Walk 仍用默认触发集——触发条件的配置项暂不生效

5.3 4xx 绝不换 key

classifyProviderError(app/dispatch.go:325)是这段里最有价值的一小段判断:429 和 5xx 可重试,其余 4xx 立即终止。拿「余额不足」或「请求非法」去试下一把 key 毫无意义,只会拖慢终态错误抵达客户端。

5.4 把「供应商账号没钱了」翻译成组织的话

applyGovernanceMessage(app/governance.go:33)在 dispatch 之后、返回之前跑一遍。它识别账号级枯竭(isAccountExhaustion):insufficient_quota 错误码、HTTP 402、或 400 带 "credit balance" 字样。命中就只改 error.message 一个字段,状态码、错误类型、request_id 全部保持原样——重试语义一个字节都没动。

注意它明确排除了「普通 429 限流」:按终态错误码判,绝不按裸状态码判,因为 OpenAI 的 rate_limit 429 是可重试的,而带 insufficient_quota 的 429 是终态的。

5.5 Bifrost 与 raw-forward

adapters/providers/bifrost.goDispatch 按请求类型分岔。其中最有意思的取舍:

  • /v1/chat/completions(OpenAI 形状) → 交给 Bifrost 归一化解析,翻译成供应商原生协议,再把响应翻回 OpenAI 形状。
  • /v1/messages(Anthropic 形状)不解析,走 Bifrost 的 raw-forward。理由:过一遍 OpenAI 解析器会静默丢掉 thinking 这类 Anthropic 独有字段。

响应侧对应地也做了 raw 判断:/v1/messages 的调用方(Anthropic SDK、claude-code)要的是供应商原生响应结构,所以命中 raw 就把字节原样返回。Gemini 直通(/v1beta/*)同理,是否流式由 URL 后缀决定(router.go:156 的注释)。

dispatcher/dispatcher.goDispatcher 是同一个 Bifrost 路由的进程内窄入口,给 services/nlpgo 用。包注释把边界列得很清楚:跳过虚拟 key 鉴权、限流、预算、缓存、护栏;不跳过供应商路由、错误分类与重试、流式原字节保留。


6. 鉴权:三级虚拟 key 缓存

这是全服务对可用性最敏感的一块——控制面抖一下,不能让 40 个人的 agent 集体 401。

6.1 三级下降

Resolve(rawKey)
│ h = sha256(rawKey) ← 缓存键从不是明文

┌─ L1:进程内 LRU
│ ├─ 未软过期 ──────────────────────► 直接返回;快到期就后台刷
│ ├─ 软过期但没到硬顶 ───────────────► 前台刷;刷不动就服务陈旧副本
│ └─ 超过硬顶 ──────────────────────► 驱逐,往下走

┌─ L2:可选外部存储(接口 L2Store)
│ └─ 命中 ──► 回填 L1 ──► 返回

└─ L3:控制面 /resolve-key
└─ 拿 JWT → 本地验签 → 再拉一次 /config → 组装 Bundle

依据:adapters/authresolver/service.go:430Resolve;hashKey:1055;L3 在 :517 resolveFresh

注意 L3 是两次调用:ResolveKey(adapters/controlplane/client.go:103)只拿到一个带身份声明的 JWT(claims 到 Bundle 的翻译在 :453claimsToBundle),策略配置得再拉一次 FetchConfig(:286)。第二次失败是非致命的——记条 warn 就返回,Bundle 带着空配置继续服务。

6.2 软 / 硬两个到期时间

type entry struct {
mu sync.Mutex
bundle *domain.Bundle
softExpiresAt time.Time // 可被推后
hardExpiresAt time.Time // 插入时定死,永不变
}

依据:adapters/authresolver/service.go:139

  • softExpiresAt 初值 = JWT 的 exp。
  • hardExpiresAt = JWT exp + HardGrace
  • 每次传输类刷新失败,soft 往后推一小段(SoftBump),但永远被 hard 夹住

HardGrace 设成 0 就是关掉这套机制,退回「JWT 一过期就硬失败」的老行为——注释里写明这是有意的退出开关。

6.3 分类比重试更重要

func classifyRefreshError(err error) refreshErrorClass {
if err == nil { return classNone }
if errors.Is(err, domain.ErrInvalidAPIKey) || errors.Is(err, domain.ErrKeyRevoked) {
return classAuthRejection
}
return classTransportFailure
}

依据:adapters/authresolver/service.go:322

分类含义动作
classAuthRejection控制面明确说「这把 key 无效 / 已吊销」(401/403/404)立刻驱逐,没有宽限期
classTransportFailure网络错、5xx、解析失败、验签失败推 soft,继续服务陈旧副本

分类函数对未知错误保守地归到 transport:宁可多服务一会儿一把好 key,也不要因为一个没见过的错误形状把它误杀——运维重建 VK 的代价远高于短暂多服务。

前台路径 refreshOrServeStale 和后台路径 refreshBackground 用同一套分类,区别只在后者是 fire-and-forget、带超时。

6.4 change feed:不等 JWT 到期就失效

只靠 JWT TTL 意味着「管理员把预算调低」要等最多 15 分钟才生效。于是有一条长轮询的变更流。

changeFeedLoop(每个活跃 org 一个游标)
│ GET /changes?organization_id=…&since=…(长轮询挂起)

控制面挂住一段时间
├─ 200 + changes[] ──► applyChange 逐条驱逐 L1
└─ 204 + 新 revision ──► 只推进游标

依据:adapters/authresolver/service.go:729 changeFeedLoop;applyChange:754 附近;客户端在 adapters/controlplane/client.go:195 PollChanges

三种事件对应三种驱逐谓词:

事件驱逐谁
PROVIDER_BINDING_UPDATED配置里含该 credential id 的 Bundle(精确匹配)
BUDGET_UPDATEDproject 下所有 Bundle(budget_id 在 Bundle 里不出现,project_id 是唯一稳定的连接键)
VIRTUAL_KEY_UPDATED该 VK 的 Bundle(精确匹配)

工程细节里有一处「踩过坑」的痕迹:驱逐是 O(N) 全表扫(evictWhere)。注释坦承这一点:1 万条量级 + 管理员变更的低频,可以接受。

6.5 和控制面之间那条 HMAC 通道

网关 → 控制面的每个内部调用都签名,规范串是四行:

METHOD \n PATH \n TIMESTAMP \n hex(sha256(body))

两端实现:Go 侧 adapters/controlplane/signer.go:29 NewSigner / Sign,TS 侧 platform/app/src/server/routes/gateway-internal.ts:114 buildGatewayCanonicalString。反方向(控制面 → 网关的 /internal/*)用同一套,验证在 adapters/httpapi/internal_middleware.go:54 InternalAuthMiddleware

三个值得学的点:

  1. 验证顺序刻意是「先比签名、后查时间戳」(internal_middleware.go:39-45 把步骤列成了清单)。倒过来的话,攻击者能从响应快慢分辨出「签名错」还是「重放了」。
  2. 签名路径三重池化:HMAC 实例池、scratch buffer 池、mac.Sum 直接写进已经不需要的 canonical 数组。
  3. 空 secret 一律 fail closed:NewSigner 直接返回错误,InternalAuthMiddleware 返回一个只会拒绝的 handler。

时间窗两端都是 300 秒(internal_middleware.go:39 的注释把窗口写进了契约)。


7. 把每次调用变成租户可见的 trace 和账

7.1 两条 trace,互不串味

网关自己的 trace客户的 trace
谁看LangWatch 运维客户自己
谁建gatewaytracer.Middlewarecustomertracebridge.Emitter
用哪个 TracerProvider全局的(pkg/otelsetup 注册)私有的
采样低采样率AlwaysSample(pkg/customertracebridge/emitter.go:206 的注释:"the gateway never drops customer spans")
Resource服务身份齐全(:209 resource.Empty())
送到哪运维的 collector按 project 分流到客户端点

客户 trace 桥现在是共享包 pkg/customertracebridge(从 adapters/ 提升,网关与其它 Go 服务共用)。隔离做得很彻底,三处都能验证:

  • gatewaytracer.Middlewaretrace.WithNewRoot() 绝不继承客户的 traceparent(adapters/gatewaytracer/tracer.go:44)。
  • 客户 traceparent 被中间件从 header 里摘走,存进 context 供桥使用(adapters/httpapi/middleware.go 的 CustomerTrace 中间件)。
  • 桥建 span 时从全新 context 起步,只把客户的 trace id 通过 W3C propagator 提取进来当父级——请求 context 的一切都不会泄进客户的 span

resource.Empty() 那一条特别值得学:客户只应该看到「有一次模型调用」,不该看到 LangWatch 网关的服务名、版本、pod 名。

7.2 一个 exporter 不够,得按 project 分流

客户 span 要送到客户自己的 OTLP 端点。routerExporter(pkg/customertracebridge/transport.go:46,导出入口 :106)的做法是:

ExportSpans(一批 span)

├─ 按 span 上的 langwatch.project_id 分桶
│ 没有这个属性的 span 直接丢

对每个 project:
├─ 缓存里有该 project 的 exporter? ──► 用它
└─ 没有 ──► 查 Registry 拿端点 + header ──► 新建 exporter ──► 存进缓存

Registry(pkg/customertracebridge/registry.go:68SetFromBundle)是 project → 端点 + header 的缓存,由鉴权后的中间件填(它必须在鉴权之后跑,读 Bundle 里的 OTLP token,adapters/httpapi/middleware.go:58-66 有显式检查)。exporter 被淘汰时会优雅关闭,不然连接会泄漏。

7.3 dropFilter:把探针调用挡在客户视野外

claude-code 会发一些零成本、零输出的探针请求(system-reminder、skills 列表)。这些如果照单全收,客户的 trace 列表会被一堆 $0 空行淹没。

做法是在 EndSpan 时打标记、在导出时过滤(pkg/customertracebridge/emitter.go:63-95dropFilterExporter,零成本零输出的 span 被盖上 drop 属性后直接不导出)。

为什么不干脆不建这个 span?注释给了答案:OTel 的 span 一旦 Start 就没法取消,硬要在生命周期里做文章只会跟 SDK 打架。所以策略是「照常开、照常关,只是不导出」——在进程内不留悬空 span。

7.4 记账闭环:spend 命令流 + process manager

机制已重做。 旧版本靠「span 上盖 virtual_key_id + gateway_request_id 属性 → trace 管线的 gatewayBudgetSync 反应器读 span 写账本」闭环;现在 span 依旧照发,但账走了一条独立的、显式的命令流,不再依赖 span 管线送达。

Go 网关 Node 控制面
─────── ──────────
spend 拦截器:admit(进门前) + confirm/fail(结局)
│ 只写进程内 channel(永不阻塞热路径)

spendemitter.Spool(本地磁盘 spool,批量写 + 周期 fsync,
每条带 per-pod 单调序号,可断言无缺口)
│ Drainer 批量外送(at-least-once,确认后才截断)

控制面 spend 命令 ingest ──▶ gateway-spend-processing 管线
(admitSpend / confirmSpend / failSpend / settleSpend 四条命令,
platform/app/src/server/event-sourcing/pipelines/gateway-spend-processing/pipeline.ts:78-81)


spendSettlement process manager + gatewayDebits process manager
(platform/app/ee/governance/process-manager/gatewayDebits.process.ts)
写 ClickHouse 账本、推进预算 spent

└──► BUDGET_UPDATED 变更事件 ──► 网关 /changes 订阅者驱逐缓存
下次请求就读到新的 spent

依据:spool 契约在 services/aigateway/adapters/spendemitter/record.go:1-11 的包注释(「请求热路径永不执行联网写、永不因可记录性被延迟或拒绝;每条记录带 per-pod 单调序号,消费者可断言无缺口;每次本地丢弃都有计数器」);命令清单 CommandAdmit/Confirm/Fail:22-26;admit 的字段组装在 emitter.goAdmitSpend

这套设计的核心不变:幂等靠表结构,不靠代码——ClickHouse 账本按 GatewayRequestId 排序去重,重放/重发的记录在 merge 时自动坍缩。所以网关那个 gateway_request_id 不只是个日志用的 id——它是记账的幂等键。spool 的 at-least-once 外送(只在确认后截断)与之配套:多发无害,漏发有计数器兜底。

缓存 token 的双重计费也在这条链上被钉死:confirm 命令把 cached tokens 从 input 计数里拆出来单列(spendemitter 的 wire-contract 测试,"the customer span and the spend record are the two producers of the same measurement"),否则一个含缓存的总 token 数会在 input 费率上再计一遍缓存费。

7.5 和第 3、4 章的边界,一句话划清

边界网关侧交出去的东西对面拿它干什么去哪章看
trace 处理一条带 gen_ai.* 语义属性的 OTel span折叠成可查询的 trace03-trace-processing.md
记账admit/confirm/fail spend 命令(spool 外送)gateway-spend 管线结算、写账本、推预算02-event-sourcing.md
评估护栏 RPC 打到控制面的 /guardrail/check由控制面决定要不要跑评估器04-evaluation.md

一句话:网关只负责把事实写成 span、spend 命令和一个请求 id,剩下的全是下游的事。


8. 传输层与运行时骨架

8.1 chi 的中间件顺序

r.Use(RequestID) → Recover() → Telemetry() → Version() → gatewaytracer.Middleware()

├── /healthz /readyz /startupz (无鉴权)

├── /v1/* Auth → CustomerTrace → TraceRegistry
│ chat/completions · messages · responses · embeddings · GET models

├── /v1beta/* Auth → CustomerTrace → TraceRegistry
│ 通配直通(Gemini 原生 SDK / gemini-cli)

└── /internal/* InternalAuth(HMAC)
validate-ottl · transform

依据:adapters/httpapi/router.go:85NewRouter

中间件的先后有硬依赖:TraceRegistryMiddleware 必须在 AuthMiddleware 之后(它要读 Bundle 里的 OTLP token),代码里也做了显式检查——拿不到 Bundle 就直接报 internal error(middleware.go:58-66)。

鉴权 token 认三个 header(middleware.go:155 extractToken):Authorization: BearerX-Api-KeyX-Goog-Api-Key。第三个是给 Gemini SDK 的——让 gemini-cli 不改任何鉴权接线就能指向网关,VK 秘钥就塞在它原本放 Google API key 的位置。

还有一个「为了让 trace 有线程 id」的小接线:clientSessionIDFromHeaders(middleware.go:202)按顺序试几个候选 header(X-Claude-Code-Session-Id 等),第一个非空的胜出,最终盖成 span 上的 gen_ai.conversation.id。各家 CLI 各用各的 header 名,网关只能挨个认。

8.2 为什么 chat / messages 要读全 body 而不是 peek

网关需要在进入 App 之前就知道两件事:model 是什么、stream 是不是 true(router.go:208PeekModel/PeekStream)。直觉做法是 peek 前几十 KiB。这条路踩过坑:Claude Code 把顶层 stream 字段放在最后,它的偏移量随对话轮次增长;一旦 body 长过 peek 窗口,stream 就读不到,流式请求被路由进非流式 handler,最终客户端收到一个把 SSE 当错误体的 502。

现在 chat / messages / responses 三条路一律 readFullBody(router.go:203:238),并且注释指出这不额外花 I/O——body 反正要整个读出来转发给上游

8.3 SSE 写出:writeSSE

writeSSE(router.go:1066)处理流式响应的写出:普通模式把每个 chunk 包成 data: <chunk>\n\n、结尾写 data: [DONE];raw 模式(Gemini 直通)原样写出、不写 DONE(Google 不用这个约定)。「供应商没报 usage」时会额外发一帧 warning 事件——这是个诚实的产品决定:客户能立刻知道这条流的记账数据不可靠,而不是看到一个悄悄为 0 的成本。

8.4 错误:原样转发,不要盖 502

writeUpstreamError 把供应商的终态响应逐字节转发:原状态码、原 body、以及 Retry-After / x-should-retry 这类重试信号 header。

理由写在 domain/errors.go:客户端(claude-code、OpenAI SDK)靠状态码判断可重试还是终态。把一个 400「余额不足」压成 502,客户端会无限重试一个永远不会成功的请求。

domain.UpstreamError 存在的另一半理由也在这里:流式 dispatch 只能返回 error,没法返回完整响应对象,所以状态码、原始 body、消息、header 全部搭在 error 上带回 HTTP 层。网关自己的错误码到 HTTP 状态的映射集中在 registerErrorStatuses(router.go:86)。

8.5 生命周期

Serve(serve.go:23)把 OTel 关闭、trace 桥关闭、鉴权 change feed、HTTP server 四样东西交给 pkg/lifecycle 的 Group;三种适配器分别对应「只需要关」「需要起也需要关」「监听型服务」(pkg/lifecycle/service.go:29 Closer:45 Worker:79 附近的 ListenServer)。

/readyz 这里有段值得读的历史。deps.go:205-211 的注释记录了一个冷启动死锁:曾经有个 auth_cache_warm 就绪检查,条件是缓存里有东西;但 K8s 在 /readyz 返回 200 之前不会给流量,没有流量就没有第一次 Resolve,缓存就永远暖不起来。修复是整个删掉——鉴权缓存在第一个请求时自然变暖,冷缓存最多多花一次控制面往返,对一次新部署来说这就是正确行为。

8.6 共享设施

网关用它干什么关键符号
pkg/retry凭据链 fallback 引擎retry.Walk(pkg/retry/retry.go:165)、BreakerChecker(:46)
pkg/ksuid生成 gateway_request_id(记账幂等键)ksuid.Generate
pkg/lifecycle优雅启停编排GroupCloser/Worker/ListenServer
pkg/customertracebridge客户 trace 桥(共享包)EmitterRegistryrouterExporter
pkg/httpmiddlewareRequestID / Recover / Telemetry / Version / MaxBodyhttpmiddleware.GetRequestID
pkg/jwtverify校验控制面签发的 JWT,支持前一把密钥(轮换)NewJWTVerifier
pkg/otelsetup全局 TracerProvider(只给网关自己的 trace 用)cfg.OTel.Configure
pkg/herr错误码 + HTTP 状态注册表herr.Newherr.RegisterStatus
pkg/forkedcontext流关闭时把 span 送出去(剥掉取消信号,保留值)ForkWithTimeout
pkg/breaker滑动窗口断路器(已接线,§5.2)breaker.NewRegistry

9. 性能:基准表怎么读,边界在哪

9.1 表本身

services/aigateway/BENCHMARKS.md 给的是可复现数字:

基准ns/opB/opallocs落在哪
Router_ChatCompletions4,83612,87075完整 chi 往返(含 httptest recorder)
Sign(带 body 的 POST)859.71,08112只在网关→控制面的内部调用上
HashKey83.8481L1 缓存键
Precheck(已缓存)4.600预算判定
Walk_PrimarySuccess71.700主凭据直接成功

9.2 加总出的「治理税」

BENCHMARKS.md 把每请求必然发生的原语加起来得到约 236 ns ≈ 0.24 μs(BENCHMARKS.md:40)。对比一次真实模型调用的 50-2000 ms,治理逻辑的自身开销是五到六个数量级之外的噪音

这个结论才是整章的性能主线:网关没有把「治理」做快,它是把治理里慢的部分挪走了——

慢动作挪去了哪
鉴权(控制面 RTT)L1/L2 缓存,只有冷路径才付(§6.1)
spend 记账本地磁盘 spool,drainer 异步批量外送(§7.4)
trace 导出私有 TP 的批量异步导出(§7.2)
HMAC 签名只在内部调用上,客户热路径永不触发

9.3 这些数字的测量边界(务必读)

  1. Router_ChatCompletions 的 4.8 μs 里混着 httptest recorder 的开销,而且不含真实网络
  2. Precheck 的 4.6 ns 是「快照已在手」的成本,不是「知道自己有多少预算」的成本。取到快照的代价是鉴权那条路上的 L1 命中或一次控制面往返,不在这个数里。
  3. 表里的 NewULID 行在本 commit 的代码里找不到对应的函数——全仓 grep NewULID 只命中 BENCHMARKS.md 自己,而 gateway_request_id 实际由 pkg/ksuid 产生。这一行是文档漂移。
  4. 明确不在表内的项:Bifrost 的供应商往返(50-2000 ms,绝对大头)、OTel span 创建、护栏(受控制面 RTT 约束)、流式吞吐。

一句话读法: 这张表证明的是「网关自己不慢」,不是「加了网关不慢」。用户能感知到的额外延迟,主要来自护栏那跳控制面调用和冷缓存往返——而这两处代码都做了 fail-open 或缓存来把它压成小概率事件。


10. 巧妙之处(可以偷走的技术)

① 拦截器只吃函数,不吃接口。 app/app.go:81a.ratelimit.Allow 而非 a.ratelimit,于是 app/pipeline 包对 app 包零依赖,整包可以搬到别的服务里。想加一层新治理,只要写一个函数加一行 append——spend 层就是这么加进来的。

② sync/stream 双变体写进类型里。 Interceptor 强制每一层同时交代「同步怎么包」和「流式怎么包」(pipeline.go:159),任一为 nil 就在那条路上跳过。这把「流式路径被遗忘」这类 bug 从运行时挪到了编译期视野里。

③ 幂等的 body 懒读 + 每层前置短路。 MaterializeBody(pipeline.go:246)幂等,加上每层先判「我这层有配置吗」,让一个没配任何策略的 VK 完全不碰 body 解析。

④ 缓存改写用 sjson 做外科手术,还先探一手。 stripCacheControlbytes.Contains(cachecontrol.go:86)再逐路径删;对比 rewriteModel 的全量 unmarshal/marshal,在高频路径上省下大量分配。

⑤ 「先比签名后查时间戳」的验证顺序。 internal_middleware.go:39-45 把验证步骤列成清单,签名比对在前。两端都写了同样的注释,说明这是一个被讨论过并写进契约的决定,不是巧合。

⑥ 陈旧服务用「双到期时间 + 错误分类」而不是「重试次数」。 authresolver 的 soft/hard 二元组(service.go:139)加上 auth-rejection 与 transport-failure 的分野(:322),把「控制面挂了」和「这把 key 被吊销了」这两件完全不同的事分开处理。绝大多数缓存实现只有一个 TTL,做不到这个区分。

⑦ 拿不准就归到「宽松」那一类。 三处一致:未知刷新错误归 transport、凭据过滤后为空就还原原链(eligible.go)、预算 precheck 出错就放行。方向是一致的:可用性优先于精确性——因为这三处的误判代价都远小于「把所有人的 agent 打死」。

⑧ 想丢的 span 照常开、照常关,只是不导出。 dropFilterExporter(pkg/customertracebridge/emitter.go:68)绕开了「OTel span 无法取消」这个硬约束,代价只是一个属性位。

⑨ 记账幂等靠表的排序键,不靠应用代码。 账本按 GatewayRequestId 排序去重,网关只需要保证 request id 唯一;spool 外送 at-least-once,重发无害。

⑩ 4xx 不换 key。 classifyProviderError(app/dispatch.go:325)。很多网关的 fallback 是「错了就换下一个」,结果把「余额不足」在五把 key 上各试一遍。

⑪ 记账不搭 span 的车。 spend 走独立的 admit/confirm 命令流,span 只是「同一测量的另一个生产者」(wire-contract 测试原话)——span 管道慢了、丢了,账都不受影响;反过来两边还要用测试钉住「必须报同一个 input 数」。


11. 边界与局限(诚实)

11.1 功能上的遗留

现状依据
Fallback 触发条件控制面下发的 Fallback.On 解析进了 domain(config_wire.go:225),但没翻成 retry.Options.Triggers,该配置不生效app/dispatch.go:297-302
/budget/check控制面实现了,Go 侧没有任何调用者(预算判定走 Bundle 快照 + spend 闭环)services/aigateway grep 零命中
/bootstrap 端点仍是 notImplementedplatform/app/src/server/routes/gateway-internal.ts:1535
NewULID 基准行表里有、代码里没有(文档漂移)BENCHMARKS.md:24

11.2 设计上刻意的取舍

  • 限流是单机的。 多副本 = 实际配额被放大若干倍(§4.1)。
  • 预算是「快照 + 事后对账」。 从消费发生到 spent 更新,中间隔着 spool 外送 + spend 管线结算,所以 on_breach=block超支一点。这是明确的 eventual consistency 选择。
  • 流式护栏只能止血。 判拦时前面的 chunk 已经出去了(§4.7)。
  • evictWhere 是 O(缓存大小)。 1 万条 × 低频变更下可接受,规模上来要换索引。
  • trace body 累加器上限 8 MiB。 超了 trace 里的响应文本被截断(客户端不受影响)(trace.go:218)。

11.3 README 与代码的两处漂移(仍以代码为准)

README 说代码实际
budget/ Precheck + outbox worker for async debit(README.md:51)budget 包注释明写「Debits are NOT sent from the gateway」,记账在控制面的 process manager(adapters/budget/budget.go:1-9)
管线是 … → Guardrail → Dispatch → Trace(README.md:109)Trace 是包在 dispatch 外面的最内层,span 只覆盖 dispatch;且最外层还有 README 没提的 spend 层

12. 它在本组里的位置

讲什么和本章的接缝
01-ingestion.md一条 span 怎么被接住网关导出的客户 span 从这里进门
02-event-sourcing.md命令、事件、投影、订阅者、process managerspend 命令在那边结算落账
03-trace-processing.mdspan 事件 → 可查询的 trace网关只管盖属性,折叠在那边
04-evaluation.md一个分数怎么算出来护栏 RPC 打到控制面之后的事
06-simulations.md把 agent 放进剧本里跑仿真跑出的调用同样可以走网关

一句话定位: 前面几章都在讲「观测到的东西怎么处理」,本章讲的是唯一一处「在事情发生之前就能拦住它」的地方——这也是它为什么被放在一个独立的、拿性能说话的 Go 进程里,而不是挂在 TypeScript 服务端那条事件管线上。


13. 代码地图(导航索引)

骨架

主题文件符号
mono-binary 分派入口cmd/service/main.goservices map、ServiceBoot
本服务入口services/aigateway/cmd/root.goRoot
配置 + 遗留变量名services/aigateway/config.goLoadConfig
DI 组装根services/aigateway/deps.goNewDeps
启停编排services/aigateway/serve.goServe

领域层

主题文件符号
VK 解析结果services/aigateway/domain/bundle.goBundleBundleConfigVKTags
三种判定services/aigateway/domain/verdict.goBudgetVerdictGuardrailVerdictCacheDecisionAITraceParams
请求 / 响应services/aigateway/domain/request.goresponse.goRequestStreamIteratorRawFramer
错误services/aigateway/domain/errors.goUpstreamError

应用层与拦截器

主题文件符号
链的构造顺序services/aigateway/app/app.goNewbuildInterceptors
端口接口services/aigateway/app/ports.goAuthResolverProviderRouterBudgetCheckerSpendEmitter
核心 dispatchservices/aigateway/app/dispatch.gocoreDispatchclassifyProviderErrorretryOpts
凭据裁剪services/aigateway/app/eligible.goeligibleCredentialsinferProviderFromModel
治理消息改写services/aigateway/app/governance.goapplyGovernanceMessageisAccountExhaustion
洋葱框架services/aigateway/app/pipeline/pipeline.goInterceptorBuildCallMetaPreOnlyMaterializeBody
spend 登记层services/aigateway/app/pipeline/spend.goSpendAdmissionSpendOutcome
限流层services/aigateway/app/pipeline/ratelimit.goRateLimit
策略层services/aigateway/app/pipeline/policy.goPolicy
模型解析层services/aigateway/app/pipeline/resolve.goModelResolverewriteModel
缓存层services/aigateway/app/pipeline/cache.gocachecontrol.goCacheapplyCacheControlstripCacheControl
预算层services/aigateway/app/pipeline/budget.goBudgetBudgetBreachError
护栏层services/aigateway/app/pipeline/guardrail.goGuardrailguardrailStreamWrapper
trace 层services/aigateway/app/pipeline/trace.goTracetraceStreamWrapperclassifyUpstreamresponseBodyCap

适配器

主题文件符号
三级鉴权缓存services/aigateway/adapters/authresolver/service.goResolveclassifyRefreshErrorchangeFeedLoopapplyChange
控制面客户端services/aigateway/adapters/controlplane/client.goResolveKeyFetchConfigPollChangesclaimsToBundle
HMAC 签名services/aigateway/adapters/controlplane/signer.goSigner.Sign
护栏 RPCservices/aigateway/adapters/controlplane/guardrails.goEvaluatePreEvaluateChunk
配置线格式services/aigateway/adapters/controlplane/config_wire.goconfigWire.toDomain
供应商路由services/aigateway/adapters/providers/bifrost.goBifrostRouter.Dispatch
spend 发射器services/aigateway/adapters/spendemitter/EmitterSpoolDrainerCommandAdmit/Confirm/Fail
预算判定services/aigateway/adapters/budget/budget.goChecker.Precheck
限流器services/aigateway/adapters/ratelimit/limiter.goLimiter.AllowsameCeilings
模型解析器services/aigateway/adapters/modelresolver/resolver.goResolver.ResolvenormalizeProvider
缓存规则services/aigateway/adapters/cacherules/evaluator.goEvaluator.EvaluatematchesRule
策略匹配services/aigateway/adapters/policy/matcher.goMatcher.CheckextractToolNamesextractURLs
网关自身 traceservices/aigateway/adapters/gatewaytracer/tracer.goMiddleware
HTTP 路由services/aigateway/adapters/httpapi/router.goNewRouterreadFullBodywriteSSEsetMetaHeaders
鉴权中间件services/aigateway/adapters/httpapi/middleware.goAuthMiddlewareextractTokenclientSessionIDFromHeaders
内部通道验签services/aigateway/adapters/httpapi/internal_middleware.goInternalAuthMiddleware
进程内窄入口services/aigateway/dispatcher/dispatcher.goDispatcher.DispatchPassthrough

客户 trace 桥(共享包)与对面(Node 控制面)

主题文件符号
客户 trace 桥pkg/customertracebridge/emitter.goEmitter.BeginSpanEndSpandropFilterExporter
按 project 导出pkg/customertracebridge/transport.gorouterExporter.ExportSpansexporterFor
project 端点表pkg/customertracebridge/registry.goRegistry.SetFromBundle
内部端点 + 验签platform/app/src/server/routes/gateway-internal.tsbuildGatewayCanonicalString/resolve-key/config/:vk_id/changes/guardrail/check
spend 命令管线platform/app/src/server/event-sourcing/pipelines/gateway-spend-processing/pipeline.tsadmitSpendconfirmSpendfailSpendsettleSpendspendSettlement
记账 process managerplatform/app/ee/governance/process-manager/gatewayDebits.process.ts账本写入
VK 服务与配置物化platform/app/src/server/gateway/virtualKey.service.tsbudget.clickhouse.repository.ts

共享 pkg

主题文件符号
fallback 引擎pkg/retry/retry.goWalkEventLogBreakerChecker
请求 idpkg/ksuid/Generate
启停pkg/lifecycle/service.goGroupCloserWorkerListenServer
HTTP 中间件pkg/httpmiddleware/RequestIDRecoverTelemetryVersion
JWT 校验pkg/jwtverify/verifier.goNewJWTVerifier
OTel 装配pkg/otelsetup/otelsetup.goProvider
断路器pkg/breaker/breaker.goNewRegistry