在当今的技术浪潮中,将 FastAPI 的高效后端与大语言模型(LLM)的强大能力相结合,已经成为构建智能应用的流行范式。然而,这条看似光明的道路上,开发者常常会遇到一些“灵异事件”——代码时而运行正常,时而却毫无征兆地抛出 500 Internal Server Error。

今天,我们就来解剖一个典型的案例,揭示这个“随机”错误的真凶,并学会如何构建一个更具鲁棒性的 AI 应用。

 案发现场:一个时好时坏的 API

想象一下我们有这样一个 FastAPI 端点,它的任务是接收一段文本,调用 LLM 提取其中提到的“文化点”,并要求 LLM 以一个包含多个对象的 JSON 数组格式返回结果。

我们的代码可能长这样:

# pydantic 模型定义
class CulturalPoint(BaseModel):
    一级项目: str
    二级项目: str
    文化点: str

# FastAPI 端点
@app.post("/extract", response_model=List[CulturalPoint])
async def extract_points_api(request: ChatRequest):
    # ... (调用 LLM API 的代码) ...
    # 假设 llm_response_str 是模型返回的 JSON 字符串
    
    try:
        # 我们天真地相信模型总是返回列表
        parsed_json = json.loads(llm_response_str)
        return parsed_json
    except json.JSONDecodeError:
        raise HTTPException(status_code=500, detail="模型返回了格式错误的JSON")

同时,我们给 LLM 的指令(Prompt)也明确要求它:“你的输出必须是一个 JSON 数组(一个 Python 列表)”

一切看起来都很完美。当我们用一个包含多个文化点的句子(比如“春节我们吃饺子,端午节要赛龙舟”)去测试时,API 返回了 200 OK,并附带一个漂亮的列表:

[
  { "一级项目": "社会生活", "二级项目": "节庆", "文化点": "春节" },
  { "一级项目": "社会生活", "二级项目": "饮食", "文化点": "饺子" },
  { "一级项目": "社会生活", "二级项目": "节庆", "文化点": "端午节" },
  { "一级项目": "社会生活", "二级项目": "节庆", "文化点": "划龙舟" }
]

但当我们换一个只包含单个文化点的句子(比如“茅台酒是中国有名的白酒”)去测试时,灾难发生了:

INFO:     127.0.0.1:12345 - "POST /extract HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
...
fastapi.exceptions.ResponseValidationError: 1 validation errors:
  {'type': 'list_type', 'loc': ('response',), 'msg': 'Input should be a valid list', 'input': {'一级项目': '社会生活', '二级项目': '饮食', '文化点': '茅台酒'}}

ResponseValidationError!FastAPI 愤怒地告诉我们:我需要一个列表(List),但你却给了我一个字典(Dict)!

揭晓真凶:AI 的“创造性”偷懒

问题不在于 FastAPI,也不完全在于我们的代码,而在于我们对 LLM 的一个错误假设:我们以为它会像传统程序一样,100% 遵守格式指令。

LLM 的行为更像一个非常聪明但偶尔会“偷懒”的助手。

  • 当有两个或以上的结果时:它会老老实实地按照你的要求,把所有结果整理好放进一个文件(列表 [])里交给你。

  • 当只有一个结果时:它可能会觉得“只有一个,还用得着放进文件夹吗?”,于是直接把那张纸(字典 {})递给了你。

正是这个“偷懒”的行为,导致我们的程序在处理单个结果时收到了一个字典,而 FastAPI 的 response_model=List[CulturalPoint] 守护者则忠实地履行职责,拒绝了这个不符合格式的返回值,从而引发了 500 错误。

构建“防御层”:我们的解决方案

面对不按常理出牌的 AI,我们不能盲目信任,而应在代码中建立一个“防御层”,对它的输出进行校验和修正。

我们的目标很简单:无论 LLM 返回的是单个字典还是一个列表,我们都确保最终交给 FastAPI 的是一个列表。

让我们来改造一下 try...except 代码块:

try:
    # 1. 解析模型返回的JSON字符串
    parsed_response = json.loads(llm_response_str)

    # 2. 核心防御逻辑:检查返回值的类型
    if isinstance(parsed_response, dict):
        # 如果是字典,说明AI“偷懒”了,我们手动将其包装成列表
        return [parsed_response]
    elif isinstance(parsed_response, list):
        # 如果是列表,一切正常,直接返回
        return parsed_response
    else:
        # 如果是其他合法JSON类型(如字符串、数字),不符合业务,返回空列表
        return []

except json.JSONDecodeError:
    # 如果模型返回的根本不是合法的JSON,记录下来并抛出异常
    raise HTTPException(
        status_code=500,
        detail=f"模型返回了格式错误的JSON,无法解析。内容: {llm_response_str}"
    )

这个小小的改动,让我们的应用瞬间变得健壮:

  1. 容忍“偷懒”:当收到单个字典时,我们优雅地将其包装成 [{...}],满足了 FastAPI 的要求。

  2. 兼容正确行为:当收到列表时,代码照常工作。

  3. 增强鲁棒性:我们甚至考虑了 LLM 返回其他奇怪但合法的 JSON 格式的可能性,通过返回空列表来避免程序崩溃。

延伸思考:与 AI 协作的黄金法则

这个小小的 Bug 教会了我们与 LLM 协作时至关重要的几条法则:

  1. 永不完全信任输出格式(Never Trust, Always Verify):无论你的 Prompt 写得多么天衣无缝,都要在代码层面假设 LLM 可能会违反格式约定。对返回结果进行类型检查、结构校验是必不可少的步骤。

  2. Prompt 约束与代码防御并重:在 Prompt 中明确格式要求是“君子协定”,能提高正确率;而在代码中进行校验和修正是“法律保障”,能确保程序在意外发生时不会崩溃。两者缺一不可。

  3. 拥抱不确定性:与确定性的传统编程不同,与 AI 协作意味着要处理概率和模糊性。在设计应用时,就要为各种可能的、甚至看似不合理的返回结果设计好“兜底”方案。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐