把 SDK 的 baseURL 改成 localhost,本地模型在协议上就像云上的 Chat Completions。负载行为不是同一回事:上下文更短、并行几乎没有、内存或显存一满,推理进程可能直接退出。本文用 Node.js 对接常见的 OpenAI 兼容本地服务,补上健康检查、单飞队列、按窗口裁剪,以及压力下的拒绝与降级,让现有应用接得上,失败语义仍然可预测。

本地模型和云 API 差在哪

接入本地模型时,最省事的一步是改地址:Ollama 默认在 http://127.0.0.1:11434/v1,LM Studio 常见是 1234,llama.cpp 的 server 也提供同一套 /v1/chat/completions。请求体仍是 model、messages、max_tokens,现有 OpenAI SDK 往往能直接打通。

云 API 把弹性藏在地址后面:限流回 429,超窗回明确错误,容量不够是服务端的问题。本地常见拓扑是一台机器、一张消费级显卡、一个推理进程。上下文长度在模型加载时就定了,4K 或 8K 都很常见。所谓并发,多半是在抢同一块 KV cache 和同一条计算流。并行请求在云上是吞吐,在本地更像事故隐患——即使服务端内部能做一点 batch,应用也不该默认“发多少就跑多少”。

更麻烦的是失败形态。显存不够、CPU 内存被换页打满、进程被系统 OOM 收掉时,Node 侧经常只看到连接拒绝或中途重置,而不是一张干净的 JSON 错误。内存预算也不是 Node 的 heap,而是整机:浏览器、IDE、Docker 和推理进程挤在一起时,先倒下的通常是模型进程。

所以,把本地模型接进现有应用,关键不是少付调用费,而是承认本地只有一个推理槽位,并在应用层把排队、窗口和失败变成稳定契约。下面的代码只用 Node.js 18+ 内置 fetch,以及真实存在的 GET /v1/models 与 POST /v1/chat/completions。

健康检查与单飞队列

先确认推理进程还活着,再发聊天。GET /v1/models 比探测 TCP 端口更接近“兼容层可用”:端口开着但模型没加载完时,列表接口也会失败或很慢。健康检查用短超时;推理超时必须单独放宽。本地首字延迟波动很大,套用云上十几秒的总超时,只会把正常生成误判成故障。

这里的单飞很具体:同一时刻只跑一个 completion。消费级 GPU 上并行两个请求,通常不会把吞吐量加起来,反而更容易把上下文和常驻权重挤到一起。应用层用并发度为 1 的队列,把“谁在跑”变成显式状态。队列本身也要有上限——Promise 堆积占用的是 Node 堆,用户感知则是无界等待。超出 maxQueue 就拒绝,让调用方立刻拿到容量错误。它不是把相同 prompt 合并成一次调用:聊天里 messages 几乎总是不同,合并并不安全。

// local-llm-gateway.mjs
// Node.js 18+:node local-llm-gateway.mjs
// 可选环境变量:LLM_BASE_URL、LLM_MODEL、LLM_N_CTX

class LlmError extends Error {
  constructor(message, { code, status, cause } = {}) {
    super(message, { cause });
    this.name = "LlmError";
    this.code = code;
    this.status = status;
  }
}

function estimateTokens(text) {
  if (!text) return 0;
  return Array.from(String(text)).length;
}

function messageTokens(msg) {
  const content = typeof msg.content === "string" ? msg.content : "";
  return estimateTokens(msg.role) + estimateTokens(content) + 8;
}

export function trimMessages(messages, tokenBudget) {
  const system = [];
  const rest = [];
  for (const m of messages) {
    if (m.role === "system") system.push(m);
    else rest.push(m);
  }
  const kept = [...rest];
  const total = () =>
    [...system, ...kept].reduce((sum, m) => sum + messageTokens(m), 0);

  while (kept.length > 1 && total() > tokenBudget) kept.shift();

  if (total() > tokenBudget) {
    throw new LlmError("context exceeds local window", {
      code: "CONTEXT_OVERFLOW",
      status: 400,
    });
  }
  return [...system, ...kept];
}

export class LocalLlmGateway {
  constructor(opts = {}) {
    this.baseURL = (opts.baseURL ?? process.env.LLM_BASE_URL ?? "http://127.0.0.1:11434/v1").replace(/\/$/, "");
    this.model = opts.model ?? process.env.LLM_MODEL ?? "llama3.2";
    this.nCtx = Number(opts.nCtx ?? process.env.LLM_N_CTX ?? 4096);
    this.maxQueue = opts.maxQueue ?? 4;
    this.healthTimeoutMs = opts.healthTimeoutMs ?? 2000;
    this.inferTimeoutMs = opts.inferTimeoutMs ?? 120_000;
    this.cooldownUntil = 0;
    this.running = 0;
    this.queue = [];
  }

  async health() {
    const res = await fetch(`${this.baseURL}/models`, {
      signal: AbortSignal.timeout(this.healthTimeoutMs),
    });
    if (!res.ok) {
      throw new LlmError(`health check failed: ${res.status}`, {
        code: "LLM_UNAVAILABLE",
        status: 503,
      });
    }
    return res.json();
  }

  chat(body) {
    const waiting = this.queue.length + this.running;
    if (waiting >= this.maxQueue + 1) {
      throw new LlmError("local llm queue is full", {
        code: "LLM_OVERLOADED",
        status: 503,
      });
    }
    return new Promise((resolve, reject) => {
      this.queue.push({ body, resolve, reject });
      this.#pump();
    });
  }

  #pump() {
    if (this.running > 0 || this.queue.length === 0) return;
    if (Date.now() < this.cooldownUntil) {
      setTimeout(() => this.#pump(), this.cooldownUntil - Date.now());
      return;
    }
    const job = this.queue.shift();
    this.running = 1;
    this.#run(job.body)
      .then(job.resolve, job.reject)
      .finally(() => {
        this.running = 0;
        this.#pump();
      });
  }

  async #run(body) {
    try {
      await this.health();
    } catch (err) {
      this.cooldownUntil = Date.now() + 3000;
      throw err;
    }

    const maxTokens = Math.min(
      body.max_tokens ?? 256,
      this.queue.length > 0 ? 128 : 256
    );
    const inputBudget = this.nCtx - maxTokens - 32;
    if (inputBudget < 64) {
      throw new LlmError("n_ctx too small for max_tokens", {
        code: "CONTEXT_OVERFLOW",
        status: 400,
      });
    }

    const messages = trimMessages(body.messages ?? [], inputBudget);
    let res;
    try {
      res = await fetch(`${this.baseURL}/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          model: body.model ?? this.model,
          messages,
          max_tokens: maxTokens,
          stream: false,
        }),
        signal: AbortSignal.timeout(this.inferTimeoutMs),
      });
    } catch (err) {
      this.cooldownUntil = Date.now() + 3000;
      const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
      throw new LlmError(timedOut ? "inference timeout" : "inference connection failed", {
        code: timedOut ? "LLM_TIMEOUT" : "LLM_UNAVAILABLE",
        status: timedOut ? 504 : 503,
        cause: err,
      });
    }

    if (!res.ok) {
      const text = await res.text().catch(() => "");
      throw new LlmError(`upstream ${res.status}: ${text.slice(0, 200)}`, {
        code: "LLM_UPSTREAM",
        status: res.status >= 500 ? 503 : 400,
      });
    }
    return res.json();
  }
}

const gw = new LocalLlmGateway();
try {
  const models = await gw.health();
  const ids = (models.data ?? []).map((m) => m.id);
  console.log("ready", ids.length ? ids.join(", ") : "(no model id)");

  const out = await gw.chat({
    messages: [
      { role: "system", content: "用一句话回答。" },
      { role: "user", content: "本地模型和云 API 最关键的差别是什么?" },
    ],
    max_tokens: 128,
  });
  console.log(out.choices?.[0]?.message?.content ?? out);
} catch (err) {
  console.error(err.code ?? err.name, err.status ?? "", err.message);
  process.exitCode = 1;
}

健康检查失败时,不要用云 API 那套高频抖动重试去打本地端口。进程若是因为内存退出,密集重连只会拖慢下一次加载。上面用了 3 秒冷却:就绪后再从队列取任务。

按窗口裁剪上下文

云上超长上下文主要是贵;本地超窗可能是服务端返回错误,也可能是推理进程异常退出。聊天记录不能原样转发。精确 token 数取决于模型词表,Node 里通常没有一份对所有本地模型都正确的 tokenizer,所以这里只用字符预算做上限,并给输出预留 max_tokens。中文按“一个字符计 1 token”做保守估计,比英文常用的 chars / 4 更不容易在 CJK 上低估。这是预算,不是对账。

裁剪规则要可解释:系统消息尽量留着;从最旧的非系统消息开始删;至少保留最后一条用户消息;若仍超预算,拒绝请求,而不是静默删掉用户刚刚说的话。max_tokens 必须计入窗口——只裁输入、不留输出,生成到一半仍可能失败。窗口数字要和服务端加载时的配置一致,例如 Ollama 的上下文设置、llama.cpp 启动时的 -c。不要在 OpenAI 兼容请求体里塞各家私有的 ctx 字段。对不齐时,Node 以为自己裁过了,服务端仍可能爆。

中文对话里系统提示和历史摘要同样占窗。工具调用、大段日志、把检索结果整页塞进 messages,都比 temperature 更贵——采样参数几乎不增加显存,序列长度才增加。用不到的字段不要传。

压力下的拒绝与降级

本地过载的信号不整齐:队列变长、健康检查失败、推理超时、连接重置。先定失败语义,再谈降级。

服务未就绪或进程消失,对上游用 503,允许重试,但必须带冷却。队列已满也是容量问题,用 503(或你们网关统一的 429),不要包装成模型“拒绝回答”。上下文超窗且无法安全裁剪,用 400,这种请求换时间重试没有意义。推理超时用 504,是否重试取决于业务能不能接受重复生成。

降级保持克制。队列里已经有人在等时,示例把本次 max_tokens 降到更小上限,缩短占用槽位的时间。非关键路径可以换更短的系统提示。不要在线上流量里临时改去加载另一个还没进内存的小模型:冷加载会再吃一截显存,把唯一的槽位占得更久。流式输出可以留给交互界面,但要清楚:流式不会增加并行能力,它只是把这一次推理的占用拉长到最后一个 token 发完。

观测不必上复杂 APM。日志里能看到队列长度、一次推理耗时、裁剪前后的字符预算、健康检查连续失败次数,就分得清是窗口问题还是进程没了。把这些状态码传给调用方,比统一返回“模型出错了”有用:对方才能区分“等一下再试”和“把对话截短后再发”。

总结

本地模型省的是调用费,不是运维复杂度和失败模式。baseURL 指向 127.0.0.1 只解决协议兼容。要在开发机或单卡环境里稳跑,需要健康检查确认进程还在,用单飞队列把并行变成有界等待,按窗口预算裁剪消息,并在队列满、超窗、超时时报出可区分的错误。上面的示例只依赖 Node 内置 fetch 和 OpenAI 兼容接口,可直接对接 Ollama、LM Studio 或 llama.cpp server。先让失败可预测,再把本地模型嵌进现有应用。