Item 6: Handle Structured Output from LLMs Correctly¶
问题¶
你要求 Agent 返回结构化数据(JSON),但它返回的是:
# 你写的代码
result = await agent.run("返回一个用户的姓名和年龄: 李明 25岁")
print(result.text)
# 输出:
# ```json
# {"name": "李明", "age": 25}
# ```
然后你尝试解析:
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
# 错误:直接解析会失败
person = Person.model_validate_json(result.text)
# ValidationError: Invalid JSON...
LLM 喜欢用 Markdown 包裹 JSON,但你的解析代码假设是裸 JSON。
深入解释¶
LLM 生成文本时,Markdown 格式是"自然"输出——它训练时见过大量代码文档,自然倾向于用 triple-backticks 包裹代码块。这不是 bug,而是 LLM 的默认行为。
常见变形:
{"name": "李明"}变形 4:嵌套在其他内容中¶
Sure! Here is the information:
变形 5:尾部有说明¶
## 推荐做法
### 方法一:Instructions 约束(推荐)
在 Instructions 中明确要求"只输出 JSON,不要其他内容":
```python
agent = Agent(
client=client,
name="DataAgent",
instructions="""
你是一个数据返回助手。你的职责是根据用户请求返回精确的 JSON 数据。
重要规则:
1. 只输出纯 JSON,不要任何 Markdown 包裹
2. 不要输出解释、说明或其他文字
3. 确保 JSON 格式正确,可以被 json.loads() 解析
"""
)
方法二:代码层面处理 Markdown 包裹¶
import re
import json
from pydantic import BaseModel
def parse_json_response(text: str) -> dict | None:
"""
从 LLM 输出中提取 JSON。
处理多种包裹形式:```json ... ```、``` ... ```、裸 JSON
"""
# 尝试提取任何 Markdown 包裹的 JSON
pattern = r"```(?:json)?\s*([\s\S]*?)\s*```"
matches = re.findall(pattern, text.strip())
for match in matches:
try:
return json.loads(match.strip())
except json.JSONDecodeError:
continue
# 尝试直接解析裸 JSON
try:
return json.loads(text.strip())
except json.JSONDecodeError:
return None
# 使用
result = await agent.run("返回一个用户的姓名和年龄")
data = parse_json_response(result.text)
if data:
person = Person.model_validate(data)
print(f"{person.name}, {person.age}岁")
方法三:使用 response_format 参数(兼容性不稳定)¶
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
# 有些 Client 支持 response_format
result = await agent.run(
"返回一个用户的姓名和年龄",
options={"response_format": Person} # 可能不支持
)
if hasattr(result, 'value'):
person = result.value # 直接获得 Pydantic 对象
好 vs 坏对比¶
# 坏做法:假设 LLM 返回裸 JSON
result = await agent.run("返回用户信息")
person = Person.model_validate_json(result.text) # 几乎必定失败
# 好做法 1:Instructions 约束输出格式
agent = Agent(
client=client,
instructions="只输出纯 JSON,不要 Markdown 包裹,不要其他文字。"
)
result = await agent.run("返回用户信息")
person = Person.model_validate_json(result.text) # 大概率成功
# 好做法 2:代码层面容错
result = await agent.run("返回用户信息")
data = parse_json_response(result.text) # 处理各种变形
if data:
person = Person.model_validate(data)
扩展讨论¶
边缘情况处理¶
def robust_json_parse(text: str, model_cls: type[BaseModel] | None = None) -> BaseModel | dict | None:
"""更健壮的 JSON 解析"""
# 1. 提取所有可能的 JSON 块
patterns = [
r'```json\s*([\s\S]*?)\s*```', # ```json ...
r'```\s*([\s\S]*?)\s*```', # ``` ...
r'\{[\s\S]*\}', # 裸 JSON(最后兜底)
]
for pattern in patterns:
matches = re.findall(pattern, text)
for match in matches:
try:
data = json.loads(match.strip())
if model_cls:
return model_cls.model_validate(data)
return data
except (json.JSONDecodeError, Exception):
continue
return None
性能考虑¶
- 额外的正则解析开销极小(微秒级),不必担心
- 但 Instructions 约束输出格式更优雅,能减少 token 消耗(少了 Markdown 包裹)
结构化输出的企业级实践¶
# 1. 定义清晰的 Schema
class ApiResponse(BaseModel):
success: bool
data: dict | None = None
error: str | None = None
# 2. Instructions 中强调 Schema 约束
instructions = """
返回格式必须严格遵循:
{
"success": true/false,
"data": {...} | null,
"error": "错误信息" | null
}
"""
# 3. 验证失败时重试
def fetch_structured(agent, prompt, model_cls, max_retries=3):
for i in range(max_retries):
result = await agent.run(prompt)
try:
return model_cls.model_validate_json(result.text)
except Exception:
if i == max_retries - 1:
raise ValueError(f"Failed after {max_retries} retries")
return None
Things to Remember¶
- LLM 倾向于用 Markdown 包裹 JSON(
```json ... ```),直接解析会失败 - 首选方案:在 Instructions 中明确约束"只输出纯 JSON,不要 Markdown",从源头解决问题
- 备选方案:代码层面用正则
r"```(?:json)?\s*([\s\S]*?)\s*```"提取 response_format参数兼容性不稳定,生产环境不建议依赖- 结构化输出应该定义清晰的 Pydantic Schema,并通过 Instructions 强调格式约束
- 验证失败时应设计重试机制,而不是假设每次都成功
- 解析 JSON 前先
.strip()去除空白,能减少奇怪的解析失败