智能体5-结构化输出

前言

前面我们学了思维链(让模型学会思考)和 Few-shot/Zero-shot(让模型理解任务)。但还有一个关键问题没解决:模型思考完之后,输出的东西怎么让程序稳定地解析? 如果模型的回答是一段自由文本,程序很难从中精准提取"意图是什么""参数有哪些"。这就是结构化输出要解决的核心问题。

什么是结构化输出?

结构化输出 是指:让 LLM 按照预定义的格式(如 JSON、XML、YAML 或自定义语法)生成内容,并且确保输出严格符合该格式。

关键点:

  • 格式预定义:开发者事先声明"我要什么结构"。
  • 强制符合:不是"建议",而是"必须"。
  • 程序可解析:输出可以直接被 json.loads() 或 XML 解析器处理,无需人工清洗。

为什么需要结构化输出?

大语言模型(LLM)本质是概率性的文本生成器。你问它一个问题,它返回的是自然语言。

这在聊天场景没问题。但在工程化应用中,我们需要把 LLM 集成到自动化系统里,例如:

  • 从发票中提取"金额、日期、供应商"并存入数据库
  • 让 LLM 决定调用哪个 API,并生成参数
  • 多个 Agent 之间交换信息

这时候自然语言就不行了:它不稳定、难解析、字段可能缺失、格式随机。

核心痛点:LLM 输出不可控,程序无法可靠消费。

非结构化输出的解析成本与风险

如果依赖非结构化输出(自由文本),你需要面对三个成本:

成本一:解析代码的复杂度爆炸

你需要写大量正则、规则来覆盖模型可能的各种表达方式。模型今天说"调用 create_inspection_task",明天说"我需要执行 create_inspection_task 功能",后天说"让我来帮你创建巡检任务"。每种变体都需要规则去匹配,规则数量很快失控。

成本二:解析失败的级联影响

一次解析失败,整个后续链路都断掉。行动组件拿不到工具名,工具调不出去;工具调不出去,任务卡死;任务卡死,用户等不到回复。一个格式问题能导致全链路崩溃。

成本三:非结构化输出的幻觉更难检测

模型在自由文本中可能混入看似合理但错误的信息。结构化输出中,如果模型输出 {"intent": "delete_all_tasks"},程序可以直接校验这个意图是否在允许列表中。自由文本里,同样的危险意图可能藏在礼貌用语中,很难被规则匹配到。

结论:在生产级智能体系统中,推理组件与行动组件之间的接口必须是结构化的。这是系统各模块能够可靠协作的前提。

格式约束的层级

不是所有场景都需要最严格的 JSON。结构化程度越高,对模型的要求越高,实现成本也越高。根据需求选择合适的约束层级,是工程上的重要权衡。

自由文本(无约束)

形态:模型输出纯自然语言,没有任何格式限制。

示例:

复制代码
我建议您创建巡检任务,时间是明天上午。

适用场景:

  • 聊天机器人的闲聊回复
  • 对用户直接展示的文本(不需要程序解析)
  • 创意写作、开放式问答

优点:模型最自然,生成质量最高,Token 消耗最少。

缺点:程序无法可靠解析,完全不适合机器消费。

标记分隔格式

形态:用特定的标记(如 XML 标签、Markdown 标题、自定义分隔符)把不同语义的部分分开。

示例(XML 标签):

xml 复制代码
<reasoning>用户想创建巡检任务,时间是明天上午。</reasoning>
<action>create_inspection_task</action>
<params>
  <time>2026-06-08T09:00</time>
</params>

示例(Markdown 标题):

shell 复制代码
### 推理
用户想要创建一个明天上午的巡检任务。

### 动作
create_inspection_task

### 参数
- time: 2026-06-08T09:00

适用场景:

  • 需要同时展示给人类和程序消费的内容
  • 输出中既有自由文本也有结构化数据
  • 模型对纯 JSON 输出不够稳定的过渡方案

优点:在自然语言和结构化之间取得平衡。人类可读,程序可以用标签作为锚点解析。

缺点:仍需写解析逻辑,标签可能出现嵌套错误或遗漏,格式稳定性不如纯 JSON。

严格结构化格式

形态:模型只输出一个合法的 JSON 或 YAML 对象,不包含任何额外文字。

json 复制代码
{
  "intent": "create_inspection_task",
  "confidence": 0.95,
  "slots": {
    "time": "2026-06-08T09:00",
    "task_type": null
  }
}

适用场景:

  • 工具调用(Function Calling)
  • 意图识别结果
  • 槽位提取结果
  • 任何需要被下游程序直接消费的接口

优点:解析极简单,json.loads 一行代码完成。格式可被 JSON Schema 验证,非法输出能被程序立即检测。 缺点:对模型能力要求高,弱模型可能输出非法 JSON(少引号、多逗号、嵌套错误)。约束越强,模型生成质量可能略有下降(模型在"分心"处理格式)。

各层级的适用场景与选择
场景 推荐格式 理由
对用户展示的最终回复 自由文本 人类消费,不需要解析
需要同时展示和解析 标记分隔格式 兼顾可读性和可解析性
工具调用、意图识别 严格 JSON 程序消费,必须稳定解析
弱模型、快速原型 标记分隔格式 比 JSON 容易让模型遵循,解析难度尚可
强模型、生产环境 严格 JSON 解析零风险,配合受限解码几乎不出错

常见的结构化输出格式

实现结构化输出的技术演进

方式一:提示词约束(最浅,最不稳定)

思路:在提示词中用自然语言描述期望的输出格式,不提供示例。

diff 复制代码
请以 JSON 格式输出,必须包含以下字段:
- intent(字符串):用户意图
- confidence(浮点数):0-1 的置信度
- slots(对象):提取的参数

只输出 JSON,不要包含其他任何文字。

效果:依赖模型的指令遵循能力。强模型能做到 80-90% 的格式正确率,弱模型可能只有 50-60%。

优点:零成本,不占用额外 Token。

缺点:格式正确率不稳定,尤其是字段多、嵌套深的时候。

方式二:JSON 模式(JSON Mode,中等可靠性)

思路:在提示词中提供 2-3 个完整示例,每个示例严格遵循期望的 JSON 格式。

css 复制代码
示例1:
用户: 帮我建一个明天上午的巡检任务
输出: {"intent": "create_inspection_task", "slots": {"time": "2026-06-08T09:00"}}

示例2:
用户: 查看6月7号的巡检任务数量
输出: {"intent": "query_inspection_task_count", "slots": {"date": "2026-06-07"}}

现在请处理以下用户输入,只输出JSON:
用户: {user_input}
输出:

效果:格式正确率可以提升到 90-98%(取决于模型能力)。

优点:比纯描述效果好很多,是当前最常用的方案。

缺点:占用额外 Token;如果示例的 JSON 结构和实际期望有细微出入,模型可能模仿错误。

方式三:API 级别的 response_format 参数

思路:直接使用 LLM API 提供的结构化输出功能。

lua 复制代码
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "intent_result",
            "schema": {
                "type": "object",
                "properties": {
                    "intent": {"type": "string"},
                    "confidence": {"type": "number"},
                    "slots": {"type": "object"}
                },
                "required": ["intent", "confidence", "slots"]
            }
        }
    }
)

效果:格式正确率接近 100%,由 API 提供方在服务端保证。

优点:最简单,不需要额外库,不需要手动设置受限解码。

缺点:仅部分 API 支持(GPT-4o、GPT-4-turbo 等较新模型);Schema 复杂度有限制;不跨模型通用。

方式四:约束解码(Constraint Decoding,最深,最可靠)

思路:在模型生成每个 token 时,通过修改输出概率分布,强制将不符合格式要求的 token 概率置为零,确保每一步生成的 token 都在合法范围内。

原理:

  • 定义输出格式的 Schema(如 JSON Schema)。
  • 在模型生成时,每一步都根据 Schema 计算出当前允许生成的 token 列表。
  • 将非法 token 的概率置零,模型只能从合法 token 中选择。

效果:格式正确率接近 100%。只要 Schema 定义对了,模型不可能输出非法 JSON。

实现方式:

  • guidance 库(微软):用模板语法定义输出结构,支持 JSON、自定义语法。
  • outlines 库:基于正则表达式或 JSON Schema 做受限生成。
  • llama.cpp 的 grammar 功能:通过 GBNF 语法约束输出。

技术原理深挖(约束解码)

传统 LLM 生成(采样):

复制代码
输入 → 模型 → 计算所有词元概率 → 采样一个词元 → 追加到输入 → 循环

约束解码

复制代码
输入 → 模型 → 计算所有词元概率 → 用 FSM 过滤非法词元 → 从合法词元中采样 → 追加 → 更新 FSM 状态 → 循环

格式解析的鲁棒性工程

无论用哪种手段,都不可能保证 100% 的格式正确率。生产系统必须有一层健壮的兜底解析,确保偶尔的格式错误不会导致全链路崩溃。

常见解析失败模式
模式一:JSON 外有额外文字
json 复制代码
好的,我已经理解了您的需求。
{"intent": "create_inspection_task", "slots": {...}}
请问还有其他需要吗?

模型在 JSON 前后加了礼貌用语。直接 json.loads 会报错。

模式二:JSON 内部语法错误
css 复制代码
{"intent": "create_inspection_task", "slots": {"time": "明天上午", }}

多了一个尾逗号。或者少了一个引号、用了中文引号、嵌套层次错误。

模式三:字段缺失或名称不对
css 复制代码
{"action": "create_inspection_task", "params": {"time": "..."}}

字段名写成了 action 而不是 intentparams 而不是 slots。虽然语义正确,但 Schema 校验不通过。

模式四:值类型不对
json 复制代码
{"intent": "create_inspection_task", "confidence": "0.95"}

confidence 应该是数字,模型输出成了字符串。

正则兜底与自动修复
策略一:JSON 提取

先不管模型在 JSON 外面加了什么,用正则把 JSON 部分提取出来:

python 复制代码
import re
import json

def extract_json(text):
    # 尝试匹配最外层大括号
    match = re.search(r'{.*}', text, re.DOTALL)
    if match:
        return match.group(0)
    return text

这解决了"JSON 外有额外文字"的问题。

策略二:自动修复

提取出 JSON 字符串后,尝试修复常见语法错误:

python 复制代码
import json

def safe_json_parse(text, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return json.loads(text)
        except json.JSONDecodeError as e:
            text = attempt_fix(text, e)
    return None

def attempt_fix(text, error):
    # 尝试修复:去掉尾逗号
    text = re.sub(r',\s*}', '}', text)
    text = re.sub(r',\s*]', ']', text)
    # 尝试修复:中文引号转英文
    text = text.replace('\u201c', '"').replace('\u201d', '"')
    # 尝试修复:单引号转双引号(需谨慎,可能在字符串内部)
    # ...
    return text
策略三:LLM 二次解析

如果规则修复都失败了,把原始输出发回给 LLM,让它重新输出正确格式:

javascript 复制代码
以下内容无法被JSON解析,请将其中的关键信息提取为合法的JSON:
{原始输出}

只输出JSON:

这是兜底中的兜底。增加一次额外的 LLM 调用,成本和延迟都上升,但能救回大部分格式错误。

解析失败后的重生成闭环

当所有兜底都失败时,进入重生成流程:

makefile 复制代码
第一次生成 → 解析失败 → 尝试修复 → 仍失败
    │
    ▼
第二次生成(带错误反馈):
    提示词中加入: "你上次的输出无法解析。请严格只输出JSON,不要包含任何其他文字。"
    │
    ├── 成功 → 返回结果 + 记录该次失败
    │
    └── 仍失败 → 第三次生成(切换模型或降级处理)
                    │
                    ├── 成功 → 返回结果 + 告警
                    │
                    └── 仍失败 → 返回兜底回复 + 触发人工介入
相关推荐
贰先生1 小时前
Xiuno BBS 重构记录贴(十九)消息通知系统
后端
wulisongsong1 小时前
双重检验锁的单例模式在高并发下的可见性问题
后端
贰先生1 小时前
Xiuno BBS 重构记录贴(十八)插件兼容扫描器
后端
神奇小汤圆2 小时前
阿里面试官:什么才是可工程化落地的RAG项目
后端
ZPYZTech2 小时前
用 Wails + Go + Vue3 开发桌面软件,聊聊踩过的坑
后端
好家伙VCC3 小时前
区块链双向支付通道实战:从签名到结算
java·后端·区块链·asp.net
我登哥MVP3 小时前
Spring Boot 从“会用”到“精通”:参数解析原理
java·spring boot·后端·spring·servlet·maven·intellij-idea
JustHappy4 小时前
古法编程秘籍(五):什么是进程和线程?从软件到 CPU 的一次完整旅程
前端·后端·代码规范
BLSxiaopanlaile4 小时前
关于常见 map的一些比较探究
后端