HeySmart:大模型开源网关基座-请求生命周期与钩子引擎

一次模型调用,从进入网关到返回客户端,要穿越十道可拦截、可改写、可拒绝的"关卡"。这些关卡不是写死在代码里的------它们是内核预留的十把钥匙插孔 ,任何插件都可以把属于自己的钥匙插进去,在请求经过的瞬间执行自己的逻辑。这篇文章揭开这套"请求处理管道"的神秘面纱,回答一个问题: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)

两个关键设计:

  1. 可插拔 :任何插件可以注册到任意钩子点,多个插件注册到同一点时互不感知------它们只认"优先级",不认"邻居"。限流插件不知道语义缓存插件存在,计费插件也不知道配额插件存在,但它们能正确协作,靠的是统一的注册表与执行器(见第三节)。
  2. 能力外延 :十个钩子点不会一次全部启用。当前系统实际填充了六个点,其余(pre_auth/post_auth/post_rate_limit/pre_transform)作为扩展插槽 等待接入------例如 WASM 沙箱插件可以按 manifest 声明动态挂载到任意钩子点(internal/kernel/wasm/hotreload.go:135),IP 黑名单插件正是这样挂到 pre_auth 的。

一个细节:认证本身由网关中间件在进入内核前完成(auth.Middleware 把验证结果写入 ctx),kernel.gopre_auth/post_auth 位上预留了钩子调用(kernel.go:171),供后续把认证下沉为插件、或追加黑名单等前置检查。插槽永远在,插不插钥匙由装配层决定。


三、优先级链式执行:积木的"排队规则"

多个钩子挂在同一个点上,谁先执行?答案在钩子引擎的核心实现 Registry.Runinternal/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)
    // ...
}

三条规则:

  1. 优先级升序priority 数字越小越先执行;
  2. 稳定序号 :同一个优先级内,按注册顺序(seq)执行------排序稳定,多次执行结果一致,行为可预期;
  3. 拷贝后排序:排序发生在拷贝上,不改变注册表的原始顺序------运行期注册新钩子不会干扰在途请求已取到的执行序列。

教科书案例:计费三重奏

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、死循环、慢查询。一个失控的钩子如果直接拖垮整个请求进程,所有用户都会遭殃。safeInvokehook.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.Metadatainterfaces.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),否则配额会泄漏。而计费的 refundHookbilling.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 重试可以干净地重新开始。


结语:扩展性的根基

回看整个生命周期,会发现一个惊人的事实:内核自己几乎不实现任何业务。认证在中间件,限流是插件,缓存是插件,配额是插件,计费是插件------内核只提供三样东西:

  1. 十个定义清晰的钩子点------请求生命周期的标准接口;
  2. 一个可靠的执行引擎------优先级有序、panic 隔离、超时保护、Deny 短路;
  3. 一张请求级的数据便签------Metadata,让钩子间零耦合协作。

新需求到来时,团队的答案不是"改内核",而是"写一个插件,注册到合适的钩子点,定义好优先级"。这就是插件化架构的根基,也是为什么 HeySmart 能在不触碰核心的前提下持续长出新的能力。

下一步:钩子引擎让插件能"在请求路上插一脚",但插件之间、插件与内核之间,还需要一种更松散、更异步的通信方式------请关注文章 03《事件总线:异步解耦的艺术》,看 HeySmart 组件之间的"神经系统"如何运转。


HeySmart --- 新一代AI大模型基座,让每一次 AI 调用都稳定可靠。 即将开源

相关推荐
苍何1 小时前
做AI视频还在拆盲盒?手把手教你导演级运镜(附教程)
后端
爱学习的小邓同学1 小时前
Golang语言入门
开发语言·后端·golang
苍何1 小时前
豆包,开始做普通人的 Codex
后端
七牛开发者1 小时前
Coding Agent 如何跑稳长任务?从上下文管理到运行时状态
前端·javascript·后端
Zane19941 小时前
遍历时删元素为什么报错:modCount 这个"计数器"在背后盯着你
java·后端
半个落月1 小时前
从 CSR 到 Server Component:吃透 Next.js 16 App Router 路由、布局与 SEO
前端·next.js
苍何1 小时前
原来世界模型,已经能边玩边生成了
后端
七牛开发者1 小时前
拆解 DeepSeek Harness:Profile 与 Bundle 如何装配运行时
前端·javascript·后端
HjhIron1 小时前
从零搭建后端业务:数据库表设计、索引优化与部署架构解析
后端