[控场AI]
· 12 分钟阅读· 6,230 字

免费模型重试前先记账:放弃的部分调用并不免费

免费模型重试前先记账:放弃的部分调用并不免费

免费模型调用并不免费:用本地账本记录已消耗的排队时间与token,再决定重试、切换付费或停止。

文章指出,取消一次正在流式输出的免费模型调用并不会把它从服务端队列中移除,已消耗的排队时间与已生成的token仍然计费到你自己的工程账上,因此“取消再重试”实际上是在跑一个看似零成本、实则按时间消耗的循环。作者主张把这个问题建模为账本问题,每次尝试后更新 spent_s、spent_tokens 和自设的 ceiling_tokens,并据此在“重试免费”“切换付费”“停止并返回已有结果”三个分支间做决策。为此提供了一个纯本地、不打开socket的 Python 脚本 book_partial.py,用三个测试用例演示三种分支,并给出用断言固定行为的测试方法。文章还强调要采样实际排队时间、取最差样本作为下一次免费尝试的假设,并提醒免费通道不适用于有人阻塞等待、非幂等、可能产生双重副作用等场景。

免费模型调用并不免费。当你取消一个正在流式输出的请求时,已经消耗了排队时间和服务器已产生的token。如果直接重试,你实际上在跑一个看似节俭、实则按分钟计费的循环。本文介绍一种基于本地账本(ledger)的重试决策方法。

Book the Partial Before You Retry

核心问题:放弃的调用不是沉没成本

你在免费共享服务器上遇到慢排队时,客户端往往过于乐观。第一次尝试流式返回了几百个token,但你承诺给用户的时间窗口即将失效,于是取消并重新发送相同提示词。第二次尝试会排在第一次作业之后,因为你关闭socket并没有把作业从池子里拉出来。token计费表上的零价格并不会回退那个队列。

这只是一个账本问题,而不是HTTP客户端的心情问题。

这里的“队列”之所以不会因为客户端关闭socket而消失,是因为免费共享推理服务通常采用请求排队(queue)与批处理/调度系统:你的HTTP连接只是把结果往回传的管道,作业(job)本身已经作为一项任务进入后端调度器的等待队列或正在执行的batch。取消连接一般只会停止向客户端继续推送token,并不会自动把已经排在前面或已经在GPU上运行的作业取消掉——许多实现甚至没有暴露“取消作业”的接口。因此第一次尝试已经占用的排队位置、已分配的算力、已生成的token,都会继续计费到服务端的总账上(即使对你显示为$0)。这也解释了为什么紧接着的第二次请求会排在第一次之后:调度器看到的是两个独立作业,而不是同一个作业的重试。理解这一点,是把“取消并重试”从一种心理安慰变成一项需要记账的工程决策的前提。

需要记录的三项数据

每次尝试后更新三个数字:

  • spent_s:你已经等待的排队时间加生成时间
  • spent_tokens:已经返回的token数,包括你不会交付的部分
  • ceiling_tokens:你为本作业设定的token上限,而非从某篇文章复制的供应商配额

上限的存在是为了让免费通道不会在你自己进程内变成无界循环。只有当下一次尝试能在剩余截止时间内完成,且已消耗token加上新的最坏估计仍在上限内时,才允许重试。否则切换到付费通道做一次干净尝试,或者停止并返回已有结果。

一个可运行的账本脚本

以下脚本 book_partial.py 是一个本地决策辅助工具,不是任何供应商的客户端,也不会打开socket。

#!/usr/bin/env python3
"""Write off partial free-lane attempts before you allow another retry."""

from __future__ import annotations
import json
import sys
from dataclasses import dataclass

@dataclass(frozen=True)
class Attempt:
    queue_s: float
    gen_s: float
    emitted_tokens: int
    shipped: bool

@dataclass(frozen=True)
class Policy:
    deadline_s: float
    ceiling_tokens: int
    next_queue_s: float
    next_gen_s: float
    next_est_tokens: int
    paid_queue_s: float
    paid_usd_per_1k: float

def book(attempts: list[Attempt], policy: Policy) -> dict:
    spent_s = sum(a.queue_s + a.gen_s for a in attempts)
    spent_tokens = sum(a.emitted_tokens for a in attempts)
    remaining_s = policy.deadline_s - spent_s
    next_free_s = policy.next_queue_s + policy.next_gen_s
    next_paid_s = policy.paid_queue_s + policy.next_gen_s
    projected = spent_tokens + policy.next_est_tokens

    if any(a.shipped for a in attempts):
        action, why = "stop", "a previous attempt already shipped"
    elif projected > policy.ceiling_tokens:
        action, why = "stop", "another try would cross your token ceiling"
    elif next_free_s <= remaining_s:
        action, why = "retry_free", "remaining clock covers one more free attempt"
    elif next_paid_s <= remaining_s:
        action, why = "cut_over", "free retry does not fit; one paid attempt does"
    else:
        action, why = "stop", "no lane finishes inside the time you have left"

    sunk_usd = (spent_tokens / 1000.0) * policy.paid_usd_per_1k
    return {
        "action": action,
        "why": why,
        "spent_s": round(spent_s, 2),
        "remaining_s": round(remaining_s, 2),
        "spent_tokens": spent_tokens,
        "projected_tokens": projected,
        "sunk_usd_if_those_tokens_were_paid": round(sunk_usd, 4),
    }

def main() -> int:
    raw = json.load(sys.stdin)
    attempts = [Attempt(**item) for item in raw["attempts"]]
    policy = Policy(**raw["policy"])
    json.dump(book(attempts, policy), sys.stdout, indent=2)
    sys.stdout.write("\n")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

脚本刻意做成纯函数加数据类(dataclass)的形式,而不是一个HTTP客户端封装,原因是:决策逻辑与网络IO分离后,才能用固定的输入在本地反复验证分支行为,不受网络抖动和真实配额影响。Attempt记录一次尝试的物理代价,Policy记录本次作业的约束(截止时间、token上限)与对未来通道的估计(下一次免费/付费尝试的排队时间、生成时间、预计token数)。book()只接收这两组数据,输出一个动作和一句理由,因此它既可以在真实调用前作为“预算检查器”同步调用,也可以在日志中回放历史决策。把估计值放进Policy而不是写死在函数里,是为了让离线采样(见下一节)的结果能通过配置注入,避免每次调整都要改代码。

三种决策分支的实际运行

第一个测试用例是一个时间窗口宽裕的夜间作业。一次部分返回,没有交付,在自设上限下仍有空间。

python3 book_partial.py <<'EOF'
{
  "attempts": [
    {"queue_s": 35, "gen_s": 9, "emitted_tokens": 220, "shipped": false}
  ],
  "policy": {
    "deadline_s": 600,
    "ceiling_tokens": 4000,
    "next_queue_s": 35,
    "next_gen_s": 12,
    "next_est_tokens": 800,
    "paid_queue_s": 2,
    "paid_usd_per_1k": 0.002
  }
}
EOF

结果应为 retry_free。已花时间44秒,剩余窗口充裕,220加估计的800仍低于4000。

第二个是交互式场景:相同部分返回,但截止时间为60秒。

python3 book_partial.py <<'EOF'
{
  "attempts": [
    {"queue_s": 35, "gen_s": 9, "emitted_tokens": 220, "shipped": false}
  ],
  "policy": {
    "deadline_s": 60,
    "ceiling_tokens": 4000,
    "next_queue_s": 35,
    "next_gen_s": 12,
    "next_est_tokens": 800,
    "paid_queue_s": 2,
    "paid_usd_per_1k": 0.002
  }
}
EOF

剩余16秒。下一次免费尝试需要47秒,而一次付费尝试需要14秒,仍能完成,因此动作为 cut_over。

第三个用例将上限降至500,即使截止时间宽裕:

python3 book_partial.py <<'EOF'
{
  "attempts": [
    {"queue_s": 35, "gen_s": 9, "emitted_tokens": 220, "shipped": false}
  ],
  "policy": {
    "deadline_s": 600,
    "ceiling_tokens": 500,
    "next_queue_s": 35,
    "next_gen_s": 12,
    "next_est_tokens": 800,
    "paid_queue_s": 2,
    "paid_usd_per_1k": 0.002
  }
}
EOF

预期token为1020,超过500上限,因此停止。

用断言固定分支行为

为了防止编辑公式时三个分支漂移,可以加入一个本地测试:

python3 - <<'PY'
import importlib.util
spec = importlib.util.spec_from_file_location("book_partial", "book_partial.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)

def run(deadline, ceiling, emitted=220):
    attempts = [mod.Attempt(35, 9, emitted, False)]
    policy = mod.Policy(deadline, ceiling, 35, 12, 800, 2, 0.002)
    return mod.book(attempts, policy)["action"]

assert run(600, 4000) == "retry_free"
assert run(60, 4000) == "cut_over"
assert run(600, 500) == "stop"
assert run(60, 500) == "stop"
print("ok")
PY

注意:先检查上限再检查截止时间,因此当两者都触发时,停止优先于切换。

如何采样排队时间

在信任 next_queue_s 之前,在你实际运行的时段内取样。发送一个短的固定提示词到免费通道,记录首token时间作为排队时间,其余作为生成时间。做三次,取最差排队时间,丢弃探测文本。如果探测失败,则该小时免费通道不可用。

不要反复重试探测直到感觉正常——那个循环正是账本要阻止的。

之所以强调“在实际运行的时段内取样、取最差排队时间”,是因为免费共享队列的延迟通常呈现极强的时段波动:夜间和凌晨可能几秒完成,白天高峰期可能几十分钟才轮到你的作业。用平均值或最优值填 next_queue_s 会让账本系统性低估成本,从而频繁做出“再试一次免费”的错误决策,把用户可接受的响应窗口拖垮。取最差样本是一种保守偏差,代价是可能过早切换到付费通道,但换来的是截止时间的可预期性。三次探测取最差、丢弃探测文本、探测失败即视为该时段免费通道不可用,这套规则的本质是:宁可承认“现在免费通道不可用”,也不要为了省一次付费调用而反复赌运气。

免费通道的适用边界

当有人阻塞等待答案、当你将放弃的尝试不是幂等的、或者你无法采样排队时间时,免费容量是错误的选择。当重试可能导致副作用被应用两次时也是错的。在第一次调用前为作业打上你自己的幂等键。将 shipped: true 视为最终状态,这样迟到的免费响应不会覆盖已返回的付费答案。

本文不涉及的内容

该函数信任你的采样。一个安静的下午不代表一周,如果队列波动,保留你运行时段的最差采样并填入 next_queue_s。它把所有已发出的token视为已消耗,这对上限是正确的偏差,对质量评分则是错误的偏差。

如果你的客户端支持继续生成,只需将缺失的间隙加入预测,然后重新运行断言。不要将此用于支付、访问控制或任何停止不可接受的路径。

#免费模型调用#重试策略#token记账#LLM成本控制#部分输出处理#API重试#免费API限制
分享:

相关推荐