[控场AI]
· 20 分钟阅读· 10,315 字

用户关掉标签页后,你的 Node 服务还在为 LLM 调用付费

用户关掉标签页后,你的 Node 服务还在为 LLM 调用付费

用户关掉标签页后 Node handler 仍在付费,需用 AbortSignal 从 socket 贯穿到每个调用。

文章指出一个隐蔽的成本漏洞:当用户关闭浏览器标签页时,Node 的 async handler 不会有任何察觉,模型流、工具调用、agent 后续轮次都会继续运行并计费,而由于没有崩溃也就没有任何告警。解决方案是把 `AbortSignal` 从 socket 检测一路传递到每一个花钱的调用。检测层要监听 ServerResponse 的 close 事件并结合 writableFinished 判断真断开,而不是用语义已变的 req.on('close') 或弃用的 'aborted'。传递层用 AbortSignal.any 合并断开信号与超时信号,把同一个 signal 交给 SDK、fetch 和 agent 循环每一轮的入口。文章还强调一个易踩的坑:Anthropic 流迭代器在 abort 时可能静默结束,仅在 catch 里处理会漏记日志,甚至把截断回复当完整答案保存,因此每次循环之后都要检查 signal.aborted。

用户请求一份完整的迁移方案,三秒后发现回答方向不对,关掉了标签页。浏览器断开了连接,Node 处理函数却毫无察觉。它继续从模型流里取 token、继续往一个没人读取的 socket 里写数据;当模型要求工具调用时,它照常执行工具,再把整段对话发回去进行下一轮。

没有任何东西崩溃,所以没有任何告警。唯一的痕迹留在账单上。

Node 不会帮你取消这些。一个 handler 只是一个 async 函数,它会一直运行到 return 为止。要让工作在用户离开时停下,你必须把这个事实从 socket 一路带到每一个花钱的调用里。实现这件事的工具,是一个传递到底层的 AbortSignal。

什么在用户离开后继续运行

如果不做任何处理,有三件事会在用户离开后继续存活。

模型流。服务器维持着一条到服务商的 HTTP 长连接,并持续读取。只要你还在读,模型就会一直生成,直到自然结束或触达 max_tokens。

工具调用。如果模型要求搜索或数据库查询,你的代码就会执行它。结果流向一个无用的地方。

后续轮次。在 agent 循环中,每一轮都会把此前积累的全部对话作为输入重新发送。你启动的每一轮都是一次输入加输出的付费。你从未启动的那一轮,成本为零。最后这一点无论服务商是谁都成立。

至于关闭上游连接后输出 token 会怎样,情况更模糊。OpenAI 与 Anthropic 的流式文档都没有明确说明。一家 AI 网关厂商 TrueFoundry 公开了按服务商区分的取消计费政策:它把 Vertex AI 和 Azure OpenAI 列为"确认不对已取消的流计费",并称 OpenAI"无论你是否继续读取,都会对完整补全计费"。该表格基于其自身测量,但未公开数据也无可引用来源。OpenAI 社区论坛上则有开发者说法相反,不过同一帖子也指出输入上下文仍会被收费。把输出侧视为因服务商而异,并核对你自己的用量日志。无论如何都要取消:跳过的轮次和工具调用,无论哪一方正确,都是实实在在的节省。

![关闭的浏览器标签页与仍在计费的服务器](

在 Node 中检测断开

大多数 handler 在这里出错。最直觉的监听器是 req.on("close"),而它早在多年前就不再表示"socket 关闭"了。Node.js HTTP 文档记录了这一变化(v16.0.0):"close 事件现在在请求完成时触发,而不是在底层 socket 关闭时。"对 POST 来说,这意味着 req 在你读完 body 后立刻就触发 close。用它来 abort,就会取消每一个请求。旧的 'aborted' 事件已被弃用,文档注明"改为监听 'close' 事件"。

应该监听 response。ServerResponse 的 close 事件"表示响应已完成,或其底层连接被提前终止"。要区分这两种情况,检查 writableFinished——只有所有数据都已 flush 完成时它才为 true:

// disconnect.ts
import type { ServerResponse } from "node:http";

export function disconnectSignal(
  res: ServerResponse,
): AbortSignal {
  const ac = new AbortController();
  res.on("close", () => {
    if (!res.writableFinished) {
      ac.abort("client_disconnected");
    }
  });
  return ac.signal;
}

这在 Express 下同样有效,因为它的 res 是 Node 原生 response 对象的增强版。再配合 AbortSignal.any(Node v20.3.0 和 v18.17.0 引入)与 AbortSignal.timeout 加一个硬性截止时间,你就得到了一个两路触发的信号。

如果你跑在 nginx 后面,它本身就会在客户端离开时关闭上游连接:proxy_ignore_client_abort 默认为 off。如果有人把它改成了 on,你的 Node 服务就永远看不到断开,上述机制都不会触发。

为什么 req.on('close') 会误判:这一改动源自 Node v16.0.0。在更早的版本里,IncomingMessage 的 close 事件在底层 TCP socket 关闭时触发,因此被直觉地当作"客户端断开"的信号。但 Node 团队重构了 HTTP 生命周期,让 close 事件对齐 HTTP 消息本身的结束——对请求对象来说,也就是请求 body 读取完毕。对一个 POST 请求而言,for await (const chunk of req) 循环退出后立即触发 req 的 close,此时连接可能完好的开着。用它来 abort(),等于在每个请求读完入参那一刻就取消掉自己。

正确的替代品是监听 ServerResponse 的 close 事件,它在 Node 文档中的语义是"响应已完成,或其底层连接被提前终止"——两种情况的混合正是难点。区分办法是检查 res.writableFinished:它只在响应所有数据都已经 flush 并且 end() 已调用后为 true。因此判断逻辑是:close 触发且 writableFinished === false → 客户端提前走了。旧的 'aborted' 事件已在文档中被标注弃用,官方明确写"改为监听 'close' 事件"。

把信号传进 SDK

两个官方 SDK 都在按请求的选项里接受 signal。Anthropic TypeScript SDK 把它文档化为"可用于取消请求的 AbortSignal",OpenAI Node SDK 的 README 也是同样的说法。以下是一个使用 Anthropic SDK 的流式路由,用原生 node:http 编写,让管路暴露无遗:

// server.ts (Node 20+)
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import Anthropic from "@anthropic-ai/sdk";
import { disconnectSignal } from "./disconnect.js";

const client = new Anthropic();
const MODEL = "claude-opus-5-5";
const MAX_TOKENS = 2048;

type Stats = { input: number; outChars: number };

async function readPrompt(req: IncomingMessage) {
  let raw = "";
  for await (const chunk of req) raw += chunk;
  return (JSON.parse(raw) as { prompt: string }).prompt;
}

处理函数把断开信号和一个两分钟截止时间合成一个信号,然后交给 SDK:

// server.ts (continued)
async function chat(req: IncomingMessage, res: ServerResponse) {
  const signal = AbortSignal.any([
    disconnectSignal(res),
    AbortSignal.timeout(120_000),
  ]);
  const prompt = await readPrompt(req);
  res.writeHead(200, {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
  });

  const stats: Stats = { input: 0, outChars: 0 };
  try {
    const stream = await client.messages.create(
      {
        model: MODEL,
        max_tokens: MAX_TOKENS,
        stream: true,
        messages: [{ role: "user", content: prompt }],
      },
      { signal },
    );
    for await (const ev of stream) {
      if (ev.type === "message_start") {
        stats.input = ev.message.usage.input_tokens;
      } else if (
        ev.type === "content_block_delta" &&
        ev.delta.type === "text_delta"
      ) {
        stats.outChars += ev.delta.text.length;
        res.write(`data: ${JSON.stringify(ev.delta.text)}\n\n`);
      }
    }
  } catch (err) {
    if (!signal.aborted) throw err;
  }

  if (signal.aborted) {
    logAborted("/chat", stats, signal.reason);
    if (!res.writableEnded) {
      res.end("event: aborted\ndata: {}\n\n");
    }
    return;
  }
  res.end("event: done\ndata: {}\n\n");
}

中止分支里的 res.end 对超时路径很重要:客户端可能仍然连接着,不调用它 SSE 响应会一直开着。在已关闭的 socket 上它什么也不做。最后,服务器本身:

// server.ts (continued)
http
  .createServer((req, res) => {
    chat(req, res).catch((err) => {
      console.error(err);
      res.destroy();
    });
  })
  .listen(3000);

chat 函数里 for await 循环之后的 signal.aborted 检查是刻意的。在 SDK 的流迭代器里,abort 错误被捕获并附注"如果用户调用 stream.controller.abort(),我们应该不抛错误地退出"。所以当信号在流中途触发时,你的 for await 循环可能悄无声息地结束,就像模型正常完成一样。只在 catch 里处理 abort 的代码永远不会记录到它。更糟的是,循环之后直接保存答案的代码,会把一个截断的回复当作完整答案存下来。每次循环之后都要检查 signal.aborted。

同一份文件还展示了另一个方向:如果你 break 跳出循环,finally 块会自动为你中止请求。在这个 SDK 里,"停止读取"和"取消"是同一个操作。

message_start 携带输入 token 计数,最终的输出计数在流接近末尾的 message_delta 里才出现,而被中止的流永远到不了那里。这就是路由改用字符计数的原因。

AbortSignal 与 AbortController 是 Web 平台引入、后被 Node.js 原生采纳的标准取消原语。AbortController 是一个控制器对象,内部持有一个 AbortSignal;调用 controller.abort(reason) 时,该信号由未中止状态切换到已中止状态,并把 reason 作为 signal.reason 暴露出来。所有此前通过 signal.addEventListener("abort", ...) 注册的监听器会被触发。关键在于这个信号可以被传引用地层层下传:HTTP 处理器把它交给 SDK,SDK 交给 fetch,fetch 交给底层 socket。任何一层只要监听同一个信号,就能在用户断开的那一刻同步停止。这也是为什么本方案的实现方式不是"层层传递 Boolean 标志",而是把同一个 AbortSignal 对象一路传到底。

AbortSignal.any 与 AbortSignal.timeout 是两个静态合成工具。AbortSignal.timeout(ms) 返回一个在指定毫秒后自动中止的信号;AbortSignal.any([...]) 接收一组信号,只要其中任意一个中止,返回的信号就立即中止。把二者合并后,"用户断连"和"硬性超时"就变成了同一个信号的两条触发路径——下游代码不需要知道是哪一条路径触发了取消,只需 if (signal.aborted) 即可。这个特性需要 Node v20.3.0 或 v18.17.0,正好与文章中示例所声明的 Node 20+ 匹配。

工具调用与 agent 循环

单次流是小场景。昂贵的是循环,因为每一轮都要重发所有内容。把同一个信号传给模型调用、传给每一个工具,并在每一轮开始前检查它。以下代码放在同一个文件里,复用 client、MODEL 和 MAX_TOKENS。先看工具:

// server.ts (continued)
const MAX_TURNS = 8;

const tools: Anthropic.Tool[] = [{
  name: "search_docs",
  description: "搜索产品文档",
  input_schema: {
    type: "object",
    properties: { query: { type: "string" } },
    required: ["query"],
  },
}];

type AgentStats = { input: number; output: number; turns: number };

async function runTool(
  name: string,
  input: unknown,
  signal: AbortSignal,
): Promise<string> {
  if (name !== "search_docs") return `unknown tool: ${name}`;
  const { query } = input as { query: string };
  const url = new URL("https://docs.example.com/search");
  url.searchParams.set("q", query);
  const r = await fetch(url, { signal });
  return await r.text();
}

然后是循环:

// server.ts (continued)
async function runAgent(
  messages: Anthropic.MessageParam[],
  signal: AbortSignal,
  stats: AgentStats,
): Promise<Anthropic.Message | undefined> {
  for (let turn = 0; turn < MAX_TURNS; turn++) {
    if (signal.aborted) return undefined;
    const msg = await client.messages.create(
      { model: MODEL, max_tokens: MAX_TOKENS, tools, messages },
      { signal },
    );
    stats.turns += 1;
    stats.input += msg.usage.input_tokens;
    stats.output += msg.usage.output_tokens;
    if (msg.stop_reason !== "tool_use") return msg;

    const calls = msg.content.filter(
      (b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
    );
    const results = await Promise.all(
      calls.map(async (c) => ({
        type: "tool_result" as const,
        tool_use_id: c.id,
        content: await runTool(c.name, c.input, signal),
      })),
    );
    messages.push({ role: "assistant", content: msg.content });
    messages.push({ role: "user", content: results });
  }
  return undefined;
}

fetch 原生接受 signal,所以进行中的工具请求会随用户一起消失。对于那些不接受 signal 的工具(CPU 循环、遗留客户端),在步骤之间调用 signal.throwIfAborted(),Node globals 文档中有记录。每一轮开头的 if (signal.aborted) 是文件里最便宜的一行:它是阻止一整轮全上下文的调用启动的那一行。轮次中途,SDK 调用和 fetch 会以 abort 错误 reject,所以像流路由那样包装调用者:catch、检查 signal.aborted、记录 stats.turns。这个工具循环的形状遵循 Anthropic 的 tool use 指南。

![一个拉绳从浏览器标签页穿过服务器、模型和两个工具盒,依次断电](

记录你没花掉的部分

你无法精确记录"节省的 token",因为从未生成的 token 没有计数。你能记录的是你已知的部分,以及一个诚实的上界。以下是 chat 调用的 logAborted:

// server.ts (continued)
function logAborted(route: string, s: Stats, reason: unknown) {
  const outEst = Math.ceil(s.outChars / 4);
  console.log(JSON.stringify({
    event: "llm.aborted",
    route,
    reason: String(reason),
    input_tokens: s.input,
    output_tokens_est: outEst,
    output_budget_unused: Math.max(0, MAX_TOKENS - outEst),
  }));
}

除以四来自 OpenAI 自己的经验法则:英文文本大约每 token 4 个字符。这只是估算,其他分词器不尽相同。output_budget_unused 是你所避免开销的上界——且仅在服务商在连接关闭时停止生成的前提下成立。对 agent 路由,在 abort 那一刻记录 AgentStats 的 turns,再与已完成运行的平均轮次对比。差值乘以你的平均每轮输入,就是你在成本评审里能站得住脚的那部分节省。

然后做聚合:按路由、按天统计被中止的请求数,以及它们占全部请求的比例。如果很大比例的聊天请求以 client_disconnected 结束,那既是成本信号,也是产品信号:用户在答案落地前就离开了。

为什么不能精确记录节省的 token:token 计费是按服务商侧实际生成的 token 数量结算的,从未生成的 token 不存在计数条目。文章给出的 /4 估算来自 OpenAI 自家 tokenizer 的经验法则——英文文本平均约 4 个字符对应 1 个 token。这个换算在包含大量中文、代码或 Markdown 结构的内容上偏差会明显拉大,因为不同分词器对 CJK 字符和符号的切分粒度差异极大(同一个汉字在不同模型下可能占 1 个或 2 个 token)。所以 output_tokens_est 应被理解为标尺而非账本,它的价值在于让"被中止的请求量级"可视化,而不是作为账单对账依据。

真正可对外承诺的数字只有 turns 那一类结构性指标:中止发生时的轮次,对比已完成运行的平均轮次,差值乘以平均每轮输入 token 数,是成本评审里站得住脚的部分。这个口径不依赖对"服务商是否对未读输出计费"的判断——无论哪一侧正确,那些根本没启动的轮次都是零成本。

上线前要检查的两个边界

浏览器侧必须真的关闭连接。如果你的 UI 有停止按钮,把它接到 fetch 调用上的 AbortController,让"停止"成为真正的断开,而不是仅仅把文字隐藏起来:

const ac = new AbortController();
stopButton.onclick = () => ac.abort();
await fetch("/chat", {
  method: "POST",
  body: JSON.stringify({ prompt }),
  signal: ac.signal,
});

而后台任务是一份不同的契约。OpenAI 的 Responses API 有后台模式,设计上让响应能在连接断开后存活;你可以从 sequence_number 游标恢复流。在那里关闭 socket 不算取消。取消端点才算,并且"只有带 background 参数设为 true 创建的响应才能被取消"。如果你使用后台模式,abort 监听器应该调用 client.responses.cancel(id),而不是依赖断开。

想在本地试这条路由,启动服务器,用 curl 加 -N 发一个长 prompt,一秒后按 Ctrl-C。你应该会看到一行 llm.aborted,而没有 event: done。

结语

取消这件事从不出现在演示里,却总是出现在账单上。把 AbortSignal 从 socket 一路贯穿到每一个花钱的调用,是一行一行写出来的纪律:监听 res 而非 req,检查 writableFinished,循环之后再检查一次 aborted,把信号交给 SDK、交给 fetch、交给每一轮的入口。

#AbortSignal#Node.js LLM 取消#SSE 流式中止#Anthropic SDK signal#agent 循环成本控制#client_disconnected#LLM 计费#writableFinished
分享:

相关推荐