本系列共 10 阶段 ,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段一 大模型基础 · 第 6 课
前面我们学习了 Prompt、消息角色和模型生成参数。现在进入 AI 应用开发中非常重要的一项能力:让大模型返回 Go 程序能够可靠处理的数据,而不仅仅是一段自然语言。
这一课会开始把 AI 和传统 Go 后端工程连接起来,为后面的 Tool Calling 做准备。
一、本课学习目标
学完之后,你应该能够:
- 理解普通文本输出与 JSON 结构化输出的区别。
- 使用 Go 的
encoding/json解析模型返回的数据。 - 区分 JSON 语法正确、字段合法和业务逻辑正确。
- 处理模型返回格式错误、缺少字段和非法枚举值等问题。
- 理解为什么真实项目中应优先使用模型服务支持的结构化输出能力,而不能只依赖 Prompt。
二、为什么需要结构化输出?
假设我们正在开发一个 Go AI 技术助手,用户问:
帮我判断这段问题属于什么技术领域,并给出简短总结:Redis 缓存穿透是什么?
如果模型返回普通文本:
text
这个问题属于 Redis 技术领域。
缓存穿透指查询一个不存在的数据,导致请求绕过缓存,频繁访问数据库。
人类可以直接理解,但 Go 程序如果想单独获取技术领域、难度和总结,就需要进一步解析文本。
如果模型返回 JSON:
json
{
"category": "redis",
"difficulty": "beginner",
"summary": "缓存穿透是指请求的数据不在缓存和数据库中,导致请求持续访问数据库。"
}
Go 程序就可以把它解析为结构体,并将字段用于业务逻辑。
例如:
category用于分类统计。difficulty用于决定解释深度。summary用于页面展示。- 校验通过后,可以将结果存入数据库。
这就是结构化输出的价值:让模型输出的数据能够被程序稳定地消费。
三、必须区分的三个层次
JSON 结构化输出不只是"让模型输出大括号"。
第一层:JSON 语法正确
例如双引号、逗号、括号等符合 JSON 语法。Go 能否解析它?
第二层:数据结构合法
必须包含需要的字段,字段类型正确,枚举值在允许范围内。
第三层:业务逻辑正确
即使 JSON 格式正确,内容也可能是错的。例如把"缓存穿透"错误地归类为"网络协议"。
这三层是逐步递进的。
json.Unmarshal 主要负责解析 JSON 和转换数据类型,它不会替你判断业务结论是否正确。
四、Go 实战:解析模型返回的 JSON
我们先不接入真实模型 API,而是用模拟响应验证 Go 后端的处理流程。
1. 创建项目
bash
mkdir go-json-output
cd go-json-output
go mod init example.com/go-json-output
新建 main.go,写入以下完整代码:
go
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
type AIResult struct {
Category string `json:"category"`
Difficulty string `json:"difficulty"`
Summary string `json:"summary"`
}
// 校验模型返回的数据是否符合业务要求。
func ValidateAIResult(result AIResult) error {
if strings.TrimSpace(result.Category) == "" {
return errors.New("category 不能为空")
}
if strings.TrimSpace(result.Summary) == "" {
return errors.New("summary 不能为空")
}
switch result.Category {
case "redis", "go", "mysql", "network":
// 合法分类
default:
return fmt.Errorf("不支持的 category: %q", result.Category)
}
switch result.Difficulty {
case "beginner", "intermediate", "advanced":
// 合法难度
default:
return fmt.Errorf("不支持的 difficulty: %q", result.Difficulty)
}
return nil
}
func ParseAIResult(raw string) (AIResult, error) {
var result AIResult
// 第一层:解析 JSON。
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return AIResult{}, fmt.Errorf("解析 JSON 失败: %w", err)
}
// 第二层:校验字段与业务约束。
if err := ValidateAIResult(result); err != nil {
return AIResult{}, fmt.Errorf("结果校验失败: %w", err)
}
return result, nil
}
func main() {
// 模拟模型返回的数据,不是真实模型 API 响应。
raw := `{
"category": "redis",
"difficulty": "beginner",
"summary": "缓存穿透是指请求的数据在缓存和数据库中都不存在。"
}`
result, err := ParseAIResult(raw)
if err != nil {
fmt.Println("处理模型响应失败:", err)
return
}
fmt.Println("解析成功")
fmt.Println("技术领域:", result.Category)
fmt.Println("难度:", result.Difficulty)
fmt.Println("总结:", result.Summary)
}
运行:
bash
go run .
预期输出:
text
解析成功
技术领域: redis
难度: beginner
总结: 缓存穿透是指请求的数据在缓存和数据库中都不存在。
2. 逐段解释关键代码
第一步:定义响应结构体
go
type AIResult struct {
Category string `json:"category"`
Difficulty string `json:"difficulty"`
Summary string `json:"summary"`
}
这里和你在 Gin 项目中定义请求 DTO、响应 DTO 的方式很相似。
JSON Tag 告诉 encoding/json:JSON 中的 category 对应 Go 结构体中的 Category。
如果 JSON 返回:
json
{"category":"redis"}
那么解析后:
go
result.Category == "redis"
需要注意,Go 的 string 字段如果没有出现在 JSON 中,通常会保持零值 ""。因此,解析成功不代表字段一定齐全。
第二步:解析 JSON
go
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return AIResult{}, fmt.Errorf("解析 JSON 失败: %w", err)
}
几个关键点:
[]byte(raw):把字符串转换为字节切片。&result:传入结构体指针,让Unmarshal将解析结果写入结构体。err != nil:处理语法错误或不兼容的类型等问题。%w:包装原始错误,便于后续使用errors.Is或errors.As检查错误链。
例如,如果模型返回:
text
{category: redis}
这不是合法 JSON,因为键名没有使用双引号,解析就会失败。
第三步:校验业务字段
go
switch result.Category {
case "redis", "go", "mysql", "network":
default:
return fmt.Errorf("不支持的 category: %q", result.Category)
}
JSON 即使能解析,也可能返回:
json
{
"category": "unknown_technology",
"difficulty": "beginner",
"summary": "这是一个技术问题。"
}
它在语法上完全正确,但 category 不属于我们允许的分类,所以需要拒绝。
这种设计与传统后端中的参数校验很相似:不能因为请求体能反序列化,就直接相信其中的数据。
第四步:分层返回错误
go
if err != nil {
fmt.Println("处理模型响应失败:", err)
return
}
真实项目中,你还可以将错误分类处理:
- JSON 解析失败:记录必要的诊断信息,视情况重试。
- 数据校验失败:拒绝进入后续业务流程。
- 模型调用超时或限流:由模型适配层处理。
- 模型返回的结论不可信:通过检索、规则校验或人工确认来处理。
不要遇到所有错误都无限重试。重试次数、超时和成本都需要有上限。
五、动手验证:哪些响应应该通过?
把下面几组模拟响应依次替换进 main.go 的 raw 变量,运行程序,对照检查是否符合预期。下表结果是根据上面的校验规则推导的,建议你亲手跑一遍确认。
| 测试数据 | 预期结果 | 原因 |
|---|---|---|
{"category":"redis","difficulty":"beginner","summary":"解释缓存穿透。"} |
✅ 通过 | 语法正确、字段齐全、枚举值合法 |
{"category":"redis","difficulty":"beginner",} |
❌ 解析失败 | 末尾多余逗号,不是合法 JSON |
{"category":"redis","difficulty":"beginner"} |
❌ 校验失败 | 缺少 summary,被解析成空字符串后拦下 |
{"category":"unknown_technology","difficulty":"beginner","summary":"..."} |
❌ 校验失败 | category 不在允许的分类白名单里 |
{"category":"redis","difficulty":"expert","summary":"..."} |
❌ 校验失败 | difficulty 不是允许的三个取值之一 |
第 3 行是最容易被忽略的一种情况:json.Unmarshal 没有报错,但数据已经不可用,只能靠第二层校验兜住。
六、Prompt 要求 JSON,为什么还不够?
你可能会想:既然我能在 Prompt 里要求模型"只返回 JSON",为什么还要写这么多校验代码?
原因是 Prompt 本质上是给模型的指令,而不是 Go 编译器或数据库约束。
模型可能出现以下问题:
- 输出 Markdown 代码围栏,例如 ```````json````。
- 忘记必需字段。
- 返回不符合约定的枚举值。
- 输出格式正确、内容却错误的数据。
- 因输出长度达到上限而被截断。
真实项目中,推荐优先级如下:
- API 原生结构化输出能力:如果模型服务支持 JSON Schema 或严格结构化输出,优先使用。
- Go 后端解析和校验:即使服务承诺结构化输出,也要对实际响应进行错误处理和业务校验。
- 有限重试或降级:对可恢复的格式错误,可以有次数限制地重新请求;业务内容错误则不能仅靠反复重试保证正确。
还有一点:如果模型返回的内容被截断,单纯把它强行解析成 JSON 通常无法解决问题。应该检查响应的结束原因和 Token 限制,必要时重新设计输出大小或请求策略。
结构化输出与 Tool Calling 的关系
后续我们会学习 Tool Calling。模型发起工具调用时,通常也需要按照约定的数据结构表达工具名称和参数。
两者存在共同点:都需要模型按照程序约定提供结构化信息。
但两者不是同一回事:
- 结构化输出:模型返回符合约定的数据。
- Tool Calling:模型提出工具调用请求,应用程序校验请求并决定是否执行工具。
模型输出了一个工具调用请求,不等于工具已经执行。真正执行操作的仍然是 Go 后端。
七、工程注意事项
结合你熟悉的 Gin、GORM 和 MySQL 项目,真实服务中还需要关注:
- 不要直接信任模型输出:进入数据库、权限判断或其他业务操作前必须校验。
- 不要把解析成功当作事实正确:格式校验和事实验证是两件事。
- 控制重试成本:为重试次数和总耗时设置上限。
- 保护敏感信息:记录模型错误时,不要无差别记录 API Key、用户隐私或完整敏感上下文。
- 保留可观测性:至少关注模型耗时、错误类型、Token 使用量和重试次数。
- 使用服务原生能力:实际接入模型时,根据对应 API 的官方文档使用结构化输出功能,不要凭空假设字段名称。
八、本课总结
本课完成了一个最小的 Go JSON 处理实验:
text
模拟模型响应 → JSON 解析 → 字段校验 → 业务校验 → 返回结果
你需要掌握的重点是:
json.Unmarshal负责将 JSON 转成 Go 数据结构。- JSON 语法正确,不代表字段齐全。
- 字段合法,也不代表业务结论正确。
- Prompt 可以提高格式遵循程度,但不能替代程序校验。
- 原生结构化输出能力和 Go 后端校验应该配合使用。
九、课后练习
练习 1
模型返回了合法 JSON:
json
{
"category": "redis",
"difficulty": "beginner"
}
json.Unmarshal 是否一定报错?为什么?
参考思路 :不会报错 。JSON 语法完全合法,只是少了一个字段;
Summary会保持string的零值""。这也是结构化输出最隐蔽的坑------解析成功的绿灯会让人误以为数据可用。真正拦住它的是第二层校验(strings.TrimSpace(result.Summary) == ""时报错)。如果业务要求字段必填,也可以把字段声明成指针或自定义类型,用nil判断"没传"和"传了空串"的区别。
练习 2
模型返回:
json
{
"category": "mysql",
"difficulty": "beginner",
"summary": "Redis 是关系型数据库。"
}
JSON 解析成功,业务字段也都存在。这个结果是否一定可以接受?你会如何处理?
参考思路 :不能接受 。三层校验它只过了两层:语法合法、字段合法、业务结论错误。程序校验抓不到语义错误,这正是幻觉最难处理的地方。处理方向:① 涉及事实性结论时走 RAG,要求给出可核查来源;② 用规则或交叉校验兜底(例如分类与 summary 关键词一致性检查);③ 高风险场景加人工确认;④ 记录模型输出与最终结果,用真实失败案例回头评测 Prompt 和模型。
练习 3
在一个 Gin 接口中,模型返回的 JSON 解析失败了。你会如何设计错误处理,避免直接把错误数据写入 MySQL,同时避免无限重试?
参考思路 :分三层。① 解析层:
json.Unmarshal失败立即返回,不进业务逻辑;② 重试层:只对"可恢复"错误(截断、围栏包裹)做至多 1 次重试,并设总耗时上限;③ 降级层:重试仍失败就返回明确的业务错误码,前端展示兜底文案,绝不把半成品写库。写入 MySQL 前再加一道事务 + 校验,保证"要么完整数据,要么什么都没有"。
下一篇:Go AI Agent 实战 07|模型幻觉、能力边界与成本控制