模型输出 JSON 到底难在哪
你让 Hermes 回答一个问题,它能给你写一篇通顺的文章。但你让它把答案按照一个固定的 JSON 格式输出,事情就变得微妙了。
难点不在于模型”不会”输出 JSON——几乎所有主流大模型都见过海量的 JSON 数据,语法层面没问题。难点在于稳定性:模型可能在 JSON 前后加上多余的解释文字、忘记闭合括号、把字段名搞错、数值类型和字符串类型混用、或者在不该换行的地方换行导致解析失败。
在 API 后端的场景里,下游系统要的是一个能直接 json.loads() 的字符串,不是一段”大概像 JSON”的文本。这就需要一些工程技巧来把模型的输出稳定在合法 JSON 的范围内。
最基础的方法:System Prompt 指令
最直接的做法——在 system prompt 里明确告诉模型只输出 JSON:
1 2 3
| system_prompt = """你是一个数据提取助手。 你的所有回复必须是合法的 JSON 格式,不要包含任何其他文字。 不要输出 markdown 代码块标记,不要输出解释,只输出纯 JSON。"""
|
这种方式对 Hermes 3 系列模型有一定效果,但还不够稳定。模型有时候会”手痒”加一句”以下是提取结果:”,然后才跟上 JSON。有时候会在 JSON 后面追一句”如果有其他需要请告诉我”。
进阶版本是把输出格式描述得更具体:
1 2 3 4 5 6 7 8
| system_prompt = """你是一个数据提取助手。 你的回复必须是且仅是一个合法的 JSON 对象。 必须严格遵守以下规则: 1. 回复的第一个字符必须是 { ,最后一个字符必须是 } 2. 不要在 JSON 前后添加任何文字、标点或空行 3. 不要使用 markdown 代码块(```)包裹 4. 所有字符串值使用双引号 5. 不要添加注释"""
|
实测中,这种”碎碎念式”的规则堆叠反而比一句简洁的指令更有效——因为它覆盖了模型最常犯的几种偏差。
用 JSON Schema 约束输出结构
光让模型输出 JSON 还不够,你通常需要它输出特定结构的 JSON。这时候需要在 prompt 里给出明确的 schema:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| system_prompt = """你是一个商品信息提取助手。从用户提供的商品描述中提取结构化信息。
你的输出必须严格符合以下 JSON Schema: { "type": "object", "properties": { "product_name": {"type": "string", "description": "商品名称"}, "price": {"type": "number", "description": "价格,数值类型,单位元"}, "category": { "type": "string", "enum": ["电子产品", "服装", "食品", "家居", "其他"], "description": "商品分类" }, "features": { "type": "array", "items": {"type": "string"}, "description": "商品特点列表" }, "in_stock": {"type": "boolean", "description": "是否有货"} }, "required": ["product_name", "price", "category"] }
只输出符合上述 schema 的 JSON 对象,不要输出其他任何内容。"""
|
用户输入:
1 2
| 这款华为 MatePad Pro 13.2 英寸平板电脑,搭载麒麟9000S芯片, 12GB+256GB,OLED柔性屏,售价4999元,支持手写笔,目前有货。
|
理想输出:
1 2 3 4 5 6 7
| { "product_name": "华为 MatePad Pro 13.2 英寸平板电脑", "price": 4999, "category": "电子产品", "features": ["麒麟9000S芯片", "12GB+256GB", "OLED柔性屏", "支持手写笔"], "in_stock": true }
|
注意 price 字段是 number 不是 string,in_stock 是 boolean 不是字符串 “true”。模型偶尔会搞混类型,后面会讲怎么处理。
Few-Shot 示例:效果立竿见影
如果光靠指令还不够稳定,加一两个 few-shot 示例效果最明显:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| messages = [ { "role": "system", "content": system_prompt }, { "role": "user", "content": "索尼WH-1000XM5头戴式降噪耳机,蓝牙5.2,30小时续航,黑色,售价2499元,暂时缺货。" }, { "role": "assistant", "content": '{"product_name":"索尼WH-1000XM5头戴式降噪耳机","price":2499,"category":"电子产品","features":["蓝牙5.2","30小时续航","黑色"],"in_stock":false}' }, { "role": "user", "content": actual_user_input } ]
|
few-shot 示例做了两件事:第一,给模型展示了正确的输出格式;第二,暗示了字段的映射逻辑(比如”暂时缺货”对应 in_stock: false)。
关于怎么写好这些引导性的 prompt,可以参考 Hermes 系统提示词工程 那篇文章里的详细方法论。
llama.cpp 的 Grammar 约束
如果你用 llama.cpp 或 llama-cpp-python 部署 Hermes,有一个”终极武器”——GBNF Grammar(Generative Backus-Naur Form)。这东西在 token 生成层面强制约束输出格式,从根源上杜绝非法 JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| from llama_cpp import Llama, LlamaGrammar
json_grammar = LlamaGrammar.from_string(r''' root ::= object value ::= object | array | string | number | ("true" | "false" | "null") ws
object ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}" ws
array ::= "[" ws ( value ("," ws value)* )? "]" ws
string ::= "\"" ( [^\\"\x7F\x00-\x1F] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]) )* "\"" ws
number ::= ("-"? ([0-9] | [1-9] [0-9]*)) ("." [0-9]+)? ([eE] [-+]? [0-9]+)? ws
ws ::= ([ \t\n] ws)? ''')
llm = Llama(model_path="./Hermes-3-Llama-3.1-8B-Q5_K_M.gguf", n_ctx=4096)
response = llm.create_chat_completion( messages=messages, grammar=json_grammar, max_tokens=1024 )
|
Grammar 的工作原理是:在每个 token 生成时,只允许那些符合语法规则的 token 被采样。如果当前位置应该是 {,那所有不以 { 开头的 token 概率都被设为 0。
优点很明显——输出 100% 是合法 JSON,不需要任何后处理。缺点是会稍微影响生成速度(大约慢 5-15%),且如果 grammar 和模型想输出的内容冲突严重,可能导致语义质量下降。
vLLM 的 Guided Decoding
如果你用 vLLM 做推理(这在服务端很常见),它内置了 guided decoding 功能,可以直接用 JSON Schema 约束输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| from vllm import LLM, SamplingParams
llm = LLM(model="NousResearch/Hermes-3-Llama-3.1-8B")
sampling_params = SamplingParams( temperature=0.1, max_tokens=1024, guided_decoding={ "json_schema": { "type": "object", "properties": { "product_name": {"type": "string"}, "price": {"type": "number"}, "category": {"type": "string"}, "features": { "type": "array", "items": {"type": "string"} } }, "required": ["product_name", "price", "category"] } } )
outputs = llm.generate([prompt], sampling_params)
|
vLLM 在底层把 JSON Schema 转换为有限状态机(FSM),效果和 llama.cpp 的 grammar 类似,但用起来更方便——直接传 JSON Schema 就行,不用手写 GBNF。
Ollama 环境下的 JSON Mode
如果你用 Ollama 部署 Hermes,获取 JSON 输出也很方便。Ollama 支持 format 参数:
1 2 3 4 5 6
| curl http://localhost:11434/api/generate -d '{ "model": "hermes3", "prompt": "从以下文本提取人名和职位:张三是某科技公司的CTO", "format": "json", "stream": false }'
|
或者在 Python 里用 ollama 库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import ollama
response = ollama.chat( model="hermes3", messages=[ { "role": "system", "content": "提取文本中的人物信息,输出JSON格式,包含name和title字段。" }, { "role": "user", "content": "李明是阿里巴巴的高级算法工程师,他的同事王芳是产品经理。" } ], format="json" )
print(response["message"]["content"])
|
Ollama 的 format: "json" 底层也是用 grammar 约束实现的,能保证输出是合法 JSON。但它不能约束 JSON 的具体结构(哪些字段、什么类型),这部分还是得靠 prompt 来引导。
后处理:防御性 JSON 解析
即使用了上面所有技巧,生产环境里你仍然需要一层后处理来兜底:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| import json import re
def extract_json(text: str) -> dict | None: """从模型输出中提取 JSON,处理各种常见问题"""
text = text.strip() try: return json.loads(text) except json.JSONDecodeError: pass
code_block_pattern = r'```(?:json)?\s*([\s\S]*?)\s*```' match = re.search(code_block_pattern, text) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass
brace_start = text.find('{') if brace_start != -1: depth = 0 for i in range(brace_start, len(text)): if text[i] == '{': depth += 1 elif text[i] == '}': depth -= 1 if depth == 0: try: return json.loads(text[brace_start:i + 1]) except json.JSONDecodeError: break
try: fixed = text.replace("'", '"') return json.loads(fixed) except (json.JSONDecodeError, ValueError): pass
return None
def validate_and_fix(data: dict, schema: dict) -> dict: """根据 schema 校验并修复常见类型错误""" properties = schema.get("properties", {}) fixed = {}
for key, prop in properties.items(): if key not in data: if key in schema.get("required", []): fixed[key] = get_default_value(prop["type"]) continue
value = data[key] expected_type = prop.get("type")
if expected_type == "number" and isinstance(value, str): try: fixed[key] = float(value.replace(",", "").replace("元", "").replace("¥", "")) except ValueError: fixed[key] = 0 elif expected_type == "boolean" and isinstance(value, str): fixed[key] = value.lower() in ("true", "是", "有货", "yes") elif expected_type == "array" and isinstance(value, str): fixed[key] = [item.strip() for item in value.split(",")] else: fixed[key] = value
return fixed
def get_default_value(type_name: str): defaults = {"string": "", "number": 0, "boolean": False, "array": [], "object": {}} return defaults.get(type_name, None)
|
这段后处理代码覆盖了几种最常见的情况:模型在 JSON 前后加了多余文字、用了 markdown 代码块、类型不匹配(把数字写成字符串)、用了单引号等。
实际应用场景
JSON Mode 在下面这些场景里特别有用:
场景一:日志/文本结构化提取
1 2 3 4
| system = """从服务器日志中提取关键信息,输出JSON: {"timestamp": "时间戳", "level": "日志级别", "service": "服务名", "message": "核心信息", "error_code": "错误码或null"}"""
user = "2026-04-10 14:23:15 ERROR [payment-service] Transaction failed: insufficient balance, code=E4012"
|
场景二:多语言内容分析
1 2
| system = """分析用户评论的情感和关键信息: {"sentiment": "positive/negative/neutral", "score": 0.0到1.0, "topics": ["话题列表"], "language": "语言代码"}"""
|
场景三:API 中间层
这是我在实际项目中用得最多的场景——把 Hermes 作为 API 中间层,接收自然语言请求,输出结构化的 API 调用参数。比如用户说”帮我查一下北京到上海后天的高铁票”,模型输出:
1 2 3 4 5 6 7 8 9
| { "action": "search_train", "params": { "from": "北京", "to": "上海", "date": "2026-04-12", "train_type": "G" } }
|
下游代码直接拿这个 JSON 去调对应的 API,不用写一堆正则或 NLU 规则。
temperature 和采样参数的影响
让模型稳定输出 JSON,采样参数的设置很关键:
1 2 3 4 5 6 7
| sampling_params = { "temperature": 0.1, "top_p": 0.9, "repeat_penalty": 1.05, "max_tokens": 2048 }
|
temperature 是最重要的旋钮。设成 0 的话,输出完全确定性,JSON 格式最稳定,但内容可能过于死板。设成 0.1-0.3 是比较好的平衡点——格式基本稳定,内容还有一点灵活度。
如果你的场景是纯数据提取(从文本里抽字段),temperature 可以直接设 0。如果需要模型有一些”创造性”(比如生成商品描述的 JSON),可以适当提高到 0.3-0.5,但要配合更强的格式约束。
嵌套结构和复杂 Schema
简单的扁平 JSON 好处理,但实际业务中经常需要嵌套结构。Hermes 对嵌套 JSON 的处理能力取决于模型大小和 schema 复杂度:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| complex_schema = { "type": "object", "properties": { "order": { "type": "object", "properties": { "order_id": {"type": "string"}, "items": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "quantity": {"type": "integer"}, "unit_price": {"type": "number"} } } }, "total": {"type": "number"} } }, "customer": { "type": "object", "properties": { "name": {"type": "string"}, "phone": {"type": "string"} } } } }
|
对于这种两三层嵌套的结构,Hermes-3 8B 大部分情况能搞定。如果嵌套更深或者数组元素更多,建议用 70B 版本,或者把复杂任务拆分成多步——关于不同尺寸模型适合什么场景,可以看 多模型路由策略 那篇。
和 Function Calling 的配合
JSON Mode 和 Function Calling 经常一起用。一个典型的模式是:
- 模型通过 Function Calling 获取外部数据
- 拿到数据后,用 JSON Mode 输出结构化的处理结果
1 2 3 4 5 6
|
follow_up_prompt = """根据搜索结果,输出以下JSON格式的摘要: {"results_count": 数量, "top_results": [{"title": "标题", "relevance": 0-1分}], "summary": "一句话总结"}"""
|
这种两步式的 pipeline 比让模型一步完成所有事情要稳定得多。
常见踩坑记录
最后记录几个我和 cocoloop 社区的朋友们在实际使用中踩过的坑:
1. 中文字符编码问题
模型有时候会在 JSON 里输出 Unicode 转义(\u4f60\u597d),而不是直接的中文字符。如果下游系统不处理 Unicode 转义会出问题:
1 2
| result = json.dumps(data, ensure_ascii=False, indent=2)
|
2. 数字精度
模型输出的数字可能精度不一致。比如价格有时是 99,有时是 99.0,有时是 99.00。在 JSON 里这些都是合法的 number,但下游如果做字符串比较会出问题,统一用 float 或 Decimal 处理。
3. 空值表示不一致
模型有时用 null,有时用空字符串 "",有时直接不包含这个字段。在 schema 里对可选字段做好默认值处理。
4. 超长输出被截断
如果要输出的 JSON 很长(比如一个包含 50 个元素的数组),可能超过 max_tokens 限制导致 JSON 不完整。解决方案是要么提高 max_tokens,要么在 prompt 里限制输出元素数量(”只输出前 10 条”)。
JSON Mode 不是什么高深的技术,但要做到生产级稳定,细节处理上需要花不少心思。选对工具链(Grammar 约束 + Schema 定义 + 后处理兜底),绝大部分场景都能搞定。