一个 NaN 让已生成的报告无法加载
LLM 评分全部缺失时,数值聚合产生 NaN;Python 默认将其写入 JSON,浏览器解析失败,导致 1940 份报告中有 31 份无法读取。作者通过 Pydantic 禁止非有限数值,并在序列化时设置 allow_nan=False 拦截问题。
AI 深度解读
报告早就生成了,用户却一直看到「正在准备」。作者排查发现,Python 把一个 NaN 写进了报告文件,浏览器拒绝解析,前端又把解析失败当成任务未完成,继续轮询。有人因此等了 51 小时。
这次故障影响了 16 天内 1,940 份报告中的 31 份,占 1.6%。它值得程序员读几分钟,因为问题贯穿了 LLM 结构化输出、数值聚合、JSON 序列化和前端状态处理:每一步看起来都成功,用户却始终拿不到结果。
合法的「无法评分」,怎么变成了非法 JSON
作者运营的 Preterview 是一个面试练习平台,提供三种面试官风格。用户完成语音面试后,LLM 按沟通能力、技术深度、回答结构等维度给每个回答评分,Python worker 汇总分数,将 report.json 写入对象存储,前端直接拉取并展示。

评分提示词允许模型返回 null,表示当前回答不足以评估某个维度。这是合理的设计:用户回答的是团队冲突,模型就不应该硬给一个系统设计分数。
问题出在后续聚合。作者把 None 转成 NaN,再让 NumPy 求平均时跳过这些缺失项:
import numpy as np
def category_mean(scores: list[float | None]) -> float:
arr = np.array([np.nan if s is None else s for s in scores], dtype=float)
return float(np.nanmean(arr))
要看的是最后一行:np.nanmean 可以忽略部分 NaN,但当整个数组都是 NaN 时,它只发出 RuntimeWarning: Mean of empty slice,然后返回 nan,不会抛出异常。
其中一种偏聊天和文化匹配的面试风格,经常整场都不涉及技术问题,于是「技术深度」全是 null。31 份坏报告中,有 26 份来自这种风格,符合这条故障路径。
这里需要区分两件事:缺少评分依据是正常业务状态,聚合后产生非有限数值才是程序问题。不能靠要求 LLM 每项都打分来消除空值,否则文件能读了,评分却可能失去依据。
为什么后端测试通过,浏览器却读不了
Python 的 json.dumps 默认使用 allow_nan=True,会把非有限浮点数写成裸露的 NaN、Infinity 或 -Infinity。这些表示不符合标准 JSON,RFC 8259 没有为 NaN 提供合法表示。
更容易误导测试的是,Python 的 json.loads 也接受这种内容。作者原先的测试只做了「构建报告、序列化、反序列化、比较结果」,两端都是 Python,因此顺利通过。
浏览器里的 JSON.parse('{"technical_depth": NaN}') 则会抛出 SyntaxError。
同一种语言能把数据写回再读出,不能证明跨语言消费者也能读取。
作者调试时还遇到了 round 的差异:round(float("nan")) 会抛出 ValueError: cannot convert float NaN to integer,但 round(float("nan"), 1) 仍然返回 nan。报告恰好保留一位小数,这一步也没能阻止坏数据流向下游。
日志同样没有提供明显信号。Python 默认的 warning 过滤行为,会让同一代码位置的同类警告通常只出现一次。worker 又是长期运行的进程,作者在 16 天的日志中只找到 11 条相关警告,对应 worker 启动后的首次触发,夹在大量正常日志里。
而后台认定任务成功的依据是:序列化返回了字符串,上传返回了 200。因此,1,940 份报告在后端全部显示成功。
作者指出,自己的 FastAPI 接口路径会报 500,写入 Postgres jsonb 也会失败;直接写对象存储绕过了这些检查。不过,原文没有给出完整的序列化器对照表和版本配置,不能把这些现象推广成所有框架组合的固定行为。
修复要覆盖数据含义和前端状态
作者先把「未评估」变成明确的数据状态,用 None/null 表示,再禁止分数字段接受非有限值:
from pydantic import BaseModel, Field
class CategoryScore(BaseModel):
category: str
mean: float | None = Field(default=None, allow_inf_nan=False)
assessed_answers: int
关键是 allow_inf_nan=False:mean 可以为空,但不能用 NaN 或无穷值冒充分数。assessed_answers 则记录实际参与评估的回答数量,让前端有依据解释结果。
聚合逻辑也改为先过滤 None:有有效分数就计算平均值,没有就返回 None。空集合成为一条显式分支,不再依赖 NumPy 的 warning 行为。
在统一写报告的函数里,作者增加了 json.dumps(report.model_dump(), allow_nan=False)。上游再混入 NaN 或 Infinity,序列化就抛出 ValueError,任务进入失败状态,现有告警才能看到它。
但合法 JSON 还不够。原文提到,Pydantic 和 orjson 可能将非有限值写成 null,前端仍可能显示「Technical depth: null」,或在调用 null.toFixed(1) 时崩溃。页面必须理解空值,并展示「本次会话未评估技术深度」。
测试配置也增加了 filterwarnings = error::RuntimeWarning。作者原来已有覆盖跳过评分维度的用例,只是没有检查输出;将 warning 视为错误,原本就能让这条路径在 CI 中失败。
前端则停止把解析错误归入「准备中」。现在遇到解析失败会结束轮询、展示错误状态并通知作者。
文件解析失败和任务尚未完成,必须是两个不同的状态。
报告在会话结束约 40 秒后就生成了。真正把故障拖成 51 小时等待的,是前端错误处理中的无限重试。
我的判断
这篇最有价值的地方,是把一条跨层故障链拆开了:LLM 的 null 合理,数值库的行为有定义,Python 序列化器允许扩展值,对象存储也成功保存了文件,但这些局部行为拼在一起,没能满足浏览器的读取要求。
作者给出了受影响数量、风格分布和明确触发路径,足以支持这次故障的解释。但没有修复前后的长期对照数据,不能据此推断整套方案能减少多少线上事故。它是一份具体事故复盘,工程建议仍有各自的适用前提。
例如,将所有 RuntimeWarning 升级为错误,适合先放在测试中观察影响;依赖数值计算的项目,需要确认已有 warning 的来源。直接从对象存储读取报告也不必因此推翻,只是应用必须自己保证写入契约。
另一个值得保留的设计是原始 LLM 评分。作者单独存储了每个回答的评分,因此只重跑聚合,就在不到两分钟内恢复全部 31 份报告,没有新增模型调用。随后联系了主动反馈的 9 人和其余 22 人。
与重新调用模型相比,这条恢复路径更便于保留原评分依据,也避免了再次评分可能产生结果变化的问题。它证明了保留中间结果对这次修复有用,原文并没有证明所有 LLM 输出都应无限期保存。
能拿来做什么
- 给 AI 评分、统计摘要等数值字段明确区分「得分为零」与「无法评估」,同时传递有效样本数量,让前端能正确解释空值。
- 检查 Python 生成、浏览器或 Node 消费的 JSON 文件,在统一写入入口启用
allow_nan=False,并让测试覆盖全空评分和真实消费者的解析行为。 - 检查轮询页面的错误分支,把任务未完成、网络请求失败和内容解析失败分开处理,避免一个永久失败的文件让用户持续等待。
- 对包含 LLM 评分与后续聚合的流程,保留恢复所需的原始结果及关联关系,使聚合逻辑修复后可以重新生成报告,无须重复调用模型。