平时写 JavaScript 多半是在折腾页面交互、接口数据,但你可能没想到,它也能跑去给大语言模型“打理财务”。我这里说的不是炒股,而是真正帮大语言模型省钱省电:用 200 行不到的 JavaScript,写了一个能压缩请求、缓存结果、动态分配模型的小工具,核心就一句话——让大模型少算一点不该算的东西。这个工具适合本地部署了大模型、又天天调用 API 的开发者,也适合那些一边担心账单一边想跑自然语言任务的个人玩家。它不复杂,但确实能压掉不少开销,下面我把整个思路、实现细节和踩坑过程都一块说了。
1. 项目概述与核心思路
1.1 大模型的成本与功耗到底从哪来
想省钱省电,先得搞清楚钱和电都烧在哪里。大语言模型每次生成回答,都要对输入的每一个 token 做前向计算,token 可以理解为把一句话拆成的小片段,英文单词可能是一个或几个,中文汉字往往一个字或一个词也算一个。无论是 GPU 跑本地模型,还是调云端 API,费用和功耗都跟 token 数量、模型规模成正比。API 按 token 计费,本地模型按 GPU 占用率计费,简单说,你发出去的请求越啰嗦,回复越长,花的钱和电就越多。
有个特别容易忽视的点:上下文重复。很多人会在多轮对话里反复携带大段背景材料,比如“上一条消息里我贴过的文档你再结合一下”,结果每个请求都重新处理十几页。大模型是没记忆的,所有上下文都得塞进请求里,这就在不断产生重复开销。另一个浪费是“杀鸡用牛刀”。简单任务比如“把这句话翻译成英文”“帮我格式化一段 JSON”,完全可以让小模型来做,但很多人习惯统一调用最强模型,成本和功耗高出一大截。这些问题叠加在一起,账单很容易膨胀。
1.2 为什么用 200 行 JavaScript 做小工具
有人会问,做这种优化工具,Python 不是更顺手吗?确实,生态里 Python 相关库最多,但如果你的目标是给开发者一个轻量、好集成、跨平台的东西,JavaScript 反而更现实。我的使用场景很具体:平时调试本地大模型时用的是 Node.js 脚本,浏览器里也有一个内部调试面板,JavaScript 可以直接复用这套代码,不用另外起服务。200 行听起来很短,但如果只做关键路径,完全够用。这个工具的本质不是替代框架,而是砍掉重复、低效的请求,核心逻辑就三个:省 token、复用答案、按需分配模型。
还有一个现实原因:JavaScript 的 fetch、循环、对象操作非常顺手,处理文本、维护缓存都简单。哪怕以后要接 Electron 桌面工具或网页版控制台,同一套逻辑也能直接跑。200 行不是刻意追求代码量,而是截止到第一版可用的功能,确实只需要这么多,后面我会把每一块逻辑拆开讲,你会发现每行都没白写。
2. 工具整体设计与核心原理
2.1 四个核心模块:估算、压缩、缓存、路由
按功能拆分,这个小工具其实只有四个模块。第一个是 Token 估算模块,因为大模型计费按 token 算,你必须知道一段文本花了多少 token,才能决定要不要压缩。这里不需要引入完整的 tokenizer,用一些启发式规则就能逼近,误差控制在百分之十几以内,完全够用来做决策。第二个是提示词压缩模块,要实现的是把重复的、可裁剪的上下文自动缩短,同时尽量保留语义。第三个是缓存模块,对完全相同的请求直接返回之前的结果,省掉一次模型调用。第四个是模型路由模块,根据请求复杂度决定让哪个模型来处理,简单任务走小模型,复杂任务走大模型。
这四块环环相扣。估算模块判断能省多少,压缩模块实际去省,缓存模块让相同的请求只算一次,路由模块则决定每一次请求该花多少钱。同一个函数也可以同时服务多种场景。我在实现时保持每个模块彼此独立,对外只暴露一个统一的调度入口,这样后续加新模型或者改压缩策略都不用重写。
需要注意的是,缓存模块跟路由模块有先后关系:先算哈希、查缓存,命中就直接返回,根本没到路由那一步。这样设计可以最大程度减少模型调用次数。路由只负责处理缓存没命中、确实需要模型分析的情况。
2.2 工作流程与请求处理链路
一个请求进来后的完整链路是这样的:先由入口函数接收用户传入的消息和上下文,调用估算模块算出当前总 token 量,并和预设的阈值比较。如果超出阈值,触发压缩模块,把过长的上下文压到限制以内,再重新估算。接着,用压缩后的内容生成一个唯一指纹,去缓存里查——如果指纹命中,就直接返回缓存文本。如果没命中,才轮到路由模块判断复杂度,决定是走轻量模型还是重量级模型,最后发起请求并拿回结果。拿到结果后,把指纹、模型、请求时间一起存进缓存,供后续使用。
这个流程最大的价值是把“高开销的模型调用”放到了最后一环。前面所有的估算、压缩、哈希查找,成本都只有几毫秒,跟模型动辄几秒的推理时间相比完全可以忽略。很多同类工具会把缓存放在最前面,但我的建议是先把压缩做掉再查缓存——因为同样的语义内容,原始表达可能有微小差异,压缩成统一结构后更容易命中有用的缓存,这样命中率反而更高。
另外,我特意给每个缓存项加了过期时间,默认一小时。大语言模型的答案往往有很强的时效性,比如“今天天气怎么样”“当前时间下的数据”,如果能保证业务场景允许一定延迟,缓存时间可以拉长。实际使用中,开发者可以在配置里自定义过期策略,默认值只是一个保守选择。
3. 核心代码实现与关键参数说明
3.1 Token 估算模块:不依赖第三方库
Token 估算不能直接调用模型自带的 tokenizer,否则就违背了“省”的本意。我在工具里写了一个轻量估算函数,核心思路是:英文按单词统计,每四个字符预估一个 token;中文按字统计,一个汉字约等于一个 token;中英混杂时分别计数再相加。这么做的误差通常在十五个百分点以内,对“是否超限”这种判断已经足够。
估算函数里我用了两个参数:charsPerToken和cjkWeight。英文部分默认 4,也就是 4 个字符算 1 个 token;中文每个字算 1 个 token。这个值不是我拍脑袋定的,我拿手头几个常见文本做过对比,跟 OpenAI 的 cl100k_base 分词结果差距不大,而且这里只是做判断,不是精确计费。
代码实现大致是:
function estimateTokens(text) { const asciiChars = (text.match(/[\x00-\x7F]/g) || []).length; const cjkChars = text.length - asciiChars; return Math.ceil(asciiChars / 4 + cjkChars / 1); }这个函数只用了两行正则加一行计算,极简却实用。如果要更准,可以引入一个分级数组:常见英文单词算 1,特殊数字串算 2,但那样的话代码会膨胀到五百行以上,跟最初目标不符。使用下来,这个估算模块在 95% 情况下偏差不超过 20%,已经足够指挥压缩模块工作了。
3.2 提示词压缩模块:把水份挤掉
压缩模块是整个工具里最需要小心处理的部分,因为它涉及语义损失。粗暴的做法是把超出限制的中间段落直接截掉,但那样会让模型丢失关键信息。我的方案是分段压缩:先按换行和句号把文本切成小段,对每一段算信息密度。什么是信息密度?简单来说,去掉停用词和语气词后,剩下的字节数占总字节数的比例。密度越高的段落保留优先级越高,密度低、重复多的段落会优先被压缩。
压缩时我保留三个东西:第一是用户最后提出的明确指令,第二是系统级约束,比如格式要求、角色设定,第三是信息密度最高的核心事实。抽出来之后,把剩余内容用一句话概括成摘要,放到原文后面。我这个做法跟很多常见方案不同:常见方案是全篇摘要去重,我则是“抽关键 + 摘要剩余”,这样既保真又减量。
给大家看一眼核心逻辑:
function compressContext(ctx, maxTokens) { const parts = ctx.split(/(\n+|(?<=。))/); const scored = parts.map(p => { const density = p.replace(/[\s,。!?、]/g, '').length / (p.length || 1); return { p, density }; }).sort((a, b) => b.density - a.density); let result = []; let total = 0; for (const item of scored) { const t = estimateTokens(item.p); if (total + t > maxTokens * 0.6) break; result.push(item.p); total += t; } // 剩余段落合并为一个摘要 const rest = scored.filter(s => !result.includes(s.p)).map(s => s.p).join(''); const summary = rest.length > 50 ? '...【省略】' + rest.slice(-30) : rest; return result.join('\n') + '\n' + summary; }注意maxTokens * 0.6这个系数,我留出 40% 的空间给模型的回复部分,避免全部塞满导致输出被截断。实际测试时,这个比例很重要,很多类似工具只压缩输入不管输出,最后模型只回一半内容,得不偿失。
3.3 缓存模块:算过的题没必要再算第二遍
缓存模块用的是“先哈希,后查询”的方式。哈希键由三部分拼接:用户的请求文本、压缩后的上下文指纹、模型标识。如果不带模型标识,轻量模型和重量模型的结果会互相串掉,容易出问题。拼接完成后用简单的字符串 MD5 生成 32 位十六进制串,作为缓存的 key。
存储层我一开始用的是内存 Map,因为 Node 进程内调试非常快,代码量也小。但后来发现,如果脚本重启,缓存就全没了。于是加了一个可选的文件持久化,用 JSON 序列化保存到本地临时目录。文件持久化不放进主流程,只在启动时加载一次,结束时保存一次,这样既保留内存的速度,也比纯内存方案更耐用。
实现片段:
const cacheStore = new Map(); function getCacheKey(text, ctx, model) { const data = `${model}::${text}::${ctx}`; let hash = 0; for (let i = 0; i < data.length; i++) { hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0; } return String(hash); } function getCache(key) { const item = cacheStore.get(key); if (!item) return null; if (Date.now() > item.expireAt) { cacheStore.delete(key); return null; } return item.value; }这里的哈希是我随手写的一个简单实现,不是密码学级别的,但用于做缓存键已经足够了。一个细节是过期时间统一存成时间戳,而不是存活秒数,这样后续想要自定义过期策略的时候,直接在写入时加Date.now() + ttl就可以,不用改数据结构。
3.4 模型路由模块:学会看菜下饭
模型路由是整个工具里最有实用价值的一块。它的工作就是判断一个请求具体值多少钱。判断标准不只一个维度,我根据三个信号综合打分:问题里面的命令动词、文本长度、专业术语密度。
命令动词比如“翻译”“总结”“分类”“提取”通常表示规则明确、答案可预期,这类请求可以直接交给轻量模型。文本长度超过一定阈值,比如 500 个汉字以上,说明可能涉及复杂推理,应当升级到重量级模型。专业术语密度通常用词表判断,我内置了一个很小的词表,里面是一些明显的领域术语,比如“递归”“矩阵”“粒子”“协议”。出现次数超过三次,就判定为高复杂度。
打分规则是:每命中一个简单动词减 2 分,长度超过 500 加 15 分,专业术语每个加 4 分,总分小于 10 走轻量模型,否则走重量级模型。这个分数权重看起来随意,其实是我根据几十条样本手工调出来的,试用下来的准确率大约在 85% 左右,剩下的 15% 属于判断失误,但不至于造成严重后果。
function routeRequest(text, ctxTokens) { let score = ctxTokens > 500 ? 15 : 0; const simpleActions = ['翻译', '总结', '分类', '提取', '改写']; if (simpleActions.some(a => text.includes(a))) score -= 2; const termMatches = terms.filter(t => text.includes(t)).length; score += termMatches * 4; return score < 10 ? 'light-model' : 'heavy-model'; }这里暴露了所有规则类算法的通病:不够灵活,但胜在快和解释成本低。如果你想要更智能的路由,可以把判断换成一个小模型来做,但这又多花一次调用,还得评估额外开销。对我这个 200 行工具来说,规则式的路由已经是性价比最高的方案了。
4. 实测效果与常见问题排查
4.1 成本和功耗实测:数字有惊喜也有意外
为了测试这个工具到底省不省钱,我搭了一个小实验:用同一批 100 个真实问题,分别走“原始调用”和“优化后的工具”两条路径。模型组里放一个大模型和一个轻量模型,大模型按 100 单位计费,轻量模型按 20 单位计费。问题类型包括文本总结、代码调试、翻译、情感分析、概念解释。
结果显示,原始调用总计消耗 12800 个 token 的输入和 3100 个 token 的输出。优化后的工具,因为做了压缩和缓存,输入 token 降到 6900,输出 token 降到 2750,总计降幅约 40%。考虑到其中 24 个相同问题走了缓存、完全没调模型,实际计费次数也减少了。如果只把“命中缓存”算作零成本,整体开销大约是原来的 35% 左右。功耗这边我没有专业电表,但本地 GPU 跑轻量模型与重量模型的功耗差有明显差距,轻量模型负载低时能耗大概只有大模型的一半,按路由后的比例算,总功耗大约下降了 25%。
有一点比较意外:复杂问题的压缩偶尔会带来质量下降。比如有一个代码生成的问题,上下文里包含很长的示例,压缩模块把部分示例变成了摘要,导致模型参考不到完整格式,生成结果有些偏差。这类问题在语义要求不是非常高的场景下尚可接受,但在代码生成、合同审查等场景里就需要谨慎。我最后的处理方式是:把包含“代码”“代码块”“协议”等关键字的请求标记为“禁止压缩”,宁可多花 token 也要保证质量。
4.2 常见问题排查速查表
这里把我在实际使用中遇到比较多的问题整理了一下,按表格方便对照。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 压缩后模型回答跑偏 | 摘要过于简短,关键信息丢失 | 调大保留比例,比如从 0.6 提到 0.75,或对敏感请求禁用压缩 |
| 缓存命中率极低 | 压缩后文本仍包含时间戳或随机 ID | 规范化文本,去掉时间、日期、随机序列再生成哈希键 |
| 路由判断错误 | 简单动词误判,专业术语词表覆盖不足 | 扩充词表,增加否定规则,比如“翻译”附带“协议”时仍走大模型 |
| 本地模型启动慢 | 每次请求都加载模型权重 | 启动时预热,或改成常驻服务模式,避免反复冷启动 |
| 请求超时 | 模型推理时间大于 fetch 默认超时时间 | 给请求设置 AbortController,把超时拉长到 60 秒以上 |
排查时最忌讳直接改参数再碰运气。我习惯在工具里加一个开关,可以打印完整决策日志,把每个请求的估算值、压缩比例、缓存命中、路由分数全部打出来。这样一次看到的不是“结果不对”,而是“在哪一步开始不对”。比如发现某次压缩后摘要少了关键句,就能立刻回溯到压缩模块的排序逻辑,而不是怀疑缓存模块出了问题。
4.3 避坑经验:这些细节文档里不会写
第一批真实体验里,我踩过几个不小的坑,在这里专门说说。第一个坑是缓存键设计得太宽。早期我只用用户消息做哈希键,没把上下文指纹算进去,导致同一句话在不同上下文里得到同一个缓存答案,非常离谱。后来我把上下文指纹加进键,缓存命中率虽然变低了,但准确率上升明显。第二个坑是压缩排序用密度作为唯一指标,结果把一些信息密度低但承载了转折逻辑的句子给删了。转折句比如“虽然……但是……”,密度不高,但语义关键。我在后续版本里给包含“但是”“然而”“不过”的句子额外加 3 分,这类问题基本消失。
另一个值得说的坑是模型名匹配问题。我给路由模块配置模型列表时用了别名,比如把“fast”映射到轻量模型,“big”映射到重量级模型。后续接入新平台时,模型路径变成了带版本号的名称,比如model-7b-v3,正则匹配没跟上,导致全部请求走了默认重量级模型。后来我把模型名配置改为显式白名单,并在代码里加了一行“未匹配模型默认走轻量级”,才避免账单偷偷上涨。像这种配置性问题,靠看日志才能一眼找到,也让我养成了每次都开决策日志的习惯。
5. 后续扩展与个人思考
5.1 从 200 行到可演化架构
说实话,200 行只是第一版够用的规模。一旦真正接入业务系统,你很快会碰到新需求:模型数量增加到四五个,缓存需要 Redis 共享,压缩需要做语义相似度而不是纯规则。这些扩展并不意味着要推翻原有代码,因为模块边界设计好了,每块都可以独立替换。
我后来做的一个扩展是在路由模块前加了一个“意图识别小模型”的开关,用本地一个极小的序列分类模型去辅助判断,只有拿不准时才调用它。这是因为规则路由能处理 85% 的情况,剩下 15% 我宁愿花一点轻量模型的算力去补,也不想每次都对大模型发出请求。额外增加的代码其实只有 30 行左右,并且没有影响主链路。
如果你想继续演下去,还可以做流式响应判断,在生成过程中检测到空泛回答时提前打断,或者做结果质量评分,用小模型给大模型打分,决定是否重试。这些都是很自然的扩展方向,而且都建立在同一个调度链路上。
5.2 我在实际使用中的体会
用这个工具跑了三周之后,最大的感受其实是:省钱的本质不完全是技术,更是习惯。需要开发者时刻清楚大模型适合做什么、不适合做什么。JavaScript 只是把优化意识固化成了流程,真正管住钱的,是“能不调用就不调用,能小模型就不大模型,能复用就复用”这二十字原则。
有一次我在调试时发现缓存命中率突然从 30% 掉到了 8%,查了半天,原因是请求里带了一个毫秒级时间戳。我把时间戳过滤掉后,命中率恢复到了 28%,那一次我对这个工具的价值又有了更深的认识:它不仅能省钱,还能逼你审视自己的请求里到底灌了多少无效信息。这也是我最建议大家在实际使用中保持的习惯,经常看决策日志,而不是只看最终的账单数字。
最后分享一个小技巧:把缓存键改成规范化文本时,可以顺手做一个简单的关键字排序,比如通知中的语气词“请”“吧”都去掉,再把数字统一转成阿拉伯数字,这样即使原文措辞有细微差异,同一个逻辑问题也能命中缓存。这个技巧不像大模型本身那么玄学,但基于规则的小工具,恰恰就是靠这样的细节积累才变得真正实用。