AI Agent 开发实战(八):输出 Schema 约束与结构化输出

上一篇我们用 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 中加 reasoningexplanation 字段,让 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 篇,系列目录:

  1. AI Agent 核心概念与架构
  2. 三大基石之 LLM 调用与 Prompt 工程
  3. 三大基石之记忆系统
  4. 三大基石之工具调用
  5. Java 生态 Agent 框架横评
  6. 用 Spring AI 搭建第一个 Agent
  7. Harness Engineering 与约束管理
  8. 本文:输出 Schema 约束与结构化输出
相关推荐
Python私教2 小时前
Codex 写出的代码能跑却算错钱:我用 3 个测试拆穿一次 AI 编程幻觉
python·单元测试·ai编程
Python私教2 小时前
我只写了一个 add 工具,终于把 MCP 的 Host、Client、Server 跑明白了
python·ai编程·mcp
码哥字节2 小时前
同样的RAG系统,分块策略不同,命中率能差20%
langchain
制造数据与AI践行者老蒋4 小时前
智联工坊实战:从“金鱼记忆”到“记住了”:给制造Agent装上记忆芯片的完整指南
人工智能·python·langchain·制造
小白的后端世界5 小时前
LangChain 模型创建与调用实战:从 init_chat_model 到企业级多模型接入
人工智能·python·langchain
名不经传的养虾人5 小时前
从0到1:企业级AI项目迭代日记 Vol.77|隔离不只是数据,还有进程、上下文和依赖
大数据·人工智能·ai编程·企业ai·多agent协作
9i编程6 小时前
AI BI Helper 开发实录 02:Graph 工作流编排——SQL 生成、执行与邮件推送
人工智能·openai·ai编程
一只小bit6 小时前
Agent 动态调控:模型、工具、提示词、输出、流模式
机器学习·langchain·llm·人机交互·langgraph
leeyi6 小时前
切片策略选错了,检索效果天差地别(第68篇-E54)
aigc·agent·ai编程