很多做 Agent 开发的同学,可能都经历过这样一个场景:模型明明能回答问题,但一旦你要它输出 JSON,它就开始给你整花活。不是多了一个尾逗号,就是字段名突然从name变成姓名,更离谱的是,它会在 JSON 前后包一段 Markdown 代码块,告诉你“这是结果”。
模型它其实不是不会写 JSON,而是它对你的“结构化要求”理解不够稳定。在单轮对话里,你靠运气。但在 Agent 项目里,这是致命的。
因为 Agent 的本质是模型输出 → 程序解析 → 决策下一个动作的循环。如果模型输出的内容不能被稳定解析成结构化数据,整个链路就断了。这也是为什么 2025 年之后,AI 大模型应用开发面试题里,越来越高频地出现“如何保证 Agent 稳定输出结构化内容”这类问题。它考察的不再是你会不会调 API,而是你对 Prompt 设计、模型参数、数据校验、容错重试这一整套工程体系的理解。
这篇文章会把这件事彻底拆开讲清楚。核心就是一套四层约束方案:
- Prompt 强制约束:在系统提示词里规定死格式。
- 正反示例约束:让模型亲眼看到什么是正确的,什么是错误的。
- 原生参数约束:使用模型自带的 JSON 模式、参数控制等手段。
- 代码校验约束:即使上面三层全做了,也要在代码里做最终兜底。
这四层不是“选一个”,而是“全部都要”。下面一层一层过。
1. 为什么你写的 Prompt 总是“翻车”?
先说一个很多人忽略的事实:大模型并不是一个严格的程序执行器,它本质上是一个概率化的文本生成模型。你给它一段 Prompt,它生成的不是“唯一正确结果”,而是“在概率分布上最可能的输出”。
这意味着,即使你在 Prompt 里写了“请必须输出 JSON”,模型在生成时会倾向于“内容正确”,但可能忽略“格式正确”。模型对格式的感知,远没有对人类意图的感知那么强。它看到“结论是……”,就会忍不住写自然语言解释,而不是一个干净的 JSON。
在实际 Agent 项目中,常见的不稳定表现有:
- 输出中夹杂自然语言,比如“以下是结果:{...}”。
- JSON 字段名不一致,大小写漂移。
- 缺少必填字段,或者多出无关字段。
- JSON 语法错误,特别是尾逗号、单引号、转义错误。
- 直接返回 Markdown 代码块,需要一层额外解析。
- 输出被截断,尤其是长内容任务中比较常见。
这些问题的核心原因,是模型在生成时没有收到足够的约束信号。你要做的,就是通过多维度手段,把概率空间不断压缩到目标格式范围内。这四层约束,本质就是一层一层缩窄可能性。
2. 第一层:Prompt 强制约束
这一层是最基础、也是最容易上手的一层。目的不是让模型“尽量输出 JSON”,而是把格式规则写成可执行的操作指令。很多人的问题在于:只写“请返回 JSON”,这在模型看来约束力太弱了。
一个有效的结构化输出 Prompt,至少要包含以下几方面信息:
- 明确指定输出格式是 JSON,并且不包含任何其他内容。
- 明确给出字段结构,包括字段名、类型、含义。
- 明确字符串内容的规范(比如名称用中文还是英文,是否保留原文)。
- 明确处理边界的指令(比如无法判断时怎么办)。
- 明确禁止行为(比如不要出现 Markdown、不要有多余注释)。
来看一个示例:
你是一个智能客服工单分类助手。请根据用户描述,输出工单分类结果。 输出要求: 1. 只输出合法的 JSON 对象,不要返回任何其他文字。 2. JSON 结构如下: { "category": "string", "priority": "string", "summary": "string", "action_items": [] } 3. 字段说明: - category 必须是以下枚举值之一:登录问题、支付问题、订单问题、物流问题、其他问题。 - priority 必须是 high/medium/low 三选一。 - summary 是对用户问题的概括,不超过 50 字。 - action_items 是建议处理动作列表,如果不存在建议动作,返回空数组。 禁止输出 Markdown 代码块,禁止在 JSON 前后添加任何解释性文字。这里有一个关键技巧:你给出的“字段说明”越具体,模型的自由度越低。枚举值、长度限制、类型定义,这些都是降低模型不确定性的锚点。
还有一点容易被忽略:输出格式描述放在系统提示词中,而非对话中。系统提示词的指令优先级更高,且不会被用户消息中的内容干扰。
这个层的局限也很明显——它对模型有引导作用,但没法百分百保证。特别是遇到一些参数较小、指令跟随能力弱的模型,即便你写了明确的 Prompt,模型仍然可能输出不合法 JSON。这也是为什么需要后面几层兜底。
3. 第二层:正反示例约束
在真实的 Agent 项目中,光是系统提示词写清楚,仍然不够稳定。尤其当模型不确定“具体格式长什么样”时,它就会自己发挥。这时候,最有效的方式是给示例。
示例分为两种:正向示例(Few-shot)和反向示例(Negative Example)。
3.1 正向示例
给模型一个输入输出对,让模型模仿输出的格式。这个示例不需要多,两到三组即可。多了会浪费 token,也容易让模型过度模仿示例内容。
示例尽量贴近真实场景。比如上面那个工单分类任务,可以这样加:
示例1: 用户输入:我昨天买的手机今天开不了机,一直黑屏,麻烦帮我处理。 模型输出: { "category": "订单问题", "priority": "high", "summary": "用户购买手机次日出现黑屏故障,要求处理", "action_items": ["核实订单信息", "安排退换货"] }这个示例的核心作用,是让模型看到“字段名的写法”“字符串里该填什么内容”“数组里是什么风格的字符串”。它把 Prompt 里的抽象规则,翻译成了具体模样。
3.2 反向示例
真正拉开水平差距的,是反向示例。原因在于,很多模型在生成回复时,会默认带出解释性语言。你如果只给它正向示例,它可能觉得“输出一个干净 JSON”就行。但如果你明确告诉它“这种写法是错误的”,它会更容易避开。
反向示例长这样:
以下输出是错误示范,绝对不要模仿: 错误输出1(包含解释文字): 好的,根据您的描述,我已经完成了工单分类。结果如下: {"category": "...", ...} 错误输出2(包含 Markdown 代码块): ```json {"category": "...", ...}注意,在企业项目中,用反向示例钉死格式边界,比单纯正向示例更有效。因为在生成模型看来,明确“禁止的事情”比“应该做的事情”信号更强。
从这个角度说,用户给的输入材料里面提到“invalid prompt: your prompt was flagged as potentially violating our usage policy”,这类问题本质上是触发了内容安全审核,不是格式问题。但在做正反示例时也要留意:不要写太极端敏感的反例,否则可能被上游模型内容审核策略拦下。这也算是一个隐藏的小坑。
4. 第三层:原生参数约束
如果说前两层是在“输入文本”上下功夫,那这一层就是在“模型生成机制”上做控制。不同的模型服务商,提供了不同级别的结构化输出支持,用的好,效果会有质的提升。
4.1 JSON Mode
OpenAI 系列模型(包括 DeepSeek、通义千问等兼容 OpenAI 接口的模型)提供了response_format参数。设置为{"type": "json_object"}后,模型会被强制生成一个合法 JSON 对象。
示例调用:
from openai import OpenAI client = OpenAI( api_key="your-api-key", base_url="your-base-url" ) resp = client.chat.completions.create( model="your-model-name", response_format={"type": "json_object"}, messages=[ {"role": "system", "content": "你是一个智能客服工单分类助手。输出 JSON。"}, {"role": "user", "content": "我买的手机开不了机"} ] ) print(resp.choices[0].message.content)需要注意,JSON Mode 只是约束了“模型输出是 JSON”,但字段名、字段类型、枚举值是否符合你的要求,模型仍然可能自由发挥。所以在 JSON Mode 基础上,Prompt 依然要写清楚字段结构。
另外,在一些模型的 JSON Mode 使用文档中,要求在系统提示词里包含“json”字样,否则会报错。不同服务商要求不同,建议在使用前看下模型商的 API 文档。这里不是偷懒,而是不同模型的行为差异确实很大。
4.2 结构化输出(Structured Outputs)
OpenAI 在 2024 年推出了更严格的 Structured Outputs,可以通过传入 JSON Schema 约束字段名、类型、枚举值。模型在生成时,会严格按照 Schema 来。
示例:
resp = client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": "你是工单分类助手。"}, {"role": "user", "content": "我买的手机开不了机"} ], response_format={ "type": "json_schema", "json_schema": { "name": "ticket_output", "schema": { "type": "object", "properties": { "category": {"type": "string", "enum": ["登录问题", "支付问题", "订单问题", "物流问题", "其他问题"]}, "priority": {"type": "string", "enum": ["high", "medium", "low"]}, "summary": {"type": "string"}, "action_items": {"type": "array", "items": {"type": "string"}} }, "required": ["category", "priority", "summary", "action_items"], "additionalProperties": False } } } )这种方案的优点是模型端保证输出格式,基本不会出现缺字段问题。缺点也很明显:不是所有模型都支持。你如果在用本地部署的开源模型,或者某些小型模型,大概率是没有这个接口的。
4.3 温度参数控制
temperature参数控制模型输出的随机性。数值越高,输出越发散;数值越低,输出越确定。
在处理结构化输出任务时,建议把温度调到0或接近0。这能显著降低模型“自由发挥”的概率。
resp = client.chat.completions.create( model="your-model-name", temperature=0.0, ... )不过要注意,temperature=0不意味着每次输出百分百一致。但它在绝大多数情况下,能让模型的格式漂移明显减少。
原生参数这一层的核心思想是:不要只靠文本约束模型,而要利用模型系统提供的控制机制。能上结构化输出就上结构化输出,上不了就开 JSON Mode,配合低温参数,三层同时生效。
5. 第四层:代码校验
走到这一层,已经不是“让模型不出错”的问题,而是“即使模型真出错了,程序也能接住”的工程兜底。
在 Agent 项目中,代码校验这一层绝对不能省。因为任何模型都有概率输出非法内容,尤其是长上下文任务、多步骤推理任务中,模型可能会在某个 step 上突然格式漂移。如果没有代码校验,整个 Agent 会直接崩溃。
代码校验要做的事,包括:
- 解析模型返回的文本,提取 JSON 部分。
- 校验 JSON 是否合法。
- 校验字段是否齐全、类型是否正确、枚举值是否合法。
- 校验失败时,做自动修复或重新调用。
来看一个完整的 Python 校验代码。
import json import re from typing import Optional from pydantic import BaseModel, Field, ValidationError class TicketOutput(BaseModel): category: str = Field(..., pattern="^(登录问题|支付问题|订单问题|物流问题|其他问题)$") priority: str = Field(..., pattern="^(high|medium|low)$") summary: str = Field(..., max_length=50) action_items: list[str] = [] def extract_json(text: str) -> Optional[str]: """从模型输出中提取合法 JSON 字符串""" text = text.strip() # 去掉 Markdown 代码块标记 text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.MULTILINE) # 尝试直接 json.loads try: json.loads(text) return text except json.JSONDecodeError: pass # 如果前面有解释文字,尝试从第一个 { 截取 start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: candidate = text[start:end + 1] try: json.loads(candidate) return candidate except json.JSONDecodeError: return None return None def validate_ticket(content: str) -> TicketOutput: """解析并校验工单 JSON,失败时抛出异常""" json_str = extract_json(content) if json_str is None: raise ValueError(f"模型输出中不包含合法 JSON: {content[:200]}") data = json.loads(json_str) try: return TicketOutput(**data) except ValidationError as e: raise ValueError(f"字段校验失败: {e}") from e # 使用示例 model_output = '```json\n{"category": "订单问题", "priority": "high", "summary": "用户反馈手机无法开机", "action_items": ["核实订单", "安排换货"]}\n```' result = validate_ticket(model_output) print(result.model_dump())这里使用了 Pydantic 做字段级校验,它能帮你检查类型、枚举值、长度,同时自动生成清晰的错误信息。
在实际项目中,更推荐的做法是不直接让校验失败崩溃,而是设计自动修复流程。比如:
- 第一次解析:尝试直接解析模型输出。
- 如果失败:尝试提取 JSON 片段。
- 如果还失败:将错误信息拼回 Prompt,让模型“重新生成一次”。
- 最多重试 2 到 3 次,如果仍然失败,再走异常处理。
这个流程,也是 Agent 框架中比较经典的“解析 → 失败 → 反馈 → 重试”闭环。
for attempt in range(3): content = call_model(messages) try: ticket = validate_ticket(content) print("校验通过:", ticket.model_dump()) break except ValueError as e: print(f"第 {attempt + 1} 次尝试失败: {e}") # 把错误信息追加到上下文,要求模型修正 messages.append({"role": "user", "content": f"你上次的输出格式不对,错误原因:{e}。请重新输出严格 JSON。"}) else: raise RuntimeError("模型连续 3 次输出格式非法,任务终止")这里的重点是:不要相信模型,只相信校验器。所有模型输出,必须以代码校验作为最终裁决。这是生产级 Agent 和 Demo 级 Agent 最大的区别。
6. 四层约束的组合实战
把上面四层放在一起,就是一个完整的 Agent 结构化输出流程。这里给一个综合示例,方便读者把握全貌。
假设我们要写一个 Agent 的“意图识别”模块,它需要把用户的自然语言输入,解析成结构化的意图和参数。
# intent_agent.py import json from typing import Optional from pydantic import BaseModel, Field from openai import OpenAI SYSTEM_PROMPT = """ 你是一个意图识别模块。你只输出 JSON,不输出任何其他内容。 JSON 结构如下: { "intent": "string", "confidence": 0.0, "params": {"key": "value"}, "need_more_info": false } 字段说明: - intent 必须是以下枚举之一:search_order, cancel_order, complaint, general_inquiry - confidence 是 0 到 1 之间的小数,表示你对意图判断的置信度 - params 是提取出的关键参数,key 为参数名,value 为参数值 - need_more_info 为 true 表示参数不足需要追问,否则为 false 禁止输出 Markdown 代码块,禁止输出任何解释文字。 正向示例: 用户:帮我查一下订单 BA20250101 输出:{"intent": "search_order", "confidence": 0.95, "params": {"order_id": "BA20250101"}, "need_more_info": false} 反向示例(绝对禁止): 我理解你想查订单,结果是: {"intent": "search_order"} """ class IntentResult(BaseModel): intent: str = Field(..., pattern="^(search_order|cancel_order|complaint|general_inquiry)$") confidence: float = Field(..., ge=0, le=1) params: dict = Field(default_factory=dict) need_more_info: bool = False def _extract_json(text: str) -> Optional[dict]: text = text.strip() start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: return None try: return json.loads(text[start:end + 1]) except json.JSONDecodeError: return None class IntentAgent: def __init__(self, api_key: str, base_url: str, model: str): self.client = OpenAI(api_key=api_key, base_url=base_url) self.model = model def parse(self, user_input: str, max_retry: int = 2) -> IntentResult: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for attempt in range(max_retry + 1): resp = self.client.chat.completions.create( model=self.model, messages=messages, response_format={"type": "json_object"}, temperature=0.0, ) content = resp.choices[0].message.content data = _extract_json(content) if data is None: if attempt < max_retry: messages.append({ "role": "user", "content": f"你上次的输出不是合法 JSON:{content}。请重新输出严格 JSON。" }) continue try: return IntentResult(**data) except Exception as e: if attempt < max_retry: messages.append({ "role": "user", "content": f"字段校验失败:{e}。请按字段要求修正 JSON。" }) continue raise RuntimeError("意图识别失败:模型多次输出非法格式") if __name__ == "__main__": agent = IntentAgent( api_key="your-api-key", base_url="your-base-url", model="your-model-name" ) result = agent.parse("我想取消订单 BA20250101") print(result.model_dump())这段代码把四层约束全部串了起来:
SYSTEM_PROMPT是 Prompt 强制约束。- 示例部分是正反示例约束。
response_format和temperature是原生参数约束。_extract_json和 Pydantic 校验是代码校验约束。
如果模型输出失败,代码会把错误信息回传给模型,要求重新生成。这是生产环境里比较实用的兜底方案。
7. 运行结果与效果验证
直接运行上面这段代码,正常情况下的输出是这样:
python intent_agent.py预期输出:
{'intent': 'cancel_order', 'confidence': 0.93, 'params': {'order_id': 'BA20250101'}, 'need_more_info': False}验证是否成功,可以参考这几个标准:
- 模型输出的内容能被
json.loads解析。 - 解析后的字段能通过 Pydantic 校验。
- 连续调用 10 次以上,格式失败率为 0。
- 故意输入极端模糊的问题,模型要么填
need_more_info: true,要么走追问流程,而不是输出非法 JSON。
如果运行失败,第一个要检查的就是base_url和api_key是否正确。很多模型的报错都来自请求配置错误,而不是 Prompt 问题。
第二个要检查的是模型是否支持response_format。如果不支持,注释掉该参数,用纯 Prompt 约束来跑。
第三个要看的是SYSTEM_PROMPT中是否出现了英文双引号导致的 JSON 转义问题。在 Python 字符串里写长 Prompt 时,建议使用三引号字符串,减少转义负担。
8. 常见问题与排查思路
在结构化输出的实际开发中,每个问题都有相对固定的排查路径。这里整理成一张表格,方便对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 模型输出带 Markdown 代码块 | Prompt 未明确禁止,或者反向示例不足 | 查看原始输出内容 | 在 Prompt 中增加“禁止输出代码块”的明确指令,代码里做代码块剥离 |
| 字段名变成中文或拼写不一致 | 示例里字段名不统一,Prompt 对字段定义不严 | 检查 Prompt 是否列出了所有字段名和类型 | 在 Prompt 中逐字段定义,使用 JSON Schema 或 Pydantic 做校验 |
| 缺少必填字段 | 模型没理解字段要求,或输出被截断 | 用 Pydantic 校验并打印错误信息 | 增加重试机制,把缺字段错误返回给模型;或启用 Structured Outputs |
| 输出被截断 | 上下文过长或 max_tokens 设置偏小 | 检查生成参数中的 max_tokens | 调大 max_tokens,或者把输出内容拆成多步生成 |
| 代码抛 JSONDecodeError | 模型输出含前后解释文字 | 抓取原始 text 内容 | 使用extract_json做容错截取,提取第一个{到最后一个} |
| 模型总是返回追问而不是结果 | Prompt 边界设置模糊,模型不确定该不该追问 | 查看置信度分数和参数完整性逻辑 | 明确“参数不足时”的处理逻辑,把决策逻辑放到代码里而非模型 |
| 使用 JSON Mode 报错 | 模型不支持该参数或 API 版本不对 | 查看 API 文档和错误信息 | 去掉 response_format,改用 Prompt 约束加代码校验 |
| 输入内容触发内容审核 | Prompt 或反例包含被拦截的内容 | 查看上游返回的拒绝原因 | 调整反例措辞,避免敏感词和极端内容 |
表格里的关键是:所有问题都要先看原始输出,再改 Prompt,最后才考虑改代码。很多人一遇到格式问题就去调 Prompt,结果发现是max_tokens太小导致输出被截断,花了半天时间在错误方向排查。
9. 四层约束的工程边界
这一节想强调一个容易被忽略的点:这四层约束并不是绝对可靠的,它们也有边界。
第一层 Prompt 约束,受限于模型的指令跟随能力。小参数量模型、旧版本模型对指令的理解力参差不齐,写得太复杂的 Prompt 可能反而引入混乱。
第二层正反示例约束,会增加 token 消耗。每次调用都会把这些示例算进去。在低延迟、高吞吐场景下,示例要精简,不能为了效果好就疯狂堆示例。
第三层原生参数约束,受限于模型服务商的能力。不是所有模型都支持 Structured Outputs,有的模型对temperature的响应并不敏感。这种情况下,原生参数的增益有限。
第四层代码校验,是真正最可靠的兜底,但它只负责“发现错误”,而不负责“修正错误”。修正错误的方法(重试、规则修复、降级方案)需要你在工程上设计和实现。
所以,这四层不是某个“银弹”,而是一套组合策略。每一层都在上一层失效时提供兜底。真正生产级的 Agent,不是靠单一技巧,而是靠多层防线和失败恢复机制来保证稳定性。
在面试中,如果被问到这个问题,比较加分的回答方式是:不仅讲清楚这四层,还要能说出每一层的边界和配套的监控指标。比如“我在项目中用这四层约束后,JSON 解析成功率从 95% 提升到 99.5%,剩余 0.5% 走人工兜底”。这种回答,远比只背概念有说服力。
10. 最佳实践与工程建议
最后整理几条真正能落地的工程建议,适用于面试和实际项目开发。
10.1 把校验逻辑做成独立模块
不要每次调用模型后临时写解析代码。把解析、校验、重试逻辑封装成独立模块或者装饰器,所有 Agent 任务复用。这样格式问题是统一治理的,不会每个任务一套逻辑。
10.2 在 Prompt 里使用分隔符
在 Prompt 中定义输入输出结构时,用分隔符把不同部分隔开。比如用###、##标记系统指令、正向示例、反向示例。这能明显提升模型对结构的理解。
10.3 记录结构失败样本
当模型输出不合法 JSON 时,把原始输出、错误原因、修复结果记录下来。这些样本是调整 Prompt 的一手数据。积累到一定程度,你会发现模型失败其实有固定模式,比如某个字段在某种句式下总是缺失。
10.4 用 Schema 同时驱动 Prompt 和校验
在 Java 项目里可以用 Jackson 的ObjectMapper配JsonSchema;在 Python 项目里,可以直接用 Pydantic 模型定义字段,然后让 Prompt 从模型定义中自动生成。
schema = IntentResult.model_json_schema() prompt_schema_text = json.dumps(schema, ensure_ascii=False, indent=2)这样 Prompt 里的字段结构始终和代码校验一致,不会出现两边不同步导致模型输出总是匹配不上校验器的情况。
10.5 区分“格式错误”和“内容错误”
代码校验通常只能检测格式错误,无法检测语义错误。比如模型输出了一个合法 JSON,但category判断错了,这个校验层是发现不了的。所以你还需要在业务层做置信度检查、人工审核、或者多模型投票。
10.6 不要盲目追求一次成功
生产级系统设计的一个基本思路是:允许失败,但要有快速的恢复路径。让模型重试一次,往往比精心雕琢一个万能 Prompt 要便宜得多。
11. 总结与下一步
回到开头那个问题:怎么让 Agent 稳定输出结构化内容?
答案不是某一个神奇 Prompt,也不是某个特定参数,而是一整套工程手段的叠加。Prompt 强制约束限定基本规则,正反示例让模型“看懂”具体格式,原生参数利用模型机制压缩生成空间,代码校验在模型失效时兜底。四层一起,才能达到生产可用的稳定性水平。
如果此刻你正在做 Agent 项目,建议从代码校验这一层开始补。因为这是最确定、最不会白做的一层。然后回头审视你的 Prompt 是否足够具体,再检查模型调用参数里有没有开启 JSON 模式。按这个顺序,你大概率能在一两个小时内把结构化输出的崩溃率降下来。
后续值得继续深入的方向,包括:
- 在 LangChain 中使用 PydanticOutputParser 封装结构化输出。
- 在更长上下文的 Agent 场景中,用多轮校验提示词保持格式一致。
- 对本地部署模型做结构化输出评测,找出适合你业务的模型和参数组合。
- 研究 Function Calling 与结构化输出的关系,两者都能限制输出格式,但适用场景不同。
这些方向的核心,其实都是同一个问题:如何让模型在不确定性中,交出一个确定性系统可以接受的结果。这个问题没有终点,但随着你对模型行为模式的理解加深,你能控制的边界会越来越大。