第2篇:LLM 结构化输出实战:Pydantic + Function Calling 告别“解析 JSON 地狱“

本系列博客基于一个真实可运行的电商评论舆情分析项目。

上一篇:《第1篇:电商评论舆情分析系统:从 0 到 1 的架构设计与数据流》


一、痛点开场

让模型"返回 JSON"很容易,但让模型稳定返回字段齐全、枚举合法、类型正确的 JSON 很难。

新手常见的做法是:

python 复制代码
result = llm.invoke("请分析这条评论的情感,返回 JSON")
text = result.content
data = json.loads(text)   # 祈祷它真的是合法 JSON
sentiment = data["sentiment_label"]   # 祈祷字段名真的叫这个

然后你就会遇到:输出里混着解释文字、字段名从 sentiment_label 漂移成 label、情感值变成 "Positive" 而不是 "positive"、score 变成字符串......每个问题都在运行时爆炸。

这篇讲我的解法:Pydantic Schema + with_structured_output(method="function_calling") + 统一模型工厂。

二、核心思路:把输出契约变成代码

结构化输出的本质是:先定义"模型必须返回什么",再让模型按这个契约输出。

在 LangChain 中,llm.with_structured_output(schema, method="function_calling") 会把 Pydantic Schema 转成函数调用参数,强制模型按 schema 输出,返回的就是 BaseModel 对象------不需要再手写 JSON 解析。

三、Schema 设计:ReviewAnalysis

这是情感分析的目标输出结构(backend/schemas/review.py):

python 复制代码
from pydantic import BaseModel, Field
from typing import Literal, List


class AspectScore(BaseModel):
    name: str = Field(description="属性名,如 物流/价格/质量/客服")
    sentiment: Literal["positive", "neutral", "negative"]
    score: float = Field(ge=0.0, le=1.0)
    mentions: List[str] = Field(default_factory=list, description="原文证据片段")


class ReviewAnalysis(BaseModel):
    sentiment_label: Literal["positive", "neutral", "negative"]
    sentiment_score: float = Field(ge=0.0, le=1.0)
    emotion: Literal["angry", "disappointed", "regret", "satisfied", "praising", "neutral"]
    aspects: List[AspectScore] = Field(default_factory=list)
    summary: str = Field(description="一句话摘要")
    confidence: float = Field(default=0.9, ge=0.0, le=1.0)

关键点:

  1. 枚举用 Literal :情感、情绪都锁死取值,模型想输出 "Positive" 都会校验失败
  2. 分数用 Field(ge=0, le=1):范围越界直接报错
  3. mentions 保留原文证据片段:为后续"验证模型没说谎"留了钩子
  4. 字段都有 description:这部分会进提示词,指导模型理解每个字段含义

四、统一模型工厂:LLMFactory

如果每个 Agent 各自 init_chat_model,后面切模型、统一超时、统一熔断都会失控。所以项目用 LLMFactory 收口(backend/core/llm_factory.py):

python 复制代码
class LLMFactory:
    _instances: dict[str, BaseChatModel] = {}
    _failure_count: dict[str, int] = {}
    _circuit_open_until: dict[str, datetime] = {}
    _lock = threading.Lock()

    @classmethod
    def get_structured_llm(cls, agent_type, output_schema, temperature=0):
        llm = cls.get_llm(agent_type, temperature=temperature, thinking="disabled")
        return llm.with_structured_output(output_schema, method="function_calling")

模型按 (agent_type → model_key, temperature, streaming) 缓存实例,避免每次请求重复初始化:

python 复制代码
cache_key = f"{model_key}_{temperature}_{streaming}"
if cache_key not in cls._instances:
    llm = init_chat_model(**kwargs)
    cls._instances[cache_key] = llm
return cls._instances[cache_key]

工厂还内置了熔断保护 :某个 agent_type 连续失败达到阈值后,临时拒绝调用(llm_circuit_breaker_threshold),给外部 LLM 服务喘息时间,避免雪崩:

python 复制代码
def _check_circuit(cls, agent_type):
    until = cls._circuit_open_until.get(agent_type)
    if until and until > now:
        raise RuntimeError(f"LLM circuit open for {agent_type}, retry in {remaining}s")

五、在 Agent 节点里使用

情感分析节点(backend/agents/sentiment/nodes.py)的调用方式:

python 复制代码
from backend.core.llm_factory import get_structured_llm
from backend.schemas.review import ReviewAnalysis

# 获取绑定 Schema 的结构化模型
structured_llm = get_structured_llm("sentiment", ReviewAnalysis)

# 每条评论调用
messages = [
    SystemMessage(content=SYSTEM_PROMPT),
    HumanMessage(content=build_user_prompt(review)),
]
try:
    result = await structured_llm.ainvoke(messages)   # result 是 ReviewAnalysis 对象
    analysis = result.model_dump()
    analysis["model_name"] = "deepseek"               # 标记来源
except Exception as e:
    analysis = _heuristic_analyze(review)             # 降级词典规则

注意这里没有 json.loads ------ainvoke 返回的就是 ReviewAnalysis 实例,model_dump() 直接得到字典。模型输出与 Python 类型之间由 LangChain 的 function calling 桥接。

六、为什么 "请返回 JSON" 不可靠

问题 现象 结构化输出解法
输出混入解释文字 "好的,分析结果如下:{...}" function calling 只取工具参数
字段名漂移 sentiment_label 变 label Schema 锁定字段名
枚举不合法 "Positive" / "负面" Literal 校验
类型错误 score 是字符串 Pydantic 强类型
字段缺失 没返回 summary Schema 必填字段校验

七、重要提醒:Schema 通过 ≠ 语义正确

结构化输出只解决"长得像不像",不解决"说得对不对"。

  • Schema 校验通过,不代表 mentions 真的来自原文
  • 不代表 sentiment_label 真的符合评论语义
  • 不代表根因、摘要没有幻觉

所以项目里做了三层防线:

  1. Schema 层:字段、类型、枚举、范围
  2. 业务层 :mentions 需在原文中出现、簇大小与统计一致、数字与数据库核对
  3. HITL 层:低置信度(< 0.75)结果进人工复核队列

这也是下一篇的主题。

八、踩坑记录

  1. cache key 维度 :早期把 thinking 也放进 cache key,导致实例漂移;后来简化成 (model_key, temperature, streaming),thinking 通过 extra_body 传入
  2. max_retries=0:工厂层故意关闭自动重试,把重试策略交给上层统一控制(避免重试风暴)
  3. trust_env=False :关闭代理环境变量干扰,保证 base_url 精确可控
  4. HTTP client 共享 :_HTTP_ASYNC_CLIENT 全局复用,注意应用关闭时要释放

九、总结

  • 用 Schema 定义输出契约,把"模型输出"变成"类型安全的数据"
  • 用工厂统一模型,路由、缓存、熔断、超时一处管理
  • 结构化 ≠ 正确,业务校验和 HITL 必须跟上

下一篇预告:《大模型系统的保命设计:LLM 失败降级 + HITL 人工复核闭环》


相关推荐
Java后端的Ai之路36 分钟前
Python进阶探索24_应用案例_linux系统的监控
linux·开发语言·python·应用·探索
happylifetree43 分钟前
Python22-26:核心语法-流程控制语句-if条件判断
python
程序员清风1 小时前
PydanticAI 实战:用类型安全构建可靠的 Python Agent
开发语言·python·安全
梦想画家1 小时前
SQLMesh Python 模型入门(三):前后置语句、蓝图建模与避坑指南
大数据·python·sqlmesh
迅猛龙办公室2 小时前
Python实现绘制同切圆
开发语言·python
言乐62 小时前
Python语音检索
开发语言·python·django·virtualenv·pygame
泡茶喝茶写代码3 小时前
A股量化数据工程:从 REST 接口到策略信号(第 1 篇):指数列表与实时行情接入
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据接口
光依旧3 小时前
PageIndex没翻车,翻车的是我的解析器
docker·langchain·大模型·向量数据库·pdf解析·rag·pageindex
happylifetree3 小时前
Python34-35:核心语法-流程控制语句-循环-综合案例
python
吃饱了得干活3 小时前
Agent 的记忆与工具:从上下文窗口到 MCP
python·agent·mcp