Skip to content
This page has been auto-translated and may contain errors.View in English

结构化输出 ​

docs.scrimba.com

先来看看这个问题的本质。结构化输出指的是让模型返回你的代码能直接读取的数据——一个你可以拿来做判断分支的字段,而不是一段需要手动解析的文字。这一章讲的就是如何让这件事变得可靠。

到目前为止,模型给你的都是文本,人类读起来没问题,但软件对一段文字没法做太多事情。要让代码能根据模型的回答采取行动,你需要的是数据:一个能读取的字段、一个能做判断的值、一条能存下来的记录。

这个转变看起来不大,但会彻底改变后面的处理方式。模型返回 { "sentiment": "negative" } 而不是一句"顾客看起来不太满意"这样的话之后,你的代码就能据此路由工单、更新仪表盘,或者发送提醒。真正有意思的问题是:模型本质上只是在预测文本,你要怎么让它产出干净的数据?答案会揭示一个你之前不知道自己有的杠杆。

模型返回的是文本,而你的代码需要的是数据:一个能读取的字段、一个能做判断的值、一条能存下来的记录。这一章就是连接这两者的桥梁,思路是不再指望模型自己把格式排好,而是主动去约束它能输出什么。

这里有一个真正的杠杆,不是什么提示技巧。LLM 的工作原理里讲到的那个采样循环,是可以被引导的,让输出被强制限定成你想要的形状,这样一来,一个模糊的文本生成器就变成了系统里其他部分可以放心依赖的组件。

一个只会返回散文的模型,是没法拿来组合使用的组件。一旦它的输出要喂给另一个系统,你就需要形状已知的数据,而且需要让获取这份数据时可能出现的失败方式是你能看见、能处理的。

这一章讲的是如何把生成过程约束到一个 schema 里,这样做在延迟上要付出什么代价,它买不到什么(正确的值),以及它在边界情况下会怎么失效。这套底层机制和驱动工具调用的机制是同一套:工具调用其实就是结构化输出,只不过用函数签名代替了 schema。

要求它返回 JSON ​

最直接的想法是在提示词里要求:"以 JSON 格式回复"——这是软件之间传递数据常用的纯文本格式,用花括号包起来的字段和值。试一下会发现它大多数时候都能生效,而这恰恰是陷阱所在。模型本质上仍然只是在预测大概率的文本,而这个"大概率的文本"有时候会带一个代码围栏,有时候前面会来一句友好的"好的,给你!",有时候后面会跟一句多余的注释。这些都会破坏读取 JSON 的代码。友好地请求只是让预测偏向 JSON,并不能强制它。

所以各家提供商给了你一个真正的杠杆。通过 response_format 打开 JSON 模式,做的事情不只是"请求":它会约束模型,让返回的语法必须是合法的 JSON。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 约束输出必须是合法的 JSON 语法
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

response_format={"type": "json_object"} 让输出成为合法的 JSON 语法,所以 json.loads 不会因为混进来的散文而报错。你还是要在提示词里描述清楚你想要的字段,因为 JSON 模式保证的是"合法的 JSON",不是"你心里想的那些具体字段"。它排除的是"根本不是 JSON"这种情况,而不是"JSON 但形状不对"这种情况。还有一件事它无法保证:如果回复在中途被截断,你可能收到半个 JSON 对象,所以它保证的是可靠的语法,不是在所有情况下都成立的硬保证。

Juno要求它返回 JSON 只在提示词里要求 JSON 是不牢靠的,因为模型只是在预测大概率的文本,可能会给它套上代码围栏,或者加一些破坏解析的闲聊。把 response_format 设为 JSON 模式,会约束语法,让它必然是合法的 JSON。但它不保证字段和你要求的一致,一个中途被截断的回复也可能变成半个对象,所以要在提示词里描述好形状,并检查最终拿到的结果。

大家的第一反应是在提示词里写"以 JSON 格式回复",JSON 就是软件之间传递数据用的、由字段和值组成的格式。这大多数时候确实能用,但问题恰恰就在"大多数时候"——这不是一个契约。模型预测的是大概率的文本,而大概率的文本有时会带一个代码围栏、一句"好的!"的开场白,或者一句多余的注记,这些都会让 json.loads 报错。

JSON 模式是第一个真正的杠杆。把 response_format 设为 JSON 对象类型,会约束生成过程,让语法必然是合法的 JSON,而不是碰巧看起来像 JSON 的散文。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 约束输出必须是合法的 JSON 语法
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

这里 response_format 的写法是 OpenAI 的;其他提供商用各自的字段暴露同样的概念,所以你要迁移的是这个思路,而不是照抄这个具体的键名。JSON 模式约束的永远只是语法,从来不是字段的形状。字段还是你在提示词里描述的,模型完全可以在保持合法 JSON 的前提下,随意改名、丢字段,或者用不同的嵌套方式。

还有一个漏洞:JSON 模式只在回复完整生成完的情况下才保证格式良好。如果在对象生成到一半的时候撞上了 max_tokens 的上限,你拿到的就是被截断、无法解析的输出,所以解析这一步仍然需要加一层防护。

Juno要求它返回 JSON JSON 模式约束的是语法,能让模型不再把数据裹在代码围栏或闲聊里,但它从不锁定字段,模型仍然可以改名或丢字段。response_format 这个键名是 OpenAI 的写法,其他提供商的叫法不一样。而且这个保证只在回复完整生成完的情况下才成立,被 max_tokens 截断的回复照样无法解析。所以无论如何都要给解析加一层防护。

在提示词里说"以 JSON 格式回复"只是一个建议,而建议在压力下是会失灵的:这里冒出一个代码围栏,那里来一句开场白,模型话多的时候还会加一句多余的注释,每一种情况到了生产环境都是一个 json.loads 异常。JSON 模式(也就是 response_format={"type": "json_object"})把"建议"替换成了对生成语法的一种约束,让返回的字节能够被解析成 JSON。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 约束输出必须是合法的 JSON 语法
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

要精确理解这到底约束了什么。JSON 模式锁定的是语法,不是 schema,而且只在回复生成完整的情况下才成立。模型仍然可以自行选择字段名、可以省略必需字段、可以随意嵌套。

这个保证是有条件的,条件就是生成过程要真正结束:如果模型在对象生成到一半时撞上了 max_tokens,你拿到的就是一段被截断、不合法的字符串——也就是说,JSON 模式恰恰没能消除的这一种失败方式,正好是你重试逻辑最需要处理的那一种。上面展示的 response_format 键名是 OpenAI 的写法,其他提供商用不同的名字实现同样的东西,所以要把这个概念当成通用的、可迁移的,而具体的字段名则不要照搬。要拿到形状上的保证,你需要的是 schema,这就是下一节要讲的内容。

Juno要求它返回 JSON JSON 模式只在回复生成完整的情况下约束语法,仅此而已:模型仍然会按自己的想法命名、省略、嵌套字段,而被 max_tokens 打断的生成会给你截断、无法解析的输出。这种截断情况恰恰是你的错误处理路径必须自己扛下来的,因为 JSON 模式管不了它。response_format 这种写法是 OpenAI 特有的,所以要迁移的是思路,不是这个键名本身。要拿到形状上的保证,你需要一个 schema。

用 schema 锁定形状 ​

JSON 模式只能保证语法合法,但模型仍然可能给字段改名、丢字段,或者用不同的方式嵌套。要消除这种不确定性,你需要给它一个**schema**:一份对输出必须具备的字段和类型的精确描述,一张模型必须照着填的表格。配合 strict: true,输出就能保证符合这份表格。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,  # 严格按照 schema 强制执行
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}

模型本质上只是在预测 token,那 schema 是怎么做到硬性保证的呢?因为提供商限制了模型能够预测的 token 范围。在每一步,模型仍然会给所有可能的下一个 token 打分排序,但系统会把那些会破坏 schema 的选项去掉,模型只能从剩下的选项里挑。

如果 schema 规定下一个字段必须是 email,那么在模型做选择之前,任何会开启其他字段的 token 都会被提前移出选项。不符合形状的 token 被直接拿掉了,所以模型没法偏离这个形状。

还有一个比较微妙的效应值得了解。你选的字段名会成为模型预测这个值时读到的一部分上下文,所以清晰的命名能引导出更好的答案。让模型填 tempC,它会倾向于给出摄氏度的数字;让它填 value,它能依据的信息就少得多。把字段命名清楚,既是一种指令,也是 schema 的一部分。

Juno用 schema 锁定形状 schema 就是模型必须照着填的那张表,配合 strict: true,它能拿到精确的字段和类型,因为系统会在模型做选择之前,把任何会破坏形状的 token 都拿掉。这是强制执行,不是礼貌请求。字段也要命名清楚,因为字段名是模型预测值时会读到的上下文。我也是花了点时间才相信 tempC 这样的名字真的有实际作用。

JSON 模式只锁定语法,模型仍然可以自由地改名、丢字段,或者重新嵌套。schema 把这个缺口补上了:一份对字段和类型的精确描述,配合 strict: true,输出就能保证与之匹配。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,  # 严格按照 schema 强制执行
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}

这套机制叫**约束解码**:把 token 剪枝应用到采样这一步。在每一个位置,模型仍然会照常给所有 token 打分排序,然后系统把任何会导致输出不再匹配 schema 的 token 都遮蔽掉,模型再从剩下的候选里采样。如果语法规定下一个 token 必须开启 email 字段,那么其他任何开头的 token 在抽取之前就会被清零。模型没法偏离这个形状,因为偏离形状的路径直接从棋盘上被拿掉了,而不是在提示词里被劝阻。

这里有两个实用的手段。字段名本身就是一种指令,所以要根据你想要的值来命名。tempC 会把模型引向摄氏度,而 value 只会让它瞎猜,字段上加一段描述会引导得更明确。同样的约束机制还能强制执行一个 enum(一组固定的允许取值列表)或者一个数值范围,这正是分类任务可靠的原因:在标签这个位置,只有你列出的那些标签能存活下来,模型没法凭空造出一个不在列表上的类别。

Juno用 schema 锁定形状 配合 strict: true,约束解码会在模型采样之前,把任何会破坏 schema 的 token 都遮蔽掉,所以形状是被强制执行的,不是被请求的。放心用它:字段名和描述本身就是指令,命名成 tempC 而不是 value,模型会填得更好。同样的遮蔽机制还能强制执行 enum,这就是分类任务可靠的原因——模型没法凭空造出一个不在你列表上的标签。

JSON 模式给你的是可解析的字节;schema 给你的是已知的形状。配合 strict: true,输出保证匹配你声明的字段集合和类型,这就是"能直接索引取值的数据"和"必须防御性探测的数据"之间的区别。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)

这套机制叫**约束解码**,值得从头理解一遍,因为它的代价不是免费的。模型在每一步都会给词表里的每一个 token 算出一个概率。约束解码会把你的 schema 编译成一套语法(一组规定哪些 token 序列是合法的规则),然后在每一步构造一个遮蔽层,把所有不合法 token 的概率清零,这样采样就只能落在能让输出保持有效的 token 上。形状是靠构造机制强制出来的,不是靠请求得到的。

这个编译过程不是免费的,会体现在延迟上。针对一个新 schema 的第一次调用要为构建语法付出一次性的成本,所以新 schema 的冷启动延迟会比稳定状态高。让你的 schema 保持稳定并重复使用,而不是每次请求都新生成一个,否则你就要一次又一次地付这笔编译成本。

additionalProperties: false 和一份完整的 required 列表不是装饰性的:它们是让系统能够彻底拒绝多余或缺失字段的关键,而在支持基于 schema 的拒绝机制的提供商上,它们也是让模型能干净地返回一个结构化拒绝,而不是一个畸形对象的关键。去掉它们,你就放宽了什么算是有效的标准,这恰恰和你使用严格模式的初衷相反。

还有几个生产环境中的现实情况。各家对严格模式的支持程度并不一致:有些提供商实现的是真正的语法约束,有些只是近似实现,或者只支持 JSON Schema 的一个子集(没有 pattern,嵌套深度有限),所以要实测你的提供商实际支持到什么程度,而不要凭规范假设。部分输出和流式输出是比较棘手的情况:在 token 流式返回的过程中,你手里握着的是一个语法上不完整的对象,所以要么等生成完整了再解析,要么用一个能容忍半成品结构的增量解析器。而且这套机制和工具调用是同一套:函数的参数 schema 被约束的方式完全一样,所以这里学到的一切都能直接搬到让工具调用变得可靠上。

Juno用 schema 锁定形状 约束解码把你的 schema 编译成一套语法,在采样之前把所有不合法的 token 清零,所以形状是靠构造机制强制出来的。这个编译过程会在针对新 schema 的第一次调用上产生延迟,所以要重复使用 schema,而不是每次请求都新造一个。保留 additionalProperties: false 和完整的 required 列表,它们是强制拒绝的关键,在某些提供商上也是干净的结构化拒绝的关键。各家对严格模式的支持程度不一样,流式输出会给你半个对象,同样的遮蔽机制也驱动着工具调用的参数,所以这里学到的东西能用两次。

依然要做校验 ​

schema 约束的是形状,但不管怎样,都要把输出当成来自外部世界的数据来对待。形状可以是合法的,但内容却可能是错的:提取出来的邮箱其实是个打错的字,一个模型瞎猜出来的数字,一个因为输入里根本没有这项信息而被留空的字段。而且这次调用本身也可能以常见的方式失败,比如被 max_tokens 截断,返回一段不完整的 JSON。要带着防御心态去解析:形状合法不代表值是对的。

python
import json

def parse_contact(raw):
    try:
        data = json.loads(raw)
        if not data.get("name") or not data.get("email"):
            return None  # 字段存在但是空的
        return data
    except json.JSONDecodeError:
        return None  # 不是合法的 JSON(比如被截断的回复)

contact = parse_contact(response.choices[0].message.content)
if not contact:
    pass  # 处理失败:重试、重新提问,或者展示一个友好的错误提示

这其实是LLM 工作原理里讲过的**幻觉**问题换了个说法出现:形状对,不代表值对。schema 能保证你拿到一个 name 字段,不能保证这个名字本身是正确的。在解析外面套一层 try/except,再检查一下值,这点成本比起收到一份格式完好却依然错误的回复来说,非常值得。

Juno依然要做校验 schema 保证的是形状,从来不保证值的真实性,所以一条合法的记录里依然可能藏着错误或空白的值,一次调用也依然可能被截断。用 try/except 包住解析过程,并在信任这些值之前先检查它们。提前想清楚校验失败之后要怎么处理——重试还是给出友好的错误提示——不要等到出问题了才手忙脚乱。

严格模式保证的是形状,不是内容。输出可以完全符合 schema,却依然是错的:一个看起来挺像样的邮箱其实是打错的,一个为了填满必需字段而被编造出来的数字,一个本该有内容却因为输入里啥都没有而变成空字符串的字段。而且当回复因为 max_tokens 被截断时,解析本身也可能直接失败。所以每次都要校验。

python
import json

def parse_contact(raw):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "unparseable"  # 被截断或者格式错误

    if not data.get("name") or not data.get("email"):
        return None, "empty_field"  # 字段存在但是空的
    if "@" not in data["email"]:
        return None, "bad_email"  # 形状没问题,值有问题
    return data, None

contact, error = parse_contact(response.choices[0].message.content)
if error == "unparseable":
    pass  # 重试一次,大概率是被截断了:提高 max_tokens 或者缩短输入
elif error:
    pass  # 记录这个内容层面的失败并走降级方案

这种模式叫**防御性解析**,它把不同的失败方式区分开来,因为它们需要不同的处理方式。JSONDecodeError 通常意味着被截断了,所以修复方式是提高 max_tokens 或者缩短输入然后重试。一个空值或者超出范围的值是内容层面的失败,不是格式层面的,所以重试同一次调用往往没什么用;应该记录下来并走降级方案。

把"没法解析"和"能解析但是错的"区分开,因为修复方式不一样。这又回到了幻觉这个话题:约束解码消除了"输出格式错误"这种失败方式,但完整地保留了"值本身错误"这种失败方式。

Juno依然要做校验 严格模式锁定的是形状,不是内容,所以每次都要校验:一条记录可以格式完好,却装着一个打错的字、一个瞎猜的值,或者一片空白。把失败情况分开处理,因为 JSONDecodeError 通常意味着被截断了(提高 max_tokens 或缩短输入然后重试),而一个坏值是内容层面的失误,重试同一次调用解决不了。约束解码删除了"输出格式错误"这个问题,却原封不动地留下了"值本身错误"这个问题。

约束解码消除了"输出格式错误"这一种失败方式,别的什么都没消除。形状是有保证的,内容不是。一个输入里根本没提供的必需字段,会被塞进一个自信满满的猜测值;一个数字会落在任何正常范围之外;一个邮箱能被解析成字符串,但其实是打错的字。

格式上的保证本身在遇到截断时也会失效。所以校验不是可有可无的卫生习惯,它是唯一能捕捉到 schema 结构上根本捕捉不到的东西的那一层。

python
import json

def parse_contact(raw, retry):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "truncated"  # 生成不完整,不是内容问题

    if not data.get("name") or "@" not in data.get("email", ""):
        return None, "invalid_value"  # 形状没问题,值有问题
    return data, None

contact, failure = parse_contact(raw, retry=False)
if failure == "truncated":
    pass  # 提高 max_tokens 或者缩短输入,然后重试这次调用
elif failure == "invalid_value":
    pass  # 修复路径:把错误的输出重新喂回去让模型重新回答,或者走降级方案

一个真正扛得住考验的校验器,和一个把所有问题都吞掉的 try/except,区别在这三点:

  • 对失败进行分类:截断是一个预算问题,靠提高 max_tokens 重试就能解决,而一个无效的值是内容问题,盲目重试只会得到同样的结果。
  • 针对内容层面的问题,搭建一条**修复路径**,而不只是一个降级方案。把无效的输出重新喂回去,加一句"请修正"的指令,往往能靠多花一次调用就把问题救回来,这比直接判定请求失败便宜得多,但要给重试次数设上限,避免一个持续错误的输入陷入死循环。
  • 控制成本:每一次重试都是一次额外的计费调用,还会带来一轮额外的延迟,所以重试预算是设计的一部分,不是事后才想起来的补丁。

要校验值本身,不能只校验解析是否成功。这其实是把幻觉的防范思路搬到了结构化数据上:约束解码给你的是一个干净的信封,但对信封里面装的是什么完全不做保证,而信封本身正是大家最容易松懈校验、结果吃亏的地方。schema 校验、范围与语义检查、以及一个有上限的修复循环,这三层各自捕捉不同的问题:schema 捕捉形状,检查捕捉荒谬的值,修复循环把还能救的救回来。

Juno依然要做校验 严格模式删除了格式错误的输出,却完整保留了错误的值,所以要校验内容,不能只校验解析是否成功。对失败分类:截断是预算问题(提高 max_tokens,重试),无效值是内容层面的失误,盲目重试只会重现同样的问题,所以要搭建一条把坏输出喂回去的修复路径,并且给重试次数设上限,避免一个坏输入拖垮你的账单。干净的信封正是大家最容易松懈检查、然后吃亏的地方。

两种模式:分类与提取 ​

大部分结构化输出的工作都属于这两种形态之一。

**分类**是把一个输入归到一组固定标签中的某一个。schema 里的 enum 靠同样的 token 剪枝手段来把输出限制到那些标签上:在标签这个位置,只有允许的取值才能被预测出来,所以模型没法凭空造出一个不在你列表上的类别。

python
# 用于分类的 schema 片段
{"type": "string", "enum": ["billing", "technical", "general"]}

提取是从自由文本里抽出特定字段,就像前面的联系人示例:一个名字、一个日期、一个金额、一份产品名称列表。分类和提取加在一起,覆盖了绝大多数实际的 AI 功能:路由支持工单、给内容打标签、把邮件转成记录、读取小票信息。这两种模式都能把一个模糊的文本生成器变成你代码可以放心依赖的组件。

Juno两种模式:分类与提取 分类是把一个输入归到一组固定标签中的某一个,用 enum 锁死,让模型没法凭空造出一个不在列表上的类别。提取是把自由文本里的具名字段抽出来放进一条记录。这两种模式都能把一个模糊的文本生成器变成可靠的组件,两者加在一起覆盖了绝大多数实用的 AI 功能。

你用结构化输出做的几乎所有事情都属于这两种形态之一,把它们叫出名字来,有助于你选对 schema。

**分类**把一个输入映射到一组固定标签中的某一个。整个任务全靠 enum 撑着:约束解码在标签这个位置遮蔽掉除你列出的标签之外的所有 token,所以模型返回的一定是其中一个,别的什么都不会有。

python
# 分类:从一个封闭集合里选一个标签
{"type": "string", "enum": ["billing", "technical", "general"]}

提取把自由文本里的具名字段抽出来放进一条记录,就是前面联系人和工单的例子。这两种模式可以组合使用:一个 schema 可以同时做分类和提取,这也是大多数实际功能的样子。封闭集合用 enum,开放的值用带类型的普通字段。

要有意识地做的一个决定是:当一个值真的是一个固定集合(状态、类别、优先级)时,用 enum,让模型没法偏离;当它是开放的(一个名字、一段摘要)时,用一个普通的带类型字段,并接受你在信任这个值的同时也要去校验它。把这两者用混了——该用 enum 的地方留了个开放字段,或者现实中存在更多情况却用了 enum——正是这类功能悄悄出问题的地方。

Juno两种模式:分类与提取 这里的两种形态覆盖了大部分场景:分类映射到一个封闭集合里的某个标签(靠 enum 强制执行),提取把具名字段抽进一条记录,一个 schema 就能同时做这两件事。要有意识地选择:固定集合用 enum,让模型没法偏离;开放的值用普通带类型字段,并做好校验的准备。一个漏掉了现实情况的 enum,正是错误标签悄悄出现的根源。

结构化输出可以归约成两种模式,而这个区分不只是术语上的分类,更是一个设计上的杠杆。分类把输入映射到一个封闭集合里的某个标签,靠一个 enum 强制执行,约束解码把它压缩成在你的取值之间做一个硬性选择。提取把带类型的字段从自由文本里抽出来。大多数生产环境的 schema 会把两者结合起来用。

python
# 分类:约束解码在标签这个位置只允许这些 token
{"type": "string", "enum": ["billing", "technical", "general"]}

杠杆在于你把封闭集合的边界画在哪里,因为 enum 是一种带着锋利边缘的正确性保证。一个真正有边界的值(一个状态、一个优先级)适合用 enum,这样模型就没法输出任何不在列表上的东西,也就把一个开放式的生成任务变成了下游代码可以放心依赖的一个检查点。

但 enum 会强制模型做出选择,即便没有一个选项真正合适:如果实际输入完全不匹配任何标签,模型也只能被迫选一个最接近的错误答案,而不能告诉你"这个不合适"。一个没有退路的 enum,会把"不匹配任何选项"变成一个自信满满的错误标签。所以对于任何要面对混乱的真实输入的分类器,都要加一个明确的**退路选项**,一个 other 或者 unknown 成员,并把它当作"该路由到别处"的信号来读取。

同样的谨慎也适用于置信度:如果某个决策要依赖这个标签,就让模型顺带输出一个置信度字段或者一句简短的理由,这样一个模糊不清的判断就会显现出来,而不是被悄悄包装成一个看起来很干净的 enum 值。这和工具调用里的思路是一样的:模型从一个封闭集合里挑一个工具,你面对的是完全相同的"如果没有一个合适该怎么办"的问题。

Juno两种模式:分类与提取 分类是靠 enum 强制执行的封闭集合,提取是从自由文本里抽出带类型的字段,而 enum 是一种带着锋利边缘的保证:它会强制做出选择,即便没有一个选项真正合适,把"不匹配"悄悄包装成一个自信满满的错误标签。加一个 other 或 unknown 成员并据此路由,当决策依赖这个标签时,顺带输出一个置信度字段。这和工具调用里从封闭集合中挑一个工具是同一种形态。

实际操作 ​

把一封杂乱的客服邮件变成一条结构化的工单,在一个 schema 里同时做分类和提取:

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

一次调用就从两个维度对邮件做了分类,并提取出一段摘要,返回一条你的代码可以路由和存储的记录,形状是有保证的,值依然值得检查一遍。到目前为止,整个流程都是文本进、文本出。接下来会让模型换上一种完全不同的"感官":在Embeddings(嵌入)这一章里,文本会变成可以按含义比较的数字,这是搜索以及处理你自己文档的基础。

Juno实际操作 一次带严格 schema 的调用,就能从两个维度对一封邮件做分类,同时提取出一段摘要,返回一条你的代码可以路由和存储的记录。形状是有保证的,值依然值得检查一遍。后面讲工具调用时会用到同一个技巧:模型发给工具的参数,本质上也是结构化输出。

一个真实的功能通常会在一个 schema 里把两种模式结合起来。这里一封客服邮件变成了一张工单:一次调用里做了两次分类和一次提取。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

两个 enum 都被严格约束住了,所以 category 和 urgency 永远是可以放心路由的。summary 是一个开放字段,所以它才是需要在信任之前先校验一下的那个。这种组合——强制执行的标签加上一个经过检查的自由文本字段——就是大多数提取类功能的样子。接下来,Embeddings(嵌入)会给模型换上一种不同的"感官":把文本变成可以按含义比较的数字,这是搜索以及处理你自己文档的基础。

Juno实际操作 一个严格 schema,一次调用就能做两次分类外加一次摘要提取,返回一条你可以路由和存储的记录。enum 是被强制执行的,所以这些标签可以放心拿来用;开放的 summary 字段才是需要校验的地方。锁定的标签配合经过检查的自由文本,这是提取类功能最常见的样子。

生产环境里最常见的形态是一种复合模式:在几个维度上做分类,同时提取一个自由文本字段,全部放在一个 schema、一次调用里完成。

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

不同字段的约束力度并不均衡,这正是设计的关键所在。enum 是硬性保证,所以 category 和 urgency 可以放心拿来路由,不需要再做额外检查。summary 是一个不受约束的字符串,所以一个幻觉出来的或者不靠谱的值恰恰可以藏在一个干净的 schema 背后,它也正是那个值得投入一次校验的字段。两个被强制执行的标签配上一个开放的 summary 字段,也是最容易受截断影响的那种 schema:一段生成得很长的摘要正是撞上 max_tokens 上限、把整个对象变得无法解析的元凶,所以预算要针对自由文本字段来设置,不是针对标签。

这和工具调用是同一套机制:工具调用就是模型按照函数的 schema 生成结构化参数,约束方式完全一样,所以这里的校验、修复、以及给 enum 留退路的直觉,能原封不动地搬到让智能体变得可靠这件事上。接下来,Embeddings(嵌入)会把模型切换到一种不同的模式:把文本变成可以按含义比较的向量,这是搜索以及在你自己的文档中定位答案依据的基础。

Juno实际操作 一个严格 schema,两个被强制执行的 enum,加上一个开放的 summary:标签可以放心拿来路由,自由文本字段则是幻觉值藏身、以及长生成结果撞上 max_tokens 导致截断的地方,所以要专门为它做预算和校验。这和工具调用是同一套机制——参数按照函数 schema 被约束——所以这里的每一个习惯:校验、修复、给 enum 留退路,都能直接搬到智能体上用。