大模型Agent结构化输出不稳定?四层防护稳定输出JSON

大模型Agent结构化输出不稳定?四层防护稳定输出JSON 在近两年的大模型 Agent 面试题里有一个问题几乎每次都会出现怎么让 Agent 稳定输出结构化内容。这个问题的价值在于它不是一个单纯的 Prompt 问题而是把 Prompt Engineering、API 参数理解、代码健壮性和工程兜底意识全部串到了一起。实际开发中也确实如此让 Agent 从一段用户输入里提取订单信息模型经常能答对内容却把字段名改了、类型写错、JSON 前面多出一段解释文字甚至输出直接截断导致 json.loads 抛异常。要解决这个问题不能只靠某一种技巧而是需要四层约束叠加Prompt 强制、正反示例、原生参数、代码校验。下面把这四层逐个讲清楚并给出一套可以在开发环境直接验证的完整思路。1. 结构化输出不稳定的根源以及四层约束为什么能解决问题1.1 模型输出本质是文本不是数据结构大模型生成内容的过程本质是按 token 概率不断预测下一个词所以它输出的第一形态永远是字符串。即使你在 Prompt 里写“请输出 JSON”模型也只是生成了一段看起来像 JSON 的文本而不是真的在底层构造了一个 Python dict 或 Java Object。这就带来了几个很常见的输出问题输出里包含 Markdown 代码围栏比如json ...。字段名被篡改比如把 order_amount 写成了 amount。字段类型不稳比如金额返回 120元 而不是 120。多加了模型自己的解释比如“好的这是你要的信息{...}”。回复在长文本中间被截断导致 JSON 结构不完整。这些问题都与模型能力无关而是生成式模型的天然特点。因此要稳定输出结构化内容必须在生成链路上做约束而不是把希望完全寄托在模型“听懂”一件事上。1.2 四层约束的分工从引导到兜底四层约束并不是四个可以随意替换的方案而是一条层层收紧的链路。第一层 Prompt 强制是在输入侧把格式规则写清楚让模型知道“我要什么”第二层正反示例是给模型提供参照物让模型知道“合格长什么样、不合格长什么样”第三层原生参数是在 API 调用侧利用平台提供的结构化能力从采样或语法层面限制输出形态第四层代码校验是在应用侧对输出做最终检查保证只有符合业务规则的 JSON 才能进入下游系统。四层约束的对比可以整理成一张表。约束层次作用位置解决的主要问题常用手段单独使用时的不足Prompt 强制模型输入让模型知道输出格式系统提示词、格式模板模型仍然可能不遵守正反示例模型输入提供格式参照和反例边界few-shot、合格/不合格示例示例不贴近任务时效果有限原生参数API 调用让服务端按结构约束生成response_format、json_schema、工具调用不同模型支持度不同代码校验应用代码拦截非法结构触发重试Pydantic、JSON Schema、解析函数只能兜底不能降低生成失败率从这个表能看出来前两层是“引导”后两层是“兜底”。在面试中如果只回答“用 JSON 模式”或者“写清楚 Prompt”都只覆盖了一部分。1.3 为什么面试官喜欢问这道题面试官问这个问题表面上是问输出格式实际是在考察三个能力是否理解大模型的输出具有不确定性。是否能把不确定性转成工程上可校验、可恢复的系统行为。是否知道有哪些手段可以组合使用而不是背一个 API 参数。尤其对于 Agent 开发场景Agent 往往需要多次调用模型、调用工具、把结果写入数据库或返回给前端。如果结构化输出不稳定整个链路都会出现问题。所以这道题能直接反映候选人做 Agent 场景的工程经验。2. 第一层用 Prompt 强制把格式规则写进系统提示词2.1 输出格式约束应该写什么第一层约束的核心是“把规则说清楚”。在系统提示词中至少要包含以下信息返回格式只能输出 JSON 对象。字段名给出完整字段名禁止改写。字段类型说明每个字段是字符串、数字、布尔还是数组。必填与可空哪些字段必须存在哪些允许 null。枚举约束如果有固定取值要明确列出。附加要求不要输出 Markdown 代码围栏不要增加解释文字不要输出多余字段。这一类规则写得越具体模型越容易生成接近预期的结果。但要注意Prompt 强制解决的是“模型应该输出什么”它不负责“模型一定输出什么”。2.2 最小提示词示例下面是一段适合信息提取场景的系统提示词你是一个信息提取助手。 根据用户输入提取以下字段并严格按下面的 JSON 对象返回 { user_name: 字符串必填, city: 字符串可空空则返回 null, order_amount: 数字必填不要加货币符号, is_vip: 布尔值必填 } 要求 1. 只返回 JSON不要包含代码块标记。 2. 不要在 JSON 前后增加任何解释。 3. 字段名不能修改不要增加字段。 4. 如果信息缺失可空字段填 null必填字段填 unknown并保证 JSON 合法。配合一段用户输入例如用户输入李四在杭州买了一单金额120元是会员。期望输出{user_name: 李四, city: 杭州, order_amount: 120, is_vip: true}这里的关键点是“不要加注释”“字段名不能修改”“不要包含代码块标记”。这些细节直接影响解析层能不能直接 json.loads。2.3 为什么只靠 Prompt 还不够Prompt 强制是四层里最便宜的一层但它只能降低失败概率不能保证 100% 成功。实际输出里经常出现模型仍然在 JSON 前后追加一句“好的这是提取结果”。金额字段被写成 120元。模型把 user_name 改成了 name。输出被 Markdown 围栏包裹。这些现象说明规则描述在没有示例、没有 API 约束、没有代码校验的情况下很容易被生成过程“软化”。所以接下来要引入正反示例。3. 第二层用正反示例让模型看清合格与不合格输出3.1 正反示例为什么有效对模型来说抽象规则不如具体示例直观。尤其当目标结构包含嵌套对象、数组或枚举时单靠文字描述很容易让模型产生歧义。正反示例的作用是给模型两组“锚点”正例告诉模型符合要求的输出长什么样。反例告诉模型哪些输出会被下游判定为非法以及为什么非法。这在少样本提示中非常常见。你并不需要给模型几十个例子往往一组正例加一组反例就可以显著提高输出稳定性。关键是示例字段必须和当前任务保持一致否则模型会把示例里的字段名也学习进去。3.2 带正反示例的完整模板以订单信息提取为例可以在系统提示词中加入以下内容你是订单信息提取助手。下面先给出一个合格示例和一个不合格示例。 合格示例 用户输入李四在杭州买了一单金额120元是会员。 助手输出 {user_name: 李四, city: 杭州, order_amount: 120, is_vip: true} 不合格示例 用户输入王五在上海买了180元的商品非会员。 助手输出 用户王五的信息为 { name: 王五, city: 上海, order_amount: 180元, is_vip: false } 原因字段名 name 应为 user_nameorder_amount 必须是数字且不能出现解释性文字。 现在请提取用户输入为以下 JSON 结构 {user_name: 字符串, city: 字符串或null, order_amount: 数字, is_vip: 布尔}注意反例不只是给一个错误输出还要给出原因。这样模型才能理解“为什么不能这样写”。如果只给一个错误例子而不解释模型可能只记住了错误例子本身反而更容易输出同样的错误。在真实项目里正反示例也可以放进多轮消息结构中作为 few-shot 样本传给模型。示例条数建议从 1 组到 3 组开始测试不要一上来堆太多。3.3 示例选择和行为边界正反示例并不是越多越好使用时要关注这几个点示例场景要和目标任务一致不要让模型参考“天气查询”示例去提取订单。示例字段必须与生产结构完全一致一旦出现额外字段模型可能把它也输出出来。反例不需要太多一组反例足够建立“错误边界”过多反例会占用上下文还可能强化错误格式。示例中的业务数据要脱敏不要把真实用户信息写进示例。设计要点推荐做法错误做法示例数量正例 1 到 3 组反例 1 组正反各 10 组占用大量 token示例字段与目标 JSON 完全一致使用字段名相似但不一致的示例反例处理错误输出后附原因只给错误输出不给解释示例数据使用脱敏或构造数据使用真实手机号、身份证等4. 第三层使用模型 API 原生的结构化输出参数4.1 原生参数不是“提示词”而是接口能力前两层都发生在 Prompt 内部它们本质上是在用自然语言说服模型。第三层则不同它是调用模型 API 时传入的接口参数让服务端在生成阶段就偏向结构化输出。不同平台能力有差异常见的有三类JSON response format指定返回内容必须是合法 JSON 对象。JSON Schema在请求中传入一个 schema模型按 schema 生成结果。工具调用 / function calling把输出结构定义成函数参数模型选择并填充参数。以常见的 OpenAI 兼容接口为例最简单的 JSON 模式是response_format{type: json_object}更严格的场景是使用 JSON Schemaresponse_format{ type: json_schema, json_schema: { name: order_info, strict: True, schema: { type: object, properties: { user_name: {type: string}, city: {type: [string, null]}, order_amount: {type: number}, is_vip: {type: boolean} }, required: [user_name, city, order_amount, is_vip], additionalProperties: False } } }使用原生参数前必须先确认你使用的模型和接口版本是否支持对应字段。不同兼容接口对 strict、additionalProperties、array item 类型等能力的支持并不完全一致。落地时以官方文档为准不要假设所有参数在所有模型上都生效。4.2 最小请求示例下面是一个结构完整的调用示例假设模型是某个支持 response_format 的 OpenAI 兼容接口from openai import OpenAI client OpenAI( api_keyYOUR_API_KEY, base_urlYOUR_API_BASE_URL, ) SYSTEM_PROMPT 你是一个信息提取助手。 根据用户输入提取以下字段并严格按 JSON 对象返回 { user_name: string, city: string or null, order_amount: number, is_vip: boolean } 只输出 JSON不要解释。 resp client.chat.completions.create( modelyour-model-name, messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: 李四在杭州买了一单金额120元是会员。}, ], response_format{type: json_object}, ) raw_content resp.choices[0].message.content print(raw_content)这段代码的关键点有三个system prompt 里仍然保留了格式模板即使开启 JSON 模式Prompt 里的格式说明也不能省。response_format 只保证尽可能生成合法 JSON 对象不保证字段名、字段类型一定符合你的业务预期。返回值是字符串仍然需要解析和校验。4.3 原生参数的价值和边界原生参数能显著提升 JSON 合法性但它的价值边界也很清晰。它适合纯结构化输出场景比如“给定文本返回固定字段”。它不适合需要自由文本嵌套 JSON 的场景比如“先写一段分析再把结论放进 JSON 中”。这种情况下强制 JSON 输出反而会压缩分析内容。另外即使 API 返回了合法 JSON模型仍可能在语义层面出错。例如字段类型正确但含义不对或者金额字段填成了其他数字。所以原生参数只能作为第三层不能替代最后的代码校验。5. 第四层用代码解析、数据校验、重试兜底5.1 为什么需要解析和校验代码无论前几层做得多么完整应用侧都必须把模型输出当作“不可信数据”处理。原因很简单模型输出是字符串即使它能被 json.loads 解析也不代表字段名、类型和业务规则全部正确。常见的校验失败场景包括JSON 合法但 order_amount 是字符串 120不是数字 120。JSON 合法但 city 字段被写成了 unknown而不是 null。JSON 合法但多出了一个模型自己发明的字段导致下游数据库插入失败。JSON 合法但字段类型都正确金额却是负数业务上不合法。所以第四层的定位是在应用代码里做结构化解析、业务约束校验和失败重试。5.2 用 Pydantic 定义目标结构并校验在实际项目中可以使用 Pydantic 这类数据校验库。定义目标结构时不仅要写明类型还要写明业务约束。先安装依赖pip install pydantic下面是一个订单信息模型from pydantic import BaseModel, Field, ValidationError class OrderInfo(BaseModel): user_name: str Field(..., description用户姓名必填) city: str | None Field(defaultNone, description城市可空) order_amount: float Field(..., gt0, description订单金额必须大于0) is_vip: bool Field(..., description是否会员)再编写解析函数把“解析 JSON”和“校验业务规则”放在一起import json from typing import Any def parse_order_info(raw: str) - OrderInfo: try: data: Any json.loads(raw) except json.JSONDecodeError as exc: raise ValueError(f模型输出不是合法 JSON: {exc}原始内容{raw}) from exc try: return OrderInfo.model_validate(data) except ValidationError as exc: raise ValueError(f字段校验失败: {exc}) from exc这样做的好处是一旦模型输出包含错误你会得到明确的错误原因而不是程序运行到一半才因为 KeyError 或类型错误崩溃。如果项目里已经使用 JSON Schema也可以用 jsonschema 库做等效校验。Pydantic 的优势是能把校验结果直接映射成 Python 类型方便后续业务代码使用。5.3 校验失败后的重试与修复校验失败后最直接的策略是重试把错误信息反馈给模型让模型修正后重新输出。下面是一个带重试逻辑的示意代码def call_agent_with_retry(user_input: str, max_retries: int 3) - OrderInfo: last_error: str | None None for attempt in range(1, max_retries 1): raw call_model(user_input, previous_errorlast_error) try: return parse_order_info(raw) except ValueError as exc: last_error str(exc) print(f第 {attempt} 次解析失败: {last_error}) raise RuntimeError(连续重试后仍然无法得到合法结构化输出)其中 call_model 在重试时要把上一次的失败原因追加进 messages。例如def call_model(user_input: str, previous_error: str | None None) - str: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] if previous_error: messages.append({ role: user, content: f你上次输出的 JSON 校验失败错误原因{previous_error}。请修正后重新输出 JSON。 }) # 继续调用模型并返回 message content这里有一个常见误区重试时如果不回传错误信息模型大概率会重复同样的错误。回传错误信息后模型才有机会根据失败原因修正输出。同时要注意控制重试次数。一般 2 到 3 次足够。如果连续多次失败说明问题大概率不在模型而在 Prompt、参数或模型能力本身。此时应该先检查上下文而不是继续消耗 token。6. 最小闭环把四层约束放进一个完整请求流程6.1 场景定义让 Agent 输出一个工单创建结果前面几层是分开讲的实际项目中四层会同时生效。这里用一个“客服工单自动创建 Agent”场景做闭环演示。场景要求用户描述一个问题Agent 提取以下字段并输出给创建工单的下游系统。目标结构如下字段类型约束titlestring必填一句话描述问题prioritystringlow / medium / high默认 lowcustomer_idstring必填客户编号tagsstring[]可选最多 3 个标签从前三层看我们需要在 Prompt 里写清结构提供正反示例并在 API 请求中开启 JSON 模式。从第四层看我们需要定义一个工单模型解析并校验输出。6.2 四层约束组合后的核心代码下面是一段组合后的调用伪代码保留了完整流程import json from openai import OpenAI from pydantic import BaseModel, Field, ValidationError client OpenAI( api_keyYOUR_API_KEY, base_urlYOUR_API_BASE_URL, ) SYSTEM_PROMPT 你是工单创建助手。从用户描述中提取工单字段严格输出 JSON。 目标结构 { title: string必填, priority: low / medium / high可选默认 low, customer_id: string必填, tags: string数组可选最多3个 } 合格示例 用户输入打印机连接不上客户ID是C1001。 助手输出 {title: 打印机连接不上, priority: medium, customer_id: C1001, tags: [打印, 网络]} 不合格示例 用户输入账号无法登录。 助手输出 账号无法登录请优先处理 {title: 账号无法登录, priority: high, customer_id: unknown, tags: 登录} 原因不能输出解释文字tags 必须是数组customer_id 不能填 unknown。 要求 1. 只输出 JSON不要输出代码块。 2. 字段名不能修改。 3. 不要在 JSON 前后增加任何解释。 class TicketInfo(BaseModel): title: str Field(..., min_length1) priority: str Field(defaultlow) customer_id: str Field(..., min_length1) tags: list[str] Field(default_factorylist, max_length3) def parse_ticket(raw: str) - TicketInfo: data json.loads(raw) return TicketInfo.model_validate(data) def create_ticket_from_user_input(user_input: str, max_retries: int 3) - TicketInfo: previous_error None for attempt in range(1, max_retries 1): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] if previous_error: messages.append({ role: user, content: f你上次输出校验失败{previous_error}请修正后重新输出 JSON。 }) resp client.chat.completions.create( modelyour-model-name, messagesmessages, response_format{type: json_object}, ) raw resp.choices[0].message.content try: ticket parse_ticket(raw) return ticket except Exception as exc: previous_error str(exc) raise RuntimeError(创建工单失败结构化输出连续不通过)这段代码里有一个关键的工程选择校验失败后不是直接让用户重新输入而是把校验错误作为下一轮辅助输入传回去让模型在生成时修正。6.3 预期输出和异常输出正常运行时日志里能看到类似下面的内容模型原始输出: {title: 打印机连接不上, priority: medium, customer_id: C1001, tags: [打印, 网络]} 校验通过: title打印机连接不上, prioritymedium, customer_idC1001, tags[打印, 网络]如果模型第一次输出不合法日志会记录失败原因第 1 次解析失败: 字段校验失败: tags 必须是数组 第 2 次输出: {title: 打印机连接不上, priority: medium, customer_id: C1001, tags: [打印, 网络]} 校验通过这套流程验证时要注意不要只验证“程序能跑通”还要故意构造几个错误输出确认重试逻辑能把结果拉回来或者在重试达到上限时能抛出明确异常。7. 常见问题排查结构化输出不稳定的现实场景7.1 模型在 JSON 前后追加了解释文字现象json.loads 直接抛 JSONDecodeError打印原始输出发现 JSON 前面有一行“好的这是你要的信息”。可能原因系统提示词没有明确要求“禁止解释”。模型为了更“友好”主动添加了过渡语。启用的某些模型对指令的遵循能力较弱。检查方式打印模型完整返回内容确认非法字符出现在哪个位置。处理建议在系统提示词中增加“只输出 JSON不要解释”。开启 response_format 的 json_object 模式降低这类问题的概率。作为兜底可以在代码中定位第一个{和最后一个}截取字符串后再解析。但要注意这只是兜底不是主要方案。7.2 字段名和业务层不一致现象JSON 能解析但字段名变成了 name、amount而不是 user_name、order_amount。可能原因Prompt 中的字段名定义不够醒目。示例中的字段名与目标结构不一致模型学习了错误示例。系统里同时存在多个版本的提示词模型产生了混淆。检查方式对比系统提示词、示例和模型输出确认是否有字段名漂移。处理建议固定一套字段名不要在多个示例里混用写法。检查 few-shot 示例里的字段名是否与最终结构完全一致。在 JSON Schema 中开启 additionalProperties: false如果平台支持能拦截未知字段。7.3 校验失败但重试一直不成功浪费大量 token现象连续重试 5 次仍然失败错误日志几乎一样token 消耗暴涨。可能原因模型本身不支持结构化输出能力或能力较弱。Prompt 中包含冲突指令模型不知道该听哪一条。最大 token 设置太小输出被截断。重试时没有把上一次的错误信息回传给模型模型每次都在重复第一次输出。检查方式先检查 max_tokens 是否足够容纳目标 JSON。再检查重试逻辑是否把 previous_error 写入 messages。最后单独用一次不重试的调用查看模型原始输出。处理建议重试次数限制在 2 到 3 次。每次重试都携带上一步的校验错误。连续失败时不要继续重试直接触发告警或人工处理。7.4 平台不支持 response_format 参数现象传入 response_format 后接口直接报错或者忽略该参数。可能原因使用的模型版本不支持 JSON 模式。兼容接口没有完整实现该能力。base_url 指向的服务端版本较旧。检查方式先看接口返回的 error message再用不带 response_format 的请求做对比。处理建议在代码层封装一个能力探测函数根据模型名或接口返回判断是否支持结构化参数。不支持时降级为“Prompt 强制 正反示例 代码修复”至少保证流程可用。把支持情况写入配置中心避免每次请求都去探测。8. 生产环境落地建议与面试答题视角8.1 学习环境与生产环境的落差很多开发者在本地用代码验证时只关注“一次调用能返回 JSON”。进入生产环境后要额外关注的不只是成功率还有失败率、重试成本、日志、监控和降级策略。关注点学习环境生产环境提示词管理写在代码里外置到配置中心支持版本变更日志打印到控制台记录原始输出、校验错误、脱敏后信息监控无统计非法 JSON 率、重试率、token 消耗失败处理手动重试自动重试加人工介入队列模型切换固定一个模型抽象模型层支持降级参数调整手动改代码动态配置 max_tokens、temperature、response_format 开关生产环境还有一个容易被忽略的问题不要把所有原始模型输出直接写入日志。模型输出里可能包含用户隐私或业务敏感信息日志要脱敏或者只记录长度和错误类型。8.2 可复用的结构化输出检查清单在接一个新的 Agent 功能时可以拿着下面这份清单逐项确认。系统提示词是否明确要求“只输出 JSON不加解释”。是否定义了字段类型、必填字段、枚举值。是否准备了正反示例且示例字段与目标结构一致。是否确认当前模型支持 response_format、json_schema 或工具调用。是否使用数据校验库而不是只做 json.loads。是否校验了业务约束例如金额大于零、枚举合法、数组长度限制。是否处理了 Markdown 围栏、截断、空输出、非法字符。是否设置了最大重试次数重试时是否回传错误信息。是否记录原始输出和校验错误并做脱敏处理。是否在连续失败时触发告警而不是无限重试。8.3 面试中怎么回答更稳这道题的回答顺序很重要。建议按照“模型本质 - 四层约束 - 失败案例 - 工程配套”来组织。先说明“大模型输出是字符串天然不稳定”这是答好这道题的前提。再提出四层约束Prompt 强制、正反示例、原生参数、代码校验。每说一层都要讲它解决了什么、遗漏了什么。最后补充一个自己真实遇到的失败案例说明如何通过代码校验和重试把问题接住。如果面试官追问“这几层哪个最重要”不要回答“都重要”。可以这样说从降低生成失败率来看原生参数效果通常最直接从保证系统稳定来看代码校验最重要。因为前者负责减少错误后者负责保证错误不会流入下游。追问还可能包括“重试会把错误重新传给模型会不会引入新问题”。回答方向是会所以重试次数要限流错误信息要尽量具体并且每次重试后都要做同样的校验如果连续失败应该从提示词和模型能力上找原因而不是继续盲目重试。如果面试官让你现场设计一个“简历解析 Agent”可以直接用这套四层方案Prompt 里写清姓名、工作年限、技能列表字段给一个合格示例和反例开启 JSON 模式再用 Pydantic 做类型和数量校验最后把失败原因回传给模型重试一次。这样回答既具体又能体现工程经验。在实际项目里最值得投入时间的不是不断改写提示词而是把“格式规则、示例、参数、校验、重试”整理成一套可复用的标准化流程。这样每个新 Agent 接入时都能直接复用同一套兜底机制。下一次遇到结构化输出不稳定的问题你也不需要从零排查而是按“Prompt 是否写清、示例是否一致、原生参数是否开启、代码校验是否生效”的顺序逐层定位。