ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

02_模型输出的JSON不可信_结构化输出的四层保障

02_模型输出的JSON不可信_结构化输出的四层保障 模型输出的 JSON 不可信结构化输出的四层保障让模型输出 JSON是 LLM 应用里最常见、也最容易踩坑的需求。你在提示词里写了请输出 JSON 格式模型也答应了然后你json.loads()就崩了。这篇文章讲清楚这件事该怎么工程化——提示词只完成了三分之一。一、模型会怎么辜负你实测中遇到的五种情况情况 1 json\n{...}\n ← 包了一层代码围栏 情况 2 好的这是结果\n{...}\n希望有帮助 ← 前后加了客套话 情况 3 {answer: x, confidence: 9.9} ← 值超出约定范围 情况 4 {answr: x} ← 字段名拼错 情况 5 抱歉我无法回答这个问题。 ← 根本没给 JSON每一种都会让你的json.loads()或后续取值崩掉。而它们的共同点是HTTP 状态码都是 200调用是成功的。二、四层保障第 1 层契约写在系统提示里JSON_CONTRACT输出必须是一个 JSON 对象且只包含以下字段 { answer: 字符串对问题的直接回答, confidence: 数字0 到 1 之间你对这个回答的置信度, tags: 字符串数组回答涉及的关键概念最多 3 个 }要点是写在系统提示而不是用户消息里。系统提示的权重更高而且不会被用户的输入冲掉。第 2 层剥壳_FENCEre.compile(r(?:json)?\s*(.*?),re.S)defstrip_fence(text:str)-str:ttext.strip()m_FENCE.search(t)returnm.group(1).strip()ifmelset第 3 层Pydantic 校验 尽力挽救defparse_structured(raw:str)-AnswerOut:cleanedstrip_fence(raw)ifnotcleaned.startswith({):i,jcleaned.find({),cleaned.rfind(})ifi0andji:cleanedcleaned[i:j1]# 截取第一个 { 到最后一个 }returnAnswerOut.model_validate_json(cleaned)注意这里抛异常而不是返回 None。吞掉错误的话上层就不知道该不该重试更不知道该把什么错误信息反馈给模型。第 4 层把错误喂回去让它自己修这是整个方案里最关键的一步也是最容易被忽略的。REPAIR_TEMPLATE你上一次的输出没有通过结构化校验 error {error} /error last_output {raw} /last_output 请修正后重新输出只输出 JSON 对象本身。为什么喂回错误比重新生成一次有效重新生成模型不知道自己上次错在哪很可能再犯同样的错。喂回错误就不一样了——它看到了我上次输出的这个东西错在 confidence 超出 0–1 范围下一次就知道收敛。这跟人改代码是一个道理看到报错再改比凭感觉重写一遍快得多。实测下来用 1 次重试就够覆盖绝大多数情况所以我默认retries1。三、失败时返回什么一个容易被搞错的设计决定except(ValidationError,ValueError,json.JSONDecodeError)ase:last_errorstr(e)[:500]ifattemptretries:return{ok:False,data:None,raw:last_raw,# 原始输出一定要留retries_used:used,validation_error:last_error,# 校验错误一定要留...}为什么不抛异常、不返回 500因为「调用失败了」和「调用成功但输出不合规」是两件完全不同的事情况该返回调用方该做什么调用失败网络/鉴权/限流HTTP 502重试、降级输出不合规HTTP 200 okFalse看raw和validation_error改提示词混为一谈的话你会把提示词写得烂误判成服务不稳定然后去加重试——而重试解决不了提示词的问题。另外raw和validation_error必须留下来。没有它们你复盘时只能看到失败了看不到为什么失败。四、多次调用的成本要累加usage_total[prompt_tokens]res[usage][in]usage_total[completion_tokens]res[usage][out]cost_totalres.get(cost_cny)or0.0latency_totalres[latency_s]一次用户请求可能对应多次 LLM 调用首次 修复重试。用户关心的是这个问题总共花了多少不是最后一次调用花了多少。所以返回的是累加值同时用retries_used告诉你发生了几次。五、实测数据单次/chat调用deepseek-flash项值prompt tokens157completion tokens81成本¥0.001025 起延迟约 1.07s修复重试次数0契约写得够清楚时测试覆盖全部 mock不花钱、不依赖网络deftest_parse_salvages_surrounding_text():模型经常先客套一句再给 JSON应该救回来而不是直接判失败。acompletion.parse_structured(好的这是结果\nVALID\n希望有帮助。)asserta.confidence0.9deftest_parse_rejects_bad_confidence():bad{answer: x, confidence: 9.9, tags: []}withpytest.raises(ValidationError):completion.parse_structured(bad)六、一个写测试时的教训我第一版写了这么一条测试deftest_chat_rejects_unknown_provider(client,monkeypatch):monkeypatch.setattr(completion,call_chat,fake)# ← 错在这rclient.post(/chat,json{message:hi,provider:不存在的厂})assertr.status_code502测试挂了期望 502实际 200。原因是我把call_chat也 mock 掉了而 provider 校验就在call_chat里面——mock 掉之后那段代码根本没执行。教训被测逻辑在哪个函数里就不能 mock 那个函数。这类错误不会报错只会让你以为自己测过了。小结层做什么解决什么1契约写进系统提示让模型知道要什么2剥代码围栏格式问题3Pydantic 校验 尽力截取类型和范围问题4把错误喂回去自修剩下的偶发问题以及两条容易搞错的失败要返回okFalse而不是 5xx多次调用的成本要累加。上一篇LLM 服务的健康检查该怎么写。下一篇Function Calling 的工程细节。
返回列表