大模型JSON输出双引号陷阱与多层防御指南

大模型在生成结构化数据方面表现很好,但在实践中我发现了一个高频且隐蔽的陷阱:当要求大模型按指定 JSON格式输出时,如果某个字段的值中恰好包含英文双引号,整个 JSON 就会因语法错误而解析失败。

1
2
3
4
{
"message": "这颗宝石被称为"海上明珠",现在价值连城",
"time": "今天晚上"
}

可是看到,”海上明珠”由于被双引号包裹,导致了JSON的解析失败。这会导致后续的代码在取值时发生一连串错误,甚至程序直接崩溃。我总结了一些应对的措施和技巧:

一、提示词层面的限制

  1. 直接替换内部引号(最实用)

对于中文语境,最简单的办法是禁止模型在字段值中使用英文双引号:

1
2
3
请将结果以JSON格式输出。
注意:JSON键值对中的文本内容如果包含引号,必须使用中文双引号(“”)或英文单引号(''),
绝对禁止在文本内容中使用英文双引号("")。
  1. 强制强调转义字符(\"

如果必须保留英文双引号,需要在 Prompt 中严厉且明确地告知模型转义规则:

1
2
3
输出必须是合法的JSON。
如果JSON的值(value)中包含英文双引号,请务必使用反斜杠进行转义(即写成 \\\" )。
例如:{\"message\": \"He said, \\\"Hello!\\\"\"}。
  1. 提供 Few-Shot 示例(效果可能较差)

Few-Shot是被验证的、可以使大模型的输出尽可能贴合给定的格式。在 Prompt 中给出一个包含复杂引号且正确转义的 JSON 示例,能大幅提高成功率。比如:

1
2
3
请参考以下标准格式输出:
输入:用户说了一句"你好呀"
输出:{"user_input": "用户说了一句\"你好呀\""}

提示词方案的局限性: 以上方法均依赖模型的”理解”与”遵循”,并不能 100%保证。对于对稳定性要求高的生产环境,提示词只应作为辅助手段,而非核心防线。

二、 API 与模型原生特性的利用(最根本的解决方案)

  1. 使用 Function Calling / Tool Calling

这是目前生成结构化数据最稳定的做法。通过定义一个包含 JSON Schema 的 Function/Tool,将需要大模型填写的 JSON 结构定义为工具的参数。大模型在返回 tool_calls 时,生成的不是自由文本,而是针对参数槽位的填充——内部双引号的转义由模型厂商的API 在底层自动处理,你拿到的直接是可解析的结构体,彻底绕过了文本解析这一环节。

  1. 开启 JSON Mode / Structured Outputs

各大模型厂商为了解决这个痛点,推出了强制 JSON 模式。以 OpenAI 格式为例:

  • JSON Mode: 在 API 调用时指定特定的参数 response_format={"type": "json_object"},模型被约束为只能输出合法的 JSON字符串,遇到语法错误时会在内部尝试修复。
1
2
3
4
5
response = client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[...]
)
  • Structured Outputs(结构化输出): 直接传入一个严格的 JSON Schema,模型生成的每一个 Token 都会受到 Schema 校验(通过受限解码实现),确保输出 100% 符合 JSON 语法规范且不会出现引号解析错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
response = client.chat.completions.create(                                                                                
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "extraction_result",
"strict": True,
"schema": {
"type": "object",
"properties": {
"message": {"type": "string"},
"time": {"type": "string"}
},
"required": ["message", "time"],
"additionalProperties": False
}
}
},
messages=[...]
)

三、更换数据传输格式

如果业务不要求一定要用JSON,那么换一个对引号更友好的格式往往是最省力的解法。

  1. 使用 YAML 代替 JSON

YAML 对多行文本和内部引号极其宽容。如果文本内容没有首尾换行,YAML 甚至不需要在外层加引号。无论里面有多少英文双引号,YAML 都能轻松 hold 住。在代码端,接收到 YAML 字符串后,用常规的 YAML 解析库将其转为字典(Dict/Object),然后再转回 JSON 即可。

  1. 使用 XML 或 Markdown 表格

如果结构简单,让模型输出 <field>包含"双引号"的文本</field>,然后再通过正则或 XML 解析器提取,能完全避开 JSON 语法的脆弱性。

四、代码层面的工程兜底

无论上游做了多少防护,在业务场景中往往需要最后的兜底机制。

  1. 使用宽容的 JSON 解析器(Lenient Parsers)

不要使用标准库中极其严格的 json.loads。可以使用一些带有纠错能力的第三方库,它们可以智能识别并修复未转义的引号、缺失的逗号或多余的尾随逗号。

  • json-repair:专为修复 LLM 输出而设计,社区活跃,能处理未转义引号、缺失逗号、多余尾随逗号及 Markdown
    代码块包裹等常见问题,是目前最推荐的选择。
  • dirtyjson:同样具备智能修复能力,可处理多种非标准 JSON 格式。
  1. 正则表达式暴力修复(最后手段)

如果你的 JSON 结构比较固定,可以在解析前用代码拦截并替换掉错误的引号。使用正则表达式定位出位于 ":"",""}" 之间的内容片段,将这些片段内部的 " 强行替换为 \" 或中文引号。但是这种方案逻辑比较复杂,可能会出错。

五、LangChain是怎么做的

理解框架内部机制,有助于我们做出更好的工程选择。

  1. 利用底层 API 的 Function Calling

这是目前 LangChain 推荐的最新且最稳定的做法。当你调用 .with_structured_output(PydanticModel) 时,LangChain 并不会在 Prompt 里求着模型输出 JSON。相反,它会将你的 Pydantic 结构转换为 OpenAI (或其它模型) 的 Tool Calling / Function Calling 参数 schema。模型在底层生成时,是针对参数位进行填充的,这使得内部双引号的转义由模型厂商的 API 接口在底层保证,极大地避免了语法错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. 定义期望的结构化输出 Schema
class DialogueExtraction(BaseModel):
speaker: str = Field(description="说话人的名字")
quote: str = Field(description="说的原话,必须精确提取。")

# 2. 绑定结构化输出!这是魔法发生的地方
structured_llm = llm.with_structured_output(DialogueExtraction)

# 3. 测试用例:用户输入了极其复杂的嵌套引号文本
test_text = """
Lee come and say: what are you doing? It called "The Great Wall". You know what im saying?
"""

# 4. 执行调用
result = structured_llm.invoke(test_text)

print(result)
# 这是打印的输出:
# speaker="Lee (a character in a conversation or story, likely pronounced like the name 'Lee') or a storyteller describing Lee's action. The second half might still be Lee's speech or the narrator's explanation." quote="Lee come and say: what are you doing? It called 'The Great Wall'."
  1. 极致的 Prompt 注入(降级方案)

如果使用的模型不支持 Tool Calling,LangChain 会回退到文本解析。PydanticOutputParser 会根据你的 Pydantic 模型,自动生成一段极其严密的“格式说明(format instructions)”注入到系统提示词中。这段说明会明确规定 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
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate

# 1. 定义预期schema结构
class DialogueExtraction(BaseModel):
speaker: str = Field(description="说话人的名字")
quote: str = Field(description="说的原话,必须精确提取。")

# 2. 输出解析器
parser = PydanticOutputParser(pydantic_object=DialogueExtraction)

# 3. 定义提示词prompt
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个天气助手,请严格按照指定格式输出 JSON,不要输出其他任何内容。\n\n{format_instructions}"),
("human", "{question}"),
]).partial(format_instructions=parser.get_format_instructions())

# 这时候的prompt就是这样的了
# 'The output should be formatted as a JSON instance that conforms to the JSON schema below.\n\nAs an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}\nthe object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.\n\nHere is the output schema:\n```\n{"properties": {"speaker": {"description": "说话人的名字", "title": "Speaker", "type": "string"}, "quote": {"description": "说的原话,必须精确提取。", "title": "Quote", "type": "string"}}, "required": ["speaker", "quote"]}\n```'

# 4. 输入提问
test_text = """
Lee come and say: what are you doing? It called "The Great Wall". You know what im saying?
"""

# 5. LCEL 链:prompt → llm → parser
chain = prompt | llm | parser
report: DialogueExtraction = chain.invoke({"question": test_text})

print(report)
# 这是打印的输出:
# speaker='Lee' quote='what are you doing? It called "The Great Wall". You know what im saying?'
  1. 自带的 JSON 解析器

大模型经常喜欢在 JSON 外面包裹 Markdown 标记(如 json ... ),或者在前后加一些废话。LangChain 内部封装的 langchain_core.utils.json.parse_json_markdown 方法会自动剥离这些无关字符,只提取核心 JSON 字符串进行解析。

六、vLLM 的约束解码(Constrained Decoding)

**约束解码 / 状态机解码(强保证 / 物理隔离)**可以保证在模型生成下一个 Token 的预测阶段(Logits 处理),直接把不符合 JSON 语法或预设 Schema 的 Token 的概率修改为 0。

具体流程有几个关键步骤:

  1. 从 Schema 到正则表达式 (Regex)

首先,你需要提供一个 Pydantic 模型或 JSON Schema。系统会将这个结构化的 Schema 转换成一个庞大但严谨的正则表达式。例如,如果要求输出{"age": int},系统会推导出类似\{\s*"age"\s*:\s*[0-9]+\s*\}的正则模式。

  1. 从 Regex 到有限状态机 (FSM)

正则表达式会被进一步编译成一个确定性有限自动机(DFA)
在这个状态机中:

  • 节点(状态): 代表当前已经生成的文本处于什么合法阶段。
  • 边(转移): 代表下一个允许生成的字符。
    例如:初始状态只允许接受 {。接收 { 后进入状态 1,状态 1 只允许接受空格或 "
  1. 词表索引构建 (Vocabulary Indexing)

这是状态机解码中最巧妙也最耗资源的一步。大模型的输出不是逐字符的,而是逐 Token 的(一个 Token 可能包含多个字符,如 {"age":)。

  • 系统会遍历大模型的整个词表(例如 Qwen 的 15 万个 Token)。
  • 它会预先计算或缓存每个 Token 在 FSM 中会触发哪些状态转移。
  • 如果某个 Token 的字符序列会导致 FSM 进入“死胡同”(非法状态),那么在当前状态下,这个 Token 就是非法的。
  1. 动态 Logits 掩码 (Logits Masking)

在推理引擎的连续批处理(Continuous Batching)循环中,每当模型计算完前向传播(Forward Pass)输出下一个 Token 的概率分布(Logits)时,会插入一个 Logits Processor

  • 引擎查询当前序列在 FSM 中处于哪个状态。
  • 引擎查表得出:在当前状态下,词表中有哪些 Token 是合法的。
  • 对于所有非法 Token,将其 Logit 值强制修改为负无穷。
  • 模型执行采样(如 Greedy, Top-p, Temperature),由于非法 Token 的概率被降为 0,模型只能在合法的 Token 中做出选择。
  1. 状态更新

选出合法的 Token 后,将该 Token 的字符送入 FSM,状态机向前移动到下一个状态。循环往复,直到 FSM 到达终止状态(例如 JSON 的最后一个 } 被闭合)。

总结

解决大模型输出 JSON 时因内部双引号导致解析崩溃的问题,最佳的工程实践是构建多重防御体系:最核心且治本的方法是利用模型原生的功能调用(Function Calling / 结构化输出模式)以及 LangChain 的 with_structured_output() 接口,将数据校验交由底层 API 自动处理以彻底规避语法错误;其次,可通过提示词明确约束转义规则或直接改用对引号更宽容的 YAML 格式进行格式转写;在后续代码中使用 dirtyjson 等宽容的解析器作为最终兜底,从而确保结构化数据的稳定生成与解析。当然,如果使用vLLM部署大模型,其框架内部已经做了对 JSON 的约束解码。