在构建任何需要大模型做结构化输出的系统时,你迟早会遇到同一个问题:
大模型不是数据库,它的输出是概率性的,而不是确定性的。
你在 Prompt 里写了"请返回 JSON 格式",它有时候给你干净的 JSON,有时候给你 JSON 外面包一层 Markdown 代码块,有时候在 JSON 前面加一段解释文字,有时候在 JSON 后面补一句"以上是我的回答",有时候干脆返回了格式完全错误的内容。
如果你的代码对此没有任何防御,json.Unmarshal 就会直接报错,整个业务流程就断了。
这篇文章系统性地梳理了在 Go 语言里可靠解析 LLM JSON 输出的完整方案,从最基础的防御手段到生产环境里的最终实践,逐层递进。
一、先搞清楚"脏输出"到底有多脏
在开始讨论解决方案之前,我们先直视问题本身。LLM 的 JSON 输出究竟会脏到什么程度?
常见的脏输出类型
1. Markdown 代码块包裹
这是最常见的情况,尤其是当你的 Prompt 里有代码示例时,模型会"礼貌地"把输出也包进代码块:
以下是您需要的 JSON:
```json
{"action": "move", "target_x": 10, "target_y": 5}
希望对您有帮助!
**2. 前缀废话**
模型在 JSON 前面加了一段它认为"有用"的说明:
根据当前场景分析,我建议如下行动:
{"action": "talk", "speak": "今天天气不错"}
**3. 后缀废话**
JSON 之后还有内容:
{"action": "stay", "target_x": 0, "target_y": 0}
注意:以上坐标为原地静止,如需移动请提供目标位置。
**4. 多个 JSON 对象**
模型输出了多个 JSON 块,只有第一个是有效的:
初步决策:{"action": "move", "target_x": 3}
备用方案:{"action": "stay", "target_x": 0}
**5. JSON 内部格式错误**
最难处理的一类——结构本身有问题,比如多了逗号、字符串没闭合、或者字段名称写错了:
{"action": "talk", "speak": "你好,}
了解了这些"敌人"的形态,才能有针对性地设计防御策略。
---
## 二、第一层防线:让模型不那么乱
解析层面的防御固然必要,但如果能在源头减少脏输出的概率,问题就会简单很多。
### 2.1 使用 JSON Mode(结构化输出强制模式)
现代主流 LLM API 都提供了某种形式的"强制 JSON 输出"模式。在 OpenAI 兼容接口里(包括很多国内厂商),这叫做 `response_format`:
```go
req := openai.ChatCompletionRequest{
Model: model,
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
},
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
}
启用 JSON Mode 之后,模型在大多数情况下会直接返回一个合法的 JSON 对象,不再附加额外的解释文字。
但这不是万能的。
首先,不是所有厂商都支持这个参数;其次,即使支持,也有模型会在 JSON 外面包一层 Markdown 代码块(这依然是合法的"JSON 内容",只不过不是有效的 JSON 字符串);第三,部分模型对这个参数的遵守程度因版本而异。
JSON Mode 是重要的第一道防线,但绝不是最后一道。
2.2 在 Prompt 里强化格式约束
除了 API 参数,Prompt 的写法也很关键。以下几个技巧可以显著降低脏输出的概率:
明确说"只返回 JSON,不要说其他的":
请严格按照以下 JSON 格式返回你的决策,不要包含任何其他文字、解释或 Markdown 代码块:
{
"action": "move | stay | talk",
"speak": "如果是 talk,填写说话内容,否则留空字符串",
"target_x": 目标X坐标(整数),
"target_y": 目标Y坐标(整数)
}
给出一个具体的示例输出:
少样本学习(Few-shot)比纯粹的指令描述效果更好。在 Prompt 里附上一个或两个标准的示例 JSON,模型会更倾向于模仿格式:
示例输出(仅供格式参考,不要照抄内容):
{"action": "move", "speak": "", "target_x": 5, "target_y": 3}
降低 Temperature(采样温度):
Temperature 控制模型输出的随机性,数值越高输出越"有创意",但同时格式遵从度越低。对于需要结构化输出的场景,把 Temperature 调低(0.1 ~ 0.3)会让模型更老实地遵守格式要求。
三、第二层防线:Go 代码里的解析防御
即使做了第一层的防御,脏输出依然可能出现。这时候就需要在解析代码里下功夫了。
3.1 最朴素的方案:直接 Unmarshal
很多人第一反应是这样写:
go
var result MyStruct
err := json.Unmarshal([]byte(respText), &result)
if err != nil {
return nil, err
}
这段代码的问题是:任何一点脏内容(哪怕只是前面多了一个空格或一个字符),都会直接导致 Unmarshal 失败。脆弱性极高。
3.2 进阶方案:找到第一个 {,用 Decoder 解析
这是我们在生产环境里真正使用的方案,也是我认为最优雅的写法:
go
func parseJSONResponse(respText string, target any) error {
// 第一步:找到第一个 "{" 的位置
// 这一步可以过滤掉 JSON 之前的所有前缀废话和 Markdown 标记
start := strings.Index(respText, "{")
if start == -1 {
return fmt.Errorf("LLM response contains no JSON object: %s", respText)
}
// 第二步:使用 json.Decoder 而非 json.Unmarshal
// Decoder 天生只读取 Reader 中的第一个合法 JSON 对象
// 遇到第一个完整的 JSON 对象后就停止,后续所有内容(废话、多余的 JSON)被自动忽略
decoder := json.NewDecoder(strings.NewReader(respText[start:]))
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("JSON decode failed: %w, raw response: %s", err, respText)
}
return nil
}
核心技巧一:strings.Index(respText, "{")
找到第一个 { 的位置,然后从这里开始解析。这一步能处理以下几类脏输出:
- 前缀的说明文字
- `````json` 之类的 Markdown 代码块标记
- 各种开头的空白字符
只需一行代码,过滤掉几乎所有的"前缀污染"。
核心技巧二:json.Decoder 替代 json.Unmarshal
这是整个方案里最关键的一个选择,值得多说几句。
json.Unmarshal 要求整个输入是一个合法的 JSON 值。任何多余的内容都会报错。
json.Decoder 则不同——它把输入当成一个流(Stream),从流的开头读取第一个合法的 JSON 值,读完之后立即停止,完全不在意后面还有什么。
这意味着:
- JSON 后面有废话?没关系,Decoder 不读。
- JSON 后面还有另一个 JSON 对象?没关系,Decoder 只取第一个。
- JSON 后面有 Markdown 的三个反引号?没关系,Decoder 停在 JSON 结束的位置。
一句话总结:json.Decoder 是宽容的流式解析器,json.Unmarshal 是严格的全量解析器。对付 LLM 输出,前者是更合适的工具。
四、处理 JSON 数组的情况
上面的方案针对的是 JSON 对象({...})。如果 LLM 返回的是 JSON 数组([...]),只需要把查找的目标字符从 { 改成 [:
go
func parseJSONArrayResponse(respText string, target any) error {
start := strings.Index(respText, "[")
if start == -1 {
return fmt.Errorf("LLM response contains no JSON array: %s", respText)
}
decoder := json.NewDecoder(strings.NewReader(respText[start:]))
return decoder.Decode(target)
}
如果不确定模型返回的是对象还是数组,可以同时找两个,取下标更小的那个作为起点:
go
startObj := strings.Index(respText, "{")
startArr := strings.Index(respText, "[")
start := -1
if startObj != -1 && startArr != -1 {
if startObj < startArr {
start = startObj
} else {
start = startArr
}
} else if startObj != -1 {
start = startObj
} else if startArr != -1 {
start = startArr
}
五、处理 Markdown 代码块的另一种思路
有些场景下,模型一定会返回 Markdown 代码块(比如当你让它写代码的时候),此时"找第一个 {"的策略可能不够用。一个更明确的方案是先剥离 Markdown 代码块标记,再做 JSON 解析:
go
func stripMarkdownCodeBlock(s string) string {
// 去除 ```json 或 ``` 开头
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
// 找到第一行的结尾(即 ```json 这一行的末尾)
firstNewline := strings.Index(s, "\n")
if firstNewline != -1 {
s = s[firstNewline+1:]
}
}
// 去除末尾的 ```
if strings.HasSuffix(s, "```") {
s = s[:len(s)-3]
}
return strings.TrimSpace(s)
}
这个函数和"找第一个 {"的策略可以叠加使用,形成双重过滤:
go
clean := stripMarkdownCodeBlock(respText)
err := parseJSONResponse(clean, &result)
六、当 JSON 本身格式有问题时
以上方案都假设 JSON 的内容是合法的,只是外面有一些噪声。但如果 JSON 结构本身就破损了(缺括号、字符串未闭合等),就进入了最难的情况。
这时候有几种思路:
方案 A:重试
最简单也是最实用的方案——遇到解析失败,直接重新调用 LLM。通常第二次或第三次模型会给出更规范的输出。
go
var result MyStruct
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
respText, err := callLLM(ctx, prompt)
if err != nil {
lastErr = err
continue
}
if err = parseJSONResponse(respText, &result); err == nil {
return &result, nil
}
lastErr = err
time.Sleep(time.Duration(1<<attempt) * time.Second) // 指数退避
}
return nil, fmt.Errorf("failed after 3 attempts: %w", lastErr)
方案 B:在 Prompt 里附上错误信息,要求模型修正
如果解析失败,把失败的原文和错误信息一起发给模型,让它自己修正:
你上一次的输出无法被解析为 JSON,原因是:[错误信息]
你的原始输出是:
[原文]
请严格按照 JSON 格式重新输出,不要包含任何额外内容。
这个方案在某些情况下效果比盲重试更好,因为模型能"看到"自己的错误。
方案 C:使用第三方宽容解析库
对于一些结构性的小问题(比如末尾多了逗号),Go 生态里有一些宽容的 JSON 解析库可以处理,比如 json5。但引入额外依赖的代价和收益需要自己权衡。
七、生产实践中的完整流程
综合以上所有技术,一个在生产环境中可用的 LLM JSON 解析流程大概是这样的:
调用 LLM API
↓
是否启用了 JSON Mode?(能用就用)
↓
去除 Markdown 代码块(如果需要)
↓
找到第一个 { 或 [
↓
用 json.Decoder 解析
↓
├── 成功 → 返回结果
└── 失败 → 带错误信息重试(最多 N 次)
↓
继续失败 → 记录日志,返回错误,触发降级逻辑
关于降级逻辑: 如果所有重试都失败了,不要让整个程序崩溃。根据业务场景,可以返回一个合理的默认值(比如"原地待机"决策),同时记录详细的日志供后续排查。健壮性 > 完美性。
总结
| 策略 | 解决的问题 | 成本 |
|---|---|---|
JSON Mode (response_format) |
从源头减少脏输出 | 低(一个 API 参数) |
| Prompt 格式约束 + 示例 | 进一步提高格式遵从度 | 低(Prompt 修改) |
strings.Index 找起点 |
过滤前缀噪声 | 极低(一行代码) |
json.Decoder 替代 Unmarshal |
忽略后缀噪声,只取第一个 JSON | 极低(三行代码) |
| Markdown 代码块剥离 | 处理 ``` 包裹 | 低 |
| 带错误信息的重试 | 处理偶发性格式错误 | 中(增加延迟和 API 成本) |
没有哪一个方案是银弹。但"找第一个 { + 用 json.Decoder 解析"这个组合,用极低的代码复杂度处理了 90% 以上的脏输出场景,是我个人最推荐的基础防线。
在这个基础上,加上 JSON Mode 参数和合理的重试机制,基本上就能应对生产环境里的各种情况。
驯服 LLM 的输出,本质上是在和概率性系统打交道。保持足够的防御意识,同时不把代码写得过于复杂,才是最务实的工程态度。