本系列共 10 阶段 ,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段一 大模型基础 · 第 6 课
上一篇:Temperature 与生成参数
前面我们学习了 Prompt、消息角色和模型生成参数。现在进入 AI 应用开发中非常重要的一项能力:让大模型返回 Go 程序能够可靠处理的数据,而不仅仅是一段自然语言。
这一课会开始把 AI 和传统 Go 后端工程连接起来,为后面的 Tool Calling 做准备。
一、本课学习目标
学完之后,你应该能够:
- 理解普通文本输出与 JSON 结构化输出的区别。
- 使用 Go 的
encoding/json解析模型返回的数据。 - 区分 JSON 语法正确、字段合法和业务逻辑正确。
- 处理模型返回格式错误、缺少字段和非法枚举值等问题。
- 理解为什么真实项目中应优先使用模型服务支持的结构化输出能力,而不能只依赖 Prompt。
二、为什么需要结构化输出?
假设我们正在开发一个 Go AI 技术助手,用户问:
帮我判断这段问题属于什么技术领域,并给出简短总结:Redis 缓存穿透是什么?
如果模型返回普通文本:
这个问题属于 Redis 技术领域。 缓存穿透指查询一个不存在的数据,导致请求绕过缓存,频繁访问数据库。人类可以直接理解,但 Go 程序如果想单独获取技术领域、难度和总结,就需要进一步解析文本。
如果模型返回 JSON:
{"category":"redis","difficulty":"beginner","summary":"缓存穿透是指请求的数据不在缓存和数据库中,导致请求持续访问数据库。"}Go 程序就可以把它解析为结构体,并将字段用于业务逻辑。
例如:
category用于分类统计。difficulty用于决定解释深度。summary用于页面展示。- 校验通过后,可以将结果存入数据库。
这就是结构化输出的价值:让模型输出的数据能够被程序稳定地消费。
三、必须区分的三个层次
JSON 结构化输出不只是"让模型输出大括号"。
第一层:JSON 语法正确
例如双引号、逗号、括号等符合 JSON 语法。Go 能否解析它?
第二层:数据结构合法
必须包含需要的字段,字段类型正确,枚举值在允许范围内。
第三层:业务逻辑正确
即使 JSON 格式正确,内容也可能是错的。例如把"缓存穿透"错误地归类为"网络协议"。
这三层是逐步递进的。
json.Unmarshal主要负责解析 JSON 和转换数据类型,它不会替你判断业务结论是否正确。
四、Go 实战:解析模型返回的 JSON
我们先不接入真实模型 API,而是用模拟响应验证 Go 后端的处理流程。
1. 创建项目
mkdirgo-json-outputcdgo-json-output go mod init example.com/go-json-output新建main.go,写入以下完整代码:
packagemainimport("encoding/json""errors""fmt""strings")typeAIResultstruct{Categorystring`json:"category"`Difficultystring`json:"difficulty"`Summarystring`json:"summary"`}// 校验模型返回的数据是否符合业务要求。funcValidateAIResult(result AIResult)error{ifstrings.TrimSpace(result.Category)==""{returnerrors.New("category 不能为空")}ifstrings.TrimSpace(result.Summary)==""{returnerrors.New("summary 不能为空")}switchresult.Category{case"redis","go","mysql","network":// 合法分类default:returnfmt.Errorf("不支持的 category: %q",result.Category)}switchresult.Difficulty{case"beginner","intermediate","advanced":// 合法难度default:returnfmt.Errorf("不支持的 difficulty: %q",result.Difficulty)}returnnil}funcParseAIResult(rawstring)(AIResult,error){varresult AIResult// 第一层:解析 JSON。iferr:=json.Unmarshal([]byte(raw),&result);err!=nil{returnAIResult{},fmt.Errorf("解析 JSON 失败: %w",err)}// 第二层:校验字段与业务约束。iferr:=ValidateAIResult(result);err!=nil{returnAIResult{},fmt.Errorf("结果校验失败: %w",err)}returnresult,nil}funcmain(){// 模拟模型返回的数据,不是真实模型 API 响应。raw:=`{ "category": "redis", "difficulty": "beginner", "summary": "缓存穿透是指请求的数据在缓存和数据库中都不存在。" }`result,err:=ParseAIResult(raw)iferr!=nil{fmt.Println("处理模型响应失败:",err)return}fmt.Println("解析成功")fmt.Println("技术领域:",result.Category)fmt.Println("难度:",result.Difficulty)fmt.Println("总结:",result.Summary)}运行:
go run.预期输出:
解析成功 技术领域: redis 难度: beginner 总结: 缓存穿透是指请求的数据在缓存和数据库中都不存在。2. 逐段解释关键代码
第一步:定义响应结构体
typeAIResultstruct{Categorystring`json:"category"`Difficultystring`json:"difficulty"`Summarystring`json:"summary"`}这里和你在 Gin 项目中定义请求 DTO、响应 DTO 的方式很相似。
JSON Tag 告诉encoding/json:JSON 中的category对应 Go 结构体中的Category。
如果 JSON 返回:
{"category":"redis"}那么解析后:
result.Category=="redis"需要注意,Go 的string字段如果没有出现在 JSON 中,通常会保持零值""。因此,解析成功不代表字段一定齐全。
第二步:解析 JSON
iferr:=json.Unmarshal([]byte(raw),&result);err!=nil{returnAIResult{},fmt.Errorf("解析 JSON 失败: %w",err)}几个关键点:
[]byte(raw):把字符串转换为字节切片。&result:传入结构体指针,让Unmarshal将解析结果写入结构体。err != nil:处理语法错误或不兼容的类型等问题。%w:包装原始错误,便于后续使用errors.Is或errors.As检查错误链。
例如,如果模型返回:
{category: redis}这不是合法 JSON,因为键名没有使用双引号,解析就会失败。
第三步:校验业务字段
switchresult.Category{case"redis","go","mysql","network":default:returnfmt.Errorf("不支持的 category: %q",result.Category)}JSON 即使能解析,也可能返回:
{"category":"unknown_technology","difficulty":"beginner","summary":"这是一个技术问题。"}它在语法上完全正确,但category不属于我们允许的分类,所以需要拒绝。
这种设计与传统后端中的参数校验很相似:不能因为请求体能反序列化,就直接相信其中的数据。
第四步:分层返回错误
iferr!=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 处理实验:
模拟模型响应 → JSON 解析 → 字段校验 → 业务校验 → 返回结果你需要掌握的重点是:
json.Unmarshal负责将 JSON 转成 Go 数据结构。- JSON 语法正确,不代表字段齐全。
- 字段合法,也不代表业务结论正确。
- Prompt 可以提高格式遵循程度,但不能替代程序校验。
- 原生结构化输出能力和 Go 后端校验应该配合使用。
九、课后练习
练习 1
模型返回了合法 JSON:
{"category":"redis","difficulty":"beginner"}json.Unmarshal是否一定报错?为什么?
参考思路:不会报错。JSON 语法完全合法,只是少了一个字段;
Summary会保持string的零值""。这也是结构化输出最隐蔽的坑——解析成功的绿灯会让人误以为数据可用。真正拦住它的是第二层校验(strings.TrimSpace(result.Summary) == ""时报错)。如果业务要求字段必填,也可以把字段声明成指针或自定义类型,用nil判断"没传"和"传了空串"的区别。
练习 2
模型返回:
{"category":"mysql","difficulty":"beginner","summary":"Redis 是关系型数据库。"}JSON 解析成功,业务字段也都存在。这个结果是否一定可以接受?你会如何处理?
参考思路:不能接受。三层校验它只过了两层:语法合法、字段合法、业务结论错误。程序校验抓不到语义错误,这正是幻觉最难处理的地方。处理方向:① 涉及事实性结论时走 RAG,要求给出可核查来源;② 用规则或交叉校验兜底(例如分类与 summary 关键词一致性检查);③ 高风险场景加人工确认;④ 记录模型输出与最终结果,用真实失败案例回头评测 Prompt 和模型。
练习 3
在一个 Gin 接口中,模型返回的 JSON 解析失败了。你会如何设计错误处理,避免直接把错误数据写入 MySQL,同时避免无限重试?
参考思路:分三层。① 解析层:
json.Unmarshal失败立即返回,不进业务逻辑;② 重试层:只对"可恢复"错误(截断、围栏包裹)做至多 1 次重试,并设总耗时上限;③ 降级层:重试仍失败就返回明确的业务错误码,前端展示兜底文案,绝不把半成品写库。写入 MySQL 前再加一道事务 + 校验,保证"要么完整数据,要么什么都没有"。
下一篇:Go AI Agent 实战 07|模型幻觉、能力边界与成本控制