作者在使用免费模型 API 时遭遇输出截断的静默失败:模型中间断掉返回无效 JSON,但代码未抛异常、只返回空值。介绍了检测和修复方法。
我搭建了一个小服务,把乱七八糟的客服记录转成干净的 JSON:摘要、情感得分和分类。它跑在一台免费的服务器上,调用一个免费的模型端点,整整两天输出表里的每一行看起来都很健康。直到某天一个用户打开其中一行,问为什么摘要内容是空的、情感得分是中性的。
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I deployed the service on MonkeyCode's free server and used their free model endpoint for the calls, so the whole experiment cost me nothing but patience. What it cost me in debugging time is another story.
那个看起来像模型问题的症状
这批数据有 200 条记录,其中 30 条返回的摘要是空的。没有异常、没有失败的 HTTP 状态码、也没有重试,因为代码压根不知道出了问题。错误那行的日志和正常那行的日志长得一模一样——这是第一个信号,说明你看到的并不是真正的错误。
# processing.py (before the fix)
def process(note: str) -> dict:
raw = call_free_model(note)
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = {"summary": "", "sentiment": "neutral", "category": "unknown"}
logger.debug("parse failed, using defaults: %s", raw[-200:])
return data
看到陷阱在哪了吗?except 分支把一个被截断的响应转换成了一个看似合理的默认对象,而证据被写到了 DEBUG 级别的日志里——生产环境根本不会写 DEBUG。而且免费服务器的日志轮转很快,等我去看的时候,连这点证据都没了。
第一个错误的假设
我的第一反应是怪模型,因为免费模型本身就很不稳定,大家都有个关于模型输出奇葩回答的故事。我加了个重试、加了条全新的 prompt,重新部署,然后看着同样的 30 条记录用同样的方式失败了。这种确定性才是我差点忽略的线索——如果是随机的模型质量问题,失败也应该是随机的。
问问自己:如果同一个输入每次都一模一样地失败,模型真的是问题所在吗?通常答案是否定的,真正的问题藏在模型和你的解析器之间。我写了个脚本,发送越来越长的记录并记录响应长度,规律立刻显现了。
复现那个悬崖
短的记录产生了完整的 JSON,长的记录产生的响应总是在同一个上限处戛然而止。看起来像是有人用剪刀把回复剪断了,而剪断的位置取决于输出长度,而不是输入内容。我跑了三遍脚本确认,上限纹丝不动。
# reproduce_truncation.py
import requests
API_URL = "https://your-free-model-endpoint.example/v1/complete"
def call_model(note: str) -> str:
prompt = (
'Return JSON only: {"summary": string, '
'"sentiment": "positive"|"neutral"|"negative", '
'"category": string}\n\nNote: ' + note
)
response = requests.post(API_URL, json={"prompt": prompt}, timeout=30)
return response.text
for size in range(500, 6001, 500):
note = "The customer says the invoice is wrong and the total looks off. " * (size // 55)
raw = call_model(note)
print(f"input={size:5d} output={len(raw):5d} tail={raw[-30:]!r}")
输出显示了一个硬性上限:一旦记录超过某个大小,响应长度就停止增长了。有些回复在半空中断,导致 json.loads 失败;有些则在刚完成一个字段后就截断了,导致解析器虽然成功但缺失了键。两种情况都产生了同样的空行,也都没有产生任何错误。
一句话讲清楚根本原因
免费模型端点强制限制了最大输出长度,而我的 prompt 要求一个很长的摘要,网关在达到限制时直接剪断了回复。我的代码随后做了最糟糕的事:把这种截断转化成了一个干净的、看似合理的、沉默的默认值。以下是每种失败模式的具体表现:
修复:让截断变得大声
修复分三步。第一步,把任何解析失败或缺失的键视为 TruncatedOutput 异常,而不是默认值。第二步,把原始响应尾部以 ERROR 级别记录,这样证据就不会被日志轮转吞掉。第三步,重试一次,这次用一条要求更短摘要的 prompt,如果再次发生就大声失败。
# processing.py (after the fix)
class TruncatedOutput(Exception):
pass
REQUIRED_KEYS = {"summary", "sentiment", "category"}
def parse_model_output(raw: str) -> dict:
stripped = raw.strip()
if stripped.startswith("```
"):
stripped = stripped.split("```
", 2)[1]
try:
data = json.loads(stripped)
except json.JSONDecodeError as exc:
raise TruncatedOutput(f"invalid JSON near: {raw[-120:]!r}") from exc
missing = REQUIRED_KEYS - data.keys()
if missing:
raise TruncatedOutput(f"missing keys {missing}; tail: {raw[-120:]!r}")
return data
def process(note: str) -> dict:
for attempt in range(2):
raw = call_free_model(note, short=attempt > 0)
try:
return parse_model_output(raw)
except TruncatedOutput as exc:
logger.error("attempt %d truncated: %s", attempt + 1, exc)
raise TruncatedOutput(f"gave up after 2 attempts for note {note[:40]!r}")
注意第二次尝试传入了 short=True,这会改变 prompt,要求生成一句摘要。这通常能塞进输出上限里。如果还是塞不进去,异常就会抛给调用者,而不是藏在默认值里消失。调用者现在有了选择:跳过这行、把它放进死信队列,或者通知真人。
本来能 catch 住这个问题的回归测试
我加了一个测试,喂一个故意写得很长的记录,然后断言函数会抛出异常而不是返回默认值。它在修复前失败、修复后通过——这正是回归测试该有的样子。这个测试故意写得无聊,因为无聊的测试最能 catch 最尴尬的那些 bug。
# test_processing.py
import pytest
from processing import process, TruncatedOutput
def test_long_note_raises_instead_of_silent_default():
long_note = "The customer says the invoice is wrong. " * 200
with pytest.raises(TruncatedOutput):
process(long_note)
用旧代码跑它会失败,因为旧代码返回了一个默认对象。用新代码跑它会通过,因为截断现在成了一等公民的失败。一个测试胜过盯着日志看半天。
局限性和谁不应该抄这个
对自己的局限性要诚实。如果你的网关暴露了 finish_reason 字段,先读那个,因为它比从 JSON 形状去猜更可靠。如果你需要保证完整的结构化输出,用一个有 JSON mode 或更高输出上限的端点,因为一个大声 raise 的守卫仍然是一个你必须处理的失败。而且如果你的下游系统不能容忍一行失败,这个模式只是把问题挪了位置:你仍然需要一个队列、一个重试策略和一个告警。
谁不应该用这个?任何处理医疗、法律或金融文本的人,因为沉默的默认值可能造成真正的伤害。对那些场景而言,被截断的响应必须阻塞流水线,而不是抛出一个被 worker 捕获并忽略的异常。
免费基础设施不会优雅地失败。它在边界处失败:内存限制、输出上限、临时磁盘和超时,每个边界在测量之前看起来都像一个产品 bug。下次当免费模型返回一个看起来差不多对的响应时,在信任它之前先问自己一个问题:回复真的完成了吗?
我在 MonkeyCode 的免费服务器上跑了修复后的版本一周,空行消失了。如果你正准备在一个免费模型端点上构建,在加功能之前先加上截断守卫。这是五分钟的改动,把一个沉默的谎言变成一个大声的、可调试的事实。