上一篇我们用 Harness Engineering 把 LLM 的行为范围框住,但还有一类问题没解决:输出格式。LLM 默认吐的是自由文本,而下游系统要的是 JSON、枚举、数组------格式不对,整条链路就断了。今天聊输出 Schema 约束,让 LLM 的输出像 API 返回值一样可预测。
一、为什么需要结构化输出?
先看一个没有约束的 Agent 输出:
markdown
用户:帮我查一下苏州工业园区的租房补贴政策
Agent:好的!苏州工业园区的租房补贴政策如下:
1. 补贴标准:本科500元/月,硕士800元/月,博士1200元/月
2. 申请条件:需要在本区就业并缴纳社保满3个月...
3. 申请方式:通过苏州人才服务中心官网在线申请
看起来没问题?但对下游系统来说,这是一坨文本,不是数据。前端没法渲染成卡片,数据库没法存入字段。
我们真正想要的是:
json
{
"subsidy": {
"bachelor": 500,
"master": 800,
"doctor": 1200
},
"requirements": ["本区就业", "社保满3个月"],
"applyUrl": "https://..."
}
结构化输出的核心价值:让 LLM 的输出从"文本"变成"数据",下游可直接消费。
二、结构化输出的三种策略
javascript
结构化输出策略
│
├── 1. Prompt 约束(最弱)
│ ├── 在 Prompt 中要求返回 JSON
│ ├── 给出 JSON 示例
│ └── 问题:LLM 可能不听话,格式不稳定
│
├── 2. 函数调用 / Tool Use(中等)
│ ├── 利用 LLM 的 function calling 能力
│ ├── 输出自然就是结构化的
│ └── 问题:不是所有场景都适合伪装成"工具调用"
│
└── 3. 输出 Schema 约束(最强)
├── 强制 LLM 按 Schema 输出
├── 支持 JSON Schema、Pydantic、Zod 等
└── 原理:引导采样过程 + 输出校验 + 自动重试
| 策略 | 约束强度 | 兼容性 | 实现复杂度 | 典型框架 |
|---|---|---|---|---|
| Prompt 约束 | 弱 | 所有模型 | 低 | 无需框架 |
| 函数调用 | 中 | 需模型支持 | 中 | OpenAI / Spring AI |
| Schema 约束 | 强 | 需模型或框架支持 | 高 | Instructor / Spring AI Structured |
三、Prompt 约束:最基础的方式
python
# 在 Prompt 中要求返回 JSON
prompt = """请分析以下文本的情感倾向,以 JSON 格式返回。
要求:
- sentiment: 正面/负面/中性
- confidence: 0-1 之间的浮点数
- keywords: 关键词数组
示例输出:
{"sentiment": "正面", "confidence": 0.85, "keywords": ["好", "优秀"]}
待分析文本:{text}"""
问题在哪?
swift
可能出现的"意外输出"
│
├── 1. 输出前后多了解释文字
│ "好的,分析结果如下:\n{\"sentiment\": ...}\n希望对你有帮助!"
│
├── 2. JSON 格式不规范
│ {"sentiment": "正面", "confidence": 0.85, "keywords": ["好", "优秀"],} ← 末尾多了逗号
│
├── 3. 字段名不一致
│ {"emotion": "正面", "score": 0.85} ← 你要的是 sentiment,它给了 emotion
│
└── 4. 嵌套结构错误
{"result": {"sentiment": ...}} ← 多包了一层
Prompt 约束的本质是"请求"而非"要求"。LLM 大部分时候会配合,但你无法保证 100%。
四、函数调用:借力 Tool Use
利用 LLM 原生的 function calling 能力,让输出天生就是结构化的:
java
// Spring AI 方式
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
// 定义函数描述
@Description("分析文本情感,返回结构化结果")
public record SentimentAnalysis(
@Description("情感倾向:正面、负面、中性") String sentiment,
@Description("置信度 0-1") Double confidence,
@Description("关键词列表") List<String> keywords
) {}
// 调用
var response = chatClient.prompt()
.user("分析这段文本的情感:今天天气真好!")
.functions("sentimentAnalysis")
.call()
.entity(SentimentAnalysis.class);
函数调用的局限:
sql
函数调用的局限
│
├── 1. 语义不匹配
│ "帮我分析情感"不是"调用工具",而是"返回结果"
│ 强行用 tool call 表达,语义上有点别扭
│
├── 2. 不是所有模型都支持
│ 开源模型(如 Qwen、DeepSeek)对 function calling 支持参差不齐
│
├── 3. 只能返回单层结构
│ 复杂嵌套 Schema 用 function call 描述起来很别扭
│
└── 4. 一次只能调一个函数
多个输出字段需要拆成多个函数,不合理
五、Schema 约束:终极方案
5.1 核心原理
javascript
Schema 约束的底层机制
│
├── 1. Schema 转化为 Prompt 引导
│ ├── JSON Schema → 自然语言描述注入 System Prompt
│ ├── 告诉 LLM "必须按以下格式输出"
│ └── 相当于 Prompt 约束的工程化版本
│
├── 2. 约束解码(Constrained Decoding)
│ ├── 某些框架/模型支持在 token 采样时强制合法
│ ├── 例如:当 Schema 要求整数,采样时只允许数字 token
│ ├── 效果:100% 格式合规(如果模型支持)
│ └── 实现:llama.cpp 的 GBNF 语法、Outlines 的 FSM
│
└── 3. 输出校验 + 自动重试
├── 解析 LLM 输出,校验是否符合 Schema
├── 不符合则将错误信息拼回 Prompt 重试
└── 最多重试 N 次,兜底返回错误
5.2 JSON Schema 示例
json
{
"type": "object",
"properties": {
"subsidy": {
"type": "object",
"properties": {
"bachelor": { "type": "integer", "description": "本科补贴(元/月)" },
"master": { "type": "integer", "description": "硕士补贴(元/月)" },
"doctor": { "type": "integer", "description": "博士补贴(元/月)" }
},
"required": ["bachelor", "master", "doctor"]
},
"requirements": {
"type": "array",
"items": { "type": "string" },
"description": "申请条件列表"
},
"applyUrl": {
"type": "string",
"format": "uri",
"description": "申请链接"
}
},
"required": ["subsidy", "requirements", "applyUrl"]
}
5.3 Spring AI 结构化输出
Spring AI 从 1.0.0 开始支持结构化输出,底层是 JSON Schema + 自动映射:
java
// 定义输出结构
public record SubsidyInfo(
@JsonProperty(required = true)
@Description("各学历补贴标准")
SubsidyDetail subsidy,
@Description("申请条件列表")
List<String> requirements,
@Description("申请链接")
String applyUrl
) {}
public record SubsidyDetail(
int bachelor,
int master,
int doctor
) {}
// 使用
var result = chatClient.prompt()
.user("查询苏州工业园区租房补贴政策")
.call()
.entity(SubsidyInfo.class); // ← 一行搞定
Spring AI 的 .entity() 做了什么?
arduino
.entity() 内部流程
│
├── 1. 将 SubsidyInfo.class 转换为 JSON Schema
│
├── 2. 将 Schema 注入 Prompt(作为格式要求)
│
├── 3. 调用 LLM,获取文本输出
│
├── 4. 尝试将输出解析为 SubsidyInfo 对象
│ ├── 解析成功 → 返回
│ └── 解析失败 → 拼入错误信息,重试
│
└── 5. 重试 N 次后仍失败 → 抛出异常
六、约束解码:从"引导"到"强制"
Prompt 引导 + 重试只是"软约束"。真正的硬约束是约束解码(Constrained Decoding)。
6.1 原理
erlang
常规解码(自由采样)
│
├── 每步从词表中采样一个 token
├── 任何 token 都可能被选中
└── 输出可能是任意文本
约束解码(Constrained Decoding)
│
├── 根据 Schema 构建一个有限状态机(FSM)或文法(Grammar)
├── 每步只允许生成 FSM 当前状态合法的 token
├── 例如:Schema 要求整数 → 只允许 0-9 token
├── 例如:Schema 要求布尔 → 只允许 true/false token
└── 输出 100% 符合 Schema(在模型支持的前提下)
6.2 实现方案对比
| 方案 | 语言 | 原理 | 支持模型 | 接入方式 |
|---|---|---|---|---|
| Outlines | Python | FSM 约束解码 | 本地模型 | Python 库 |
| llama.cpp GBNF | C++ | 文法约束 | GGUF 模型 | API 参数 |
| Instructor | Python | Schema + 重试 | OpenAI 兼容 API | Python 库 |
| Spring AI | Java | Schema 注入 + 重试 | 任何模型 | Java 框架 |
| OpenAI Structured Outputs | API | 约束解码 | GPT-4o 等 | API 参数 |
6.3 OpenAI Structed Outputs 示例
python
from openai import OpenAI
from pydantic import BaseModel
class SubsidyDetail(BaseModel):
bachelor: int
master: int
doctor: int
class SubsidyInfo(BaseModel):
subsidy: SubsidyDetail
requirements: list[str]
apply_url: str
client = OpenAI()
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": "查询苏州工业园区租房补贴政策"}],
response_format=SubsidyInfo, # ← 直接传 Pydantic 类
)
# 输出 100% 符合 SubsidyInfo 结构
七、实战:多层 Schema 设计
真实 Agent 的输出往往不是扁平的 JSON,而是多层嵌套结构。
7.1 一个商机分析 Agent 的输出 Schema
java
public record OpportunityAnalysis(
@Description("商机基本信息")
BasicInfo basicInfo,
@Description("风险评估")
RiskAssessment risk,
@Description("推荐动作")
List<Action> actions,
@Description("综合评分 0-100")
int score
) {}
public record BasicInfo(
@Description("客户行业")
String industry,
@Description("预计预算(万元)")
double budget,
@Description("决策周期(天)")
int decisionCycle
) {}
public record RiskAssessment(
@Description("风险等级:低/中/高")
String level,
@Description("风险因素列表")
List<String> factors,
@Description("风险说明")
String description
) {}
public record Action(
@Description("动作类型:联系/跟进/放弃/升级")
String type,
@Description("动作描述")
String description,
@Description("优先级 1-5")
int priority
) {}
7.2 枚举约束
java
// 使用枚举限制取值范围
public enum RiskLevel { LOW, MEDIUM, HIGH }
public enum ActionType { CONTACT, FOLLOW_UP, ABANDON, ESCALATE }
public record RiskAssessment(
RiskLevel level, // ← 只能是四个值之一
List<String> factors,
String description
) {}
public record Action(
ActionType type, // ← 只能是四个值之一
String description,
@Min(1) @Max(5) int priority // ← 限制范围
) {}
枚举约束的效果:
ini
没有枚举约束
├── risk.level = "比较危险" ← 不在预期范围内
├── action.type = "打电话" ← 下游系统无法识别
└── score = 150 ← 超出 0-100 范围
有枚举约束
├── risk.level = "MEDIUM" ← 严格限定
├── action.type = "CONTACT" ← 严格限定
└── score = 75 ← 范围限定
八、Schema 约束的代价
结构化输出不是免费的,有三个关键代价:
8.1 推理质量下降
json
自由输出 vs 结构化输出
│
├── 自由输出
│ "经过分析,这个商机值得跟进,但预算可能需要调整,
│ 建议先与客户技术负责人建立联系..."
│ → 表达丰富,推理自然
│
└── 结构化输出
│ {"score": 75, "risk": {"level": "MEDIUM", ...}}
│ → 表达受限,推理被"压缩"进字段
│ → 某些微妙判断可能丢失
缓解策略 :在 Schema 中加 reasoning 或 explanation 字段,让 LLM 先推理再输出结构。
java
public record OpportunityAnalysis(
@Description("推理过程:先思考,再给结论")
String reasoning, // ← 给 LLM 一个"思考空间"
BasicInfo basicInfo,
RiskAssessment risk,
List<Action> actions,
int score
) {}
8.2 Token 消耗增加
javascript
自由输出:~200 tokens
结构化输出:~500 tokens(JSON key + 语法 + 枚举值)
增加约 2-3 倍 token 消耗
8.3 延迟增加
自由输出:1-2 秒
结构化输出:
├── 首次调用 + 解析:2-3 秒
├── 重试 1 次:+2-3 秒
└── 重试 2 次:+2-3 秒
最差情况:7-9 秒
代价总结:
| 代价 | 幅度 | 缓解方式 |
|---|---|---|
| 推理质量 | 下降 5-15% | 加 reasoning 字段 |
| Token 消耗 | 增加 2-3 倍 | 精简 Schema,少用嵌套 |
| 延迟 | 增加 1-3 秒 | 限制重试次数,选快速模型 |
九、Schema 约束 vs Harness 约束:互补而非替代
上一篇讲的 Harness Engineering 和这篇的 Schema 约束,两者互补:
arduino
约束体系
│
├── Harness 约束(行为层面)
│ ├── 限制可用工具
│ ├── 限制执行流程
│ ├── 限制输入范围
│ └── 解决:"Agent 能做什么、不能做什么"
│
└── Schema 约束(输出层面)
├── 限制输出格式
├── 限制字段取值
├── 限制嵌套结构
└── 解决:"Agent 输出什么、怎么输出"
实战中的组合:
java
// Harness:限制工具和流程
var agent = ChatAgent.builder()
.tools(List.of(searchTool, calculatorTool)) // 只允许两个工具
.maxSteps(5) // 最多 5 步
.build();
// Schema:限制输出格式
var result = agent.run("分析这个商机")
.entity(OpportunityAnalysis.class); // 输出必须是这个结构
javascript
完整约束链
│
├── 输入约束:Harness 限制可接收的用户输入范围
│
├── 工具约束:Harness 限制可用工具集
│
├── 流程约束:Harness 限制执行路径和步骤数
│
└── 输出约束:Schema 限制输出格式和字段取值
├── 格式约束:必须是 JSON
├── 字段约束:字段名、类型、必填
├── 取值约束:枚举、范围、正则
└── 嵌套约束:对象引用、递归结构
十、框架实战对比
10.1 Instructor(Python)
python
import instructor
from openai import OpenAI
from pydantic import BaseModel
client = instructor.from_openai(OpenAI())
class Analysis(BaseModel):
score: int
reason: str
result = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "分析这段文本"}],
response_model=Analysis, # ← 核心参数
max_retries=3, # ← 自动重试次数
)
特点:Pydantic 定义 + 自动重试 + 流式支持,Python 生态最成熟的方案。
10.2 Spring AI(Java)
java
var result = chatClient.prompt()
.user("分析这段文本")
.call()
.entity(Analysis.class); // ← 一行搞定
特点:Java 生态最简洁的方案,但底层是 Prompt 注入 + 重试,非约束解码。
10.3 LangChain(Python/JS)
python
from langchain_core.output_parsers import PydanticOutputParser
parser = PydanticOutputParser(pydantic_object=Analysis)
prompt = ChatPromptTemplate.from_messages([
("system", "按以下格式输出:\n{format_instructions}"),
("human", "{input}")
])
chain = prompt | model | parser
result = chain.invoke({"input": "分析这段文本",
"format_instructions": parser.get_format_instructions()})
特点:Parser 模式,灵活但啰嗦,适合已有 LangChain 链的项目。
十一、选择建议
javascript
如何选择结构化输出方案?
│
├── 场景一:只需要简单字段,容忍偶尔格式错误
│ └── Prompt 约束就够了
│
├── 场景二:需要稳定的 JSON 输出,Python 生态
│ └── Instructor(首选)或 LangChain + Pydantic
│
├── 场景三:需要稳定的 JSON 输出,Java 生态
│ └── Spring AI .entity()
│
├── 场景四:需要 100% 格式保证,使用 OpenAI API
│ └── OpenAI Structured Outputs
│
└── 场景五:需要 100% 格式保证,使用本地模型
└── Outlines 或 llama.cpp GBNF
十二、最佳实践 Checklist
| # | 实践 | 说明 |
|---|---|---|
| 1 | 优先用 Schema 约束而非 Prompt 约束 | 工程化 > 提示词技巧 |
| 2 | 给 Schema 的每个字段加 Description | LLM 看到 description 才知道该填什么 |
| 3 | 枚举字段用 enum 而非 string | 避免 LLM 自由发挥 |
| 4 | 加 reasoning 字段保留推理质量 | 让 LLM 先思考再结构化输出 |
| 5 | 限制重试次数(建议 2-3 次) | 避免无限重试浪费 token |
| 6 | 监控 Schema 校验失败率 | 失败率高说明 Schema 设计有问题 |
| 7 | 嵌套不超过 3 层 | 太深 LLM 容易出错 |
| 8 | Schema 和 Harness 配合使用 | 输入约束 + 输出约束 = 完整约束 |
| 9 | 用 @Min/@Max 限制数值范围 | 防止 LLM 输出离谱的数值 |
| 10 | 输出校验失败时降级为文本 | 给用户兜底体验 |
下一篇我们聊 Grill Me 反问式规划:Agent 不是应该什么都听用户的,而是要学会"反问"------在动手之前先搞清楚需求边界、风险点和遗漏信息。这是一套让 Agent 从"执行者"变成"协作者"的关键模式。
本文是 AI Agent 开发实战系列第 8 篇,系列目录: