2024 年,一次"我用大模型把用户的自然语言解析成 JSON,测试时好好的,一上线就偶发崩溃"的事故,把我对"大模型输出"这件事的理解,从头到尾翻新了一遍。我们做了个功能:用户用大白话写一句日程,比如"明天下午三点和张总开个会",我们把这句话丢给大模型,让它返回一个结构化的 JSON:{"date": "...", "time": "...", "title": "...", "participants": [...]},然后我们拿这个 JSON 去建日历。我在开发机上测了几十句,模型每次都稳稳地吐回一个干净的 JSON,我用 json.loads() 一解析,字段齐整,完美。我很满意,上线。结果上线没几天,错误监控就开始零零星星地报:json.decoder.JSONDecodeError。不是每次都错,是【偶发】——同一个功能,大部分请求好好的,时不时崩一次,崩了用户那次日程就建不出来。我懵了。我的代码一个字没改,模型也没换。同样是"自然语言进、JSON 出"的活,为什么测试时一次没错,上线后却像抽风一样,时不时给我一个解析不了的东西?如果模型"会"输出 JSON——它在我测试时明明输出得好好的——那它上线后给我的那些解析失败的东西,到底【是什么】?它是不是,根本就没在"保证"给我 JSON?我一直以为我从模型那里拿到的是"一个 JSON",会不会,我拿到的从来只是"一段【看起来像】 JSON 的文本"?这件事逼着我把大模型到底是什么、它为什么不保证输出合法 JSON、防御性解析怎么做、function calling / JSON mode 是干什么的、结构化输出该怎么校验和兜底,彻底理清了。本文复盘这次实战。
问题背景
环境:一个功能,用大模型把用户自然语言解析成 JSON
事故现象:
- 测试环境:几十句输入,模型每次都返回干净 JSON,零失败
- ★ 上线后:偶发 json.decoder.JSONDecodeError,功能崩
- 代码没改、模型没换 —— 就是"时不时"崩一次
现场排查:
# 1. ★ 关键的一步:把"模型返回的原始文本"原样打出来
# —— 别只看 json.loads 抛的异常,看它到底返回了啥
$ 在 json.loads 之前,把模型原始返回打到日志
# 2. ★★ 几次解析失败的案例,模型实际返回的是这些:
# 案例 A:外面包了一层 markdown 代码围栏
# ```json
# {"date": "2024-06-08", "title": "和张总开会"}
# ```
# ★ json.loads 解析不了开头那个 ```
# 案例 B:JSON 前面有一句"客气话"
# 好的,已为您解析,结果如下:
# {"date": "2024-06-08", "title": "开会"}
# ★ 前面那句中文,直接让解析炸了
# 案例 C:JSON 不完整 —— 被截断了
# {"date": "2024-06-08", "title": "和张总讨论下半年市场预算分配
# ★★ 后面没了!少了右引号、右括号 —— 输出被 max_tokens 截断
# 案例 D:用了单引号 / 带尾逗号
# {'date': '2024-06-08', 'title': '开会',}
# ★ 单引号、尾逗号 —— Python dict 像,但不是合法 JSON
# 3. ★ 为什么测试时没事
# 测试输入都短而规整,模型"心情好",输出也规整;
# 上线后输入五花八门(超长、口语、含特殊符号),
# 把模型各种"不规矩"的输出形态全触发出来了。
根因(后来想清楚的):
1. ★★ 大模型【不是 JSON 生成器】。它本质是一个
"文本概率生成器" —— 它做的事是"根据上文,预测
下一个最可能的字",它生成的是【最像 JSON 的文本】,
而【不保证】是一个语法合法的 JSON。
2. ★ "输出 JSON"对它来说,只是"输出一种碰巧长这样
的文本"。它没有一个语法检查器在出口处帮你把关。
3. ★ 所以它【时常】会:包代码围栏、加解释性的话、
用单引号、留尾逗号;输出太长还会被 max_tokens
【拦腰截断】,留下半个残缺 JSON。
4. ★ 我的错:我把它当成了一个"确定性的 JSON 接口",
直接 json.loads。我没有给它的输出做任何【清洗、
校验、容错】 —— 它一旦不规矩,我就崩。
真相:大模型的输出是【概率性】的文本,不是【确定性】
的 JSON。凡是拿模型输出当结构化数据用,出口处
必须有"防御性解析 + schema 校验 + 失败兜底"。
修复 1:JSON 解析偶发失败——先把"模型原始返回"打出来
# === ★ 解析失败,第一刀:看模型到底返回了"什么" ===
# === ★ 别盯着 JSONDecodeError 的堆栈,那没用 ===
# ★ json.loads 抛 JSONDecodeError,异常信息只会告诉你
# "在第几行第几列有个非法字符" —— 它【不会告诉你】
# 模型返回的整段文本长什么样。
# ★ ★ 而定位这类问题,唯一有用的信息,就是【模型
# 返回的原始文本本身】。你得亲眼看见那段文本,
# 才知道它是多了个代码围栏、还是被截断了、还是
# 前面多了句话。
# === ★★ 决定性动作:解析前,先把原始返回完整记下来 ===
# ★ 在 json.loads(raw) 这一行【之前】,把 raw 这个
# 原始字符串,完整地打进日志(或在出错时打)。
# ★ ★ 这一步价值极高:它把"偶发、抓不住"的问题,
# 变成了"出错时,案发现场的文本就躺在日志里"。
# 本文那 4 个案例(代码围栏 / 客气话 / 截断 / 单引号),
# 全是这么一眼看出来的。
# === ★ 把失败样本归归类 ===
# ★ 攒上十几二十个失败的原始返回,你会发现它们不是
# 乱七八糟,而是【几种固定的"坏形态"】反复出现:
# - 形态①:被 ```json ... ``` 代码围栏包住;
# - 形态②:JSON 前后有解释性的自然语言;
# - 形态③:JSON 被截断,后半截没了;
# - 形态④:不合 JSON 规范(单引号、尾逗号、注释)。
# ★ 认清这几种形态,后面的"防御性解析"才有的放矢。
# === 认知 ===
# ★ JSON 解析偶发失败,别盯着 JSONDecodeError 的堆栈
# (它只说第几列非法,不告诉你全貌)。★★ 决定性
# 动作:在 json.loads 之前把【模型返回的原始文本】
# 完整记进日志 —— 把"偶发抓不住"变成"案发现场就在
# 日志里"。攒一批失败样本会发现坏输出就那么几种固定
# 形态:代码围栏、前后夹自然语言、被截断、单引号尾
# 逗号 —— 认清形态,防御性解析才有的放矢。
import json, logging
def parse_llm_json(raw: str):
"""解析大模型返回的 JSON —— 出错时,把原始返回留证"""
try:
return json.loads(raw)
except json.JSONDecodeError as e:
# ★★ 关键:把模型返回的原始文本,完整记下来
logging.error("LLM JSON 解析失败 | err=%s | 原始返回=%r", e, raw)
raise
# ★ 上线后翻这条日志,4 类"坏形态"一眼看清:
# 原始返回='```json\n{"date":"..."}\n```' <- 代码围栏
# 原始返回='好的,结果如下:\n{"date":"..."}' <- 前面夹了话
# 原始返回='{"date":"2024-06-08","title":"和张总 <- 被截断
# 原始返回="{'date':'2024-06-08',}" <- 单引号+尾逗号
修复 2:核心认知——大模型是"概率文本生成器",不是"JSON 生成器"
# === ★ 这一节是全文的认知核心:模型到底在"生成"什么 ===
# === ★ 大模型做的事,只有一件:预测下一个 token ===
# ★ 剥到最本质,一个大语言模型做的事【只有一件】:
# 给定前面已有的文本,预测【下一个 token 最可能
# 是什么】,然后吐出来;再把它接上,预测再下一个。
# ★ ★ 它是一个"文本接龙"机器。它生成的每一个字,
# 都是一次【概率采样】的结果 —— 是"最可能",而
# 【不是"必然"】。
# === ★★ 于是,关于"输出 JSON",真相是残酷的 ===
# ★ 当你让它"返回 JSON",它【并不理解】"JSON 必须
# 语法合法"这条硬约束。它做的,只是因为训练数据里
# 见过海量 JSON,所以它会生成一段【在统计上非常
# 像 JSON 的文本】。
# ★ ★★ 关键:它的出口处,【没有一个 JSON 语法检查器】
# 在把关。它不会在吐出 } 之前回头数一下括号配没
# 配对。它只是"觉得"这里大概率该是个 } 就吐了。
# ★ ★ 所以它生成的,是"最像 JSON 的文本",不是
# "保证合法的 JSON"。绝大多数时候,"最像"恰好
# 就是"合法";但【偶尔】,概率的骰子掷出了一个
# 不合法的形态 —— 这就是你的"偶发崩溃"。
# === ★ 为什么会包代码围栏、加客气话 ===
# ★ 这恰恰是"概率"的体现:模型的训练数据里,JSON
# 大量出现在 markdown 代码块 ```json 里、出现在
# "这是结果:"这类话后面。所以它生成 JSON 时,
# 【顺手】把这些它"见惯了的伴生物"也一起生成了 ——
# 在它的统计世界里,这很"自然"。
# === ★ 为什么会被"截断" ===
# ★ 这是另一回事,但同样致命:你设了 max_tokens 上限
# (该设,见前文成本篇)。模型生成到一半,token
# 数撞上上限,就【被强行喊停】 —— 不管这个 JSON
# 有没有写完。于是你拿到半个 JSON,必崩。
# === ★ 结论:认知必须转过来 ===
# ★ ★ 别再把大模型的输出,当成一个"确定性 API 的
# 返回值"。它是一段【概率性生成的文本】。
# ★ 凡是要把它当"结构化数据"用,你【必须】在它和
# 你的代码之间,架一道"防线":清洗 + 校验 + 兜底。
# 把"它大概率会对"这件事,和"我的代码必须不崩"
# 这件事,彻底解耦。
# === 认知 ===
# ★★ 大模型本质是"文本概率生成器",它做的唯一一件事
# 是"预测下一个最可能的 token",生成的每个字都是
# 概率采样的结果 —— 是"最可能"不是"必然"。让它
# "返回 JSON",它只是生成一段【统计上最像 JSON 的
# 文本】,出口处【没有语法检查器把关】。绝大多数
# 时候"最像"恰好"合法",但偶尔骰子掷出不合法形态
# 就是你的偶发崩溃。★ 认知必须转过来:模型输出是
# 概率性文本不是确定性 JSON,当结构化数据用就必须
# 在出口架"清洗+校验+兜底"的防线。
修复 3:防御性解析——别直接 json.loads,先清洗再容错
# === ★ 既然模型输出"不规矩",解析就得"防着它" ===
# === ★ 第一步:剥掉 markdown 代码围栏 ===
# ★ 针对"坏形态①":返回被 ```json ... ``` 包着。
# 解析前,先把开头的 ```json / ``` 和结尾的 ```
# 这几个标记,统统去掉。
# === ★ 第二步:从一堆文本里,"抠"出那个 JSON ===
# ★ 针对"坏形态②":JSON 前后夹着自然语言。
# ★ ★ 一个稳的做法:在整段文本里,找【第一个 {】
# 和【最后一个 }】,把它俩之间的内容截出来 —— 前后
# 那些客气话,自然就被甩掉了。
# ★ (如果要的是 JSON 数组,就找第一个 [ 和最后一个 ]。)
# === ★ 第三步:对常见的"不规范"做容错修补 ===
# ★ 针对"坏形态④":单引号、尾逗号。
# - 尾逗号:把 ",}" 替换成 "}"、",]" 替换成 "]";
# - 单引号:谨慎地替换成双引号(★ 这步有风险 ——
# 内容里本身带引号会被误伤,能用 prompt 让模型
# 别犯就别靠这步硬修)。
# ★ ★ 容错修补是"亡羊补牢",能修一部分,但别指望
# 它修好一切 —— 真正的解法在下一节(让模型少犯错)。
# === ★ 第四步:截断的,基本救不回来 ===
# ★ 针对"坏形态③":被 max_tokens 截断的半个 JSON。
# ★ ★ 这种【基本没法靠解析救】—— 数据本身就缺了。
# 正解是:① 把 max_tokens 调大到足够;② 让输出
# 更紧凑(少返回无关字段);③ 解析失败后重试。
# === ★ 把这几步,封装成一个"防御性解析"函数 ===
# ★ 别在业务代码里到处 json.loads。统一收口到一个
# 函数里:剥围栏 -> 抠 JSON -> 容错修补 -> 解析,
# 失败了再走重试 / 兜底。出口干净,业务才干净。
# === 认知 ===
# ★ 模型输出不规矩,解析就得"防着它",做防御性解析:
# ① 剥掉 markdown ```json 代码围栏;②★ 从文本里找
# 第一个 { 和最后一个 } 把 JSON"抠"出来,甩掉前后
# 的自然语言;③ 对尾逗号、单引号等做容错修补(单
# 引号替换有误伤风险,能靠 prompt 避免就别硬修);
# ④ 被 max_tokens 截断的半个 JSON 基本救不回来,要
# 靠调大 max_tokens / 紧凑输出 / 重试。把这几步收口
# 成一个统一的防御性解析函数,别在业务代码到处 loads。
import json, re
def safe_parse_json(raw: str):
"""防御性解析:剥围栏 -> 抠 JSON -> 容错修补 -> 解析"""
text = raw.strip()
# ★ 1. 剥掉 markdown 代码围栏 ```json ... ```
text = re.sub(r'^```(?:json)?\s*', '', text)
text = re.sub(r'\s*```$', '', text)
# ★ 2. 从第一个 { 到最后一个 },抠出 JSON 主体
start, end = text.find('{'), text.rfind('}')
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
# ★ 3. 容错:去掉对象/数组里的尾逗号
text = re.sub(r',\s*([}\]])', r'\1', text)
try:
return json.loads(text)
except json.JSONDecodeError:
return None # ★ 修不好就返回 None,交给上层重试/兜底
# ★ 业务代码统一调 safe_parse_json,不再到处裸 json.loads
修复 4:治本——让模型"更可能"输出对(JSON mode / function calling)
# === ★ 防御性解析是"接住错",这一节是"少出错" ===
# === ★ 手段 1:prompt 里把要求"钉死" ===
# ★ 别只说"返回 JSON"。要明确、强硬地约束:
# - "只返回 JSON 本身,不要任何解释、不要 markdown
# 代码块、不要 ``` 标记";
# - 把你要的【字段名、类型、枚举值】一条条列清楚;
# - ★ 给一两个完整的"输入 -> 输出"示例(few-shot)
# —— 模型照着例子的样子学,比你干讲规则管用得多。
# === ★★ 手段 2:用"JSON mode" —— 让 API 替你保证合法 ===
# ★ ★ 现在主流大模型 API,大多提供一个【JSON mode】
# (response_format 设成 json_object 之类)。
# ★ ★★ 开了它,API 会【保证】返回给你的是一个语法
# 合法的 JSON —— 它在生成时受约束,不会再给你
# 代码围栏、客气话、尾逗号。这一招直接干掉"坏
# 形态①②④"。
# === ★★ 手段 3:function calling / 结构化输出 —— 连字段都锁死 ===
# ★ JSON mode 只保证"是个合法 JSON",不保证"字段
# 符合你的要求"。再进一步:
# ★ ★ function calling(工具调用)/ structured output:
# 你把想要的结构,写成一个 JSON Schema 交给 API。
# API 会【强制】模型的输出严格贴合这个 Schema ——
# 字段名、类型、必填项、枚举值,全锁死。
# ★ ★★ 这是目前拿大模型做"结构化数据抽取"最稳的
# 方式 —— 把"输出对不对"这件事,从"求模型自觉",
# 变成了"API 强制保证"。优先用它。
# === ★ 手段 4:max_tokens 给够,别让 JSON 被截断 ===
# ★ 针对"坏形态③"截断:预估你这个 JSON 最长会有多少
# token,把 max_tokens 设得【足够大】,留出余量。
# ★ 同时让输出尽量紧凑(别让模型返回一堆你不要的
# 字段、别让它在 JSON 里写长篇大论)。
# === 认知 ===
# ★ 防御性解析是"接住错",这一节是"从源头少出错":
# ① prompt 把要求钉死 —— 明说只返回 JSON 不要解释
# 不要代码块,列清字段名/类型/枚举,给 few-shot 示例;
# ②★★ 用 API 的 JSON mode,它保证返回语法合法的
# JSON,直接干掉代码围栏/客气话/尾逗号;③★★ 更进
# 一步用 function calling / 结构化输出,把 JSON Schema
# 交给 API 强制模型输出贴合 schema,字段类型枚举全
# 锁死 —— 这是结构化抽取最稳的方式,优先用;④ max_tokens
# 给足,别让 JSON 被拦腰截断。
# ★ 用 function calling / 结构化输出:把 schema 交给 API 强制约束
schema = {
"type": "object",
"properties": {
"date": {"type": "string"},
"time": {"type": "string"},
"title": {"type": "string"},
"participants": {"type": "array", "items": {"type": "string"}},
},
"required": ["date", "title"], # ★ 必填字段
"additionalProperties": False, # ★ 不许多塞字段
}
resp = client.chat.completions.create(
model="...",
messages=[
{"role": "system", "content": "把用户日程解析成 JSON。只返回 JSON。"},
{"role": "user", "content": "明天下午三点和张总开个会"},
],
response_format={ # ★★ 让 API 强制贴合 schema
"type": "json_schema",
"json_schema": {"name": "schedule", "schema": schema},
},
max_tokens=500, # ★ 给足,别截断
)
# ★ 这样拿到的输出,字段名/类型/必填项,API 已替你保证
修复 5:出口校验与失败兜底——把"概率"挡在业务之外
# === ★ 最后一道防线:校验 + 重试 + 兜底 ===
# === ★ 第一关:就算解析成功,也要做 schema 校验 ===
# ★ json.loads 成功,只代表"它是个合法 JSON",【不
# 代表】"它是你要的那个 JSON"。模型可能:少了
# 必填字段、字段类型不对(date 给成了数字)、枚举
# 值飘了、还幻觉多塞了字段。
# ★ ★ 所以解析成功后,必须再用一个【JSON Schema】
# 校验一遍:字段全不全、类型对不对、必填项有没有。
# 用 jsonschema 这类库,几行代码的事。
# === ★★ 第二关:校验不过,带着错误信息去重试 ===
# ★ 解析失败、或校验不过 —— 别直接崩,★ 重试。
# ★ ★ 重试有个技巧:别原样再问一遍。把"模型上次
# 返回的错误内容 + 具体哪里不对",一起塞回 prompt:
# "你上次返回的 JSON 缺少 date 字段,请修正后重新
# 只返回 JSON" —— 带着反馈重试,成功率高得多。
# ★ 重试要有【次数上限】(如 2~3 次),别无限试。
# === ★ 第三关:重试还是不行,走"兜底" ===
# ★ 试了 N 次仍拿不到合法结果 —— 这时【一定要有
# 一个兜底】,绝不能让异常裸奔到用户面前:
# - 给用户一个友好提示("没太理解,请换个说法");
# - 或退回一条人工处理的链路;
# - 或返回一个安全的默认值。
# ★ ★ 核心原则:模型那一侧的"概率不确定性",必须
# 【在你的出口处被完全消化掉】 —— 流到业务逻辑、
# 流到用户面前的,只能是确定的、合法的数据。
# === ★ 把整条链路串起来 ===
# ★ 调模型 -> 防御性解析 -> schema 校验 -> 不过则带
# 错误重试(限次)-> 仍不过走兜底。这一整条,就是
# 把一个"概率性的文本源",改造成一个"对业务确定
# 可靠的数据源"的全部工序。
# === 认知 ===
# ★ 最后一道防线:① json.loads 成功只代表"是合法
# JSON",不代表"是你要的 JSON" —— 必须再用 JSON
# Schema 校验字段全不全、类型对不对、必填项在不在
# (jsonschema 库);②★ 解析失败或校验不过别直接崩,
# 带着"上次错在哪"的具体反馈重试,成功率远高于原样
# 重问,重试要限次;③ 重试仍失败必须有兜底(友好
# 提示/人工链路/安全默认值),绝不让异常裸奔到用户。
# ★★ 核心原则:模型侧的概率不确定性必须在你的出口处
# 被完全消化,流进业务的只能是确定合法的数据。
from jsonschema import validate, ValidationError
def get_structured(user_input, max_retry=3):
"""完整链路:解析 -> 校验 -> 带反馈重试 -> 兜底"""
err_feedback = ""
for attempt in range(max_retry):
raw = call_llm(user_input, extra=err_feedback) # 调模型
data = safe_parse_json(raw) # ★ 防御性解析
if data is None:
err_feedback = "你上次的返回不是合法 JSON,请只返回 JSON"
continue
try:
validate(instance=data, schema=schema) # ★ schema 校验
return data # ★ 全部通过
except ValidationError as e:
# ★ 带着"具体哪里不对"去重试,比原样重问强得多
err_feedback = f"你上次的 JSON 不合规:{e.message},请修正"
# ★★ 重试用尽 —— 兜底,绝不让异常裸奔到用户
logging.error("LLM 结构化输出多次失败 | input=%r", user_input)
return {"_fallback": True, "msg": "没太理解,请换个说法试试"}
修复 6:LLM 结构化输出排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ JSON 解析偶发失败,先把"模型原始返回"完整打进日志 ===
# === 2. ★★ 大模型是概率文本生成器,不保证输出合法 JSON ===
# === 3. ★ 失败样本归类:代码围栏 / 夹自然语言 / 被截断 / 单引号尾逗号 ===
# === 4. ★ 别裸 json.loads,统一走防御性解析:剥围栏 + 抠 JSON + 容错 ===
# === 5. ★★ 优先用 API 的 JSON mode / function calling,让 API 强制保证 ===
# === 6. ★ prompt 明确"只返回 JSON 不要解释",给 few-shot 示例 ===
# === 7. ★ max_tokens 给足,别让 JSON 被拦腰截断 ===
# === 8. ★★ 解析成功 ≠ 数据正确,必须再用 JSON Schema 校验字段和类型 ===
# === 9. ★ 失败带错误信息重试(限次),仍失败必须有兜底 ===
# === 10. 把模型输出改造成可靠数据源的工序链 ===
$ 调模型 # ① 优先开 JSON mode / 结构化输出
$ 防御性解析 # ② 剥围栏、抠 JSON、容错修补
$ schema 校验 # ③ 字段、类型、必填项
$ 带反馈重试(限次) # ④ 把"哪里错了"塞回 prompt
$ 兜底 # ⑤ 仍失败,给用户安全的默认结果
命令速查
需求 做法
=============================================================
定位解析失败 json.loads 前把模型原始返回完整记进日志
剥 markdown 代码围栏 去掉开头 ```json 和结尾 ```
从杂文本里抠 JSON 截取第一个 { 到最后一个 } 之间
容错尾逗号 正则把 ",}" "]" 前的逗号去掉
保证返回合法 JSON 开 API 的 JSON mode(response_format)
锁死字段和类型 function calling / json_schema 结构化输出
防止 JSON 被截断 max_tokens 给足余量 + 输出紧凑
校验结构正确 jsonschema.validate 按 schema 校验
解析/校验失败 带"哪里错了"的反馈重试,限次数
重试仍失败 兜底:友好提示 / 人工链路 / 安全默认值
口诀:大模型是概率文本生成器 不保证输出合法 JSON
出口必须 防御性解析 + schema 校验 + 失败兜底
避坑清单
- JSON 解析偶发失败先把模型返回的原始文本完整打进日志,异常堆栈本身定位不了问题
- 大模型是概率文本生成器不是 JSON 生成器,它生成最像 JSON 的文本但不保证语法合法
- 模型坏输出就那么几种固定形态,包代码围栏、前后夹自然语言、被截断、单引号尾逗号
- 别在业务代码里裸 json.loads,统一走防御性解析,剥围栏、抠出 JSON 主体、容错修补
- 被 max_tokens 截断的半个 JSON 基本救不回来,要靠调大 max_tokens 和让输出更紧凑
- 优先用 API 的 JSON mode,它能保证返回的是语法合法的 JSON,直接干掉大半坏形态
- 更稳的是 function calling 结构化输出,把 JSON Schema 交给 API 强制约束字段和类型
- prompt 要明确只返回 JSON 不要任何解释和代码块,并给一两个输入输出的 few-shot 示例
- json.loads 成功只代表是合法 JSON 不代表是你要的 JSON,必须再用 schema 校验字段类型
- 解析或校验失败要带着具体错误信息重试且限次数,重试仍失败必须有兜底不让异常裸奔
总结
这次"大模型解析 JSON,测试全过、上线偶崩"的事故,纠正了我一个关于"接口"的、藏得极深的错觉。在我过去的脑子里,我调用的任何一个东西,只要它叫"接口"、它"返回 JSON",它就属于一个我无比熟悉的世界:一个【确定性】的世界。在这个世界里,一个接口说它返回 JSON,它就【永远】返回合法的 JSON;同样的输入,它就【永远】给同样的输出;它对了一万次,我就有理由相信它第一万零一次也对。我这十几年写代码,调的全是这样的接口——它们像齿轮一样精确、像数学一样可靠。所以当大模型这个"接口"也对我说"我返回 JSON",我想都没想,就把它归进了那个确定性的世界,我对它,用的是我对一个普通函数的那种【完全的信任】:它返回什么,我就 json.loads 什么,中间不设一道防。它在测试时对了几十次,我那条"它会一直对"的直觉,就被彻底坐实了。直到上线后那一次次"偶发"的崩溃,我才如梦初醒地看清:我把一个【概率性】的东西,错放进了【确定性】的格子里。大模型这个"接口",它和我熟悉的那些接口,长着同一张脸——同样的函数签名、同样的"传入文本、返回 JSON"——可它们的【内核】是两个物种。普通接口的内核是【逻辑】:它执行一段确定的代码,结果是被推导出来的,所以它确定。大模型的内核是【概率】:它的每一个字都是一次采样,结果是被"掷"出来的,所以它,天生就【不确定】。它给我的从来不是"一个 JSON",而是"一段大概率长得像 JSON 的文本"——"大概率"这三个字,一直都在,只是测试时它运气好,没让我看见。复盘到最深,我意识到这件事真正教给我的,是世界上的"组件",其实分两种:一种是确定性的,你可以信任它的【每一次】;另一种是概率性的,你只能信任它的【绝大多数次】。而这两种东西,最危险的地方在于,它们在表面上、在"绝大多数次"里,长得一模一样——概率性组件在 99% 的情况下,表现得和确定性组件【完全没有区别】,温顺、可靠、规规矩矩。它把那 1% 的狰狞,藏在了你测试覆盖不到的角落里。你一旦因为那 99% 而给了它确定性组件的待遇——直接信任、不设防线——那 1% 就会在你最没防备的某个上线后的深夜,准时降临。这个教训,我后来到处都看见它的影子:一个"几乎总是成功"的网络请求,一个"基本不会为空"的缓存,一个"正常情况下有序"的消息队列,一个"通常很快"的下游服务——它们全都是概率性的,而我们太容易因为那个"几乎、基本、通常、正常情况下",就把它们当成"永远"来依赖。这次最大的收获,是我给自己立了一条新规矩:每当我要依赖一个外部的东西,我都会先给它分类——它,是确定性的,还是概率性的?如果是概率性的,那我就【必须】在它和我的核心逻辑之间,亲手砌一道墙:校验它、容错它、给它的失败准备好退路。我不再问"它会不会出错",我默认它【一定会】,我只问"它出错的时候,我接得住吗"。大模型那段时灵时不灵的 JSON 教给我的,不是一个解析技巧,而是一种更彻底的清醒:真正可靠的系统,不是用一堆"永远可靠"的零件搭起来的——那种零件根本不存在;它是在你【承认每个零件都可能失手】之后,靠你在零件与零件之间,亲手砌起的那一道道墙,才得以可靠的。你信任的不该是零件,你信任的,只能是你自己砌的那道墙。
—— 别看了 · 2026