项目拆解(二):模型输出是"野"的——三层兜底+重分析,把AI变成可靠零件

研发效能智能助手 · 第 2 个项目第 2 篇 | 上篇拆架构,这篇拆容错

上一篇把 AI 代码归档系统的骨架拆完了:FastAPI 四层结构、裸调 Ollama、Prompt 设计、SQLite 归档。评论区很多人在问同一个问题:“你那个模型返回的 JSON 要是坏了怎么办?模型崩了怎么办?”

问得好。这才是 AI 工程和调 API 玩具的分水岭。

这篇就把系统里最"见功夫"的部分全拆开:错误兜底的三层设计 + 重分析机制。所有代码都是项目里真实跑着的,每个坑都是真机踩出来的。

一、先说透:大模型是系统里最不可靠的零件

做传统后端的人有个习惯:相信自己的代码。写 json.loads 就觉得一定会成功,调接口就觉得一定会返回。

做 AI 工程,第一课就是把这种自信戒掉。大模型有三大"野":

  1. 会挂:Ollama 没启动、显存不够、机器卡死、请求超时
  2. 会疯:让它输出 JSON,它给你 json ... 包起来,或者直接输出一句"好的,我来分析一下:"
  3. 会骗:字段该是数组给你字符串,该是数字给你"五十",该有风险给你空

任何一个"野"没兜住,用户看到的就是白屏、报错、或者更糟——假结果。

所以这个系统从设计第一天就定了规矩:大模型只负责"提内容",工程负责"保可靠"。围绕一次分析,三层兜底,层层设卡。

二、错误全景图:一次分析要过四道鬼门关

用户点一下"让 AI 分析并存档",背后是这样的链路:

前端点击 → POST /api/analyze → 拼Prompt → 调Ollama → 解析JSON → 校验字段 → 入库 → 渲染上屏

每一段都可能挂。我们把故障点全部列出来,逐个设计对策:

环节会挂成什么样兜底层级
调 Ollama连不上 / 超时 / HTTP 500第一层:调用层
JSON 解析多字了 / 用代码块包了 / 结构不对第二层:解析层
字段校验类型错 / 缺字段第二层:解析层
前端渲染空数组 / 带空格空串 / 空指针第三层:展示层

下面一层一层拆。

三、第一层兜底:调用层,把技术错误翻译成人话

直接看代码(图1是 analyzer.py 的异常处理):

在这里插入图片描述

try:
    resp = requests.post(
        f"{OLLAMA_BASE_URL}/api/generate",
        json=payload,
        timeout=REQUEST_TIMEOUT,   # 120秒,7b首次加载要冷启动
    )
except requests.exceptions.ConnectionError:
    raise OllamaError("连不上 Ollama,请先在命令行执行: ollama serve")
except requests.exceptions.Timeout:
    raise OllamaError(f"Ollama 超过 {REQUEST_TIMEOUT} 秒没响应,模型可能在加载或机器卡顿")

if resp.status_code != 200:
    raise OllamaError(f"Ollama 返回 HTTP {resp.status_code}", raw=resp.text[:300])

三个设计细节,面试都能讲:

1. 超时给 120 秒,不是随便拍的。 7b 模型在 4GB 显存上首次加载要冷启动,第一次请求慢得离谱。120 秒是实测出来的上限,后面改成了"真实倒计时"给用户看(见第五层),用户就不焦虑了。

2. 异常翻译成"人话"。 ConnectionError 对用户没有意义,但"连不上 Ollama,请先在命令行执行 ollama serve"是可执行的指令。AI 工程的产品化,就是让错误提示可行动。

3. 业务异常要带原始报文。 OllamaError 里存了 raw=resp.text[:300]——前端只看到一句中文,但日志里能查到模型到底返回了什么。排查问题全靠这个尾巴。

这里有个反直觉的设计:底层异常一律翻译成业务异常抛出去,不在调用层 try 到底。翻译是职责,抛出是态度——路由层统一捕获,转成 502,前端统一渲染。错误处理最忌讳"每层都 try 一下,最后用户看到一脸懵"。

四、第二层兜底:解析层,JSON 坏了先别慌,降噪重试一次

第一层兜住了"调不通",但模型可能调通了却胡说八道。format=json 只是让 Ollama 用 JSON schema 解码,不保证 7b 一定输出合法 JSON。实测它会干这些事:

  • 在 JSON 外面包 ```````json ````代码块
  • 输出"好的,我分析如下:"再跟 JSON
  • 字段名改了(language 写成 Language)
  • 直接整个 JSON 语法错误

解析层设计(图2是完整重试机制):

在这里插入图片描述

# 第一次:正常温度,先试
raw = _call_ollama(prompt, temperature=0.1)
analysis = _try_parse(raw)
if analysis is not None:
    return analysis

# 第二次:温度降到 0,再试一次
logger.warning("第一次 JSON 解析失败,用 temperature=0.0 重试")
raw = _call_ollama(prompt, temperature=0.0)
analysis = _try_parse(raw)
if analysis is not None:
    return analysis

# 两次都失败,才认输
raise OllamaError("模型返回了无法解析的 JSON,可能是模型能力不足或代码太奇怪", raw=raw[:300])

核心逻辑是 _try_parse——解析失败返回 None 而不是抛异常,让上层决定要不要重试:

def _try_parse(raw: str) -> CodeAnalysis | None:
    raw = raw.strip()
    # 防御:有些模型还是会用 ```json ... ```包一下,剥掉
    if raw.startswith("```"):
        raw = raw.strip("`")
        if raw.lower().startswith("json"):
            raw = raw[4:].strip()
    try:
        data = json.loads(raw)
        return CodeAnalysis(**data)
    except (json.JSONDecodeError, ValueError) as e:
        logger.warning("JSON 解析失败: %s; 原文前200字: %s", e, raw[:200])
        return None

三个关键点:

1. 先剥代码块再解析。 7b 实测经常用 ```````json ````包输出——这是它训练语料的习惯。strip("")+ 去json` 字样,一次剥干净。这层防御让"输出格式不完美"从失败变成成功。

2. 重试时温度降到 0.0。 第一次用 0.1 是想让模型"活一点"别太机械,但解析失败说明它已经疯了一次,重试就必须把随机性压到最低——temperature=0.0 让输出退化成确定性,通常第二次就老实了。实测重试成功率挺高,用户几乎感觉不到这次重试。

3. 两次失败才认输,且带上原文。 不无限重试(浪费算力、拖死用户体验),两次是实测出来的平衡点。认输时把 raw[:300] 塞进异常——开发者看到"模型返回了无法解析的 JSON"能立刻去日志翻原文,而不是瞎猜。

五、第三层兜底:展示层,模型输出的最后一道清洗

前两层都过了,数据入库了,最后一步是上屏。但这里还有坑——模型在"无风险"时会返回什么?

真机实测:它返回了 [""],甚至 [" "](带空格的空串)。前端直接渲染,用户看到一个红色的空警告框,吓一跳。

修复代码(index.html 里真实跑着的):

(a.risks||[]).map(t=>t&&t.trim()).filter(Boolean).length
  ? a.risks.map(t=>`<span class="risk">${esc(t)}</span>`).join("")
  : '<span style="color:#34c759;">无,这段代码挺干净</span>'

注意细节:先 t.trim() 再 filter(Boolean)。只 filter(Boolean) 挡不住 [" "](带空格的字符串是真值),必须先清空格再过滤。这是真机踩出来的:第一次只写了 filter(Boolean),第二天用户截图说"为什么没有风险还弹红框",一查是 [" "]。

同一行的另一个细节:空的时候显示绿字"无,这段代码挺干净"。空数组不展示空容器,而是展示"无风险"的正面反馈——这种小细节决定了产品是"能用"还是"好用"。

六、展示层的另一个细节:真实倒计时,不骗用户

用户点了"分析",本地 7b 要跑 10-30 秒。进度条要不要?不要——因为我们自己都不知道还要多久,预估个假进度条,用户一眼看穿更糟。

方案是真实计时器(index.html):

const startTs = Date.now();
let timer = setInterval(() => {
  const sec = Math.round((Date.now() - startTs) / 1000);
  $("result").innerHTML = `AI 分析中... 已用时 ${sec} 秒(本地 7b 模型稍慢,请稍候)`;
}, 1000);

每秒更新"已用时 X 秒"——数字是真实流逝的时间,不是拍脑袋的预估。用户体验:知道系统没死,知道大概要多久。同时分析期间按钮禁用($("btn").disabled = true),防止用户手滑重复提交把 Ollama 打爆。结果一到,finally 里停计时器、恢复按钮。

小功能,但"等待"是所有 AI 产品最容易被吐槽的环节,值得较真。

七、重分析机制:一个"不覆盖"的设计取舍

系统里有已归档的片段。用户经常想:“这段代码当时分析漏了个风险,能不能重新分析?” 这就是重分析需求。

最初的设计方案是"原地覆盖":重分析 → 更新同一条记录。但细想有两个问题:

  1. 历史被抹掉了。如果新分析反而更差(小模型不稳定),旧结果找不回来
  2. 无迹可循。看不到"上次怎么判的、这次怎么判的",没法对比模型进步

所以最终做了**“回填式重分析”**:点击列表里的片段 → 右侧展示结果 + 左侧自动回填代码 → 用户改不改都行 → 再点"让 AI 分析并存档" → 生成一条新记录。

交互上它就像"基于旧代码再分析一次",但数据上每次都留痕。旧记录还在列表里,新分析也入库了,两个结果摆在一起天然可对比。等以后模型换大版本,还能批量重跑一遍旧片段,看模型进步了多少——这是"覆盖式"永远给不了的价值。

顺手一提:点击回填这个动作还顺带实现了"查看详情"(点列表 → 右侧显示完整分析),一个交互两种用途,前端一行 onclick="showDetail(id)" 就搞定。

八、真机实测:这些错误场景,你迟早会遇到

最后交个底,这个系统在 4GB 显存 / 16GB 内存的机器上跑出来的真实错误场景:

场景现象兜底结果
Ollama 没启动就点分析ConnectionError中文提示"连不上 Ollama,请先执行 ollama serve"
模型首次加载冷启动请求挂起很久120 秒超时兜底,前端真实倒计时安抚
模型输出被 ```json 包裹json.loads 失败剥代码块后解析成功,用户无感知
模型输出非法 JSON第一次解析失败temperature=0.0 重试成功,用户无感知
连续两次都失败重试也失败抛"无法解析的 JSON"+原文前300字进日志
无风险时返回 [" "]前端渲染空红框trim+filter(Boolean) 清洗,显示"无风险"绿字

你看,真正的 AI 工程能力,全在这些"用户无感知"的兜底里。用户只看到"结果出来了"或"一句人话报错",背后是三层设计在默默干活。

九、总结:兜底设计三条原则

  1. 每个不可靠点都必须有失败路径。从调用到解析到渲染,逐个列出故障点,逐个设计兜底,没有"反正不会坏"的侥幸
  2. 错误必须可读、可行动。技术异常翻译成人话,人话里带执行指令
  3. 兜底要可观测。重试要打日志、异常要带原文、结果要留痕——出了问题能在日志里还原全程

把这三条做到,你就把"调大模型的 Demo"升级成了"AI 工程"。


这套系统的完整源码(含开发文档、全部注释、可直接运行)在这里:

👉 下载源码:AI代码片段智能归档系统-FastAPI+Ollama本地大模型-完整源码

📦 资源地址:https://download.csdn.net/download/weixin_54679968/93681154

系列第三篇预告:Prompt 工程实战——怎么让 7b 小模型稳定输出 60%-70% 准确率(防照抄、防误报、边界认知全复盘)。关注不迷路。

关键词:FastAPI、Ollama、Qwen2.5、本地大模型、错误兜底、JSON容错、重试机制、Prompt 工程、Java+Python

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐