写了很多AI应用了------RAG、Agent、GraphRAG......有一个问题一直困扰我:AI的输出太不可控了。
比如我让Agent查订单状态,期望它返回一个JSON:
json
{"order_id": "ORD123", "status": "已发货", "eta": "2025-06-20"}
实际返回的是:
根据查询,您的订单ORD123目前已经发货啦!预计2025年6月20日送达,请耐心等待哦~
一段废话,代码根本解析不了。 后面还要把订单状态写进数据库、触发通知------全废了,因为拿不到结构化数据。
这不是个例。RAG的回答是自然语言,Agent的观察是自然语言,GraphRAG的查询结果是自然语言......整个AI应用的上下游全是自然语言,只有我写的代码需要结构化数据。
让LLM稳定输出结构化数据(JSON、表格等),是AI应用从"能用"到"生产可用"的关键一步。我踩了4个坑才搞明白这件事。
先说结论
| 维度 | 自然语言输出 | 结构化输出 |
|---|---|---|
| 可解析性 | ❌ 需要正则/后处理 | ✅ 直接json.loads() |
| 下游处理 | ❌ 写一堆if-else猜格式 | ✅ 字段确定,类型确定 |
| 可靠性 | ❌ 每次格式可能不同 | ✅ Schema约束,格式固定 |
| 调试难度 | 😫 看不懂AI在说什么 | 😎 JSON字段一目了然 |
用Java人的理解:自然语言输出 ≈ 返回Object,你不知道里面有什么,只能强转+判断;结构化输出 ≈ 返回OrderDTO,字段、类型全确定,直接用。
一句话:AI应用要上生产,结构化输出不是可选项,是必选项。
坑1:Prompt里写"请返回JSON",AI偏不配合
翻车现场
我一开始的方案很朴素------在Prompt里加一句"请以JSON格式输出":
ini
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="qwen-plus")
prompt = """请根据以下信息,返回订单状态的JSON。
订单信息:订单ORD123,已发货,顺丰SF123456,预计6月20日送达。
请以JSON格式输出,包含字段:order_id, status, tracking_number, eta。"""
result = llm.invoke(prompt)
print(result.content)
实际输出(10次运行,至少3种不同的"创意"格式):
json
// 第1次:多了个解释
这是您的订单状态:
{
"order_id": "ORD123",
"status": "已发货",
"tracking_number": "SF123456",
"eta": "2025-06-20"
}
// 第2次:字段名不一致
{
"orderId": "ORD123",
"orderStatus": "已发货",
"trackingNo": "SF123456",
"estimatedDelivery": "2025-06-20"
}
// 第3次:嵌套结构
{
"order": {
"id": "ORD123",
"current_status": "shipped"
},
"delivery": {
"tracking": "SF123456",
"eta": "2025-06-20"
}
}
// 第4次:直接返回Markdown
```json
{
"order_id": "ORD123",
...
}
python
**4次输出,4种格式。** `json.loads()`能成功1次就算运气好。
### 根本原因
**Prompt不是合同,AI没有义务遵守。** "请返回JSON"只是请求,不是约束。LLM是语言模型,它擅长生成自然语言,不擅长严格遵守格式规范。
### 正确做法:用LLM的结构化输出功能(不要自己解析)
```python
from pydantic import BaseModel
from langchain_openai import ChatOpenAI
# 1. 用Pydantic定义输出Schema
class OrderStatus(BaseModel):
order_id: str
status: str
tracking_number: str
eta: str
# 2. 用with_structured_output让LLM自动遵守
llm = ChatOpenAI(model="qwen-plus")
structured_llm = llm.with_structured_output(OrderStatus)
# 3. 调用------返回的直接就是Pydantic对象
result = structured_llm.invoke(
"订单ORD123,已发货,顺丰SF123456,预计6月20日送达。"
)
print(type(result)) # <class 'OrderStatus'>
print(result.order_id) # ORD123
print(result.status) # 已发货
print(result.eta) # 2025-06-20
不需要自己解析JSON,不需要写正则,LLM直接返回Pydantic对象。 with_structured_output会自动:
- 把你的Schema转换成LLM能理解的约束
- 让LLM输出符合Schema的数据
- 自动解析成Pydantic对象
支持结构化输出的模型:
| 模型 | 支持方式 | 可靠性 |
|---|---|---|
| 通义千问 qwen-plus | with_structured_output |
⭐⭐⭐⭐⭐ |
| OpenAI gpt-4o | with_structured_output |
⭐⭐⭐⭐⭐ |
| Ollama qwen2.5 | with_structured_output(部分支持) |
⭐⭐⭐ |
| Ollama llama3 | 不支持,需手动解析 | ⭐⭐ |
坑2:复杂嵌套结构,AI字段填错或漏填
翻车现场
需求升级:订单查询不只返回状态,还要返回商品列表、收货地址等嵌套结构:
python
from pydantic import BaseModel
from typing import Optional
class ProductItem(BaseModel):
name: str
quantity: int
price: float
class ShippingAddress(BaseModel):
province: str
city: str
detail: str
class OrderDetail(BaseModel):
order_id: str
status: str
products: list[ProductItem]
shipping_address: ShippingAddress
tracking_number: Optional[str] = None # 可选字段
测试时发现3个问题:
python
问题1:quantity返回了字符串"2"而不是整数2
问题2:price返回了"约200元"而不是数字200.0
问题3:tracking_number为空时返回了"暂无"而不是None
嵌套越深,类型出错概率越高。 AI分不清"字符串2"和"整数2",也分不清"暂无"和null。
正确做法:Schema里加字段描述 + Optional类型
ini
from pydantic import BaseModel, Field
from typing import Optional
class ProductItem(BaseModel):
name: str = Field(description="商品名称")
quantity: int = Field(description="购买数量,必须为整数,如:2")
price: float = Field(description="单价,必须为数字,如:199.9,不要带货币符号")
class ShippingAddress(BaseModel):
province: str = Field(description="省份,如:浙江省")
city: str = Field(description="城市,如:杭州市")
detail: str = Field(description="详细地址")
class OrderDetail(BaseModel):
order_id: str = Field(description="订单号,格式:ORD+数字")
status: str = Field(description="订单状态:待支付/已支付/已发货/已完成/已取消")
products: list[ProductItem] = Field(description="商品列表")
shipping_address: ShippingAddress = Field(description="收货地址")
tracking_number: Optional[str] = Field(
default=None,
description="物流单号,未发货时为null,不要填'暂无'等文字"
)
关键改进:
| 改进 | 作用 | 示例 |
|---|---|---|
Field(description=...) |
告诉AI每个字段应该填什么 | "必须为整数,如:2" |
Optional[str] |
明确标记可选字段 | tracking_number未发货时为None |
default=None |
给可选字段默认值 | 避免AI自己编一个值 |
| description里给示例 | AI照着示例填,出错率降50% | "格式:ORD+数字" |
用Java人的理解:Field(description=...) ≈ Swagger的@ApiModelProperty注释------不仅定义了类型,还说明了取值范围和格式。AI就是你的"自动填充器",描述越清楚填得越准。
坑3:AI返回了"合理"但"错误"的值------幻觉不是只发生在文本里
翻车现场
结构化输出解决了解析问题,但引入了一个新问题:AI会编造符合Schema但不符合事实的数据。
python
class OrderStatus(BaseModel):
order_id: str
status: str
tracking_number: Optional[str] = None
eta: Optional[str] = None
result = structured_llm.invoke(
"帮我查一下订单ORD999的状态" # ORD999根本不存在
)
print(result)
# OrderStatus(order_id='ORD999', status='已取消', tracking_number='SF888999', eta='2025-06-25')
ORD999这个订单根本不存在,但AI没有报错,反而编造了一整套"看起来合理"的数据!
这比自然语言幻觉更危险------因为JSON格式是对的,下游代码能正常解析,错误数据就悄无声息地写进了数据库。
解决方案:在Schema里加"不确定"选项 + 后端校验
ini
from enum import Enum
class QueryStatus(str, Enum):
found = "found" # 查到了
not_found = "not_found" # 没查到
error = "error" # 查询出错
class OrderQueryResult(BaseModel):
"""订单查询结果------包含查询状态,避免AI编造数据"""
query_status: QueryStatus = Field(
description="查询状态:found=查到订单, not_found=订单不存在, error=查询出错"
)
order_id: Optional[str] = Field(
default=None,
description="订单号,仅当query_status=found时填写"
)
status: Optional[str] = Field(
default=None,
description="订单状态,仅当query_status=found时填写"
)
message: str = Field(
description="查询结果说明,如:'订单不存在'、'查询成功'、'系统繁忙请稍后重试'"
)
# 测试:查不存在的订单
result = structured_llm.invoke("帮我查一下订单ORD999的状态")
# OrderQueryResult(query_status='not_found', order_id=None, status=None, message='订单ORD999不存在')
# 测试:查存在的订单
result = structured_llm.invoke("帮我查一下订单ORD123的状态")
# OrderQueryResult(query_status='found', order_id='ORD123', status='已发货', message='查询成功')
关键设计:
| 设计 | 作用 |
|---|---|
query_status枚举 |
强制AI先判断"查没查到",而不是直接编数据 |
| 可选字段+default=None | 没查到时字段为空,不会编造 |
message字段 |
AI可以用自然语言解释,但结构化数据不含幻觉 |
后端校验同样不能省:
python
def process_order_query(result: OrderQueryResult):
"""后端处理:结构化输出也要校验"""
if result.query_status == QueryStatus.found:
# 二次校验:真的去数据库查一下这个订单是否存在
db_order = db.get_order(result.order_id)
if not db_order:
return {"error": "AI返回了订单数据,但数据库中不存在该订单"}
# 校验AI返回的状态是否和数据库一致
if result.status != db_order.status:
return {"error": f"AI返回状态为{result.status},但数据库为{db_order.status}"}
return {"order": result}
elif result.query_status == QueryStatus.not_found:
return {"message": result.message}
else:
return {"error": result.message}
结构化输出的幻觉比自然语言幻觉更危险,因为它更隐蔽。 自然语言幻觉你一眼就能看出来不对劲,JSON幻觉格式正确、字段齐全、值看起来合理------你根本不会怀疑。
用Java人的理解:前端传过来的参数你一定会校验,对吧?AI的结构化输出也一样------永远不要信任AI返回的数据,即使格式是对的。 该做的数据库校验一个都不能少。
坑4:流式输出 + 结构化输出,鱼和熊掌怎么兼得
翻车现场
产品提了个需求: "AI回复的时候能不能像ChatGPT一样一个字一个字蹦出来?但数据还得是JSON格式的。"
问题来了:
- 流式输出:LLM一个token一个token返回,输出不完整时JSON无法解析
- 结构化输出:必须等LLM输出完整JSON才能解析
两个需求天然矛盾。
解决方案:分离"展示内容"和"结构化数据"
python
from pydantic import BaseModel, Field
from typing import Optional
class StreamResponse(BaseModel):
"""流式响应:展示内容+结构化数据分离"""
display_text: str = Field(
description="给用户看的自然语言回复,可以包含emoji和格式化"
)
data: Optional[dict] = Field(
default=None,
description="结构化数据,供程序处理。如果本次回复没有需要程序处理的数据,则为null"
)
# 使用:先流式输出展示内容,最后输出结构化数据
async def stream_chat(question: str):
"""流式聊天:自然语言实时流式,结构化数据最后返回"""
# 第一步:流式输出自然语言回复
stream = llm.astream(
f"用户问题:{question}\n请先给出自然的文字回复。"
)
display_text = ""
async for chunk in stream:
token = chunk.content
display_text += token
yield {"type": "text", "content": token} # 前端实时显示
# 第二步:流式结束后,输出结构化数据
structured_result = structured_llm.invoke(
f"基于刚才的回复,提取结构化数据。\n用户问题:{question}\n回复内容:{display_text}"
)
yield {"type": "data", "content": structured_result.model_dump()}
前端处理:
ini
// 前端代码示例
const eventSource = new EventSource('/api/chat/stream');
eventSource.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'text') {
// 实时显示文字
appendToChat(msg.content);
} else if (msg.type === 'data') {
// 结构化数据,触发业务逻辑
handleStructuredData(msg.content);
}
};
效果:
css
用户:查一下订单ORD123到哪了
AI(逐字显示):📦 您的订单ORD123已发货!
顺丰快递单号SF123456,预计6月20日送达。
[同时,后端收到结构化数据]
{
"display_text": "📦 您的订单ORD123已发货!...",
"data": {
"order_id": "ORD123",
"status": "已发货",
"tracking_number": "SF123456",
"eta": "2025-06-20"
}
}
展示用自然语言(流式体验好),处理用结构化数据(可靠可控)。 两者分离,各取所长。
| 方案 | 流式体验 | 数据可靠性 | 适用场景 |
|---|---|---|---|
| 纯自然语言流式 | ✅ | ❌ 难解析 | 纯聊天场景 |
| 纯结构化输出 | ❌ 等完整输出 | ✅ | 批处理/API场景 |
| 分离式 | ✅ | ✅ | 推荐:生产级AI应用 |
用Java人的理解:这和前后端分离一个道理------HTML给用户看,JSON给程序处理。AI输出也可以分离:自然语言给前端展示,结构化数据给后端处理。
完整代码:结构化输出的订单查询AI
python
"""
LLM结构化输出:订单查询AI
依赖:pip install langchain langchain-openai pydantic
"""
from enum import Enum
from pydantic import BaseModel, Field
from typing import Optional
from langchain_openai import ChatOpenAI
# ============ Schema定义 ============
class QueryStatus(str, Enum):
found = "found"
not_found = "not_found"
error = "error"
class ProductItem(BaseModel):
name: str = Field(description="商品名称")
quantity: int = Field(description="购买数量,必须为整数")
price: float = Field(description="单价,数字,不带货币符号")
class OrderDetail(BaseModel):
order_id: str = Field(description="订单号")
status: str = Field(description="订单状态:待支付/已支付/已发货/已完成/已取消")
products: list[ProductItem] = Field(description="商品列表")
tracking_number: Optional[str] = Field(default=None, description="物流单号,未发货为null")
eta: Optional[str] = Field(default=None, description="预计到达时间,未发货为null")
class OrderQueryResult(BaseModel):
"""带查询状态的结果------避免AI编造数据"""
query_status: QueryStatus = Field(
description="查询状态:found/not_found/error"
)
order: Optional[OrderDetail] = Field(
default=None,
description="订单详情,仅query_status=found时有值"
)
message: str = Field(description="查询结果说明")
# ============ 使用 ============
llm = ChatOpenAI(model="qwen-plus")
structured_llm = llm.with_structured_output(OrderQueryResult)
def query_order(question: str) -> dict:
"""查询订单,返回结构化结果"""
result = structured_llm.invoke(
f"用户问题:{question}\n\n"
f"已知数据:\n"
f"- 订单ORD123:已发货,含2件商品(iPhone 16 x1 单价6999、AirPods x1 单价1299),"
f"顺丰SF123456,预计6月20日送达\n"
f"- 订单ORD456:待支付,含1件商品(MacBook Pro x1 单价12999),未发货\n"
f"- 其他订单号均不存在"
)
# 后端校验
if result.query_status == QueryStatus.found and result.order:
# 实际项目:查数据库确认
print(f"✅ 查到订单 {result.order.order_id}")
print(f" 状态:{result.order.status}")
print(f" 商品:{[p.name for p in result.order.products]}")
print(f" 物流:{result.order.tracking_number}")
return result.order.model_dump()
elif result.query_status == QueryStatus.not_found:
print(f"❌ 订单不存在:{result.message}")
return {"error": result.message}
else:
print(f"⚠️ 查询出错:{result.message}")
return {"error": result.message}
# ============ 测试 ============
if __name__ == "__main__":
print("--- 测试1:查存在的订单 ---")
query_order("ORD123到哪了?")
print("\n--- 测试2:查不存在的订单 ---")
query_order("帮我查订单ORD999")
print("\n--- 测试3:查待支付的订单 ---")
query_order("ORD456发货了吗?")
4个坑的总结
| # | 坑 | 错误做法 | 正确做法 | 一句话 |
|---|---|---|---|---|
| 1 | Prompt写"返回JSON"不可靠 | 自己写Prompt+手动解析 | with_structured_output + Pydantic |
别自己解析,用LLM的结构化输出能力 |
| 2 | 嵌套结构字段填错 | Schema只写类型 | Field(description=...) + 示例值 |
description是给AI看的接口文档 |
| 3 | AI编造"合理"的错误数据 | 只看格式不校验内容 | 查询状态枚举 + 后端二次校验 | 结构化幻觉比自然语言幻觉更危险 |
| 4 | 流式输出和结构化输出矛盾 | 只选一个 | 分离展示内容和结构化数据 | 自然语言给前端,结构化给后端 |
Pydantic Schema设计的4条铁律
| 铁律 | 说明 | 反例 |
|---|---|---|
| 每个字段加description | 告诉AI填什么、格式是什么 | 只有类型没有描述 |
| 可选字段用Optional+default | AI不会编造不存在的值 | 必填但AI不知道填什么 |
| 用枚举限制取值范围 | status只能从几个选项中选 | status是自由文本,AI可能编造新状态 |
| 加查询状态字段 | 先判断"有没有"再填数据 | AI不管有没有都编一整套数据 |
这些铁律的本质:把"信任AI"变成"约束AI"。 Java开发天然理解这个道理------你不会信任前端传来的参数不加校验就写库,对AI的结构化输出也一样。
从自然语言到结构化的演进
| 阶段 | 做法 | 适用场景 |
|---|---|---|
| 阶段1:Prompt里写"请返回JSON" | 自己解析+正则 | Demo、快速验证 |
阶段2:with_structured_output |
Pydantic自动解析 | 大多数生产场景 |
| 阶段3:+查询状态+后端校验 | 防幻觉+防编造 | 高可靠性场景 |
| 阶段4:展示/数据分离 | 流式+结构化兼得 | 用户交互场景 |
我的建议:直接从阶段2开始。 with_structured_output零成本接入,不用自己解析JSON。阶段3按需加,阶段4看产品需求。
你让AI返回结构化数据踩过什么坑?评论区聊聊 👇