从模型返回结构化数据指南

377 阅读10分钟

如何从模型获取结构化数据

引言

在许多应用场景中,我们希望大型语言模型 (LLM) 返回的不仅仅是自由格式的文本,而是具有特定结构的数据。例如,从一段非结构化文本中提取信息并将其格式化以便存入数据库,或者调用外部 API 时需要特定格式的参数。本指南将介绍几种让模型输出结构化数据的方法。

1. 首选方法:使用 .with_structured_output()

对于原生支持结构化输出功能的模型(例如,支持工具调用或 JSON 模式的模型),.with_structured_output() 是最简单、最可靠的方法。它利用了模型底层提供的能力来确保输出符合指定的格式。

  • 前提条件:

    • 支持的模型: 确认您使用的模型支持结构化输出。可以在 LangChain 集成文档 中查找带有 "Structured output" 功能标签的模型。
    • 基本设置: 安装必要的库并初始化您的聊天模型。
    # 安装相关库 (根据需要选择)
    # pip install -qU langchain-openai typing_extensions # 或其他模型提供商的库
    
    import getpass
    import os
    from langchain_openai import ChatOpenAI # 以 OpenAI 兼容接口为例
    
    # 设置 API 密钥 (替换为您模型提供商的密钥)
    # 例如: os.environ["OPENAI_API_KEY"] = getpass.getpass()
    if "TOGETHER_API_KEY" not in os.environ: # 使用 Together AI 作为示例
        os.environ["TOGETHER_API_KEY"] = getpass.getpass("请输入 Together AI API 密钥:")
    
    # 初始化聊天模型 (根据您的提供商调整)
    llm = ChatOpenAI(
        base_url="https://api.together.xyz/v1",
        api_key=os.environ["TOGETHER_API_KEY"],
        model="mistralai/Mixtral-8x7B-Instruct-v0.1",
    )
    
  • 定义输出结构: 您可以通过以下三种方式之一定义期望的输出结构(模式):

    • 使用 Pydantic 类 (推荐): Pydantic 提供了数据验证功能。如果模型输出不符合定义的类结构(如缺少字段、类型错误),Pydantic 会自动抛出错误。类名、文档字符串和字段描述对模型理解目标结构至关重要。

      from typing import Optional
      from pydantic import BaseModel, Field
      
      class Joke(BaseModel):
          """要讲给用户的笑话。"""
          setup: str = Field(description="笑话的铺垫")
          punchline: str = Field(description="笑话的笑点")
          rating: Optional[int] = Field(
              default=None, description="笑话有多好笑,从1到10"
          )
      
      # 将 Pydantic 类绑定到 LLM
      structured_llm_pydantic = llm.with_structured_output(Joke)
      
      # 调用模型
      result = structured_llm_pydantic.invoke("给我讲个关于猫的笑话")
      print(f"Pydantic 输出: {result}")
      # 输出: Pydantic 输出: setup='为什么猫坐在电脑上?' punchline='因为它想监视鼠标!' rating=7
      
    • 使用 TypedDict: 如果您不想引入 Pydantic 依赖,或者需要流式处理输出(见下文),可以使用 TypedDict。推荐从 typing_extensions 导入以获得更好的跨版本兼容性。

      # 确保安装了 typing_extensions
      # pip install typing_extensions
      
      from typing import Optional
      from typing_extensions import Annotated, TypedDict # 推荐导入
      
      class JokeDict(TypedDict):
          """要讲给用户的笑话。"""
          setup: Annotated[str, "笑话的铺垫"]
          punchline: Annotated[str, "笑话的笑点"]
          rating: Annotated[Optional[int], "笑话有多好笑,从1到10"]
      
      # 将 TypedDict 绑定到 LLM
      structured_llm_typeddict = llm.with_structured_output(JokeDict)
      
      # 调用模型
      result = structured_llm_typeddict.invoke("给我讲个关于狗的笑话")
      print(f"TypedDict 输出: {result}")
      # 输出: TypedDict 输出: {'setup': '为什么狗狗过马路?', 'punchline': '为了去“汪”公园!', 'rating': 6}
      

      注意: Annotated 中的描述是可选的。默认值(如 Optional[int] 中的 None)仅用于模式定义,模型不一定会生成它们,也不会自动填充。

    • 使用 JSON Schema: 您可以直接提供一个 JSON Schema 字典来定义结构。

      json_schema = {
          "title": "joke",
          "description": "要讲给用户的笑话。",
          "type": "object",
          "properties": {
              "setup": {"type": "string", "description": "笑话的铺垫"},
              "punchline": {"type": "string", "description": "笑话的笑点"},
              "rating": {"type": "integer", "description": "笑话有多好笑,从1到10"},
          },
          "required": ["setup", "punchline"],
      }
      
      # 将 JSON Schema 绑定到 LLM
      structured_llm_json_schema = llm.with_structured_output(json_schema)
      
      # 调用模型
      result = structured_llm_json_schema.invoke("给我讲个关于鸟的笑话")
      print(f"JSON Schema 输出: {result}")
      # 输出: JSON Schema 输出: {'setup': '为什么鸟儿要飞到南方过冬?', 'punchline': '因为走过去太远了!', 'rating': 5}
      
  • (可选)在多个模式间选择: 如果希望模型根据情况选择不同的输出结构,可以定义一个包含 Union 类型的父模式。

    from typing import Union
    
    class ConversationalResponse(BaseModel):
        """以对话的方式回应。保持友好和乐于助人。"""
        response: str = Field(description="对用户查询的对话式回应")
    
    class FinalResponse(BaseModel):
        """最终的响应,可以是笑话或对话。"""
        final_output: Union[Joke, ConversationalResponse] # 联合类型
    
    structured_llm_multi = llm.with_structured_output(FinalResponse)
    
    # 请求笑话
    result_joke = structured_llm_multi.invoke("给我讲个关于鱼的笑话")
    print(f"多模式 (笑话): {result_joke}")
    # 输出: 多模式 (笑话): final_output=Joke(setup='为什么鱼生活在盐水里?', punchline='因为胡椒让它们打喷嚏!', rating=4)
    
    # 进行对话
    result_convo = structured_llm_multi.invoke("今天天气怎么样?")
    print(f"多模式 (对话): {result_convo}")
    # 输出: 多模式 (对话): final_output=ConversationalResponse(response='作为一个AI,我无法感知天气,但我可以帮你查询你所在地区的天气预报。你想查询哪个城市的天气呢?')
    
  • (可选)流式处理: 当使用 TypedDict 或 JSON Schema 定义模式时(输出为字典),可以流式获取输出块。注意,这些块是逐步聚合的。

    structured_llm_stream = llm.with_structured_output(JokeDict) # 使用 TypedDict
    
    print("\n开始流式传输:")
    for chunk in structured_llm_stream.stream("给我讲个关于程序员的笑话"):
        print(chunk)
    print("流式传输结束。")
    # 输出: (一系列逐步聚合的字典块)
    # {}
    # {'setup': ''}
    # {'setup': '为'}
    # ...
    # {'setup': '为什么程序员总是混淆万圣节和圣诞节?', 'punchline': '因为 Oct 31 == Dec 25!', 'rating': 9}
    
  • (可选)使用少量示例 (Few-Shot) 提示: 对于复杂的模式,在提示中提供示例可以显著提高模型输出的准确性。

    • 方法一:在系统消息中添加示例 (通用):

      from langchain_core.prompts import ChatPromptTemplate
      
      system_few_shot = """你是一个搞笑的喜剧演员。返回一个包含铺垫和笑点的笑话。
      
      示例:
      用户: 关于电脑的笑话
      助手: {{"setup": "电脑为什么感冒了?", "punchline": "因为它开了太多的窗口!", "rating": 6}}
      
      用户: 关于书的笑话
      助手: {{"setup": "悲伤的数学书为什么哭泣?", "punchline": "因为它有太多的问题!", "rating": 7}}
      """
      prompt_few_shot = ChatPromptTemplate.from_messages([
          ("system", system_few_shot),
          ("human", "{input}")
      ])
      
      # 链式连接: 提示 -> 结构化LLM
      chain_few_shot = prompt_few_shot | structured_llm_pydantic # 使用 Pydantic 输出
      
      result = chain_few_shot.invoke({"input": "关于太空的笑话"})
      print(f"\n少量示例 (系统消息): {result}")
      # 输出: 少量示例 (系统消息): setup='宇航员最喜欢的键盘按键是什么?' punchline='空格键!' rating=8
      
    • 方法二:使用显式工具调用示例 (特定于工具调用模型): 如果模型底层使用工具调用,可以构造包含 tool_calls 的消息历史作为示例。

      from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
      
      examples_tool_call = [
          HumanMessage("关于电脑的笑话", name="example_user"),
          AIMessage(
              content="", name="example_assistant",
              tool_calls=[{"name": "Joke", "args": {"setup": "电脑为什么感冒了?", "punchline": "因为它开了太多的窗口!", "rating": 6}, "id": "tc_1"}]
          ),
          ToolMessage(content="已生成笑话", tool_call_id="tc_1"), # 表示工具调用完成
          # ... 更多示例 ...
      ]
      
      system_tool_call = "你是一个搞笑的喜剧演员。使用提供的工具返回笑话。"
      prompt_tool_call = ChatPromptTemplate.from_messages([
          ("system", system_tool_call),
          ("placeholder", "{examples}"),
          ("human", "{input}")
      ])
      
      chain_tool_call_few_shot = prompt_tool_call | structured_llm_pydantic
      
      result = chain_tool_call_few_shot.invoke({
          "input": "关于食物的笑话",
          "examples": examples_tool_call # 传入示例
      })
      print(f"\n少量示例 (工具调用): {result}")
      # 输出: (取决于模型是否正确处理工具调用示例)
      
  • (高级)指定底层方法: 对于同时支持工具调用和 JSON 模式的模型,可以通过 method 参数强制使用其中一种。

    # 强制使用 JSON 模式 (仍需在提示中指导模型输出 JSON)
    structured_llm_json_mode = llm.with_structured_output(JokeDict, method="json_mode")
    
    prompt_json_mode = """给我讲个关于动物的笑话。
    请严格按照以下 JSON 格式回答,包含 setup, punchline, 和 rating 键:
    {
      "setup": "...",
      "punchline": "...",
      "rating": ...
    }
    result = structured_llm_json_mode.invoke(prompt_json_mode)
    print(f"\n强制 JSON 模式输出: {result}")
    # 输出: 强制 JSON 模式输出: {'setup': '长颈鹿的脖子为什么那么长?', 'punchline': '因为它的头离身体太远了!', 'rating': 5}
    

    重要: 当使用 method="json_mode" 时,传递给 with_structured_output 的模式主要用于 解析 输出,而模型需要通过 提示 来被告知要生成 JSON。

  • (高级)获取原始输出与错误处理: 模型生成结构化输出并非总是完美的。通过设置 include_raw=True,可以在解析失败时不引发错误,而是获取包含原始模型响应、尝试解析的结果以及任何解析错误信息的字典。

    structured_llm_include_raw = llm.with_structured_output(Joke, include_raw=True)
    
    result = structured_llm_include_raw.invoke("用一句无法解析的话告诉我一个笑话") # 模拟可能失败的情况
    print(f"\n包含原始输出: {result}")
    # 可能的输出 (如果解析失败):
    # {
    #  'raw': AIMessage(content='这是一个关于猫的笑话,但格式不对。', ...),
    #  'parsed': None,
    #  'parsing_error': PydanticValidationError(...) # 或其他解析错误
    # }
    # 可能的输出 (如果解析成功):
    # {
    #  'raw': AIMessage(content='', tool_calls=[...]),
    #  'parsed': Joke(setup='...', punchline='...', rating=...),
    #  'parsing_error': None
    # }
    

2. 备选方法:手动提示与解析 (适用于不支持的模型)

如果您的模型不直接支持 .with_structured_output(),您需要采取手动步骤:

  1. 提示工程: 在您的提示中明确指示模型按特定格式(通常是 JSON)输出。
  2. 输出解析: 编写代码或使用解析器从模型的原始文本输出中提取所需的数据结构。
  • 使用 PydanticOutputParser: 这是一个方便的工具,可以根据 Pydantic 模型生成格式指令,并解析模型的输出。

    from typing import List
    from langchain_core.output_parsers import PydanticOutputParser
    from langchain_core.prompts import ChatPromptTemplate
    from pydantic import BaseModel, Field
    
    class Person(BaseModel):
        """关于一个人的信息。"""
        name: str = Field(..., description="这个人的名字")
        height_in_meters: float = Field(..., description="这个人的身高(米)。")
    
    class People(BaseModel):
        """文本中所有人的信息。"""
        people: List[Person]
    
    # 1. 设置解析器
    parser = PydanticOutputParser(pydantic_object=People)
    
    # 2. 创建提示,包含解析器的格式指令
    prompt_manual_parser = ChatPromptTemplate.from_messages([
        ("system", "回答用户查询。严格按照以下格式输出 JSON。\n{format_instructions}"),
        ("human", "{query}"),
    ]).partial(format_instructions=parser.get_format_instructions()) # 注入指令
    
    # 3. 创建链: 提示 -> LLM -> 解析器
    chain_manual_parser = prompt_manual_parser | llm | parser
    
    # 4. 调用链
    query = "张三身高 1.75 米,李四身高 1.88 米。"
    result = chain_manual_parser.invoke({"query": query})
    print(f"\nPydanticOutputParser 结果: {result}")
    # 输出: PydanticOutputParser 结果: people=[Person(name='张三', height_in_meters=1.75), Person(name='李四', height_in_meters=1.88)]
    

    get_format_instructions() 会生成详细的说明,告诉模型如何构建符合 People Pydantic 模型结构的 JSON。

  • 使用自定义解析逻辑: 您可以编写自己的函数来解析模型的输出,通常与正则表达式或字符串处理结合使用。

    import json
    import re
    from langchain_core.messages import AIMessage
    
    # 1. 定义 Pydantic 模型 (同上)
    # class Person(BaseModel): ...
    # class People(BaseModel): ...
    
    # 2. 创建提示,要求模型用 Markdown JSON 代码块输出
    prompt_custom_parse = ChatPromptTemplate.from_messages([
        ("system", "回答查询。将答案格式化为 JSON 对象,包含 'people' 列表。\n"
                   "将整个 JSON 输出包裹在 ```json 和 ``` 标签中。\n"
                   f"JSON 模式: {People.schema_json(indent=2)}"), # 提供模式参考
        ("human", "{query}")
    ])
    
    # 3. 编写自定义解析函数
    def extract_json_from_markdown(message: AIMessage) -> List[dict]:
        """从 Markdown 代码块中提取 JSON"""
        match = re.search(r"```json(.*?)```", message.content, re.DOTALL)
        if not match:
            raise ValueError(f"在输出中找不到 JSON 代码块: {message.content}")
        try:
            return [json.loads(match.group(1).strip())]
        except json.JSONDecodeError as e:
            raise ValueError(f"解析 JSON 失败: {e}\n内容: {match.group(1).strip()}")
    
    # 4. 创建链: 提示 -> LLM -> 自定义解析函数
    chain_custom_parse = prompt_custom_parse | llm | extract_json_from_markdown
    
    # 5. 调用链
    query = "王五身高 5 英尺 10 英寸。" # 约 1.778 米
    try:
        result = chain_custom_parse.invoke({"query": query})
        print(f"\n自定义解析结果: {result}")
        # 输出: 自定义解析结果: [{'people': [{'name': '王五', 'height_in_meters': 1.778}]}]
        # 可以进一步用 People.parse_obj(result[0]) 验证
    except ValueError as e:
        print(f"\n自定义解析失败: {e}")
    

参考文献