一次模型调用,从进入网关到返回客户端,要穿越十道可拦截、可改写、可拒绝的"关卡"。这些关卡不是写死在代码里的------它们是内核预留的十把钥匙插孔 ,任何插件都可以把属于自己的钥匙插进去,在请求经过的瞬间执行自己的逻辑。这篇文章揭开这套"请求处理管道"的神秘面纱,回答一个问题:HeySmart 为什么可以被无限扩展?
一、一个请求的一生
先看全景:一次 POST /v1/chat/completions 请求从进入系统到返回客户端,在时间轴上依次经历哪些阶段。
go
t0 ── 请求到达 HTTP 网关
│ ① 认证中间件验证 Bearer appId:secret ──► Identity 写入 ctx
│ ② RequestID / SessionID / ClientIP 注入 ctx
│
▼
t1 ── 内核 ProcessChat(kernel.go:166)
│ ★ pre_auth (认证前:WASM 黑名单等插件可挂)
│ ★ post_auth (认证后:读取确认后的身份)
│ ★ pre_rate_limit (限流:QPS 配额检查)
│
▼
t2 ── 模型解析与驱动调用
│ ★ pre_transform (请求改写:改写消息/参数)
│ ★ pre_driver (驱动调用前:配额预检、语义缓存查询)
│
│ ├─► 语义缓存命中 ──► 短路返回(跳过驱动与计费)
│ │
│ └─► callWithFailover(动态路由 → 负载均衡 → Driver.Call → ≤3 次故障切换)
│
▼
t3 ── 驱动调用成功,踏上回程
│ ★ post_driver (驱动调用后:异步写缓存/会话记录)
│ ★ pre_billing (计量 → 定价 → 预扣 Hold)
│ ★ post_billing (实扣 Capture)
│
▼
t4 ── 返回 HTTP 响应(非流式);流式则逐 chunk 透传
│
└── 任意阶段出错 → ★ error(退款/回滚预占)
★ = 钩子点(HookPoint),插件可就地注入逻辑
这条链路看似固定,实则每一处 ★ 都是一块可替换的积木 。认证、限流、缓存、计费、会话记录------所有这些能力都不是内核的一部分,而是被插入到特定 ▲ 位置的插件。内核只负责两件事:编排顺序 和保证安全。
引出核心问题:这条链路上的每一个环节,真的都能被"拦截"和"改写"吗?
答案是:能,而且这正是钩子引擎存在的意义。
二、十个钩子点全景
钩子点定义在契约包 pkg/interfaces/interfaces.go:25,一共十个,覆盖请求生命周期的每个阶段:
| 钩子点 | 时机 | 典型用途 | 当前实例 |
|---|---|---|---|
pre_auth |
认证之前 | 黑名单拦截、来源检查 | WASM 插件动态挂载(如 IP 黑名单) |
post_auth |
认证之后 | 读取身份做二次校验 | 预留,待插件扩容 |
pre_rate_limit |
限流之前 | QPS/配额检查,超限拒绝 | 限流插件(main.go:168,P50) |
post_rate_limit |
限流之后 | 记录限流结果、计数 | 预留 |
pre_transform |
请求改写 | 修改消息/参数/注入上下文 | 预留 |
pre_driver |
驱动调用之前 | 配额预检、语义缓存查询、请求改写 | 配额预检(P40)+ 语义缓存(P80) |
post_driver |
驱动调用之后 | 异步写缓存、会话记录、结果加工 | 语义缓存/会话记录(P80) |
pre_billing |
计费之前 | 计量 → 定价 → 预扣 | 计费三重奏(P50/60/70) |
post_billing |
计费之后 | 实扣入账、确认配额 | 实扣(P50)+ 配额确认(P60) |
error |
任意阶段出错 | 退款、回滚预占、告警 | 退款(P50)+ 配额回滚(P40) |
两个关键设计:
- 可插拔 :任何插件可以注册到任意钩子点,多个插件注册到同一点时互不感知------它们只认"优先级",不认"邻居"。限流插件不知道语义缓存插件存在,计费插件也不知道配额插件存在,但它们能正确协作,靠的是统一的注册表与执行器(见第三节)。
- 能力外延 :十个钩子点不会一次全部启用。当前系统实际填充了六个点,其余(
pre_auth/post_auth/post_rate_limit/pre_transform)作为扩展插槽 等待接入------例如 WASM 沙箱插件可以按 manifest 声明动态挂载到任意钩子点(internal/kernel/wasm/hotreload.go:135),IP 黑名单插件正是这样挂到pre_auth的。
一个细节:认证本身由网关中间件在进入内核前完成(
auth.Middleware把验证结果写入 ctx),kernel.go在pre_auth/post_auth位上预留了钩子调用(kernel.go:171),供后续把认证下沉为插件、或追加黑名单等前置检查。插槽永远在,插不插钥匙由装配层决定。
三、优先级链式执行:积木的"排队规则"
多个钩子挂在同一个点上,谁先执行?答案在钩子引擎的核心实现 Registry.Run(internal/kernel/hook/hook.go:59):
go
// 每次执行前,拷贝该点的全部注册项
regs := make([]registration, len(r.hooks[point]))
copy(regs, r.hooks[point])
// 按 priority 升序;同优先级按注册序号(seq)------排序稳定,行为可预期
sort.SliceStable(regs, func(i, j int) bool {
if regs[i].priority != regs[j].priority {
return regs[i].priority < regs[j].priority
}
return regs[i].seq < regs[j].seq
})
// 链式逐个执行
for _, reg := range regs {
resp, err := r.safeInvoke(ctx, point, reg.fn, req)
// ...
}
三条规则:
- 优先级升序 :
priority数字越小越先执行; - 稳定序号 :同一个优先级内,按注册顺序(
seq)执行------排序稳定,多次执行结果一致,行为可预期; - 拷贝后排序:排序发生在拷贝上,不改变注册表的原始顺序------运行期注册新钩子不会干扰在途请求已取到的执行序列。
教科书案例:计费三重奏
pre_billing 点上挂了三把钥匙(internal/billing/billing.go:86-104),优先级设计得恰到好处:
sql
pre_billing 钩子链(按优先级升序执行)
│
├─ ① P50 计量(metering) ──► 统计 token/次数,产出 billing.measurement
│
├─ ② P60 定价(pricing) ──► 查模型定价与倍率,产出 billing.price
│
└─ ③ P70 预扣(hold) ──► 冻结用户余额,产出 billing.session
计量必须最先做(没有用量,定什么价?),定价其次(不知道价格,冻结多少钱?),预扣最后(一切就绪才动钱)。如果三个钩子优先级相同,顺序将取决于注册次序------可读性差且脆弱。优先级把"管道顺序"显式化,让代码的意图一目了然。
同样地,pre_driver 点上配额预检(P40)先于语义缓存查询(P80)执行------先确认有配额,再去查缓存;若缓存命中短路,已预占的配额由 error 钩子的回滚逻辑释放,不会泄漏。
四、panic 隔离与超时保护:坏钩子不连坐
钩子代码同样可能写 bug------panic、死循环、慢查询。一个失控的钩子如果直接拖垮整个请求进程,所有用户都会遭殃。safeInvoke(hook.go:104)为每个钩子提供了三道防线:
go
func (r *Registry) safeInvoke(ctx context.Context, point interfaces.HookType, fn interfaces.HookFunc, req *interfaces.HookRequest) (resp *interfaces.HookResponse, err error) {
done := make(chan result, 1)
go func() {
defer func() { // 防线 1:recover 捕获 panic
if rec := recover(); rec != nil {
// 完整 stack trace 记入内核日志,请求继续走错误路径
done <- result{nil, fmt.Errorf("钩子 %s panic: %v", point, rec)}
}
}()
resp, err := fn(ctx, req)
done <- result{resp, err}
}()
select {
case res := <-done: // 正常完成
return res.resp, res.err
case <-timer.C: // 防线 2:超时强制中断
return nil, fmt.Errorf("钩子 %s 执行超时(上限 %s)", point, r.timeout)
case <-ctx.Done(): // 防线 3:请求取消时同步中止
return nil, fmt.Errorf("钩子 %s 因请求取消而中止: %w", point, ctx.Err())
}
}
| 防线 | 机制 | 效果 |
|---|---|---|
| 1 | 每个钩子在独立 goroutine 执行,recover 捕获 panic |
钩子崩溃只影响自己,整条链路与其他请求不受影响;完整 stack trace 写入内核日志供排查 |
| 2 | 超时 timer(Hook.TimeoutSeconds 配置) |
死循环/慢钩子被强制中断,不阻塞后续阶段 |
| 3 | 监听请求 ctx 取消 | 客户端断开时钩子即刻中止,不空转 |
一个细节值得注意:超时后原 goroutine 仍在运行直至其自行退出------引擎不强行杀灭 goroutine,而是要求钩子尊重传入的 ctx 及时退出。这是 Go 协作式取消的典型取舍:宁可在极端情况下让慢钩子多跑一会儿,也不冒锁/资源被中途破坏的风险。
五、钩子间数据传递:接力棒 Metadata
钩子之间需要传递数据------认证钩子要告诉计费钩子"你是谁",计量钩子要告诉定价钩子"用了多少"。这个接力棒就是 HookRequest.Metadata(interfaces.go:377-390):一个贯穿请求生命周期的 map[string]any。
go
前一个钩子写入 后一个钩子读取
──────────────────── ────────────────────
配额预检 (P40) ──► semantic cache (P80)
quota.session_id cached_response
quota.estimated_tokens (命中即短路,见 kernel.go:195)
│
▼
计量 (P50) ──► billing.measurement ──► 定价 (P60)
│
▼
定价 (P60) ──► billing.price ──────────► 预扣 (P70)
│
▼
预扣 (P70) ──► billing.session ────────► 实扣 (P50)/退款 (error)
Registry.Run 负责自动回写:钩子返回的 HookResponse.Data 会被合并进 req.Metadata(hook.go:85-90),后续钩子直接读取------钩子之间没有任何显式依赖,只通过 Metadata 这张"共享便签"协作。
典型的身份传递链:
scss
网关认证中间件 (auth.Middleware)
│ WithIdentity(ctx, identity)
▼
内核 newHookRequest(kernel.go:396)
│ Identity: interfaces.IdentityFromContext(ctx)
▼
计费钩子(pre_billing)
│ hr.Identity.UserID ──► 查余额、扣款
▼
审计事件
HookRequest 里还挂着 Request(可改写)、Response(可加工)、Err(错误钩子用)、RealModel(动态路由解析后的上游真实模型名)------它们与 Metadata 一起,构成了钩子完整的"可见世界"。
为什么用共享 map 而不是强类型字段? 因为钩子体系是开放的:内核不知道未来会有什么插件。Metadata 是"约定优于接口"------插件间通过约定好的 key 协作(如
billing.session),互不感知、可独立替换。代价是需要约定文档,收益是无限扩展且零改动内核。
六、Deny 语义与短路:拒绝要显式,放行是默认
HookResponse 的设计有一个精妙之处(interfaces.go:436-440):
go
// HookResponse 钩子处理结果。零值即"放行",
// 只有显式设置 Deny 才会拦截请求------避免空结构体被误判为拒绝。
type HookResponse struct {
Deny bool // 拒绝请求(配合 Err 字段返回错误)
Err *KernelError // Deny 时返回给客户端的结构化错误
Data map[string]any // 回写 Metadata 的数据
}
零值即放行 ------钩子正常完成、什么也不做,请求继续前行。只有钩子明确 设置 Deny: true 才会拦截。这避免了"忘了初始化结构体导致误杀请求"的经典 bug。
Registry.Run 遇到 Deny 时的行为(hook.go:91-96):
go
if resp.Deny {
if resp.Err == nil {
resp.Err = interfaces.ErrAuthentication("请求被钩子拒绝") // 兜底错误
}
return resp.Err // 中断请求,返回结构化错误
}
Deny 与返回 error 的区别
钩子可以有两种"失败"方式,语义截然不同:
| 方式 | 语义 | 请求结果 | 典型场景 |
|---|---|---|---|
返回 (nil, nil) |
放行 | 继续执行后续钩子 | 正常路径 |
返回 (resp, error) |
钩子自身故障 | 中断链路,作为内部错误处理 | 钩子的依赖挂了、panic、超时 |
返回 (Deny, Err) |
主动拒绝请求 | 立即返回结构化错误给客户端 | 限流超限→429、余额不足→402、认证失败→401 |
区别在于错误的性质 :error 是"系统出问题了",Deny 是"这个请求不该被处理"。前者记内部日志,后者直接面向客户端返回精准错误码。
一个 Deny 的完整旅程
以限流为例(pre_rate_limit 钩子,main.go:168):
scss
请求超限(QPS 配额耗尽)
│
▼
限流插件 Hook() 返回 HookResponse{ Deny: true,
Err: interfaces.ErrRateLimited("请求过于频繁") }
│
▼
Registry.Run 立即返回该错误 ──► 后续钩子(pre_transform/pre_driver/...)全部跳过
│
▼
内核 emitAPICall 记录审计 → 返回 429 给客户端(附带 retry_after_ms 提示)
│
▼
请求生命周期戛然而止,网络层与计费层零消耗
限流 Deny 发生在模型解析之前------请求还没消耗任何上游资源、也不知道是哪个账号,拦截成本最低。这正是钩子点顺序设计的另一层用意:能早拒绝就早拒绝。
七、短路:缓存命中时,剩下的路都不用走
pre_driver 上的语义缓存钩子(P80)是"短路"的绝佳示范。当缓存命中时(kernel.go:195-208):
go
if cached, ok := hr.Metadata["cached_response"].(*interfaces.ChatResponse); ok && cached != nil {
// 1. 覆盖 model 字段:缓存里存的是上游真实模型名,对外暴露请求时的别名
cached.Model = req.Model
// 2. 主动触发 error 钩子:让配额回滚钩子释放 pre_driver 已预占的配额
hr.Err = interfaces.ErrInternal("语义缓存命中,驱动阶段已跳过")
_ = k.hooks.Run(ctx, interfaces.HookError, hr)
hr.Err = nil
// 3. 记录审计后直接返回,跳过后面的驱动调用、计费、实扣
k.emitAPICall(apiCallFromHR(hr, nil, 200))
return cached, nil
}
这里的细节值得品味:缓存命中本身不经过 pre_billing/post_billing,但必须经过 error 钩子 ------因为配额预检钩子(P40)在缓存查询之前 已经预占了 token 配额,短路路径需要 error 钩子里的回滚逻辑把这份预占释放掉(rollbackHook,P40),否则配额会泄漏。而计费的 refundHook 因 billing.session 不存在而安全跳过。
这说明钩子体系不是"执行链",而是一张有依赖的网:跳过某些阶段时,设计者必须想清楚"哪些前置副作用需要被回收"。
八、错误路径:同一张网上,还有一张"安全网"
请求在任意阶段失败,error 钩子点都会兜底。内核在每个失败出口调用 k.hooks.Run(ctx, interfaces.HookError, hr)(kernel.go:215、306、379),把 hr.Err 交给所有 error 钩子:
| error 钩子 | 优先级 | 职责 |
|---|---|---|
配额回滚(quota rollbackHook) |
40 | 释放 pre_driver 预占的 token 配额 |
计费退款(billing refundHook) |
50 | 释放 pre_billing 预扣的余额,幂等安全 |
两者都消费 hr.Err 作为回滚原因,并清理各自写入的 Metadata------保证失败请求不留下任何悬空的预占/预扣。下一次同 key 重试可以干净地重新开始。
结语:扩展性的根基
回看整个生命周期,会发现一个惊人的事实:内核自己几乎不实现任何业务。认证在中间件,限流是插件,缓存是插件,配额是插件,计费是插件------内核只提供三样东西:
- 十个定义清晰的钩子点------请求生命周期的标准接口;
- 一个可靠的执行引擎------优先级有序、panic 隔离、超时保护、Deny 短路;
- 一张请求级的数据便签------Metadata,让钩子间零耦合协作。
新需求到来时,团队的答案不是"改内核",而是"写一个插件,注册到合适的钩子点,定义好优先级"。这就是插件化架构的根基,也是为什么 HeySmart 能在不触碰核心的前提下持续长出新的能力。
下一步:钩子引擎让插件能"在请求路上插一脚",但插件之间、插件与内核之间,还需要一种更松散、更异步的通信方式------请关注文章 03《事件总线:异步解耦的艺术》,看 HeySmart 组件之间的"神经系统"如何运转。
HeySmart --- 新一代AI大模型基座,让每一次 AI 调用都稳定可靠。 即将开源