镜像自 github.com/Chieko3020/ai-gateway
  • C++ 78.3%
  • Python 12.8%
  • Shell 7.1%
  • CMake 1.8%
Find a file
Chieko3020 1aef456168 docs: 补缓存键等价类说明;标注 filter 两个上限的后果
- README 新增「缓存键与'什么算同一个请求'」一节:三段键构成、哪些生成参数进指纹、
  messages 为何不进、判定类回答为何要逐字、以及命中率取舍
- cache.semantic_verdict_veto 补进配置表与 gateway.example.json
- filter.max_input_chars 默认 500 → 0:它截断的文本**会被用作缓存键**(转发体不受影响),
  设正值会让前 N 字符相同而后文不同的提问被判为同一文本
- filter.max_output_chars 补注:上限是累积字节且把 reasoning_content 算进去,
  推理模型会先吃满 → 流在推理阶段被切断、content 为空、finish_reason 缺失,
  上层表现为"空响应"(2026-09-30 实际事故),默认 0 不要设小值
- 性能表补本次重测:命中 p50 3.3ms / 未命中 p50 7.7ms(与改动前一致,
  参数指纹只在构造时算一次,判定热路径只比较定长前缀)
2026-09-30 08:34:12 +08:00
config docs: 补缓存键等价类说明;标注 filter 两个上限的后果 2026-09-30 08:34:12 +08:00
include fix(cache): 缓存键纳入生成参数指纹;判定类回答不跨文本外推 2026-09-30 08:34:04 +08:00
model feat: 模型文件内置 model/ (23MB) 2026-07-31 21:47:44 +08:00
results bench: 全量重测性能数据(2026-09-21 批)并回填 README 2026-09-21 04:04:43 +08:00
scripts fix(deploy): 单元改 Restart=always 并新增健康探针脚本 2026-09-28 20:46:05 +08:00
src fix(cache): 缓存键纳入生成参数指纹;判定类回答不跨文本外推 2026-09-30 08:34:04 +08:00
tests test: 覆盖参数隔离与判定类回答的缓存行为 2026-09-30 08:34:04 +08:00
.gitignore bench: 全量重测性能数据(2026-09-21 批)并回填 README 2026-09-21 04:04:43 +08:00
CMakeLists.txt fix: 连接生命周期(EOF/空闲超时/连接上限)、drain、每线程 curl、缓存与统计口径 2026-09-18 20:04:33 +08:00
README.md docs: 补缓存键等价类说明;标注 filter 两个上限的后果 2026-09-30 08:34:12 +08:00

AI Gateway — C++20 语义缓存透明代理

  • 部署在客户端与 LLM API 之间,通过语义缓存减少重复的 API 调用节省 token
  • epoll ET + 非阻塞 I/O + 单 Reactor + 线程池架构,适配并发场景
  • HNSW 图索引进行缓存查询,近似 O(log N) 检索
  • 本地 ONNX Runtime 运行 INT8 量化 bge-small-zh 模型,进程内推理
  • LLM 后端独立配置,支持任意 OpenAI 兼容 API
  • LRU + TTL 内存缓存 + JSON 持久化,重启不丢缓存,默认 7 天自动过期
  • 开发环境:Ubuntu 24.04 LTS, GCC 13.3, CMake 3.28

项目简介

客户端每日处理大量用户消息,其中语义重复或相似的请求反复调用 LLM API,造成不必要的费用。AI Gateway 作为透明代理层插入客户端与 LLM 后端之间,通过 C++ ONNX Runtime 进程内向量化用户消息、HNSW 图索引检索历史缓存、余弦相似度判断是否命中,命中时直接返回缓存,未命中时转发 API 处理消息并将结果写入缓存。相较于现有方案(GPTCache / LiteLLM),本项目的 C++ 实现更为轻量。

功能特性

核心功能

  • 语义缓存: 基于 bge-small-zh Embedding 的向量检索 + 余弦相似度匹配,非精确文本也能命中
  • 安全过滤: 请求/响应双向过滤(反注入检测、URL 拦截、关键词屏蔽、长度截断)。 定位说明:这些规则都是模式匹配,作用是减少无意的暴露面(模型顺手贴出链接、 出现明确不想转发的词),不构成安全边界——对有意规避(变形 URL、拆字、编码、 把信息藏进看似正常的文本)天然无效,而且这一点与命中缓存无关(两条路径走同一套规则)。 真要防外泄/诱导,需要结构性手段(出站 URL 一律改写为白名单跳转页),而不是继续加规则
  • 实时统计: 每 60 秒输出命中率/token 消耗/费用节省/延迟统计到 systemd journal
  • 命名空间隔离: 不同 system prompt 缓存自动隔离,防止不同对话交叉污染
  • 定时持久化: 每 60 秒自动保存缓存到磁盘,意外宕机不丢数据

请求能力的边界

  • 流式(stream: true)真透传 + 语义缓存:上游 SSE 逐块转发给客户端(llm_client 用 write 回调,不再整段缓冲),Content-Type 按上游原样透传(text/event-stream),长度语义用 Transfer-Encoding: chunked。
    • 流式请求同样走语义缓存:命中时回放当初记下的上游原始 SSE 字节(而不是把缓存的 文本重新合成事件——流式响应没有可以改写的 JSON 容器,合成的流会自己造 finish_reason/usage/事件切分,与上游字节不等价)。命中响应带 X-Cache: hit,流首附一条 : cache hit 注释行(SSE 规范允许、客户端默认忽略)。未命中时边透传边捕获原始字节, 只有正常流完(含 [DONE])才回填——客户端中途断开留下的半截答案不会写进缓存。 命中的条目若没有 SSE 字节(非流式路径写入的条目),按未命中回源。
    • 输出过滤按 SSE 事件边界逐条判定:命中拦截规则的那一条事件被丢弃,其余事件照常透传 (不会把整段流替换成错误 JSON)。对可能构成 URL 的内容还会启用跨事件判定—— 违规串被 delta 边界劈开时(前半在一个事件、后半在下一个)也能拦下。被拦时发一个 data: {"error":"response filtered"} 事件再加 [DONE],让客户端正常收尾,而不是看到 "连接异常中断"。启用 filter.max_output_chars 时,到达上限会补发一个 data: [DONE] 并停止透传。客户端断开或写死线到点时,网关立即中停上游传输。
    • 命中缓存的流式响应同样要过输出过滤,判定口径与写入侧完全一致:缓存里存的是 上游原文(不是过滤后的结果),不重过滤的话,"先让含 URL 的答案入缓存、再命中"就能 绕过输出过滤;被拦时在写响应头之前返回 502,客户端看到的是干净的 502。
    • 边界如实说明:流式不可撤回——已经写出去的字节收不回来,所以流式过滤只能"拦截后续", 达不到非流式"整段拦下、客户端什么都看不到"的强度。
    curl -N http://127.0.0.1:9000/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{"model":"deepseek-flash","stream":true,"messages":[{"role":"user","content":"你好"}]}'
    
    使用 stream: true 的客户端(例如 DSH 的 pi-ai 层在 openai-completions 里硬编码 stream: true) 可以直接把本网关当作 provider。
  • HTTP keep-alive:HTTP/1.1 默认复用连接(Connection: close 的请求仍会被关闭; HTTP/1.0 默认不复用)。空闲连接由 server.idle_timeout_seconds(默认 30s)回收。 流式响应用 chunked 承载,因此流式响应结束后连接同样可复用。
  • 工具调用类请求(tools / functions / tool 角色 / tool_calls / function_call)走旁路:不查缓存、 不写缓存、不参与请求合并,但仍执行输入/输出安全过滤,并按上游状态码原样透传。
  • 落盘向量带 embedding 指纹:cache/lru_store.json 里记录产生向量的模型与词表哈希、维度、 分词器标识。换了模型或改了分词之后加载,指纹不一致的向量会被丢弃、文本条目保留 (日志 WARN 提示),并按新模型重建索引——避免"旧向量当新向量用"造成的静默错误命中。 旧格式(无指纹)文件同样能读入:此时向量按不可考处理(丢弃)并打印 WARN。 另一种旧格式的后果要特别注意:若条目连 src(来源文本)字段都没有(早期版本只写 ctime/key/value), LruStore 在重算向量时会直接跳过它们(for_each_missing_embedding 遇空 source 即 continue), 于是重建后的索引可能是 0 向量,而 index_ready 仍会被置 1 —— 外部表现为"语义缓存静默失效、 只剩精确命中可走"。线上确实出现过该状态(journal 里 cache: index rebuilt, 0 vectors (0 built + 0 backfilled)), 排查时先看这一行的计数与 /metrics 的 index_vectors;这类旧条目无法重回语义索引,只能清空后从零积累。

技术特性

  • 并发模型: 单 Reactor + 线程池,主线程管理连接,线程池处理缓存和 LLM 转发
  • 向量检索: 按论文实现 HNSW 图索引(分层结构、几何分布层数、启发式邻居选择、邻居满时收缩重选)。实测(见 tests/recall_bench):320 向量下自检索 top-1 100%(320/320)、top-3 召回率 99.7%;1000 / 5000 / 10000 向量下自检索 top-1 均为 100%,top-3 召回率 99.7% / 93.8% / 81.7%(ef_search=50 固定时规模增大导致束搜索覆盖不足,属 HNSW 固有特性;缓存只需 top-1,故不影响命中),相对暴力检索加速比 1.17x / 2.73x / 4.70x(完整四行见下文「实测性能」表,含真实集 320 的 1.31x)
  • 存储引擎: LRU + TTL 缓存管理,JSON 持久化
  • 嵌入推理: C++ ONNX Runtime 进程内 INT8 量化推理,零外部依赖
  • 模型量化: scripts/quantize.py — HuggingFace → FP32 ONNX → 动态量化 INT8 (~90MB→~23MB)
  • 连接池: libcurl Keep-Alive 复用 TCP 连接(每线程一个 CURL*,互不共享)
  • 索引生命周期: 向量索引在后台线程构建(1 万条真实 embedding 约 7s),构建期间进程即可服务, 语义检索暂时退化为线性扫描(万条约 15ms,仍能命中),完成后原子交换并自动恢复; 运行中的重建(幽灵率或过期清理触发)同样不阻塞检索,因为它用旧索引继续服务
  • 崩溃安全的持久化: 锁内取快照 + 锁外序列化 + 临时文件 + fsync + rename 原子替换; 向量以 base64 存原始 float 字节(1 万条 31MB),加载时兼容旧的 JSON 数组格式

技术栈

组件 选型
编程语言 C++20
JSON nlohmann/json
HTTP 客户端 libcurl
Embedding 推理 ONNX Runtime C API + bge-small-zh-v1.5 INT8 (512d)
构建系统 CMake 3.16+

项目结构

ai-gateway/
├── include/
│   ├── common/                  # 公共组件
│   │   ├── config.h             #   JSON 配置加载
│   │   ├── logger.h             #   日志宏
│   │   ├── log_file.h           #   日志输出与轮转
│   │   ├── types.h              #   公共类型(ErrorCode)
│   │   ├── curl_client.h        #   RAII libcurl 封装
│   │   ├── singleflight.h       #   同义在途请求合并
│   │   └── thread_pool.h        #   线程池
│   ├── server/                  #   模块1: HTTP 接入
│   │   ├── http_server.h        #     epoll ET + 线程池调度 + ResponseWriter
│   │   ├── connection_handler.h #     连接生命周期与 keep-alive 决策
│   │   ├── request.h            #     HTTP/1.1 请求解析
│   │   ├── response.h           #     HTTP 响应构造
│   │   ├── filter.h             #     安全过滤器(含 SSE 按事件过滤)
│   │   ├── router.h             #     URL 路由
│   │   ├── metrics.h            #     /metrics 的 Prometheus 文本渲染
│   │   ├── sse_usage.h          #     旁路解析 SSE 里的 token 用量
│   │   └── sse_capture.h        #     旁路解析 SSE,还原回答文本(流式回填用)
│   ├── cache/                   #   模块2: 语义缓存
│   │   ├── cache_engine.h       #     缓存协调器(命名空间隔离 + 实体否决)
│   │   ├── lru_store.h          #     LRU + TTL 存储 + JSON 持久化(含原始 SSE 字节)
│   │   ├── hnsw_index.h         #     HNSW 图索引(header-only)
│   │   ├── onnx_embedding.h     #     ONNX Runtime 进程内推理 + WordPiece 分词
│   │   ├── entity_tokens.h      #     实体标记提取与不对称差集判定
│   │   └── embedding_fingerprint.h  # 向量指纹(模型/词表/分词器标识)
│   ├── gateway/                 #   模块3: 请求管道
│   │   └── pipeline.h           #     输入过滤 → 流式/缓存/合并 → 上游 → 统计 → 输出过滤
│   ├── backend/                 #   模块4: LLM 后端
│   │   └── llm_client.h         #     LLM API 客户端(含 SSE 流式回调)
│   └── stats/                   #   模块5: 统计
│       └── stats.h              #     命中率/token/费用/延迟统计
├── src/                         # 源文件(与 include/ 同构)
│   ├── main.cpp                 #   入口:组装 + 启动 + 信号处理 + 优雅停机
│   ├── common/  server/  cache/  gateway/  backend/  stats/
├── model/                       # 内嵌模型文件
│   ├── model_int8.onnx          #   INT8 量化 bge-small-zh (23MB)
│   └── vocab.txt                #   词表 (107KB)
├── tests/                       # 单元测试(23 个 ctest 目标)
├── config/
│   └── gateway.example.json     #   配置模板
├── scripts/
│   ├── ai-gateway.service       #   systemd 服务模板(Restart=always)
│   ├── healthcheck.sh           #   健康探针:探测失败自动重启并邮件告警(配 timer 周期运行)
│   ├── benchmark.py             #   综合压测脚本
│   ├── integration_pipeline_test.sh  # 真二进制端到端集成验证(A–F 六段)
│   ├── gw_probe.py              #   集成验证用的探针 / mock 上游
│   ├── eval_semantic_cache.py   #   公开数据集上的缓存质量评测
│   ├── fetch_eval_datasets.sh   #   下载 LCQMC / PAWS-X
│   └── quantize.py              #   FP32 → INT8 动态量化
├── .gitignore
├── CMakeLists.txt
└── README.md

核心模块

server — HTTP 接入层

  • HttpServer: epoll ET 主循环 + 线程池调度
  • ConnectionHandler: 连接生命周期(keep-alive 决策、借出与归还)
  • Filter: 安全过滤(反注入 / URL / 关键词 / 长度),流式输出按 SSE 事件边界逐条判定 (对可能构成 URL 的内容另有跨事件判定),命中缓存的响应回放前同样要过一遍
  • Router: URL 路由表(METHOD + path 精确匹配)
  • Request/Response: HTTP/1.1 解析与响应构造

gateway — 请求管道

  • Pipeline: 一次请求的完整生产路径(输入过滤 → 工具请求旁路 → 流式分支 → 语义缓存 → 请求合并 → 上游转发 → 写缓存 → 统计 → 输出过滤)。抽成独立库是为了让单测链接同一份 对象代码,而不是测试里另写一份逻辑。

cache — 语义缓存引擎

  • CacheEngine: 编排缓存流程(向量化、Top-K 检索、阈值 + 命名空间 + 实体一致性判定)
  • HnswIndex: HNSW 图索引(header-only;按论文实现几何分布层数、启发式邻居选择与邻居饱和收缩, shared_mutex 保护读写并发,visited 标记线程本地复用)
  • LruStore: LRU + TTL 内存缓存 + JSON 持久化(锁内只取快照,序列化与落盘在锁外; 临时文件 + fsync + rename 原子替换)。条目同时保存回答文本与上游原始 SSE 字节
  • OnnxEmbedding: C++ ONNX Runtime 进程内 INT8 推理 + 对齐官方的 BERT WordPiece 分词
  • EntityTokens / EmbeddingFingerprint: 实体标记不对称差集否决;向量指纹(换模型 / 改分词后 旧向量不再被当作新向量使用)

backend — LLM 转发

  • LlmClient: LLM API 客户端(Keep-Alive 连接池复用 TCP;流式路径用 write 回调逐块交付, 并支持按"两次数据的间隔"判定的上游空闲死线)

stats — 统计

  • Stats: 命中率 / token / 费用 / 延迟统计,每 60s 定期输出

架构设计

客户端 (QQ Bot / Web / API)
    │  POST /v1/chat/completions
    ▼
┌────────────────────────────────────────────────────────────────┐
│  HTTP 接入层 (epoll ET + 非阻塞 IO)                              │
│  模块1: ConnectionHandler (解析、路由、过滤)                      │
├────────────────────────────────────────────────────────────────┤
│  安全过滤器 (MessageFilter)                                      │
│  模块1: 反注入/URL/关键词/长度 (双向)                              │
├────────────────────────────────────────────────────────────────┤
│  语义缓存                                                        │
│  模块2: CacheEngine                                              │
│    1. C++ ONNX Runtime 进程内向量化 (WordPiece 词表 + INT8 推理, 512d)  │
│    2. HNSW 图索引检索 Top-K 相似条目                              │
│    3. max(cosine) >= 0.85                                        │
│       命中: 返回 LruStore 中的缓存回复                             │
│       未命中: 继续                                                │
├────────────────────────────────────────────────────────────────┤
│  LLM 后端转发                                                    │
│  模块3: LlmClient (Keep-Alive 连接池)                             │
│  模块4: Stats (命中率/token/费用/延迟)                            │
└────────────────────────────────────────────────────────────────┘
    │  ↑
    │  缓存写入(未命中后:HNSW 索引 + LruStore 双向存入)
    ▼
┌──────────────────────────────────────┐
│  LLM 后端 (可替换)                    │
│  DeepSeek / OpenAI / Ollama / vLLM   │
└──────────────────────────────────────┘

编译和运行

依赖

# 系统库
sudo apt install build-essential cmake g++-13 \
    libcurl4-openssl-dev nlohmann-json3-dev

# ONNX Runtime C 库(pip 安装后自动可用)
pip install onnxruntime
# 头文件从 GitHub Release 获取
sudo cp onnxruntime_c_api.h onnxruntime_error_code.h /usr/local/include/
# 动态库通常位于 Python site-packages
export LD_LIBRARY_PATH=$(python3 -c "import onnxruntime; print(onnxruntime.__path__[0])/capi"):$LD_LIBRARY_PATH

构建

cmake -B build -S . && cmake --build build -j$(nproc)

配置

cp config/gateway.example.json config/gateway.json
echo "LLM_API_KEY=sk-your-key" > config/gateway.env
chmod 600 config/gateway.env

启动

# 网关(模型文件在 model/ 内,无需额外服务)
./build/src/ai-gateway config/gateway.json

集成到现有客户端

# 将 LLM API URL 指向网关
LLM_URL = "http://127.0.0.1:3003/v1/chat/completions"
# 原来的 URL 可以移除,API Key 由网关统一管理

systemd 部署

sudo cp scripts/ai-gateway.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ai-gateway

配置项

字段 默认 说明
server.port 3003 监听端口
backend.url DeepSeek API LLM 后端 URL(OpenAI 兼容)
backend.model deepseek-v4-flash 模型名称
backend.timeout_seconds 60 请求超时
embedding.model_path model/model_int8.onnx 进程内 ONNX 模型路径
embedding.vocab_path model/vocab.txt WordPiece 词表路径
embedding.dim 512 向量维度;与模型实际输出不符时启动即失败
cache.enabled true 启用语义缓存
cache.similarity_threshold 0.85 余弦相似度阈值
cache.entity_veto true 实体一致性否决:候选与查询的数字 / 大写缩略语 / 混合标识符存在不对称差集时否决该次命中(关掉可退回纯阈值判定,用于 A/B 对照)
cache.semantic_verdict_veto true 判定类回答不许语义外推:回答被识别成"结构化结论"({"allow": false} / {"safe": 0} 这类扁平小 JSON)时,只允许逐字相同的文本命中,语义相近的变体一律回源。开关写进缓存键前缀,改动后旧条目不再被精确命中(等其过期)
cache.max_entries 10000 最大缓存条目
cache.ttl_days 7 缓存过期天数
cache.store_vectors true 是否把向量一并落盘。false 时只存文本(原始提问已在 src 字段里),启动按它重算向量;落盘更小,代价是启动多一次全量编码(落在后台建图线程里)
filter.max_input_chars 0 输入最大字符(0 = 不截断)。⚠️ 它截断的文本会被用作缓存键(转发给上游的请求体不受影响),因此设成正值时,长的提问会被截到此长度再做缓存判定——前 N 字符相同而后文不同的两个提问会因此被判为同一文本。除非确实要压缩长 prompt 的缓存粒度,否则保持 0
filter.max_output_chars 0 输出最大字符(0 = 不截断)。流式响应按事件累计,到达上限后补发一个 data: [DONE] 并停止透传——截断也必须给客户端一个明确的流结束标志。⚠️ 该上限是累积字节数且把 reasoning_content 算进去:后端若是推理模型(如 DeepSeek 的 flash 系),推理内容很容易先吃满上限 → 流在推理阶段就被切断、可见 content 为空、finish_reason 缺失,上层客户端表现为"空响应"。默认 0,不要设小值
filter.block_urls false 是否拦截输出里的 URL。默认关闭:它只能拦"LLM 顺手贴出的链接"这类无意情形,对有意变形的 URL(hxxp://、插零宽字符、编码拼接)天然无效——那是模式匹配的边界;开着还会误伤正常回答里的文档链接与代码示例。需要"减少无意暴露面"时再打开
server.max_connections 256 并发连接上限(超出回 503)
server.idle_timeout_seconds 30 连接空闲超时(秒)
server.stream_idle_timeout_seconds 60 流式请求的上游空闲死线:上游连续多久不发数据就中停它(按"两次数据的间隔"判定,不是平均速率)
log.sample_every 1 每请求 INFO 采样率(1 = 全量,N = 每 N 条留 1 条)
log.max_bytes 10485760 单文件日志上限(字节),达到后切分;0 = 关闭轮转
log.keep_files 5 日志历史保留份数(不含当前文件)
cost.input_per_1k 0.001 输入单价(元/1K tokens),用于费用估算
cost.output_per_1k 0.001 输出单价(元/1K tokens);缺省与旧口径一致(统一 0.001)

API Key 通过 config/gateway.env(systemd EnvironmentFile)或环境变量 LLM_API_KEY 注入。

缓存键与"什么算同一个请求"

语义相似度只描述提问文本的接近程度,它与"上游会给出什么回答"之间还差一整套生成参数。 因此缓存键由三段共同决定,各管一件事:

缓存键 = [生成参数指纹 + 判定标记] ⊕ namespace(system prompt 哈希) ⊕ 原文+向量
         └── request_signature ──┘   └── 由 CacheEngine 负责"这算不算同一个问题"
  • 生成参数指纹:model / temperature / top_p / n / stop / max_tokens / max_completion_tokens / presence_penalty / frequency_penalty / logit_bias / logprobs / top_logprobs / seed / response_format / tool_choice / user 中请求体里实际出现过的那些。缺省值与"显式写了上游默认值"上游行为相同,故不必要地拆条。 为什么必须有它:同一个 prompt 只把 max_tokens 从 256 改成 512,上游输出就不同; 旧实现的键只有 namespace + 文本 + 向量,第二个请求会直接拿到第一个的响应 (实测:max_tokens=512 那次返回了 256 的正文与 usage,且 0 次上游调用)。
  • messages 刻意不进指纹:进了就意味着任何改写过的提问都得到不同键, "语义缓存"会退化成精确匹配。不同文本为什么不能共享答案,由实体一致性否决与 判定类回答的逐字要求负责,不靠指纹。
  • 判定类回答的逐字要求:回答形如 {"allow": false} / {"safe": 0} 这类扁平小 JSON (整份回答恰好一个 JSON 值、≤256 字节、对象 13 键或数组 13 元素、叶子是 bool/number/短字符串) 时,只允许逐字相同的文本命中。这类回答的语义几乎没有等价类: 推荐一部好看的电影 与 推荐一部炸弹的电影 余弦实测 0.93~0.95,结论却相反, 命中一次就是一次安全审核被绕过(把"允许"发给了本该拒绝的请求)。 判定标记进的是键前缀——命中判定发生在取回复之前,所以"这条条目是不是结论型" 必须在键里,写入侧与读取侧才能达成一致。
  • 已知取舍:同一个 prompt 在不同生成参数下会各占一条条目(命中率下降、token 节省下降)。 这是安全方向的取舍——命中的前提是"上游真的会给同样的答案",而不是"看起来是同一个问题"。 流式与非流式不按 stream 隔离:条目形态自带守卫(流式命中一条无 SSE 字节的条目时回源, 非流式则取 SSE 提取出的文本),最坏情况是流式多回源一次并覆盖该条目。

可观测性

GET /metrics — Prometheus 文本格式(text/plain; version=0.0.4),与 POST /v1/chat/completions 是两条独立路由,新增该端点不改变既有 POST 行为:

curl -s http://127.0.0.1:3003/metrics
# prometheus.yml
scrape_configs:
  - job_name: ai-gateway
    static_configs:
      - targets: ["127.0.0.1:3003"]

暴露 ai_gateway_requests_total(可缓存流量,不含旁路与合并)、ai_gateway_cache_hits_total / _misses_total、ai_gateway_cache_hit_ratio、ai_gateway_cache_bypassed_total、 ai_gateway_cache_merged_total、ai_gateway_cache_writes_total(回填次数)、 ai_gateway_tokens_{prompt,completion,saved}_total、 ai_gateway_cost_yuan_total / ai_gateway_cost_saved_yuan_total、延迟分位数 ai_gateway_latency_milliseconds{quantile="0.5|0.95|0.99"}、旁路延迟与线程池队列深度。

向量索引与降级单列一组:ai_gateway_index_ready(1 = 语义检索可用,0 = 后台建图中)、 ai_gateway_index_building(1 = 有后台建图在进行)、ai_gateway_index_vectors(当前索引中的向量数)、 ai_gateway_cache_degraded_searches_total(索引不可用时改走线性扫描的查询数)—— 没有这几个量,"后台在忙"只能表现为命中率莫名下降。

流式流量单列一组:ai_gateway_streams_total(含正常完成与中止)、ai_gateway_streams_aborted_total (上游空闲超时 / 客户端断开 / 写死线)、ai_gateway_streams_without_usage_total(上游没给 usage)、 ai_gateway_stream_cache_hits_total(命中并回放缓存的流)。

口径与日志里的 [STATS] 摘要一致,且只含聚合数值,不含任何请求内容或键哈希。 注意"可缓存流量"的口径:经过缓存查询的流式请求计入命中率分母(命中记 hits,回源记 misses), 工具调用类请求仍记 bypassed——把两者混起来会让命中率的分子分母口径不一致。

日志轮转 — gateway.log 达到 log.max_bytes 后改名为 gateway.log.1,旧的依次 后移,最老一份删除,共保留 log.keep_files 份;进程启动时若发现当前文件已超限会先归档。 设 log.max_bytes: 0 可退回"单文件一直追加"。轮转出的文件与当前文件同为 0600。

测试

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
# 23/23 目标(Release 与 Debug 各跑一遍都是全绿)

# 集成验证:真二进制 + 真 TCP + mock 上游,A–F 六段共 30 条断言
bash scripts/integration_pipeline_test.sh ./build/src/ai-gateway

# HNSW 召回率基准(手动运行,不纳入 ctest)
cmake --build build --target recall_bench
# 真实数据集模式(ONNX 编码后建索引)
./build/tests/recall_bench scripts/datasets/synthetic.jsonl model/model_int8.onnx model/vocab.txt
# 合成向量模式(规模曲线:抽样 200 查询与暴力检索对比)
./build/tests/recall_bench --synthetic 10000 --dim 512 --queries 200

# 语义缓存质量评测(公开数据集,先下载)
bash scripts/fetch_eval_datasets.sh
python3 scripts/eval_semantic_cache.py --limit 3000

两个测量脚本(README 里的性能数字由它们产出,原始输出在 results/): scripts/latency_bench.py(keep-alive 连接下的命中/未命中延迟分位数)、 scripts/fill_cache_bench.py(灌库 + 逐点采样 RSS:稳态标记、落盘窗口、峰值)。

两点判据上的说明:

  • test_pipeline 链接的是与可执行文件同一份请求管道对象代码(ai_gateway_pipeline), 覆盖的是生产路径本身,而不是测试里另写的一份逻辑。此前管道只存在于 main.cpp, 于是 drain、请求合并的命名空间隔离、keep-alive 决策三处缺陷都落在零覆盖的代码层。
  • integration_pipeline_test.sh 在启动实例时做一次监听者校验(端口 inode ↔ 进程 fd 比对), 避免端口被残留进程占着、测试却连到别的实例上得出假结论;A–F 六段共用这一个实例。

实测性能

测试环境:2 vCPU / 1968 MB 的 VPS(压测端与被测服务同机环回) 上游:DeepSeek v4-flash(公网 API);Embedding:ONNX bge-small-zh-v1.5 INT8 (512d),进程内推理 测量脚本:scripts/eval_semantic_cache.py(语义质量)、build/tests/recall_bench(索引与检索)、 scripts/fill_cache_bench.py(灌库内存与落盘)、scripts/latency_bench.py(网关自身延迟)、 scripts/benchmark.py(真实上游回放)。原始输出全部在 results/(文件名带日期)。

语义匹配质量(公开数据集)

缓存判定的质量用公开数据集量,而不是自建的相似句集合——自造集的重复度会把命中率抬到不可信的高位。

判定策略(LCQMC 3000 对) 召回 误命中 F1
0.85 + 实体一致性否决(当前默认) 87.80% 29.59% 80.42%
0.80 纯阈值 88.55% 30.38% 80.53%

LCQMC 的"相似"包含问答对关系,当作"同义改写"用会天然偏高,因此误命中率才是关键指标: 0.80 时近三成的"命中"是错的;0.85 叠加实体一致性否决(数字 / 大写缩略语 / 混合标识符存在 不对称差集即否决)后降到 29.59%。PAWS-X 上精确率在所有阈值下都只有 43% 左右,说明这个量级的 embedding 对"语序颠倒 / 主宾互换"这类深层改写无能为力——这也是需要实体否决的原因。 阈值扫描与逐条明细由 scripts/eval_semantic_cache.py 输出(results/eval_semantic_cache_*.log)。

延迟

指标 数值 说明
命中 p50(真实上游回放) 11 ms(synthetic)/ 16 ms(real) 含进程内向量化与图检索
未命中 p50(真实上游回放) 633 ms(synthetic)/ 614 ms(real) 含公网 LLM API 往返,衡量的是上游不是网关
命中 p50 / 未命中 p50(mock 上游) 6.7 ms / 7.7 ms 本地 mock 上游,量的是网关自身处理开销
命中 p50 / 未命中 p50(mock 上游,参数隔离改动后重测) 3.3 ms / 7.7 ms 300 次命中全中、300 次未命中全回源;未命中与改动前完全一致,命中更快是因为当时缓存仅少量条目(HNSW 检索随规模变化)

mock 那一行是刻意补的:把上游摘掉之后,命中 6.7 ms 就是"进程内编码 + 图检索 + 输出过滤 + HTTP 处理"的全部开销;未命中 p95 明显高于 p50(49.0 ms vs 7.7 ms),说明满缓存下的淘汰与 分配抖动集中在少数请求上。

参数隔离未引入延迟开销:缓存键新增的生成参数指纹只是一次字段拼接与 64 位摘要 (构造签名时算一次,判定热路径只比较定长前缀),未命中 p50 与改动前同为 7.7 ms。

流式

指标 回源(未命中) 命中(回放)
首字节(TTFB) 0.240 s 0.024 s
端到端 7.894 s 0.033 s
客户端收到的字节 391,356 391,369(= 首次 + 注释行,去掉注释行后逐字节相同)
delta 事件数 1296 1296

流式命中回放的是当初记下的原始 SSE 字节,因此客户端侧无需任何特殊处理: 两次的事件序列与字节数完全一致,usage 完整。

内存与落盘

向量以 base64 原始 float 字节落盘(而不是 JSON 数字数组):

状态 数值
空载 RSS 63.6 MB
灌入 5000 条 124.2 MB
灌入 10000 条 178.9 MB
静置后(含落盘) 179.1 MB
峰值 223.9 MB(出现在落盘窗口内)
落盘文件(10000 条) 31.4 MB

落盘用 base64 原始 float 字节而不是 JSON 数字数组:JSON 形态每个 float 要 21 个字符 (序列化按 17 位有效数字输出),同样 1 万条的落盘会膨胀到 107.7 MB,峰值也随之抬高 ——峰值那部分主要来自落盘期间要构建等量的 DOM 与 dump 字符串。base64 下 DOM 里每个向量 只是一个字符串。

启动

指标 数值
加载 10000 条缓存(31.4 MB)到可接受连接 0.58 s(5 轮中位 0.575 s、范围 0.575–0.684 s)
后台建图(10000 条真实 embedding) 约 6 s

建图已移到后台线程,因此它不阻塞服务:进程在 0.58 s 就能接受连接,建图期间 /metrics 的 index_ready 为 0,语义检索退化为线性扫描(详见下节),建图完成后自动恢复。

检索:索引与降级

规模 自检索 top-1 top-3 召回 HNSW 检索 暴力检索 加速比 建图
真实集 320(ONNX 编码) 200/200 = 100% 100% 192.9 µs 253.9 µs 1.32x 74 ms
合成 1000 200/200 = 100% 99.67% 651.3 µs 779.9 µs 1.20x 1.20 s
合成 5000 200/200 = 100% 93.83% 1.46 ms 4.06 ms 2.78x 46.5 s
合成 10000 200/200 = 100% 81.67% 1.72 ms 8.16 ms 4.74x 116.8 s

ef_search=50 固定时,规模增大会让束搜索覆盖不足(HNSW 固有特性);缓存只取 top-1, 这一项不影响命中。建图耗时对数据分布极敏感:真实 embedding 1 万条约 6 s, 而 512 维随机向量(无簇结构、点与点近似等距,启发式邻居选择无从剪枝)要 117 s —— 评估建图性能必须用带簇结构的数据。

索引不可用时(启动或重建窗口)检索不退化为"放弃语义",而是走 LruStore::scan_topk() 线性扫描:建图窗口内命中 p50 15.2 ms(建图完成后 HNSW 路径 6.7 ms),50/50 全部命中。 代价约 8.5 ms,换来的是这段时间不必回源(未命中一次 633 ms 且真花 token)。 走了降级路径的查询数会在 /metrics 的 ai_gateway_cache_degraded_searches_total 里单列。

真实上游回放

824 次真实请求(DeepSeek、阈值 0.85 + 实体否决、空缓存起步):

数据集 R1 R2 合计 命中 p50 未命中 p50
synthetic 320 × 2 轮 16.2% 100% 58.1% 11 ms 633 ms
real 92 × 2 轮 5.4% 100% 52.7% 16 ms 614 ms

/metrics 汇总:命中 469 / 未命中 355 = 56.9%,省下 32,617 tokens。 注意 tokens_saved 是按已发生未命中的平均调用量外推的估算值,对外引用用 token 数比 用金额稳(金额随上游定价变动)。R2 的 100% 里很大一部分是"命中自己上一轮写入的向量", 所以自建集的合计命中率只作链路是否打通的证据,不当生产命中率引用。