如何从LLM返回结构化的数据

How to return structured data from a model

Posted by Brian on Wednesday, July 30, 2025

前提

Python version: v3.13

Langchain version: v0.3

假设你已经熟悉了聊天模型与工具调用,文章中我将使用 deepseek 进行演示

from langchain_deepseek import ChatDeepSeek

llm = ChatDeepSeek(
    model="deepseek-chat",
    temperature=0,
    max_tokens=None,
    timeout=None,
    max_retries=2,
    api_key="Your api key",
    # other params...
)

结构化返回数据在大模型开发中是非常重要的。一个比常见的示例就是从文本中提取数据插入数据库或用于下游系统。

在Langchain中使用 .with_structured_output 方法来返回结构化数据

注意:

并不是所有的大模型都支持该方法。这个方法只针对那些提供原生 API 以结构化输出的模型实现的,比如工具/函数调用或 JSON 模式,并在底层利用这些功能。

对于不支持的该方法的则需要自定义解析,或者使用 PydanticOutputParser

.with_structured_output 支持TypedDict 类、JSON Schema 或 Pydantic 类,如果使用 TypedDict 或 JSON Schema,Runnable 将返回一个字典;如果使用 Pydantic 类,Runnable 将返回一个 Pydantic 对象。下面我们通过让模型给我们讲笑话演示。

Pydantic 类

使用该方式模型将返回一个 Pydantic 对象。这种方式的好处是模型生成的输出会被验证。如果任何必填字段缺失或字段类型不正确。Pydantic 将引发错误。

from typing_extensions import Annotated, Optional

class Joke(BaseModel):
    """Joke to tell user."""
    setup: str = Field(description="The setup of the joke")
    punchline: str = Field(description="The punchline of the joke")
    rating: Optional[int] = Field(description="How funny the joke is, from 1 to 10", default=None)

structured_llm = llm.with_structured_output(Joke)
res = structured_llm.invoke("Tell me a joke about cats")

print(res)
setup="Why don't cats play poker in the wild?" punchline='Too many cheetahs!' rating=7

注意在定义 Pydantic 类时,类的名称、文档、参数名称提供的描述也是非常重要的。

TypedDict 或 JSON Schema

如果不想对参数进行验证,或者希望能够流式输出。这时就可以使用 TypeDict 类定义。同时使用 LangChain支持的特殊 Annotated语法。

from typing_extensions import Annotated, Optional
from pydantic import BaseModel, Field

class Joke(BaseModel):
    "Joke to tell user."
    setup: Annotated[str, ...,"The setup of the joke"]
    punchline: Annotated[str, ...,"The punchline of the joke"]
    rating: Annotated[Optional[int], None,"How funny the joke is, from 1 to 10"]

structured_llm = llm.with_structured_output(Joke)
res = structured_llm.invoke("Tell me a joke about cats")
print(res)
setup="Why don't cats play poker in the wild?" punchline='Because there are too many cheetahs!' rating=5

强烈建议从 typing_extensions 导入 AnnotatedTypedDict 而不是 typing ,以确保跨 Python 版本的行为一致。

同样地,我们可以传入一个 JSON Schema 字典。这种方式写起来比较啰嗦。

json_schema = {
    "title": "joke",
    "description": "Joke to tell user.",
    "type": "object",
    "properties": {
        "setup": {
            "type": "string",
            "description": "The setup of the joke",
        },
        "punchline": {
            "type": "string",
            "description": "The punchline to the joke",
        },
        "rating": {
            "type": "integer",
            "description": "How funny the joke is, from 1 to 10",
            "default": None,
        },
    },
    "required": ["setup", "punchline"],
}
structured_llm = llm.with_structured_output(json_schema)

res = structured_llm.invoke("Tell me a joke about cats")
print(res)
{'setup': "Why don't cats play poker in the jungle?", 'punchline': 'Too many cheetahs!', 'rating': 7}

可以看到这两种方式都是一样的效果,只不过是书写方式不一样。

在多个模式之间进行选择

如果我们有多种输出请求,每一种格式都不一致。比如正常聊天的返回聊天的格式。讲笑话的返回讲笑话的格式。这样子的需求就可以使用联合属性。

from pydantic import BaseModel, Field
from typing import Union

class Joke(BaseModel):
    """Joke to tell user."""

    setup: str = Field(description="The setup of the joke")
    punchline: str = Field(description="The punchline to the joke")
    rating: Optional[int] = Field(
        default=None, description="How funny the joke is, from 1 to 10"
    )

class ConversationalResponse(BaseModel):
    """Respond in a conversational manner. Be kind and helpful."""

    response: str = Field(description="A conversational response to the user's query")

class FinalResponse(BaseModel):
    final_output: Union[Joke, ConversationalResponse]

structured_llm = llm.with_structured_output(FinalResponse)

res = structured_llm.invoke("Tell me a joke about cats")
print(res)
res = structured_llm.invoke("hello how are your?")
print(res)
final_output=Joke(setup="Why don't cats play poker in the jungle?", punchline='Too many cheetahs!', rating=7)
final_output=ConversationalResponse(response="Hello! I'm just a virtual assistant, so I don't have feelings, but I'm here and ready to help you with anything you need. How about you? How are you doing today?")

从结果中可以看到。当我们要求讲笑话时。模型返回 Joke 类型的数据给我们。而当我们正常交谈时它采用了 ConversationalResponse 类型返回。这个示例中我们使用的是 pydantic。你也可以使用 TypedDict

from typing import Optional, Union
from typing_extensions import Annotated, TypedDict

class Joke(TypedDict):
    """Joke to tell user."""

    setup: Annotated[str, ..., "The setup of the joke"]
    punchline: Annotated[str, ..., "The punchline of the joke"]
    rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]

class ConversationalResponse(TypedDict):
    """Respond in a conversational manner. Be kind and helpful."""

    response: Annotated[str, ..., "A conversational response to the user's query"]

class FinalResponse(TypedDict):
    final_output: Union[Joke, ConversationalResponse]

structured_llm = llm.with_structured_output(FinalResponse)

res = structured_llm.invoke("Tell me a joke about cats")
print(res)
res = structured_llm.invoke("hello how are your?")
print(res)
{'final_output': {'setup': "Why don't cats play poker in the wild?", 'punchline': 'Because there are too many cheetahs!', 'rating': 7}}
{'final_output': {'response': "Hello! I'm just a virtual assistant, so I don't have feelings, but I'm here and ready to help you. How about you? How are you doing today?"}}

这里的返回类型是 dict 。而上面返回的是 pydantic 对象类型。效果是一样的。

流式传输

from typing_extensions import Annotated, TypedDict

# TypedDict
class Joke(TypedDict):
    """Joke to tell user."""

    setup: Annotated[str, ..., "The setup of the joke"]
    punchline: Annotated[str, ..., "The punchline of the joke"]
    rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]

structured_llm = llm.with_structured_output(Joke)

for chunk in structured_llm.stream("Tell me a joke about cats"):
    print(chunk)
{}
{'setup': ''}
{'setup': 'Why'}
{'setup': 'Why don'}
{'setup': "Why don't"}
{'setup': "Why don't cats"}
{'setup': "Why don't cats play"}
{'setup': "Why don't cats play poker"}
{'setup': "Why don't cats play poker in"}
{'setup': "Why don't cats play poker in the"}
{'setup': "Why don't cats play poker in the wild"}
{'setup': "Why don't cats play poker in the wild?"}
{'setup': "Why don't cats play poker in the wild?", 'punchline': ''}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many che'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many cheet'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many cheetahs'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many cheetahs!'}
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many cheetahs!', 'rating': 7}

Few-shot prompting (少样本学习)

这种方式对于解决解决模型的幻想有很有帮助的。使用方式有两种,方式一在系统提示中添加示例:

from langchain_core.prompts import ChatPromptTemplate
from typing_extensions import Annotated, TypedDict
class Joke(TypedDict):
    """Joke to tell user."""

    setup: Annotated[str, ..., "The setup of the joke"]
    punchline: Annotated[str, ..., "The punchline of the joke"]
    rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]


system = """You are a hilarious comedian. Your specialty is knock-knock jokes. \
Return a joke which has the setup (the response to "Who's there?") and the final punchline (the response to "<setup> who?").

Here are some examples of jokes:

example_user: Tell me a joke about planes
example_assistant: {{"setup": "Why don't planes ever get tired?", "punchline": "Because they have rest wings!", "rating": 2}}

example_user: Tell me another joke about planes
example_assistant: {{"setup": "Cargo", "punchline": "Cargo 'vroom vroom', but planes go 'zoom zoom'!", "rating": 10}}

example_user: Now about caterpillars
example_assistant: {{"setup": "Caterpillar", "punchline": "Caterpillar really slow, but watch me turn into a butterfly and steal the show!", "rating": 5}}"""

prompt = ChatPromptTemplate.from_messages([("system", system), ("human", "{input}")])
structured_llm = llm.with_structured_output(Joke)
few_shot_structured_llm = prompt | structured_llm
res = few_shot_structured_llm.invoke("what's something funny about woodpeckers")
print(res)
{'setup': 'Woodpecker', 'punchline': 'Woodpecker who? Woodpecker stop knocking, I’m trying to sleep!', 'rating': 7}

当底层结构输出的方法为工具调用时,我们可以将示例作为明确的工具调用传入。你可以在你所使用的模型的 API 参考中查看是否使用了工具调用。

from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate

from typing_extensions import Annotated, TypedDict
class Joke(TypedDict):
    """Joke to tell user."""

    setup: Annotated[str, ..., "The setup of the joke"]
    punchline: Annotated[str, ..., "The punchline of the joke"]
    rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]

examples = [
    HumanMessage("Tell me a joke about planes", name="example_user"),
    AIMessage(
        "",
        name="example_assistant",
        tool_calls=[
            {
                "name": "joke",
                "args": {
                    "setup": "Why don't planes ever get tired?",
                    "punchline": "Because they have rest wings!",
                    "rating": 2,
                },
                "id": "1",
            }
        ],
    ),
    # Most tool-calling models expect a ToolMessage(s) to follow an AIMessage with tool calls.
    ToolMessage("", tool_call_id="1"),
    # Some models also expect an AIMessage to follow any ToolMessages,
    # so you may need to add an AIMessage here.
    HumanMessage("Tell me another joke about planes", name="example_user"),
    AIMessage(
        "",
        name="example_assistant",
        tool_calls=[
            {
                "name": "joke",
                "args": {
                    "setup": "Cargo",
                    "punchline": "Cargo 'vroom vroom', but planes go 'zoom zoom'!",
                    "rating": 10,
                },
                "id": "2",
            }
        ],
    ),
    ToolMessage("", tool_call_id="2"),
    HumanMessage("Now about caterpillars", name="example_user"),
    AIMessage(
        "",
        tool_calls=[
            {
                "name": "joke",
                "args": {
                    "setup": "Caterpillar",
                    "punchline": "Caterpillar really slow, but watch me turn into a butterfly and steal the show!",
                    "rating": 5,
                },
                "id": "3",
            }
        ],
    ),
    ToolMessage("", tool_call_id="3"),
]
system = """You are a hilarious comedian. Your specialty is knock-knock jokes. \
Return a joke which has the setup (the response to "Who's there?") \
and the final punchline (the response to "<setup> who?")."""

prompt = ChatPromptTemplate.from_messages(
    [("system", system), ("placeholder", "{examples}"), ("human", "{input}")]
)
# structured_llm = llm.with_structured_output(Joke)
few_shot_structured_llm = prompt | llm
res = few_shot_structured_llm.invoke({"input": "crocodiles", "examples": examples})
print(res)
content='<|tool▁calls▁begin|><|tool▁calls▁begin|><|tool▁calls▁begin|>```json\n{\n  "setup": "Crocodile",\n  "punchline": "Crocodile my heart if you think I\'m not the snappiest dresser in the swamp!",\n  "rating": 8\n}\n```' additional_kwargs={'refusal': None} response_metadata={'token_usage': {'completion_tokens': 56, 'prompt_tokens': 232, 'total_tokens': 288, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 192}, 'prompt_cache_hit_tokens': 192, 'prompt_cache_miss_tokens': 40}, 'model_name': 'deepseek-chat', 'system_fingerprint': 'fp_8802369eaa_prod0623_fp8_kvcache', 'id': '09a58411-8851-4042-9ee5-bcd4c59fd37f', 'service_tier': None, 'finish_reason': 'stop', 'logprobs': None} id='run--e808104b-e8f0-4adc-83a5-c4b79e6e8a4d-0' usage_metadata={'input_tokens': 232, 'output_tokens': 56, 'total_tokens': 288, 'input_token_details': {'cache_read': 192}, 'output_token_details': {}}

示例代码提供了3个示例对话:

  • 飞机笑话1 :“Why don’t planes ever get tired?” → “Because they have rest wings!”
  • 飞机笑话2 :“Cargo” → “Cargo ‘vroom vroom’, but planes go ‘zoom zoom’!”
  • 毛毛虫笑话 :关于变蝴蝶的笑话

system 指定AI扮演喜剧演员角色,专门讲敲门笑话。

输出:

{
  "setup": "Crocodile",
  "punchline": "Crocodile my homework, but I don't think the teacher will swallow that excuse!",
  "rating": 7
}

💡 Few-Shot Learning 的工作原理

  1. 模式学习 :AI通过3个示例学会了笑话的格式和风格
  2. 结构化输出 :自动按照 Joke 类型输出结构化数据
  3. 创意生成 :基于"鳄鱼"主题创造了新笑话
  4. 评分机制 :AI还学会了给笑话打分(这里给了7分)

指定结构化输出(高级)

对于支持多种输出结构方式(即同时支持工具调用和 JSON 模式)的模型,您可以通过 method= 参数指定使用哪种方法。

structured_llm = llm.with_structured_output(None, method="json_mode")

res = structured_llm.invoke(
    "Tell me a joke about cats, respond in JSON with `setup` and `punchline` keys"
)
print(res)
{'setup': "Why don't cats play poker in the wild?", 'punchline': 'Too many cheetahs!'}

原始输出(高级)

class Joke(BaseModel):
    "Joke to tell user."
    setup: Annotated[str, ...,"The setup of the joke"]
    punchline: Annotated[str, ...,"The punchline of the joke"]
    rating: Annotated[Optional[int], None,"How funny the joke is, from 1 to 10"]
structured_llm = llm.with_structured_output(Joke, include_raw=True)

res = structured_llm.invoke("Tell me a joke about cats")
print(res)
{'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_0_25e0c65d-090c-4804-9fb0-ac5058ba0a14', 'function': {'arguments': '{"setup":"Why don\'t cats play poker in the jungle?","punchline":"Too many cheetahs!","rating":8}', 'name': 'Joke'}, 'type': 'function', 'index': 0}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 41, 'prompt_tokens': 169, 'total_tokens': 210, 'completion_tokens_details': None, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 128}, 'prompt_cache_hit_tokens': 128, 'prompt_cache_miss_tokens': 41}, 'model_name': 'deepseek-chat', 'system_fingerprint': 'fp_8802369eaa_prod0623_fp8_kvcache', 'id': '3e86c747-7672-48cf-b5ed-11d0c77d4d14', 'service_tier': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run--38faf280-97a9-4c4b-ba7a-4f7862c45b36-0', tool_calls=[{'name': 'Joke', 'args': {'setup': "Why don't cats play poker in the jungle?", 'punchline': 'Too many cheetahs!', 'rating': 8}, 'id': 'call_0_25e0c65d-090c-4804-9fb0-ac5058ba0a14', 'type': 'tool_call'}], usage_metadata={'input_tokens': 169, 'output_tokens': 41, 'total_tokens': 210, 'input_token_details': {'cache_read': 128}, 'output_token_details': {}}), 'parsed': Joke(setup="Why don't cats play poker in the jungle?", punchline='Too many cheetahs!', rating=8), 'parsing_error': None}

直接提示和解析模型输出

对不不支持 .with_structured_output() 的模型。需要直接提示模型使用特定格式。并使用输出解析器从原始模型中提取结构化响应。

PydanticOutputParser

以下示例使用内置的 PydanticOutputParser 来解析被提示匹配给定 Pydantic 模式的聊天模型的输出。请注意,我们从解析器的方法中直接将 format_instructions 添加到提示中:

示例开始前让我们把 模型换成 deepseek-reasoner。这个不支持 with_structured_output 方法。

from typing_extensions import List

from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field

class Person(BaseModel):
    """Information about a person."""

    name: str = Field(..., description="The name of the person")
    height_in_meters: float = Field(
        ..., description="The height of the person expressed in meters."
    )

class People(BaseModel):
    """Identifying information about all people in a text."""

    people: List[Person]

# Set up a parser
parser = PydanticOutputParser(pydantic_object=People)

# Prompt
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "Answer the user query. Wrap the output in `json` tags\n{format_instructions}",
        ),
        ("human", "{query}"),
    ]
).partial(format_instructions=parser.get_format_instructions())

query = "Anna is 23 years old and she is 6 feet tall"

print(prompt.invoke({"query": query}).to_string())
System: Answer the user query. Wrap the output in `json` tags
The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}
the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.

Here is the output schema:
```
{"$defs": {"Person": {"description": "Information about a person.", "properties": {"name": {"description": "The name of the person", "title": "Name", "type": "string"}, "height_in_meters": {"description": "The height of the person expressed in meters.", "title": "Height In Meters", "type": "number"}}, "required": ["name", "height_in_meters"], "title": "Person", "type": "object"}}, "description": "Identifying information about all people in a text.", "properties": {"people": {"items": {"$ref": "#/$defs/Person"}, "title": "People", "type": "array"}}, "required": ["people"]}
```
Human: Anna is 23 years old and she is 6 feet tall

这部分的意思是我们告诉大模型返回的格式。什么是好的什么是不好的。现在让我们运行起来看看:

res = chain.invoke({"query": query})
print('++++++====================')
print(res)
people=[Person(name='Anna', height_in_meters=1.8288)]

结果如期返回了People类型工。并且 list 也是 Person类型。

自定义解析

使用 LangChain 表达式语言 (LCEL) 创建自定义提示和解析器,通过一个普通函数来解析模型输出的结果:

import json
import re
from typing import List

from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field

class Person(BaseModel):
    """Information about a person."""

    name: str = Field(..., description="The name of the person")
    height_in_meters: float = Field(
        ..., description="The height of the person expressed in meters."
    )

class People(BaseModel):
    """Identifying information about all people in a text."""

    people: List[Person]

# Prompt
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "Answer the user query. Output your answer as JSON that  "
            "matches the given schema: ```json\n{schema}\n```. "
            "Make sure to wrap the answer in ```json and ``` tags",
        ),
        ("human", "{query}"),
    ]
).partial(schema=People.schema())

# Custom parser
def extract_json(message: AIMessage) -> List[dict]:
    """Extracts JSON content from a string where JSON is embedded between ```json and ``` tags.

    Parameters:
        text (str): The text containing the JSON content.

    Returns:
        list: A list of extracted JSON strings.
    """
    text = message.content
    # Define the regular expression pattern to match JSON blocks
    pattern = r"\`\`\`json(.*?)\`\`\`"

    # Find all non-overlapping matches of the pattern in the string
    matches = re.findall(pattern, text, re.DOTALL)

    # Return the list of matched JSON strings, stripping any leading or trailing whitespace
    try:
        return [json.loads(match.strip()) for match in matches]
    except Exception:
        raise ValueError(f"Failed to parse: {message}")

query = "Anna is 23 years old and she is 6 feet tall"

print(prompt.format_prompt(query=query).to_string())
System: Answer the user query. Output your answer as JSON that  matches the given schema: ```json
{'$defs': {'Person': {'description': 'Information about a person.', 'properties': {'name': {'description': 'The name of the person', 'title': 'Name', 'type': 'string'}, 'height_in_meters': {'description': 'The height of the person expressed in meters.', 'title': 'Height In Meters', 'type': 'number'}}, 'required': ['name', 'height_in_meters'], 'title': 'Person', 'type': 'object'}}, 'description': 'Identifying information about all people in a text.', 'properties': {'people': {'items': {'$ref': '#/$defs/Person'}, 'title': 'People', 'type': 'array'}}, 'required': ['people'], 'title': 'People', 'type': 'object'}
```. Make sure to wrap the answer in ```json and ``` tags
Human: Anna is 23 years old and she is 6 feet tall

这个展示了提示器是如何设计的。以及规定了返回的的格式。调用:

chain = prompt | llm | extract_json

res = chain.invoke({"query": query})
print(res)
[{'people': [{'name': 'Anna', 'height_in_meters': 1.8288}]}]

Ok, 成功按我想所想的返回了JSON数据。这里需要注意extract_json方法使用的是正则提取的方式这也是 agent 经常使用的方式了。

总结

  • 对于支持 .with_structured_output的模型, 我们可以使用这个方法来返回结构数据,支持 Pydantic 、 TypedDict、Json Schema。

  • 对于不支持 .with_structured_output则使用 PydanticOutputParser 或者自定义解析来完成结构化数据的返回。

  • 要使用流式输出只需要调用 stream 方法即可。

  • Few-shot prompting 少样本提示,可以减少大模型幻想的问题。

  • 如果开发的设计的模式过于复杂时可以使用 include_raw=True来避免引发异常并自行处理原始输出。