作者使用免费模型端点跑2000条数据,HTTP全部返回200,三天后发现4.2%的数据被截断/空值/重复。揭示了AI批处理中「成功≠正确」的质量盲区。
你有没有见过一个批处理任务以完美的成功率结束,却发现数据悄悄变成了垃圾?上周我跑了 2,000 个 prompt 通过一个免费模型端点,每个请求都返回了 HTTP 200,日志里零错误。三天后,数据质量检查发现了 84 行被截断、为空或重复的数据。我的 pipeline 当时 100% 成功,同时 4.2% 损坏率。
披露:本文是 MonkeyCode 产品推广的一部分。
这个任务本身无聊到了最佳状态:从队列读一个 prompt,发给模型,然后把响应存到 Postgres。我用了 MonkeyCode 的免费模型访问权限,因为工作负载是异步的,我不想在一次性的富化过程中花付费积分。运行了大约一个小时,日志看起来很美。
全部 2,000 个请求都返回了 HTTP 200。
零超时异常,零连接错误,零 429。
平均延迟 1.8 秒,感觉完全合理。
我转向其他工作,完全忘记了这个任务。三天后,我对表做了质量检查,数字不对劲。检查统计了空 body、不可解析的 JSON 和重复响应,标记了 2,000 行中的 84 行有问题。这是 4.2% 的损坏率,藏在 100% 成功率背后。
核心问题是我把 HTTP 200 当成了一份契约,实际上它只是一张收据。200 响应只意味着服务器收到了请求并返回了某些东西;它并不能说明那个东西是否完整、格式是否正确,甚至是否和你发送的 prompt 有关。我开始查看那些损坏的行,每一行都讲述了不同的故事——一个请求如何在仍然报告成功的情况下失败。
免费层并没有导致这些失败,但它让它们更明显了,因为负载下的免费服务器比有充足余量的付费服务器更可能偷工减料。你有多少次检查了 response.status_code == 200 然后就直接跳过去而不检查 body?这个习惯正是 84 行垃圾进入我数据库的方式。
我把 84 行坏数据分成了三类,每一类都有独特的签名,指向不同的失败模式。
响应 body 在字符串中间结束,通常恰好在 1,024 字符左右,这是一个可疑的整数,暗示了内部缓冲区限制。服务器显然触发了内部限制,返回了当时已缓冲的内容,并带有一个与截断 body 匹配的内容长度头。我的代码解析 JSON 时遇到异常,异常处理程序把原始字符串写入了数据库而不是让该行失败。
# The bug: swallowing parse errors and storing garbage
try:
data = json.loads(response.text)
text = data["text"]
except json.JSONDecodeError:
text = response.text # <-- this is how garbage got in
大约三十行有一个 200 状态但 body 是空字符串,这意味着没有 JSON、没有空白、完全没有可解析的内容。服务器可能内部超时了,返回了一个空响应而不是错误,我的客户端欣然接受了它作为一个有效答案。
十二行包含了与之前某行完全相同的响应文本,尽管 prompt 完全不同。服务器可能返回了缓存的或重复的生成内容,但响应头中没有任何内容表明这是重复的。我的去重逻辑不存在,所以每个副本都毫无怨言地落入了表中。
修复方案不是让客户端更激进或重试更聪明。修复方案是停止信任状态码,在写入数据库之前验证每个响应。我写了一个小函数,对每个响应应用五条规则,任何违反规则的响应都被视为可重试的失败。
import json
import hashlib
seen_hashes = set()
def validate_response(prompt: str, response_text: str) -> str | None:
# Rule 1: reject empty bodies
if not response_text.strip():
return None
# Rule 2: reject unparseable JSON
try:
data = json.loads(response_text)
except json.JSONDecodeError:
return None
# Rule 3: reject missing required fields
if "text" not in data or not data["text"].strip():
return None
# Rule 4: reject suspiciously short answers
if len(data["text"]) < 20:
return None
# Rule 5: reject exact duplicates
digest = hashlib.sha256(data["text"].encode()).hexdigest()
if digest in seen_hashes:
return None
seen_hashes.add(digest)
return data["text"]
函数对任何可疑内容返回 None,调用方把 None 视为可重试的失败。我还在整个流程外层加了一个重试循环,最多三次尝试。三次尝试后仍然失败的 prompt 进入一个单独的隔离表,等待人工检查。
def process_with_validation(prompt: str, max_attempts: int = 3) -> str:
for attempt in range(max_attempts):
response = client.chat(prompt)
validated = validate_response(prompt, response.text)
if validated is not None:
return validated
time.sleep(1 + attempt)
raise ValidationError(f"prompt failed validation after {max_attempts} attempts")
部署之后,损坏率从 4.2% 降到了零,隔离表给了我一份真正需要关注的 prompt 清单。重试循环处理了临时截断的情况,验证层在其余所有内容污染数据集之前捕获了它们。
更深的教训是状态码是传输层面的细节,不是数据质量的保证。我围绕 200 意味着成功这个假设构建了整个 pipeline。那个假设悄悄腐蚀了 84 行数据,我差点就用来做下游分析了。验证层花了我大约一个小时来写,但它把我从基于悄悄出错的数据做决策中拯救了出来。
你的代码库里有多少 pipeline 检查了状态码然后就不假思索地信任 body?如果你和我一样,答案是太多了。修复方案不是更仔细地编码;修复方案是一个验证步骤,把响应 body 当作不受信任的输入。
这个验证层不是通用解决方案,有些情况下它会弊大于利。如果你的模型输出自由形式的创意文本,没有固定模式,像最小长度和必填字段这样的规则会拒绝完全有效的响应。你应该根据实际分布调整阈值。
去重检查也假设完全相同的响应总是错误的,这在我的富化工作负载中成立,但对许多其他用例是错误的。如果你在生成相似文档的摘要,相同的输出可能是合法结果,hash 检查会错误地丢弃它。
如果你只是在原型阶段,数据不喂给任何重要的东西,完整的验证层可能就过度了。这种方案的成本是真实的:更多代码、更多重试,以及需要人工关注的隔离工作流。对于一次性的实验,你可以负担得起跳过它;但对于喂给一个你打算信任的数据库的任何东西,你不能。
MonkeyCode 的免费层对这个工作负载是够用的,验证层把一个悄悄腐蚀的数据集变成了干净的。验证逻辑本身是我会到处复用的部分。状态码是起点,不是终点,下次一个批处理任务报告 100% 成功时,我会先检查实际的行再相信它。