[控场AI]
· 14 分钟阅读· 7,353 字

免费模型返回有效JSON,我的Schema却沉默了

免费模型返回有效JSON,我的Schema却沉默了

免费模型层夜间任务的48小时踩坑与故障分类方法论。

作者将批处理提取任务指向免费模型层和免费服务器实例,结果在48小时内连续踩坑:Schema 允许空数组导致"成功"却无输出、对内容错误做无效重试、模型名拼写错误引发整夜退避、以及短暂主机被回收吞噬日志。文章据此提出一套故障分类法,把故障分为 transport、rate_limited、truncated、schema、semantic、config 六类,明确哪些可重试、哪些应立刻终止运行。配套给出预检探针脚本、工作进程骨架、对账检查与结果模板,核心结论是:Schema 必须写入业务约束,重试前先分类,配置错误应在第一分钟失败,日志必须存放在主机之外。

两天前,我上线了一个小型夜间提取任务,看着它退出码为0,然后心满意足地去睡觉。第二天早上,下游报告空空如也,日志干干净净,数据库里每一行都是 records: []。模型返回了完全有效的JSON,而我的Schema从未告诉过它,空数组是我唯一不能接受的答案。

这是接下来48小时的现场笔记:我尝试了什么、什么真正出了问题,以及哪些部分我会毫不犹豫地重复。如果你正把批处理任务指向免费模型层和免费服务器实例,这就是你即将掉进去的坑的形状。

免费模型返回有效JSON但Schema沉默

我到底在要求什么

这个任务本身故意设计得很无聊。它读取一批小文本块,要求模型提取行项目(id、amount、currency),验证结果,然后每个文本块写入一行。工作进程运行在我指定的任何主机上,而这次运行的主机是一个免费服务器选项,而不是我按月租用的机器。

我使用了MonkeyCode的免费模型访问来进行提取调用,工作进程本身也运行在同一个产品提供的免费服务器选项上。披露:本文是作为MonkeyCode产品推广的一部分准备的。我故意没有引用配额、令牌上限或延迟数字,因为我昨晚在一个实例上测量到的任何数据,并不代表你将会得到什么。

这是第一个教训,它花了我大约六个小时:在免费层上,你测量到的数字不是被保证的数字。无论如何都要测量,但要把测量结果当作你验证的下限,而不是可以依赖的上限。

问题暴露的顺序

第3小时:空成功

我的JSON Schema写着 "records": {"type": "array"}。空数组是有效的数组。模型面对一个它无法自信解析的文本块时,做出了合理的选择:返回空,而不是编造行项目。我没有写任何规则说“空就是失败”,所以没有任何东西失败。

一个关键字的修复是 "minItems": 1,这是整篇文章中价值最高的一行。现在我写的每个Schema都至少包含一个业务约束,因为结构化的Schema只能证明形状,不能证明含义。

JSON Schema 是一种用于描述和验证 JSON 文档结构的声明式语言,本身只约束数据的形状而非语义。type: array 只要求值是数组,空数组 [] 完全满足;items 只约束数组内元素的形态,对长度没有任何要求。要让 Schema 表达业务含义,必须借助 minItems(最少元素数)、minLength(字符串最短长度)、required(必填字段)、enum(枚举取值)、pattern(正则匹配)等约束关键字。理解这一点很关键:验证器通过了不等于数据可用,Schema 的默认行为是宽松的,所有业务不变量都需要你显式写入。

第11小时:没有修复任何东西的修复循环

我的第一反应是在验证失败时重试。我写了一个三次尝试带退避的循环,在400个文本块上运行,然后花了大量时间看着同样的畸形负载返回三次。重试一个内容失败不是重试,而是多走几步的抛硬币。

退避重试(backoff retry)是分布式系统中的常见模式:失败后等待一段时间再试,等待时间通常按指数增长并加入随机抖动,以避免多个客户端同时重试造成惊群。但它成立的前提是故障具有暂时性,例如网络抖动、上游短暂过载。对于内容层面的失败——模型返回了格式正确但语义错误的输出,或 JSON 被截断——再次请求同一个提示词大概率得到同样错误的结果,因为输入和模型都没有变化。识别故障是否可重试,是重试策略设计中最容易被忽略的一步。

第19小时:本会运行整晚的配置拼写错误

一个拼错的模型名称导致每个请求都返回4xx。我的重试包装器把所有非200都当作暂时性错误,于是任务愉快地退避并重试了数小时。没有任何退避策略能修复一个错误的环境变量,也没有理由在早上7点才发现这一点。

HTTP 4xx 状态码属于客户端错误类别,其中 401(未授权)、403(禁止访问)、404(资源不存在)通常指向凭证或配置问题,而非服务端瞬时故障;5xx 才是服务端错误,具备重试价值。许多重试库的默认策略是"非 2xx 即重试",这会把一个拼错的模型名称或过期的 API Key 变成整夜的空转。正确做法是在重试逻辑之前先做状态码分类,把 401/403/404 视为致命错误,立即终止整个批次并告警,而不是悄悄衰减成大量看起来像进展的重试记录。

第27小时:不属于我的机器

运行中途,工作进程消失了,我一直在追加的日志也跟着没了。提取调用没有问题。主机是短暂的,这对免费服务器选项来说很正常,而我一直把别人的机器当作自己的磁盘。

短暂实例(ephemeral instance)是指生命周期不由用户掌控的计算资源,常见于免费层、无服务器函数和抢占式实例。这类实例可能在空闲超时、资源回收或平台调度时被随时终止,其本地磁盘上的数据随之丢失。因此任何需要持久化的内容——日志、中间结果、断点状态——都必须写入外部存储(对象存储、数据库、或你自己控制的机器)。一个实用的原则是:把工作进程视为无状态的,把持久化视为显式的网络写入,这样实例消失时你的进度不会一起消失。

我现在使用的故障分类法

在我触及重试策略之前,我会写下我正在面对的是哪种故障。这张表就是整个设计:

故障类别表现重试有帮助?行动
transport超时、连接重置、502/503有时有界退避,最多3次
rate_limited429带重试提示是,稍后遵守提示,然后停止
truncatedJSON在令牌中间结束,没有闭合括号否缩小输入,然后重试一次
schema解析正常,违反约束否一次修复尝试,然后失败
semantic通过Schema,但对账失败否隔离等待人工处理
config模型名称返回401/403/404从不停止整个运行

我最关心的规则是最后一行。配置错误应该在第一分钟就杀死运行,而不是悄悄衰减成一千次记录在案的重试,在你的仪表盘上看起来像进展。

预检探针:首次真实运行前40分钟

以下是我在将任何夜间任务指向免费模型层之前运行的测试计划。这是一个计划,不是基准测试,旨在由你在自己的账户上运行。

  1. 发送十个相同的请求,记录是否全部十个都满足Schema。
  2. 发送一个故意模糊的文本块,检查模型是编造数字还是返回空列表。
  3. 发送一个超大文本块,注意你得到的是截断、拒绝还是4xx。
  4. 发送一个故意错误的模型名称请求,确认你的代码停止而不是重试。
  5. 睡过你怀疑的任何空闲窗口,然后发送另一个请求。
  6. 在运行中途杀死工作进程,确认日志在你控制的某个地方存活。

探针足够小,可以一口气读完:

# probe.py - 在信任免费层运行夜间任务之前运行。
import os, json, time, requests

ENDPOINT = os.environ["MODEL_ENDPOINT"]   # 来自提供商的文档化聊天端点
MODEL    = os.environ["MODEL_NAME"]       # 今天列出的名称,不是一年前的
HEADERS  = {"Authorization": f"Bearer {os.environ['MODEL_KEY']}"}

SCHEMA = {
    "type": "object",
    "required": ["records"],
    "properties": {
        "records": {
            "type": "array",
            "minItems": 1,               # <-- 拯救我的关键字
            "items": {
                "type": "object",
                "required": ["id", "amount", "currency"],
                "properties": {
                    "id":       {"type": "string", "minLength": 1},
                    "amount":   {"type": "number"},
                    "currency": {"type": "string", "enum": ["USD", "EUR", "JPY"]},
                },
            },
        }
    },
}

PROMPT = (
    "Extract every line item from TEXT into JSON matching SCHEMA. "
    "Never invent an amount. If a line has no amount, skip it. "
    "If the text has no line items, return an empty records array.\n\n"
    "SCHEMA: {schema}\n\nTEXT:\n{text}\n"
)

def ask(text, timeout=60):
    body = {
        "model": MODEL,
        "messages": [{"role": "user", "content": PROMPT.format(
            schema=json.dumps(SCHEMA), text=text)}],
    }
    started = time.monotonic()
    r = requests.post(ENDPOINT, headers=HEADERS, json=body, timeout=timeout)
    elapsed = round(time.monotonic() - started, 2)
    return r.status_code, elapsed, r.text

if __name__ == "__main__":
    cases = {
        "clean":    "2 x widget @ 19.00 USD\n1 x cable @ 4.50 USD",
        "ambiguous": "some widgets, price on invoice",
        "empty":    "no line items in this document",
    }
    out = open("probe_results.jsonl", "a")   # 保持在免费机器之外
    for name, text in cases.items():
        status, elapsed, raw = ask(text)
        out.write(json.dumps({"case": name, "status": status,
                              "elapsed_s": elapsed,
                              "bytes": len(raw)}) + "\n")
        out.flush()
        print(name, status, elapsed, len(raw))

运行它,填入你看到的结果,让数字与你的假设争论。我的数字与我的好几个假设都争论了。

我会重复的工作进程骨架

第二个工件是我现在每当模型输出必须满足契约时就会重用的循环。它在重试之前进行分类,并把状态保存在机器无法删除的地方。

import json, time

RETRYABLE = {"transport", "rate_limited"}
CONTENT   = {"schema", "semantic", "truncated"}
FATAL     = {"config"}

def classify(status, parsed, violations):
    if status in (401, 403, 404):      return "config", status
    if status == 429:                  return "rate_limited", status
    if status >= 500:                  return "transport", status
    if parsed is None:                 return "truncated", status
    if violations:                     return "schema", violations[:3]
    return "ok", None

def handle(status, parsed, violations, repair_used):
    kind, detail = classify(status, parsed, violations)
    if kind == "ok":            return "accept", None
    if kind in FATAL:           return "stop_run", detail
    if kind == "rate_limited":  return "sleep_then_retry", detail
    if kind == "transport":     return "backoff_retry", detail
    if kind == "truncated":     return "shrink_input_retry", detail
    if kind == "schema" and not repair_used:
        return "repair_once", detail   # 把验证器错误发回去,一次
    if kind == "schema":        return "fail_row", detail
    return "quarantine", detail        # semantic:不要自动修复

修复步骤是额外一次调用,包含验证器的错误信息和违规负载,只尝试一次。如果那次尝试失败,该行大声失败,落入一个我可以在喝咖啡时阅读的隔离文件。

不是Schema检查的检查

结构不是真相。验证后我会对账:汇总提取的金额,并与我从源文档已知的总数进行比较。如果差异超过我按币种设置的容差,该行被隔离而不是重试。这一项比较比所有Schema关键字加起来抓到的坏行还多,而且完全不需要模型访问。

结果模板

我不会把我的延迟数字贴出来当作你的。相反,这是我每次探针运行后填写的表格:

探针用例通过?故障类别采取的行动
十个相同的干净请求
模糊文本块
超大文本块
错误的模型名称
空闲后请求
运行中途杀死工作进程

如果两行或更多行返回 schema 或 semantic,不要扩大批次。先修复契约,因为免费层会乐意免费给你一千个格式良好的错误答案。

局限性,以及谁不应该这样做

  • 你不能把免费服务器选项当作持久存储。把日志和结果放在机器之外,或者接受重启会吃掉你的进度。
  • 这个工作流需要一个验证器和一个对账来源。如果你没有可比较的地面真相,隔离无处可送。
  • 如果你需要硬性延迟SLO或保证的并发性,免费层是错误的工具,再多的重试逻辑也无法解决。
  • 不能离开你网络的受监管数据不应该发送到任何托管模型,无论免费还是付费。
  • 如果你的输出必须逐字节确定,模型加验证器仍然是一个前面有门控的概率系统。

我会重复什么

在首次运行之前把业务约束写入Schema。在重试之前对故障进行分类。在配置错误时停止运行。把日志保存在主机无法删除的地方。并且每次更改模型名称时都运行探针,因为最便宜的Bug是在第一分钟就失败,而不是第十九小时。

如果你想自己尝试探针,它可以直接对MonkeyCode的免费模型访问运行,工作进程可以坐在免费服务器选项上,而日志留在你自己的磁盘上。只需在信任它处理400个文本块之前,诚实地填写结果表。

你最近一次“有效”的响应悄悄毁掉你早晨的是什么?

#JSON Schema验证#免费模型API#批量提取任务#故障分类重试#minItems约束#预检探针#LLM输出验证
分享:

相关推荐