Go AI Agent 实战 06|JSON 结构化输出与 Go 后端校验

本系列共 10 阶段 ,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段一 大模型基础 · 第 6 课

上一篇:Temperature 与生成参数

前面我们学习了 Prompt、消息角色和模型生成参数。现在进入 AI 应用开发中非常重要的一项能力:让大模型返回 Go 程序能够可靠处理的数据,而不仅仅是一段自然语言。

这一课会开始把 AI 和传统 Go 后端工程连接起来,为后面的 Tool Calling 做准备。

一、本课学习目标

学完之后,你应该能够:

  1. 理解普通文本输出与 JSON 结构化输出的区别。
  2. 使用 Go 的 encoding/json 解析模型返回的数据。
  3. 区分 JSON 语法正确、字段合法和业务逻辑正确。
  4. 处理模型返回格式错误、缺少字段和非法枚举值等问题。
  5. 理解为什么真实项目中应优先使用模型服务支持的结构化输出能力,而不能只依赖 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 编译器或数据库约束。

模型可能出现以下问题:

  1. 输出 Markdown 代码围栏,例如 ```````json````。
  2. 忘记必需字段。
  3. 返回不符合约定的枚举值。
  4. 输出格式正确、内容却错误的数据。
  5. 因输出长度达到上限而被截断。

真实项目中,推荐优先级如下:

  1. API 原生结构化输出能力:如果模型服务支持 JSON Schema 或严格结构化输出,优先使用。
  2. Go 后端解析和校验:即使服务承诺结构化输出,也要对实际响应进行错误处理和业务校验。
  3. 有限重试或降级:对可恢复的格式错误,可以有次数限制地重新请求;业务内容错误则不能仅靠反复重试保证正确。

还有一点:如果模型返回的内容被截断,单纯把它强行解析成 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|模型幻觉、能力边界与成本控制

相关推荐
vibecoding772 小时前
Codex 分层用模型完整教程:让 GPT-6.1 Sol 拍板、GPT-6 Luna 跑侦察
人工智能·gpt
新知图书2 小时前
1.3 启动第一个机器人
人工智能·机器人
明志数科3 小时前
自主探索范式下机器人示教数据的工程定位:少量示范为何对质量更敏感
人工智能·机器学习·机器人
Maynor9963 小时前
WorkBuddy 一键接通 gpt-6.1-sol:MomoAI / TryAllAPI 把最新模型接到桌面
人工智能·gpt·ai·大模型·编程工具
火山引擎开发者社区3 小时前
问得出、搜得准、信得过:ContextSearch 重构销售检索体验
人工智能
Raymond16883 小时前
AI配音工具长文本合成架构技术解析:全局音色锚定与声学状态跟踪
人工智能·架构·实时互动·视觉检测·音视频·语音识别
Codiggerworld3 小时前
从“装一套协议”到“扔进终端”:MCP 与 CLI 的 Agent 工具层之争
人工智能·程序人生·ai编程
ViperL13 小时前
[智能计算]AutoDiffusion-基于进化NAS的扩散模型
人工智能·深度学习·计算机视觉