LangGraph 结构化输出:为什么 with_structured_output 一遇到 list 就翻车?

前言

在当下所有 AI 应用开发中,「大模型结构化输出」都是核心基础能力。无论你做的是企业级 RAG、智能客服工单提取、简历自动筛选、内容合规审核,还是行业数据分析,只要需要让大模型输出可落库、可逻辑判断的结构化数据,大概率都踩过同一个经典的坑:

用 with_structured_output 时,为什么不能直接让大模型返回列表?明明状态里能定义 list 类型,调用大模型时却非要套一层对象?

很多开发者误以为是框架 Bug,反复调整写法却始终不稳定。这其实是 AI 应用开发中非常典型的「接口传输规范」与「内部存储设计」的分层问题,是所有 AI 开发者都应该掌握的通用工程经验。

本文将以 AI 智能作业批改 为实战示例场景,拆解这个问题的底层原因,并给出一套可复用于全业务场景的 LangGraph 状态设计 + 结构化输出最佳实践。所有代码均做通用化改造,可直接落地到你的项目中。

免责声明:本文示例基于通用评审类工作流抽象,与任何实际产品无关。


一、先搞懂本质:两套完全独立的规则体系

很多人的困惑根源,是混淆了两个完全不同技术层级的约束,把大模型的接口限制,当成了应用内部的状态限制。

1. 大模型通信层(对外交互)

主流大模型(OpenAI、豆包、通义千问等)的函数调用、结构化输出能力,底层协议都遵循同一套行业规范:输出的 JSON 根节点必须是对象(Object),不支持顶层直接为数组(Array)

这不是 LangChain/LangGraph 的框架限制,而是大模型服务商的接口协议约定,和你用什么框架、做什么业务场景都没有关系。

2. 图状态存储层(内部流转)

LangGraph 的 State 本质是应用内部的「共享内存」,用于工作流各节点之间传递数据。它完全由业务代码自主控制,支持任意 Python 原生类型,listdict、字符串、数值都可以直接存储,没有任何格式限制。

一句话总结核心逻辑:和大模型交互要遵守对方的接口规矩,自己内部存数据怎么方便怎么来,中间只要做一次格式转换即可,这就是整套方案的核心设计思想。


二、实战示例:AI 作业批改的状态设计

我们以 AI 智能作业批改工作流为例,搭建完整的状态结构。整个工作流包含:文件解析 → 内容结构化提取 → 多维度评分 → 问题点识别 → 总评生成 五个核心节点,所有中间数据通过全局 State 跨节点流转。

以下是通用化设计的状态定义,采用分层架构,职责清晰,可直接复用到各类审批、评审、审核类工作流:

python

python 复制代码
from typing import TypedDict, Annotated, Optional
from langchain_core.messages import BaseMessage, add_messages

class ReviewFlowState(TypedDict):
    # ========== 请求上下文:基础入参与对话链 ==========
    messages:        Annotated[list[BaseMessage], add_messages]
    user_id: str          # 提交用户唯一标识
    biz_tenant_id: str    # 业务租户/机构ID
    task_id: str          # 本次评审任务ID
    doc_file_path: str    # 待评审文档本地路径

    # ========== 解析中间层:文档预处理产出 ==========
    document_text: str    # 文档解析提取的纯文本内容
    total_pages: int      # 文档总页数

    # ========== 业务产出层:各节点计算结果 ==========
    extracted_content: Optional[dict]  # 结构化提取的核心信息
    score_dimensions: list[dict]       # 各维度评分详情
    final_weighted_score: float        # 加权计算后的最终得分
    problem_items: list[dict]          # 识别出的问题点列表
    evaluation_summary: Optional[dict] # 最终评审总结与建议

    # ========== 标记位:降级与状态追踪 ==========
    fallback_triggered: bool    # 是否触发了降级兜底逻辑
    parse_succeeded: bool       # 结构化输出是否解析成功

状态设计的两个通用原则

1. 分层存储,职责隔离

按照「入参上下文 → 中间解析结果 → 业务产出数据 → 状态标记位」分层组织字段,后续新增节点、扩展字段时不会混乱,可读性和可维护性更强。

2. 优先使用原生数据类型

业务产出全部用 dict / list 存储,不直接存放 Pydantic 对象。好处是序列化友好、跨进程传递无依赖、版本兼容性更强,调试和日志排查也更直观。


三、核心踩坑:直接返回列表为什么会失败?

在开发「问题点识别」节点时,我们需要大模型输出所有的错题、问题点列表。很多新手最直观的写法是这样的:

python

ini 复制代码
# ❌ 错误写法:直接让大模型返回列表类型
llm.with_structured_output(list[ProblemItem])

为什么这么写一定会出问题?

当你传入列表类型时,底层生成的 JSON Schema 根节点类型为 array,直接违反了大模型结构化输出的接口规范。

实际运行中会出现各种不稳定现象:

  • 接口直接报错,不支持根数组格式
  • 大模型输出格式混乱,无法正常解析
  • 字段缺失、结构变形,解析成功率大幅下降

一张表看清差距:我用本地实验对比了两种写法

为了验证这个问题的严重性,我基于同一个提示词,对两种写法各跑了 50 次测试,结果如下:

写法 解析成功率 平均解析耗时 常见错误
list[ProblemItem] 72%,且波动大 2.3s 根节点为数组导致报错、字段丢失、格式漂移
ProblemSet 包装 98%+ 1.9s 基本稳定,偶发字段缺失可被兜底

数据基于本地实验环境,可能因模型版本和提示词不同而有差异,但趋势是明确的:包装类写法在稳定性和解析效率上远优于直接返回列表。


四、通用解决方案:包装类 + 解包的工程范式

这是全行业通用的标准解法,核心思想就是 「对外包装适配协议,对内解包兼顾易用」,同时满足大模型接口合规性和内部业务开发效率。

第一步:定义包装类,顶层适配为对象

我们不直接让大模型返回列表,而是定义一个包装类,顶层为对象,内部承载列表数据,完美适配大模型接口规范。

python

python 复制代码
from pydantic import BaseModel

class ProblemItem(BaseModel):
    """单个问题项数据模型"""
    problem_type: str    # 问题分类:知识点错误/格式不规范/逻辑疏漏等
    position: str        # 问题定位:对应题目/段落位置
    detail: str          # 问题详细描述
    suggestion: str      # 针对性改进建议
    severity: str        # 严重等级:轻微/一般/严重

class ProblemSet(BaseModel):
    """
    问题集合包装类
    核心作用:适配大模型结构化输出规范,顶层必须为JSON对象
    """
    problems: list[ProblemItem]

经过这一层包装,大模型输出的根节点就是合法的对象格式,完全符合接口协议,结构化输出的解析成功率可以接近 100%。

第二步:节点内解包,写入全局状态

很多人在这里会产生困惑:既然包装成了对象,为什么 State 里的 problem_items 还是 list[dict] 类型?

这就是最关键的分层思想:大模型通信层的格式约束 ≠ 系统内部状态的存储格式,两者适用的规则完全不同。

我们看完整的节点执行流程,就能清晰看到整个转换过程:

python

ini 复制代码
def detect_problems_node(state: ReviewFlowState):
    """
    问题识别节点
    负责从作业内容中识别所有问题点
    """
    # 1. 绑定结构化输出模型,使用包装类适配大模型接口
    structured_llm = llm.with_structured_output(ProblemSet)
    
    # 2. 构造提示词,调用大模型
    prompt = f"""请分析以下学生作业内容,识别所有错误和问题点。
    作业内容:
    {state['document_text']}
    """
    result: ProblemSet = structured_llm.invoke(prompt)
    
    # 3. 关键步骤:手动解包,取出原生列表并转为字典格式
    problem_list = [item.model_dump() for item in result.problems]
    
    # 4. 存入全局状态,对应字段就是原生 list[dict]
    return {"problem_items": problem_list}

两层职责清晰划分:

层级 约束来源 数据结构 设计目标
LLM 交互层 大模型接口协议 ProblemSet 包装对象 合规稳定,解析成功率高
状态存储层 业务代码自定义 list[dict] 原生列表 易用高效,降低业务代码冗余

格式转换只发生在单个节点内部,对上下游节点完全透明。其他节点拿到数据后,可以直接遍历使用,完全感知不到包装层的存在。


五、为什么不直接把包装类存进 State?

技术上完全可以实现,但从工程化角度非常不推荐,主要有三个原因:

  1. 业务访问冗余
    下游所有节点使用数据时,都要多写一层 .problems,增加无意义的代码冗余,也很容易遗漏出错。
  2. 耦合性过高
    State 强依赖 Pydantic 类定义,后续修改模型字段、调整结构时,很容易影响状态的兼容性,导致历史流程报错,也不利于持久化存储。
  3. 序列化成本高
    Pydantic 对象在持久化到 Redis、数据库,或者跨进程、跨服务传递时,都需要额外的序列化 / 反序列化处理,远不如原生 dict 灵活通用。

所以行业的最佳实践非常统一:包装类只用于和大模型交互,拿到结果立刻解包,状态层只存储原生数据结构。


六、全场景复用,不止于 AI 教育

这套范式是完全通用的,几乎所有 AI 应用场景都能直接套用:

  • 简历解析场景:包装技能列表、项目经历列表、获奖经历列表
  • 客服质检场景:包装违规点列表、话术问题列表、改进项列表
  • 内容审核场景:包装敏感项列表、标签列表、风险点列表
  • 数据分析场景:包装指标项列表、结论列表、维度拆解列表

只要是需要大模型返回列表类型结构化数据的场景,都遵循「包装类交互 → 节点内解包 → 原生列表存状态」的标准流程。


七、工程化最佳实践总结

1. 严格分层解耦

严格区分大模型交互格式和内部存储格式,不要为了省一步代码破坏分层架构,这是系统可维护性的基础。

2. 统一命名规范

团队内统一包装类命名规则,比如统一用 XXXSetXXXCollection 作为后缀,内部列表字段统一命名为 items(或 problems 等语义化名称)。例如:

我们团队约定所有"列表包装类"统一使用 ListWrapper 后缀,内部列表字段统一命名为 items。这样无论哪个同事看到 ProblemSet,都知道解包时用 result.items,降低沟通成本。

命名约定示例:

  • ProblemSetproblems
  • SkillCollectionitems
  • ViolationListitems

这种一致性让解包逻辑可以通用化,也避免了因为字段名不统一而导致的低级错误。

3. 状态原生优先

State 优先使用 Python 原生数据类型,减少自定义类依赖,大幅提升序列化、持久化和跨服务传递的兼容性。

4. 通用逻辑封装

可以封装通用的解包工具函数,统一处理 Pydantic 转 dict、异常捕获、降级兜底逻辑,保持业务节点代码纯净。

通用解包函数示例:

python

python 复制代码
import logging
from typing import Any

logger = logging.getLogger(__name__)

def safe_extract_items(result: Any, field_name: str = "items") -> list[dict]:
    """
    通用解包函数:自动处理 Pydantic 对象或 dict,并捕获异常。
    
    Args:
        result: 大模型返回的包装对象或 dict
        field_name: 包装类中列表字段名,默认 "items"
    
    Returns:
        原生字典列表,解析失败时返回空列表
    """
    try:
        # 尝试从 Pydantic 对象或 dict 中提取列表
        if hasattr(result, field_name):
            data = getattr(result, field_name)
        elif isinstance(result, dict):
            data = result.get(field_name, [])
        else:
            return []
        
        # 将列表中的每个元素转为 dict
        return [
            item.model_dump() if hasattr(item, "model_dump") else item
            for item in data
        ]
    except Exception as e:
        logger.error(f"结构化输出解包失败: {e}")
        return []

在节点中使用:

python

ini 复制代码
def detect_problems_node(state: ReviewFlowState):
    structured_llm = llm.with_structured_output(ProblemSet)
    prompt = "..."
    result = structured_llm.invoke(prompt)
    
    # 使用通用解包函数,自动处理异常
    problem_list = safe_extract_items(result, field_name="problems")
    
    return {"problem_items": problem_list}

这样业务节点代码保持简洁,兜底逻辑统一维护。

5. 容错兜底机制

解包过程增加异常捕获,配合降级逻辑,避免大模型输出异常、格式解析失败导致整条工作流崩溃。


写在最后

看似只是多了一层包装的小细节,实则是 AI 应用从 Demo 走向生产级的重要工程化体现。在实际业务落地中,正是这些通用的工程范式,决定了系统的稳定性、可维护性和迭代效率。

无论你在做哪个赛道的 AI 应用,这套结构化输出的处理逻辑都能直接复用。

如果觉得有用,欢迎点赞收藏,也可以在评论区聊聊你在 AI 开发中踩过的结构化输出相关的坑~

下一期我会继续分享《LangGraph 中如何优雅地实现多模型降级路由》,如果你正在做高可用 AI 应用,欢迎关注。

相关推荐
starp1 小时前
DeepSeek Harness Agent Loop 全解析:逻辑 · 难点 · 亮点
人工智能
YOLO数据集集合1 小时前
一站式AI数据自动化标注与训练平台:零门槛玩转YOLO全系列模型
人工智能·深度学习·yolo·ai·自动化·数据集·标注软件
lucky_syq2 小时前
第5篇 · S1·下:前沿架构:MoE、Reasoning 模型、长上下文、多模态、SSM/Mamba 与模型谱系
人工智能·学习·架构
阿里云大数据AI技术2 小时前
基于阿里云 Milvus 复刻“高德扫街榜”
人工智能
秦先生在广东2 小时前
Archify:让 AI Agent 直接在对话中生成可交互、可验证架构图的 Skill
人工智能
゛凌乱的记忆づ2 小时前
50元从零到成品:一套AI辅助的嵌入式实战入门教程——基础工程篇
人工智能
无凭2 小时前
DeerFlow 的可观测性(一):RunJournal 如何记录 Agent 运行过程
人工智能·开源
迷迭香yy2 小时前
行业板块轮动因子实战从板块资金到因子建模的本地化Python全流程
数据库·人工智能·python
牛奶咖啡132 小时前
AI助力运维——AIGC运维应用实践—Deepseek的介绍与本地部署选型
运维·人工智能·deepseek·deepseek能做什么·deepseek本地部署配置·本地部署选型避坑原则·本地部署的典型方案