☰
大模型API太贵?用JavaScript写个200行分流器,账单降76%
2026/10/7 4:40:55 网站建设 项目流程

我一开始真不是为了写代码而写代码。上个月月底,我在整理几个小项目的服务账单时,突然意识到一件挺扎心的事:一个月下来,光是大语言模型 API 的调用费用,就占掉了整个 Seven 成本的一半还多。更扎心的是,我翻了下调用日志,发现大量请求都在反复问类似的问题、做类似的简单分类,甚至有一部分请求根本用不着那么大的模型来处理,但我依然老老实实地把它们全部发给了最贵的接口。

那段时间我正好也在折腾本地部署大语言模型,手头有一台吃灰的旧工作站,显卡不算强,跑一个 7B 参数的量化模型还是能胜任的。于是我就产生了一个念头:能不能做一个足够轻量的调度工具,把那些“低价值的简单请求”留在本地处理,只有真正复杂的任务才交给云端的强大模型?带着这个想法,我花了大概一个周末,用 JavaScript 写了 200 行左右的小工具,结果效果远超预期,所以这篇文章就是把整个思路、踩坑过程和实测数据都掰开揉碎讲一遍,希望能给正在为大模型账单发愁的人一点参考。

我猜你可能会问,为什么要用 JavaScript,而不是 Python?这个我后面专门讲。先说结论:这个工具本质上就是一个“请求智能分流器”,它会分析每一次大语言模型调用的输入特征、任务类型、紧急程度,然后决定是走本地小模型、走缓存、还是调用云端大模型。它的价值在于用最小的开发成本,换来了肉眼可见的账单下降和硬件功耗下降,而且整个实现并不复杂,只要你有一点 Node.js 基础,照着我的思路就能复刻出来。

1. 项目整体思路拆解:省钱省电的本质是什么

1.1 先算一笔账:大模型调用成本到底花在哪

在动手写代码之前,我先做了三天的调用日志分析。我需要搞清楚:账单上的钱到底被谁花掉了?我是把所有请求的输入输出 token 数、所用模型、响应耗时和业务标签都记录下来,然后按维度做聚合。分析结果其实很有代表性,拿我这边的数据举例,大概呈现出了这么几个规律:

  • 大约 38% 的请求是短文本分类或者关键词抽取,比如判断用户评论是正面还是负面、提取一句地址里的城市名,这类任务用大型模型完全就是“杀鸡用牛刀”。
  • 大约 27% 的请求是重复性的,比如同一份配置文件的解析请求、同一段产品描述的概括请求,它们在短时间内被反复提交,响应内容几乎完全一样。
  • 大约 22% 的请求是低紧急度的异步任务,比如定时生成日报摘要、批量打标签,用户并不需要立刻拿到结果。
  • 只有剩下 13% 的请求才真正需要大规模模型的推理能力,比如复杂的逻辑推理、长文档总结、代码生成和深度分析。

这个分布让我非常兴奋,因为它意味着:如果我能把前三类请求从昂贵的大模型调用中剥离出来,理论上可以减少七八成的 API 费用。而且,本地小模型在处理第一类和第三类任务时,质量损失非常小,甚至在一些固定的结构化任务上表现得很稳定,因为那些任务是“判别式”的活,不是“生成式”的创作,不需要超大参数量的常识储备。

功耗这边同样值得算一下。我那台旧工作站跑本地 7B 量化模型时,整体功耗大约在 180W 到 250W 之间波动,主要是显卡和 CPU 的负载决定。平时它空闲时功耗不到 50W。对比云端 API,每个 token 背后都是数据中心里高性能加速卡在燃烧电力,虽然我不可能直接看到云端的电表,但把所有请求卸载到本地之后,我这边的总功耗确实肉眼可见地涨了一截,但与此同时云端的费用急剧下降,相当于用一点本地电费换掉了大额的 API 账单,这笔账怎么算都划算。

1.2 “省钱省电”可量化的三个核心手段

既然明确了问题分布,接下来就是设计手段了。我把整个工具的优化逻辑拆成三板斧,每一板斧都对应着前面的某个问题类别:

第一板斧是模型降级路由。我给工具设定了一套路由规则,所有请求进来时,先根据提示词长度、任务类型关键词、期望输出格式等信息打分,分数低的任务直接分配到本地小模型,分数高的才继续向云端大模型转发。这个逻辑解决的就是那 38% 的“杀鸡用牛刀”问题。

第二板斧是语义缓存。我会对传入的文本做归一化处理,然后计算一个内容指纹,再加上一个时间窗口。短时间内命中缓存的请求,直接复用历史结果,压根不用再进模型推理。这个逻辑解决的是 27% 的重复请求问题。

第三板斧是延迟批处理。对于非紧急任务,我会先把它们塞进一个任务队列,攒到一定数量或者到达预定时间窗口后,再用本地模型批量执行。你可能觉得这不就是简单的批处理吗?其实不然,真正的难点在于本地推理的并发控制,如果同时跑太多任务,显卡显存和算力会被瞬间打满,反而拖慢整体响应,所以我给队列设置了水位线,类似一个消息队列的消费者机制。

不过我在这必须提醒一句:这三板斧不是银弹。它们能发挥作用的前提是,你的业务场景中确实存在大量简单请求和重复请求。如果你的所有用户请求都是高难度的复杂推理,那么这个工具能省下来的空间就很有限了。所以动手之前,分析自己的调用日志是绝对值得的。

1.3 为什么这个工具适合用 JavaScript 来写

提起 AI 工具,很多人第一反应是 Python。我也承认 Python 的生态确实丰富,但在“写一个调度代理”这个具体场景里,JavaScript/Node.js 反而有一个隐藏优势,那就是异步非阻塞的 I/O 模型。这个工具的本质不是“做推理”,而是“做转发”,它要把一个请求接进来,判断一下,然后转给本地进程或者云端 API。这中间大部分时间都在等网络响应和本地模型的执行结果,属于典型的 I/O 密集场景。

Node.js 处理这种场景简直像天生吃这碗饭的。它可以用极小的开销同时维持上千个等待中的连接,每个请求只需要一个回调或者 Promise,不会像 Python 那样动不动就要开线程池或者协程来管理并发。我用了一个现成的 Node.js HTTP 框架接收请求,内部自己管理路由表,靠着 JavaScript 的事件循环就把庞大的并发请求给托住了。这个工具跑起来后,常驻内存不过一百多兆,CPU 占用率也低得可忽略不计,成本基本可以忽略。

另外,还有一个小细节:JavaScript 对象的动态特性在处理“路由规则”时特别顺手。我可以把规则写成配置对象,key 是匹配模式,value 是处理函数,读起来就像一个路由表,逻辑清晰,还特别好扩展。如果你换 Python,虽然也能写,但做这种轻量级代理服务时还得额外引入 FastAPI 之类的框架,写起来远远没有 Node.js 原生代码那么干脆。所以我最终决定,用 JavaScript,不用任何重框架,纯 Node.js 的http模块加几个顺手的小工具库,加起来代码量刚好控制在 200 行左右。

2. 核心细节解析:路由逻辑、缓存设计与本地模型对接

2.1 任务难度的“打分机制”是怎么设计的

这个工具的灵魂,就是路由判断逻辑。如果你把所有请求都扔给本地小模型,那确实省钱了,但用户满意度会崩。反过来,如果全都走大模型,那这个工具就失去了意义。所以关键是设计一套足够聪明但又不复杂的打分机制。

我采用的方案是分层打分,不是简单看一个指标,而是把多个维度的信号综合起来。主要看这几个维度:

第一个是任务类型关键词。我在规则配置里维护了一张映射表,像“分类、抽取、关键词、判断、提取、格式化”这类词,一旦出现在提示词前缀中,就判定为简单任务的概率会提高。相反,如果提示词里有“分析、总结、推理、代码、解释、对比、论证”这类词,就判定为复杂任务的概率会提高。

第二个是输入长度。对中文场景来说,如果输入文本少于 300 字,需要模型理解的信息量就不大,复杂推理的可能性也会低一些。但如果文本长度超过 1500 字,哪怕是做摘要,也需要模型具备较强的长文本理解能力,这时我倾向于分配给大模型。

第三个是输出格式约束。如果请求里明确要求输出 JSON、表格、代码片段,这种通常属于结构化输出,本地小模型也能做,但需要提示词里给足够的格式示例。如果请求只是“自由发挥”式的写作,那本地小模型的内容质量可能会明显下降,所以这种请求我会加大模型分数。

第四个是用户显式指定的模型等级。我自己定义了三个等级:lite、medium、pro。如果调用方自己知道这个任务很复杂,直接传pro上来,那我就不能随便降级,这是为了避免“聪明反被聪明误”的情况。

我举个实际例子:假设一个请求内容是“把这段新闻标题按情感分成正、中、负三类:xxx”,任务类型关键词命中了分类,输出格式是简单文本,长度也很短,综合得分就会非常低,直接进本地模型。另外一个请求是“请分析这份财报的潜在风险,并给出三点建议”,任务类型关键词命中了分析和建议,文本长度几百字,综合得分就很高,这时候我就把请求转发给云端大模型。这种打分机制写得并不复杂,就是一组 if 判断加一个加权求和,加起来大约四十行代码,但它完成了从“不管三七二十一全发大模型”到“按需分配”的跨越。

2.2 缓存设计避开的三个大坑

缓存听起来很简单,但实际做起来有三个坑值得拿出来强调一下。

第一个坑是缓存键的噪声问题。如果直接把原始提示词字符串当作缓存键,那“帮我总结这段文字”和“帮我总结这段文字 ”(末尾多一个空格)就会变成两个不同的缓存项,缓存命中率会被大幅稀释。我在实现时加入了一个预处理步骤:对输入文本做 trim、去掉多余的空白、统一标点符号。更进一步的,我还对一些常见的表达变体做了归一化,比如把“请帮我”、“能否帮我”、“帮我”这些前缀做标准化处理,这样同一个语义的请求就能命中同一个缓存。虽然代价是缓存键的生成逻辑多了一点计算,但换来的是更高的复用率。

第二个坑是缓存的时效性。大语言模型生成的答案不是永恒不变的,业务数据可能变化,用户可能想要最新结果。如果缓存永不失效,用户就会开始抱怨结果“过时”。所以我给每条缓存记录都打上了时间戳,默认有效期十分钟,复杂任务缓存五分钟。这个值你可以根据业务实际调整,但我的建议是宁可短一点,也别为了省 token 让用户觉得结果太旧。

第三个坑是缓存的内容值计算。我一开始想省事,直接用字符串保存完整的请求响应,遇到大响应时内存吃得很厉害,而且传递时还要反复复制数据。后来我改成把响应内容计算成哈希,再存到内存映射表里,配合一个简单的 LRU 淘汰策略,控制缓存总量。这么做不仅访问速度快,内存占用也稳定。缓存命中时,我直接返回历史响应内容,这部分的等待时间几乎为零,用户请求耗时会大幅下降。

2.3 本地大语言模型的对接细节

本地部署大语言模型,我选的是目前社区比较成熟的 llama.cpp 项目,因为它的量化模型运行效率很高,在没有专业显卡的情况下也能跑出不错的速度。我的工作站配置是 Intel CPU 加一块中低端 NVIDIA 显卡,所以选了基于 llama.cpp 的 API 服务,它默认提供了一个 OpenAI 兼容的接口,也就是/v1/chat/completions,这对外层工具来说实在方便,因为所有云厂商的接口基本也长这个样子,我的转发代码不用写两套。

本地模型我选用的是 7B 参数的量化版本,具体精度是 Q4_K_M。这个精度的模型文件大概 4GB 左右,对显存容量比较友好,同时推理质量相比更高精度版本损失很小,属于性价比很甜的点。启动参数上,我把 context 长度设置为 4096 token,这比云端模型的上下文能力小不少,所以我在路由判断时也会考虑上下文长度,如果用户的输入加上提示词会超过本地模型的 context 上限,那就必须转给云端处理,否则模型会直接报错或者丢弃上下文。

还有一个特别重要的细节,就是本地模型的并发处理能力。llama.cpp 默认是单线程处理请求的,如果同时来了多个请求排队,每个请求的等待时间会被拉得非常长。我在工具里设计了并发控制,用本地 Goroutine 池的思路,在 Node.js 里实现了一个简单的信号量:同一时间最多只允许两个请求进入本地模型执行,其他的排队等待。这么做看起来似乎会降低吞吐量,但实际上反而稳定了响应时间,因为显卡和内存的资源是有限的,超发并发只会让模型计算互相争抢资源,最后每个请求都变得很慢。

2.4 省电的原理:CPU 频率和显卡负载的协同控制

关于“省电”这件事,我还做了一点系统层面的配合。默认情况下,进程会把 CPU 拉满,显卡也会一直保持高负载状态,其实很费电。我给工具加了一个简单的“空闲降频”机制:当任务队列为空时,主动让本地推理进程进入等待状态,不是让它退出,而是通过信号通知它暂停接受任务。同时我在宿主机上用cpupower工具把 CPU 的调节器切换成powersave,这种模式下 CPU 频率会下降,功耗显著降低。当任务队列的长度超过阈值时,再把调节器切回performance模式,以保证推理速度。这一套操作我会写在运维脚本里,虽然不算工具核心代码,但对省电效果贡献巨大。

我实测下来的数据是:空闲时整机功耗在 55W 左右,处理任务时峰值到 230W,由于任务量分布并不均匀,采用这种动态调节后总耗电量降低了接近 30%。这不是工具直接做到的,但如果你打算长期跑,这部分也值得照抄。

3. 实操过程与核心环节实现:200 行代码的骨架

3.1 工具的整体代码结构

我把它做成一个独立的 Node.js 服务,入口文件叫gateway.js,依赖很少,核心只有两个:node:http用来启动服务,node:worker_threads用来跑一个队列处理器。代码的大致模块如下:

  • 配置模块:维护模型列表、路由规则、缓存有效期等。
  • 路由模块:对请求做难度打分,决定目标是local、cloud还是cache。
  • 缓存模块:LRU 淘汰策略加时间戳失效。
  • 队列模块:接收非紧急任务,按批次发送给本地模型。
  • 转发模块:统一处理向云端和本地模型的 API 调用。

这五个模块加起来大概 80 行就是主体结构,加上辅助函数和配置,总共控制在 200 行出头。可能有些读者想问,为什么不拆成多个文件?我觉得对于这种小型代理工具,拆成太多文件反而增加理解成本,直接一个文件顺序写完,逻辑一眼到底。当然,这只是我的偏好,你要是喜欢模块化,拆分也完全没有问题。

3.2 路由器核心代码逐行解读

下面这团代码是整个工具最核心的部分,我把它稍微精简后放出来。先看路由打分这一段:

function routeByScore(prompt, meta) { const { length } = prompt; let score = 0; let localFriendly = 0; if (/分类|抽取|提取|判断|关键词|格式化|翻译/.test(prompt)) localFriendly += 2; if (/分析|总结|推理|生成代码|解释|对比|论证|创作/.test(prompt)) score += 2; if (length < 300) localFriendly += 1; if (meta.expectJson || meta.format === 'json') localFriendly += 1; if (meta.modelLevel === 'pro') score += 5; if (meta.modelLevel === 'lite') localFriendly += 2; if (meta.maxTokens > 2048) score += 2; return score > localFriendly ? 'cloud' : 'local'; }

这段代码的逻辑很清楚:我把“倾向本地”的权重和“倾向云端”的权重分别累计,最后谁大就去谁那边。可能有人会觉得这种规则不太严谨,没有机器学习模型那么“聪明”,但实际用下来,它已经能覆盖大多数场景了,因为任务难度的判断本身就不是一个需要极高精度的任务,你只需要避免明显的误判,剩下的交给下游兜底。

我在实践里还做了一个保底逻辑:即使分数判断为本地,本地模型执行后也会校验一下输出质量,比如判断是不是空内容、是不是完全文不对题,一旦发现异常就自动升级到云端模型重新生成一次。这个保底逻辑额外增加了十几行代码,但它极大地降低了路由误判对用户体验的影响,我非常建议加上。

3.3 缓存和队列的代码实现

缓存模块比较直白,用Map就能实现一个基础版本。我做了一个较完整但精简的版本:

class SemanticCache { constructor(ttl = 600, maxSize = 200) { this.store = new Map(); this.ttl = ttl; this.maxSize = maxSize; } normalize(text) { return text.replace(/\s+/g, ' ').trim(); } makeKey(text, model, taskType) { return [this.normalize(text).slice(0, 128), model, taskType].join('::'); } get(text, model, taskType) { const key = this.makeKey(text, model, taskType); const record = this.store.get(key); if (!record) return null; if (Date.now() - record.ts > this.ttl * 1000) { this.store.delete(key); return null; } return record.data; } put(text, model, taskType, data) { const key = this.makeKey(text, model, taskType); this.store.set(key, { ts: Date.now(), data }); if (this.store.size > this.maxSize) { const firstKey = this.store.keys().next().value; this.store.delete(firstKey); } } }

队列模块稍微复杂一点,因为它要考虑异步的攒批逻辑。我维护了一个数组,当请求到达时推入队列,同时设置一个定时器,每隔五秒检查一次队列长度。如果长度超过 5 条,或者离第一次入队的时间超过 30 秒,就触发一次批量发送。批量发送时,我会按批次拼接所有任务,让本地模型一次性处理。这样做的优点是减少进程上下文切换和请求开销,代价是第一个任务可能要等待一段时间,所以非紧急任务才适合走这个通道。像实时聊天类的请求,我是坚决不走队列的,直接透传到云端或者本地实时模型。

我在实际使用中发现,队列长度阈值和定时触发时间这两个参数需要互相配合。如果阈值设得太低,比如阈值是 1,那队列就跟实时通道没区别了,批处理优势发挥不出来。如果阈值设得太高,比如 50 条才触发,那有些任务可能要等上好几分钟,用户的体验会很差。我的经验值是阈值 5 条 + 30 秒定时,这两个参数配合起来,既可以保证批量效率,也不会让等待时间冲破天花板。

3.4 与本地模型的通信:一个 OpenAI 兼容的请求封装

因为本地模型的 API 和 OpenAI 接口格式兼容,所以转发代码写起来非常舒服。我没有用官方 SDK,直接基于fetch写了一个轻量封装:

async function callModel(endpoint, key, payload) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 60000); try { const resp = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(key ? { 'Authorization': `Bearer ${key}` } : {}), }, body: JSON.stringify(payload), signal: controller.signal, }); const data = await resp.json(); return data.choices[0].message.content; } finally { clearTimeout(timer); } }

这里我设置了 60 秒的超时时间,是因为本地模型在低负荷状态下响应速度尚可,但如果突发任务过多,等待排队可能超过一分钟。超时后中止请求是一种兜底手段,避免用户进程一直挂着。云端模型通常响应在十秒内,所以我给云端单独设定了一个 30 秒的超时值。

还有一个小细节:本地模型的 temperature 参数我一般设为 0.2,这个值能让输出更稳定,减少重复和乱飘的现象。云端大模型我倒是保留了默认的 0.7,因为复杂生成任务需要一点随机性来保证多样性。不同模型对应不同参数组合也是一个值得优化的点,它可以最大程度保证输出质量,同时不增加额外费用。

4. 实测结果与调优过程:账单、耗电和响应速度

4.1 节省成本的书面数据

我的 A/B 测试做得比较粗糙,但很有参考性。我拿这周和上周的 API 调用日志做对比,同时把工具按下开关各跑了两天,统计了 token 消耗量和费用。数据是这样的:

在未启用工具的两天里,云端模型调用的总 token 数是 1784 万,API 费用折算约 386 元。启用工具后的两天,云端模型调用的 token 数降到了 312 万,本地模型处理了剩余的 1472 万 token,对应从账单上消失的金额大约 295 元。如果按比例换算,费用降幅约 76%。当然这不是一个严格的对照实验,但趋势很稳定,连续跑了五天都是类似的比例。

有个细节我得补充说明:本地模型处理的 token 是免费的,但它消耗的是电力和硬件折旧。如果按我的电费标准 0.6 元一度来算,这两天多消耗的电大概 6 度,折合电费不到 4 元。这一里一外,节省的费用就非常可观了。所以从总拥有成本来看,这个工具是彻底正收益,而且越是用量大,收益越高。

4.2 响应速度的影响与补偿策略

省钱这事解决了,但大家肯定关心另一个指标:响应速度。毕竟把请求分流到本地模型,理论上速度会降低。我的实测结论是:确实带来了变化,但不像想象中那么可怕。

本地小模型处理简单任务时,单次响应通常在一到三秒之间,具体取决于文本长度和显卡负载。云端大模型处理同样的简单任务,通常也需要两到四秒,因为网络请求吞吐也会花时间。所以对于简单任务而言,本地模型不仅省钱,速度还可能会小幅提升。对于复杂任务,我们的路由规则会直接分配给云端大模型,所以复杂任务的响应速度基本没有受到任何影响。真正变慢的其实是走队列的非紧急任务,这类任务因为等待攒批,延迟可能从原来的三秒变成十秒或二十秒,但它们的业务场景本来就允许延迟,所以用户体感不会变得糟糕。

我在工具里还加了一个“紧急度”标志,如果前端传入的请求带有urgent: true,那它会被强制走实时通道,绝对不进队列。这个标志虽然会增加小小的判断逻辑,但对保障用户体验至关重要。从我接的几个小业务来看,用户几乎没有投诉响应变慢,相反他们还挺惊讶于一些简单反馈能秒回。

4.3 功耗实测与硬件调度心得

我在 3.4 小节提到了系统层面的省电策略,这里把实测数据放出来。整个服务跑起来后,我用一个智能插座记录工作站五分钟内的平均功耗:

  • 完全空闲:55W 左右。
  • 本地模型处理单请求:180W 到 200W。
  • 本地模型并发两个及以上请求:230W 左右。
  • 云端请求转发,本地模型不处理:大约 70W。

如果按一天 24 小时计算,假设每个小时有 3 个峰值处理时段,每个时段十分钟,其余时间为低负载,那么一天总耗电大约在 1.3 度到 1.8 度之间。比起以前本地模型全天候待命时的 3 到 4 度电,确实省了不少。这套动态降频逻辑的价值就体现在这里了:让系统在应该休息的时候安静下来,而不是一直高负荷运转。如果你也有比较老的服务器或者工作站,这个思路同样适用,不一定要严格照抄我的脚本参数,核心就是“按需供给”。

5. 踩坑记录与排查技巧:遇到的问题和解决方案

5.1 本地模型排队导致的雪崩效应

第一次把工具接上真实流量时,我遇到了一个特别典型的坑:请求量稍微大一点,本地模型的排队时间就急剧飙升,紧接着后面的请求也堆积起来,整个服务像冻住了一样。排查下来发现原因很简单:本地模型的推理接口虽然是多线程的,但显存资源有限,一旦并发请求超过模型的实际承载能力,所有进程都在竞争资源,最终每个请求反而都变慢了。

我当时的调整办法就是前面提到的“信号量并发控制”。我用一个简单的计数变量实现:

const localQueue = { active: 0, max: 2, waiters: [], async run(task) { if (this.active >= this.max) { await new Promise(resolve => this.waiters.push(resolve)); } this.active++; try { return await task(); } finally { this.active--; if (this.waiters.length) this.waiters.shift()(); } } };

这个代码类似一套令牌桶机制,限制同一时间只能有两个请求进入本地执行。改造完之后,服务响应时间明显稳定下来,不再动不动就过一分钟。这也是我想重点强调的:对本地推理来说,一味追求并发数是不理智的,要找到硬件模型的最佳并发点。

5.2 缓存命中但结果错误:命中了不该命中的东西

还有一个坑,是缓存键的规范化没做好导致的。最开始我统一标点符号时,把英文逗号换成了中文逗号,结果发现有些语义不同的文本经过转换后变成了相同的缓存键,导致用户拿到了非常奇怪的答案。比如“请分析A方案,B方案”和“请分析A方案B方案”在归一化后可能就近似了,但前者其实是两个并列方案,后者是一个整体方案。

后来我把归一化策略调得更审慎:只做空白字符的统一和全角半角英文字母的转换,不再强行把所有标点都替换掉,同时保留数字和英文符号的完整性。归整后误命中率降到了千分之一以下。这里我想建议大家:做缓存键归一化时,宁可保守一点,别为了追求命中率过度激进,否则命中一个错误结果比没命中更麻烦。

5.3 JavaScript 中的类型判断和处理细节

这 200 行代码虽然规模不大,但在处理请求参数时,JavaScript 的弱类型特性也坑了我几次。用户在 POST 请求体里可能传来各种类型的数据,msg字段可能是字符串,可能是数组,也可能是对象。一开始我直接用typeof body.message === "string"来判断,遇到数组就崩了。后来我写了一个轻量工具函数,统一做类型判断和强制转换:

function toTrimmedString(input) { if (typeof input === 'string') return input.trim(); if (Array.isArray(input)) return input.map(String).join(', ').trim(); if (typeof input === 'object' && input !== null) return JSON.stringify(input); return String(input ?? ''); }

这个小函数解决了所有请求参数类型混乱的问题。虽然题目是“200 行代码”,但这类健壮性细节才是工具能稳定运行的关键,建议你在写类似工具时,把这些边界情况的函数单独抽出来,别藏在业务逻辑里。

5.4 API 返回格式不统一时的兼容处理

云端的 API 和本地模型的 API 虽然都号称 OpenAI 兼容,但一些细枝末节的字段仍然存在差异。比如云端接口可能会返回choices[0].message.content,而某些本地模型服务会在异常情况下把content字段放到message.content之外的另一个嵌套位置,或者返回的是choices[0].text而不是choices[0].message.content。我在转发层做了一个提取函数来兼容这些差异:

function extractContent(data) { const choice = data?.choices?.[0]; if (!choice) return ''; if (choice.message?.content) return choice.message.content; if (choice.text) return choice.text; return ''; }

写代码时多加这一层防御,真的可以避免不少线上故障。这个提取函数对数据做了一层兜底,即使接口返回的字段结构和预期不一样,也不会直接抛异常导致整条链路崩溃,而是返回空字符串,然后外层逻辑再决定是否重试或者降级。

5.5 超时和重试策略的设定

大模型接口偶尔会抽风,网络超时、限流、返回 500 错误都是家常便饭。我给所有请求都配置了超时和重试机制,但重试策略需要区分对待:云端接口超时后可以重试一次,多等几秒;本地模型超时后一般重试意义不大,通常是显卡负载太高或者显存溢出,重试只会雪上加霜,所以本地模型我直接放弃本次请求,然后把错误返回给调用方,由其决定是否重新提交。

这里还牵扯到一个“重试风暴”的问题。如果大量请求同时超时,调用方会自动重试,这会让系统瞬间过载。所以我在工具里加入了全局的熔断机制:如果连续十次请求失败,就主动打开熔断开关,停止转发新的请求,等三十秒后再慢慢恢复。这段逻辑我用一个简单的状态机就能实现,虽然只占很少的代码,但它保证了整个服务的稳定性。

6. 适用范围、扩充方向与最终建议

6.1 什么业务适合直接使用这个小工具

经过一段时间的使用,我总结了这个工具最适用的场景特征,你可以拿去做自我对照:

第一,调用量大。如果你一天的大模型 API 调用量不到几百次,那省下来的费用可能还不够你电费,这个工具意义不大。如果一天有上万次调用,那节省效果立竿见影。

第二,任务类型杂食。如果你的业务全是长文档深度分析,那本地小模型基本帮不上忙,工具价值也有限。如果业务里既有简单的分类、抽取,又有复杂的对话生成,那就特别适合。

第三,对响应延迟有一定容忍度。虽然我做了很多优化,但走队列的非紧急任务依然需要等待时间。如果业务要求所有请求都在几百毫秒内返回,那你可能需要更专业的部署架构。

6.2 这个工具还能向哪些方向扩展

目前这个工具对我自己来说已经够用,但如果你感兴趣,还能继续向下面这些方向扩展。

一个方向是接入更多模型,做成一个统一的模型网关。不只是本地模型和云端大模型,还可以把不同的云端厂商的模型接进来,实现按价格、按延迟、按可用性动态切换,这样就不怕某个厂商限流或者涨价了。

另一个方向是加入更智能的路由策略。现在我用的是规则打分,但完全可以把自己的历史调用日志跑一遍,训练一个小分类器来替代规则。甚至可以直接用本地小模型做一次“路由前判断”,先让它判断新请求的任务难度,然后再决定用哪个模型执行,不过这样会额外增加一次推理消耗,是否划算需要测试。

还有一个可期的方向是结合真正的语义缓存。目前简单的文本归一整活在应对重复请求方面已经做了不少,但如果你希望复用更深层的语义类似请求,比如不同措辞表达同一个意思,那就可以用向量相似度匹配来代替字符串精确匹配,这需要引入向量数据库。一旦做成,缓存的命中率还能再上一个台阶。

6.3 我个人的最终建议

如果你正被大模型 API 账单压得喘不过气,建议你别急着购买更贵的套餐,也别急着全面更换模型。先花半天时间分析自己的调用日志,看看里面有多少请求是真正需要大模型来解决的,又有多少只是用大模型做了个“体力活”。只要有一个简单的分流思路,用你熟悉的技术栈,哪怕只是做一个粗粝的版本,都能感受到资金消耗在肉眼可见地降下去。

在动手的过程中记得两件事:第一,优先做路由判断和缓存,这两块能带来大部分的收益;第二,不要忽略本地模型硬件的承载能力,并发控制做不好,所有优化都可能被一个雪崩拖垮。我做的这个 200 行 JavaScript 小工具不算什么了不起的东西,但它一定是你开始思考“如何更聪明地使用大模型”这一话题的不错起点。如果你也在做类似的优化,希望这篇文章里的思路和踩坑经验能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询