Hermes JSON Mode 使用指南:让模型输出结构化数据

详细讲解如何让 Hermes 模型稳定输出合法 JSON,涵盖 System Prompt 技巧、Schema 约束、常见陷阱和生产级最佳实践。

目录

  1. 模型输出 JSON 到底难在哪
  2. 最基础的方法:System Prompt 指令
  3. 用 JSON Schema 约束输出结构
  4. Few-Shot 示例:效果立竿见影
  5. llama.cpp 的 Grammar 约束
  6. vLLM 的 Guided Decoding
  7. Ollama 环境下的 JSON Mode
  8. 后处理:防御性 JSON 解析
  9. 实际应用场景
  10. temperature 和采样参数的影响
  11. 嵌套结构和复杂 Schema
  12. 和 Function Calling 的配合
  13. 常见踩坑记录

模型输出 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 # 包含 schema 定义
},
{
"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
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"])
# {"people": [{"name": "李明", "title": "高级算法工程师"}, {"name": "王芳", "title": "产品经理"}]}

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,处理各种常见问题"""

# 1. 尝试直接解析
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass

# 2. 去掉 markdown 代码块标记
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

# 3. 提取第一个完整的 JSON 对象
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

# 4. 处理单引号(模型偶尔会用单引号代替双引号)
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
# 推荐的 JSON Mode 参数
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 经常一起用。一个典型的模式是:

  1. 模型通过 Function Calling 获取外部数据
  2. 拿到数据后,用 JSON Mode 输出结构化的处理结果
1
2
3
4
5
6
# 第一步:工具调用获取原始数据
# (模型输出 <tool_call> 调用搜索 API)

# 第二步:拿到搜索结果后,要求模型以 JSON 格式输出摘要
follow_up_prompt = """根据搜索结果,输出以下JSON格式的摘要:
{"results_count": 数量, "top_results": [{"title": "标题", "relevance": 0-1分}], "summary": "一句话总结"}"""

这种两步式的 pipeline 比让模型一步完成所有事情要稳定得多。

常见踩坑记录

最后记录几个我和 cocoloop 社区的朋友们在实际使用中踩过的坑:

1. 中文字符编码问题

模型有时候会在 JSON 里输出 Unicode 转义(\u4f60\u597d),而不是直接的中文字符。如果下游系统不处理 Unicode 转义会出问题:

1
2
# 确保 JSON 输出使用中文而非 Unicode 转义
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 定义 + 后处理兜底),绝大部分场景都能搞定。

参与讨论

对这篇文章有疑问或想法?cocoloop 社区有不少开发者在讨论 Hermes 相关话题,欢迎加入交流。

前往 cocoloop 社区 →