用户关掉标签页后,你的 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 社区论坛上则有开发者说法相反,不过同一帖子也指出输入上下文仍会被收费。把输出侧视为因服务商而异,并核对你自己的用量日志。无论如何都要取消:跳过的轮次和工具调用,无论哪一方正确,都是实实在在的节省。
,而它早在多年前就不再表示"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 指南。

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、交给每一轮的入口。
相关推荐

Claude Code+Codex多智能体系统实测:缓存率98%背后的真问题
开发者公布Claude Code+Codex多智能体系统7天实测:缓存率98%、输入输出比354:1,作者却卡在组织调度与输出比例,并追问行业基准与成本度量方法。

Cursor SDK 新功能:实时操控运行中的 AI 代理
Cursor SDK 新增 run.steer() 功能,允许开发者在 AI 代理运行中实时注入消息,调整执行方向。子代理遇 steer 时自动转入后台继续任务,为 AI 编程工作流带来更灵活的运行时干预能力。

如何评估AI生成的研究摘要?三个关键维度
AI生成的研究摘要读起来流畅,但事实未必可靠。本文基于Reddit讨论,梳理评估AI研究摘要系统的三个核心维度:来源可追溯性、事实一致性与不确定性处理,并探讨其对深度学习从业者的实践意义。