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

免费模型层夜间任务的48小时踩坑与故障分类方法论。
作者将批处理提取任务指向免费模型层和免费服务器实例,结果在48小时内连续踩坑:Schema 允许空数组导致"成功"却无输出、对内容错误做无效重试、模型名拼写错误引发整夜退避、以及短暂主机被回收吞噬日志。文章据此提出一套故障分类法,把故障分为 transport、rate_limited、truncated、schema、semantic、config 六类,明确哪些可重试、哪些应立刻终止运行。配套给出预检探针脚本、工作进程骨架、对账检查与结果模板,核心结论是:Schema 必须写入业务约束,重试前先分类,配置错误应在第一分钟失败,日志必须存放在主机之外。
两天前,我上线了一个小型夜间提取任务,看着它退出码为0,然后心满意足地去睡觉。第二天早上,下游报告空空如也,日志干干净净,数据库里每一行都是 records: []。模型返回了完全有效的JSON,而我的Schema从未告诉过它,空数组是我唯一不能接受的答案。
这是接下来48小时的现场笔记:我尝试了什么、什么真正出了问题,以及哪些部分我会毫不犹豫地重复。如果你正把批处理任务指向免费模型层和免费服务器实例,这就是你即将掉进去的坑的形状。

我到底在要求什么
这个任务本身故意设计得很无聊。它读取一批小文本块,要求模型提取行项目(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_limited | 429带重试提示 | 是,稍后 | 遵守提示,然后停止 |
truncated | JSON在令牌中间结束,没有闭合括号 | 否 | 缩小输入,然后重试一次 |
schema | 解析正常,违反约束 | 否 | 一次修复尝试,然后失败 |
semantic | 通过Schema,但对账失败 | 否 | 隔离等待人工处理 |
config | 模型名称返回401/403/404 | 从不 | 停止整个运行 |
我最关心的规则是最后一行。配置错误应该在第一分钟就杀死运行,而不是悄悄衰减成一千次记录在案的重试,在你的仪表盘上看起来像进展。
预检探针:首次真实运行前40分钟
以下是我在将任何夜间任务指向免费模型层之前运行的测试计划。这是一个计划,不是基准测试,旨在由你在自己的账户上运行。
- 发送十个相同的请求,记录是否全部十个都满足Schema。
- 发送一个故意模糊的文本块,检查模型是编造数字还是返回空列表。
- 发送一个超大文本块,注意你得到的是截断、拒绝还是4xx。
- 发送一个故意错误的模型名称请求,确认你的代码停止而不是重试。
- 睡过你怀疑的任何空闲窗口,然后发送另一个请求。
- 在运行中途杀死工作进程,确认日志在你控制的某个地方存活。
探针足够小,可以一口气读完:
# 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个文本块之前,诚实地填写结果表。
你最近一次“有效”的响应悄悄毁掉你早晨的是什么?
相关推荐

空陆两栖变形无人机:除了省电100倍,还有什么真需求?
空陆两栖变形无人机是否只是噱头?从M4 Morphobot的100倍功耗差异出发,探讨变形无人机的真实应用场景与论文选题思路,分析纯无人机和纯地面机器人均失效的边界条件。

Warhound融资2亿美元:基因工程“战犬”搭配Anduril装备
Warhound完成2亿美元融资,由Ben Lamm和Palmer Luckey推动,通过基因工程改造犬类用于军事和执法,并搭配Anduril装备。本文分析其技术方向、融资逻辑与伦理争议。

Claude API登陆Google Cloud:IAM集成与SDK接入指南
Anthropic将完整Claude API托管在Google Cloud上,通过Google Cloud IAM控制权限并统一计费。本文梳理Vertex AI与Anthropic托管两个服务入口、IAM角色配置要点及SDK接入方式,帮助企业复用现有云治理框架引入Claude模型。