单文件 Node.js 实现 SHA-256 请求缓存,对重复 prompt 返回命中统计,可直接部署在免费服务器上。
免费模型端点按 token 计费。发送相同 prompt 两次,就要付两次费用。重试机制保护你免受失败影响,但无法保护你免受重复请求影响。一层缓存可以做到。本文从零开始构建这样一个缓存。每个阶段结尾都有验证步骤。不依赖框架、不依赖第三方库。一个 Node.js 文件搞定。
CI 作业会重复执行相同的 prompt。测试会重新运行相同的摘要。预览会重新生成相同的补全。每次重复都是一次完整的 token 计费。你的重试层处理错误,但无法处理相同请求。缓存层置于端点之前,从内存中直接响应重复请求。
这个模式很小:把请求哈希化,存储响应,返回副本。你能得到三个好处:降低配额消耗、降低延迟、减少限流触发。
Stage 0 — 前置条件
你需要三样东西:Node.js 18 或更高版本、一个有 API Key 的免费模型端点、一台可以访问的 Linux 服务器。本教程以 MonkeyCode 的免费模型访问和免费服务器选项为目标。二者都满足约束条件:真实端点、真实限流、零成本。披露:本文是 MonkeyCode 产品推广的一部分。
Stage 1 — 带健康检查的转发器
从最小的有用部分开始。一个将 POST 请求体转发到上游端点的服务器。它同时暴露 /health 端点,以便验证存活状态。
// gateway.js — stage 1: forward only
const http = require("http");
const UPSTREAM = process.env.UPSTREAM_URL;
const API_KEY = process.env.UPSTREAM_KEY;
const PORT = process.env.PORT || 8080;
const server = http.createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString("utf8");
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
},
body: rawBody,
});
const body = await upstream.text();
res.writeHead(upstream.status, { "content-type": "application/json" });
res.end(body);
});
server.listen(PORT, () => console.log(`gateway on :${PORT}`));
用你的端点值运行它。
UPSTREAM_URL="https://your-endpoint.example/v1/chat/completions" \
UPSTREAM_KEY="your-key" \
node gateway.js
在另一个终端验证。
curl -s http://localhost:8080/health
# {"ok":true}
curl -s -X POST http://localhost:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-o /dev/null -w "%{http_code}\n"
# 200
健康检查证明进程存活。POST 证明上游路径可用。Stage 1 完成。
Stage 2 — 添加缓存
缓存键是 method、URL 和原始请求体的 SHA-256 哈希。相同请求体对应相同键,不同请求体对应不同键。不做 JSON 解析,不做 schema 推断。
const crypto = require("crypto");
const TTL_MS = Number(process.env.CACHE_TTL_MS || 60_000);
const cache = new Map();
let hits = 0;
let misses = 0;
function cacheKey(method, url, rawBody) {
return crypto
.createHash("sha256")
.update(`${method} ${url} ${rawBody}`)
.digest("hex");
}
然后把上游调用替换为缓存检查。
const key = cacheKey(req.method, req.url, rawBody);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
hits++;
res.writeHead(cached.status, {
"content-type": "application/json",
"x-cache": "HIT",
});
res.end(cached.body);
return;
}
misses++;
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
},
body: rawBody,
});
const body = await upstream.text();
if (upstream.status >= 200 && upstream.status < 300) {
cache.set(key, {
status: upstream.status,
body,
expiresAt: Date.now() + TTL_MS,
});
}
res.writeHead(upstream.status, {
"content-type": "application/json",
"x-cache": "MISS",
});
res.end(body);
有两个细节很重要。只有 2xx 响应才会进入缓存。错误响应原样透传。TTL 默认在 60 秒后使条目过期。可以通过 CACHE_TTL_MS 修改。
重启服务器。用相同的请求两次来验证缓存。
curl -s -X POST http://localhost:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-D - -o /dev/null | grep -i x-cache
# x-cache: MISS
curl -s -X POST http://localhost:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-D - -o /dev/null | grep -i x-cache
# x-cache: HIT
这个响应头就是你的证明。MISS 表示一次上游调用。HIT 表示零次。
Stage 3 — 添加统计端点
没有数字的缓存只是一种信仰。添加 /stats 来报告命中数、未命中数和命中率。
if (req.method === "GET" && req.url === "/stats") {
const total = hits + misses;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({
hits,
misses,
hitRatio: total ? Number((hits / total).toFixed(3)) : 0,
cacheSize: cache.size,
}));
return;
}
curl -s http://localhost:8080/stats
# {"hits":1,"misses":1,"hitRatio":0.5,"cacheSize":1}
这个比率告诉你缓存对你的负载是否值得。0.5 意味着一半的请求从未到达模型。
Stage 4 — 完整文件
这是完整的 gateway。保存为 gateway.js。
// gateway.js — cache-first proxy for free model endpoints
const http = require("http");
const crypto = require("crypto");
const UPSTREAM = process.env.UPSTREAM_URL;
const API_KEY = process.env.UPSTREAM_KEY;
const PORT = process.env.PORT || 8080;
const TTL_MS = Number(process.env.CACHE_TTL_MS || 60_000);
const cache = new Map();
let hits = 0;
let misses = 0;
function cacheKey(method, url, rawBody) {
return crypto
.createHash("sha256")
.update(`${method} ${url} ${rawBody}`)
.digest("hex");
}
async function callUpstream(rawBody) {
const res = await fetch(UPSTREAM, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
},
body: rawBody,
});
const body = await res.text();
return { status: res.status, body };
}
const server = http.createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, cacheSize: cache.size }));
return;
}
if (req.method === "GET" && req.url === "/stats") {
const total = hits + misses;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({
hits,
misses,
hitRatio: total ? Number((hits / total).toFixed(3)) : 0,
cacheSize: cache.size,
}));
return;
}
if (req.method !== "POST") {
res.writeHead(405, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "method not allowed" }));
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString("utf8");
const key = cacheKey(req.method, req.url, rawBody);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
hits++;
res.writeHead(cached.status, {
"content-type": "application/json",
"x-cache": "HIT",
});
res.end(cached.body);
return;
}
misses++;
const upstream = await callUpstream(rawBody);
if (upstream.status >= 200 && upstream.status < 300) {
cache.set(key, {
status: upstream.status,
body: upstream.body,
expiresAt: Date.now() + TTL_MS,
});
}
res.writeHead(upstream.status, {
"content-type": "application/json",
"x-cache": "MISS",
});
res.end(upstream.body);
});
server.listen(PORT, () => console.log(`gateway on :${PORT}`));
缓存是一个普通的 Map。它会持续增长直到条目过期。对于单用户 gateway 来说没问题。对于繁忙的代理,需要添加大小上限或定期清理。
Stage 5 — 部署到免费服务器
把文件复制到你的服务器。命令假设通过 SSH。如果你的免费服务器提供的是 Web 终端,在那个 shell 里运行相同的命令即可。
scp gateway.js user@your-server:/opt/gateway/
创建一个 systemd unit,这样 gateway 可以在重启后存活。
[Unit]
Description=model cache gateway
After=network-online.target
[Service]
Environment=UPSTREAM_URL=https://your-endpoint.example/v1/chat/completions
Environment=UPSTREAM_KEY=your-key
Environment=PORT=8080
ExecStart=/usr/bin/node /opt/gateway/gateway.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
安装并启动。
sudo systemctl daemon-reload
sudo systemctl enable --now gateway
curl -s http://your-server:8080/health
# {"ok":true,"cacheSize":0}
如果请求超时,检查提供商的防火墙规则。有些免费服务器默认阻止入站端口。在提供商控制台中放行 8080。
然后对远程地址重复缓存检查。
curl -s -X POST http://your-server:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-D - -o /dev/null | grep -i x-cache
# x-cache: MISS
curl -s -X POST http://your-server:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-D - -o /dev/null | grep -i x-cache
# x-cache: HIT
Stage 5 完成。gateway 现在运行在 localhost 之外。
Stage 6 — 证明节省效果
在 TTL 窗口内运行十次相同的请求。
for i in $(seq 1 10); do
curl -s -X POST http://your-server:8080/ \
-H "content-type: application/json" \
-d '{"prompt":"explain TCP backoff in one sentence"}' \
-o /dev/null
done
curl -s http://your-server:8080/stats
# {"hits":10,"misses":1,"hitRatio":0.909,"cacheSize":1}
十次请求。一次上游调用。节省了九次。现在打开模型提供商的用量仪表板。该 prompt 的 token 计数应该只显示一次计费,而非十次。那个仪表板是外部证明。统计端点是内部证明。
缓存只对重复 prompt 有帮助。独特 prompt 永远会未命中。缓存存在于内存中。重启会清空它,第一次重复会再次消耗配额。TTL 会使过期文本继续服务。如果模型输出发生变化,在过期之前你会一直提供旧的补全结果。键是原始请求体。不同的空白符意味着不同的键。如果这对你很重要,需要对 JSON 做规范化。这不是重试层。429 或 5xx 会原样透传。如果两者都需要,搭配一个重试层使用。gateway 会缓冲完整响应。流式请求不会表现良好。
谁不应该使用这个
对于有独特用户消息的聊天负载,跳过这个模式。命中率会接近零。对于流式补全,跳过。缓冲会失去其意义。对于个性化输出,跳过。把用户 A 的缓存响应发给用户 B 是一个正确性 bug。对于高吞吐生产环境,跳过。你需要 Redis 或 CDN,而不是一个 Node 进程里的 Map。
这个模式很小:哈希化、存储、检查、服务。它在每个重复 prompt 上节省配额。它把限流头痛变成缓存命中。从上面的完整文件开始。连续一周测量你的命中率。如果它保持在 0.2 以上,缓存就在为你买单。如果需要一个免费端点来测试,MonkeyCode 的免费模型访问是一个合理的起点。