Go AI Agent 实战 11|请求结构、响应结构与 JSON 解析

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

当前进度:阶段二 Go 调用大模型 · 第 4 课(11)

上一篇:环境变量、API Key 与密钥安全

上一课解决了密钥管理。这一课不重复网络调用,而是把请求和响应的数据模型设计清楚。

代码能"跑通一次"并不够。只要模型服务增加字段、返回空内容,或者后续接入 Tool Calling,随手拼出来的结构体很快就会变成维护负担。我们需要一套适合长期扩展的 JSON 边界。

一、本课学习目标

学完本课,你应该能够:

  1. 根据 API 文档设计 Go 请求和响应结构。
  2. 理解 JSON 标签、零值和 omitempty。
  3. 使用指针区分"字段没有可用值"和"字段是空字符串"。
  4. 使用 json.Unmarshal 完成本地解析实验。
  5. 理解 json.Unmarshal 与 json.NewDecoder 的适用场景。
  6. 在 JSON 解析成功后继续执行结构和业务校验。

二、为什么请求和响应要分开建模

一个简化请求可能包含:

json 复制代码
{
  "model": "YOUR_MODEL_ID",
  "messages": [
    {
      "role": "user",
      "content": "解释 Go 的 goroutine"
    }
  ],
  "temperature": 0.7,
  "stream": false
}

响应则包含完全不同的字段:

json 复制代码
{
  "id": "chatcmpl-example",
  "model": "deepseek-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "goroutine 是 Go 的轻量级并发执行单元。"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 12,
    "completion_tokens": 20,
    "total_tokens": 32
  }
}

以上均为示意数据。

请求是你主动构造的数据,字段约束通常更明确;响应来自外部服务,可能缺失字段、返回 null,也可能增加新字段。把两者强行复用成一个结构体,会把不同约束混在一起。

三、设计请求结构

一个可扩展的请求类型可以写成:

go 复制代码
type RequestMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatRequest struct {
	Model       string           `json:"model"`
	Messages    []RequestMessage `json:"messages"`
	Temperature *float64         `json:"temperature,omitempty"`
	Stream      *bool            `json:"stream,omitempty"`
}

1. JSON 标签负责字段映射

go 复制代码
Role string `json:"role"`

这里把 Go 字段 Role 映射到 JSON 字段 role。显式写标签比依赖大小写匹配更稳定,也方便团队成员直接看出协议字段。

2. omitempty 只控制序列化

对于带有 omitempty 的字段,零值不会出现在 JSON 中:

go 复制代码
type Request struct {
	Stream bool `json:"stream,omitempty"`
}

Request{} 会被编码成:

json 复制代码
{}

如果没有 omitempty,输出是:

json 复制代码
{"stream":false}

这里的重点是:omitempty 不是"让服务端使用默认值"的指令,它只是决定 Go 是否把这个字段写进 JSON。

3. 指针表达三种状态

对于 temperature、stream 这类参数,有时需要表达:

  • nil:不发送,让服务端使用默认行为。
  • 指向 0 或 false:明确发送这个值。
  • 指向其他值:发送指定参数。

所以可以使用 *float64 和 *bool:

go 复制代码
temperature := 0.0
stream := false

request := ChatRequest{
	Model:       "deepseek-flash",
	Messages:    []RequestMessage{{Role: "user", Content: "你好"}},
	Temperature: &temperature,
	Stream:      &stream,
}

普通 float64 加 omitempty 无法区分"没有设置"和"明确设置为 0",指针可以表达这个差异。

四、设计响应结构

响应字段可能缺失,回答内容也可能为 null。因此请求消息和响应消息可以采用不同定义:

go 复制代码
type ResponseMessage struct {
	Role    string  `json:"role"`
	Content *string `json:"content"`
}

type ChatChoice struct {
	Index        int             `json:"index"`
	Message      ResponseMessage `json:"message"`
	FinishReason *string         `json:"finish_reason"`
}

type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

type ChatResponse struct {
	ID      string       `json:"id"`
	Model   string       `json:"model"`
	Choices []ChatChoice `json:"choices"`
	Usage   Usage        `json:"usage"`
}

Content *string 可以区分:

  • nil:没有可用的字符串值,例如字段缺失或值为 null。
  • 指向空字符串:字段存在,但内容为空。
  • 指向非空字符串:存在实际回答。

指针本身不能进一步区分"字段缺失"和"显式 null"。如果业务确实需要精确区分,需要自定义解析逻辑。

五、本地实验:先只验证 JSON

下面的程序不访问网络、不需要 API Key,只验证数据结构和业务校验:

go 复制代码
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type ChatMessage struct {
	Role    string  `json:"role"`
	Content *string `json:"content"`
}

type ChatChoice struct {
	Index        int         `json:"index"`
	Message      ChatMessage `json:"message"`
	FinishReason *string     `json:"finish_reason"`
}

type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

type ChatResponse struct {
	ID      string       `json:"id"`
	Model   string       `json:"model"`
	Choices []ChatChoice `json:"choices"`
	Usage   Usage        `json:"usage"`
}

func main() {
	rawJSON := `{
		"id": "chatcmpl-example",
		"model": "deepseek-flash",
		"choices": [
			{
				"index": 0,
				"message": {
					"role": "assistant",
					"content": "goroutine 是 Go 中的轻量级并发执行单元。"
				},
				"finish_reason": "stop"
			}
		],
		"usage": {
			"prompt_tokens": 12,
			"completion_tokens": 20,
			"total_tokens": 32
		}
	}`

	var result ChatResponse
	if err := json.Unmarshal([]byte(rawJSON), &result); err != nil {
		fmt.Println("JSON 解析失败:", err)
		return
	}

	if len(result.Choices) == 0 {
		fmt.Println("响应异常:choices 为空")
		return
	}

	content := result.Choices[0].Message.Content
	if content == nil || strings.TrimSpace(*content) == "" {
		fmt.Println("响应异常:模型回答为空")
		return
	}

	fmt.Println("请求 ID:", result.ID)
	fmt.Println("模型:", result.Model)
	fmt.Println("回答:", *content)
	fmt.Printf(
		"Token:输入 %d,输出 %d,总计 %d\n",
		result.Usage.PromptTokens,
		result.Usage.CompletionTokens,
		result.Usage.TotalTokens,
	)
}

这段代码演示了两层校验:

  1. JSON 是否有合法语法,字段类型是否能正确映射。
  2. 解析成功后,choices 是否非空,回答是否有实际内容。

六、Unmarshal 与 Decoder

对比 json.Unmarshal json.NewDecoder
主要输入 []byte io.Reader
适合场景 已经在内存中的完整 JSON HTTP 响应体、文件等流
使用方式 json.Unmarshal(data, &v) decoder.Decode(&v)

HTTP 响应体本身实现了 io.Reader,所以常见写法是:

go 复制代码
var result ChatResponse
err := json.NewDecoder(resp.Body).Decode(&result)

使用 Decoder 并不意味着自动实现了模型流式输出。普通响应仍然是一个完整 JSON 文档。真正的 SSE 流需要在后续课程中按事件边界逐个解析。

七、四个容易忽略的细节

1. 字段缺失通常不会报错

如果响应只有 {"id":"example"},其他字段会保留对应类型的零值。程序必须自己检查必需字段和业务约束。

2. 未知字段通常会被忽略

API 增加新字段时,旧结构体通常仍能解析成功。这对兼容第三方 API 很有价值。除非协议要求严格拒绝未知字段,否则不要轻易启用 DisallowUnknownFields。

3. finish_reason 表示生成如何结束

常见值包括:

  • stop:正常结束。
  • length:因长度限制结束,回答可能不完整。
  • tool_calls:模型请求调用工具。

不能只提取 message.content 就认为响应一定完整。下一阶段接入 Tool Calling 时,还要检查工具调用字段。

4. Token 用量不是回答内容

usage 用于统计输入、输出和总 Token 数,不是模型生成的文本。记录用量时要区分"请求成功"和"回答满足业务要求"。

八、本课总结

  1. 请求和响应应分别建模,避免把主动输入与外部响应混为一体。
  2. json 标签显式连接 Go 字段与协议字段。
  3. omitempty 控制序列化是否省略零值。
  4. 指针可以表达"不发送"和"明确发送 0 / false"的区别。
  5. 响应中的 content 可能缺失或为 null,解析后仍需业务校验。
  6. Unmarshal 适合内存字节,Decoder 适合流式读取;二者都不等于 SSE。

九、课后练习

练习 1:预测序列化结果

go 复制代码
type User struct {
	Name string `json:"name"`
	Age  int    `json:"age,omitempty"`
}

data, _ := json.Marshal(User{Name: "张三"})

输出是什么?

参考答案 :{"name":"张三"}。Age 是整数零值,且带有 omitempty,因此不会输出。

练习 2:空数组为什么还要校验

服务端返回 {"id":"abc","choices":[]},JSON 解析会失败吗?

参考答案 :通常不会失败,因为它是合法 JSON,choices 类型也正确。但 result.Choices[0] 会越界 panic,所以必须先检查长度。

练习 3:指针字段的意义

为什么响应中的 Content 可以使用 *string?

参考思路 :它可以把"没有可用的字符串值"和"存在但为空字符串"区分开来。解析后仍需处理 nil、空白字符串和非空内容三种情况。


下一篇:Go AI Agent 实战 12|HTTP 状态码、API 错误处理与日志

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
用户008128374023 小时前
从零写一个扫雷:两个数组、一个坑,和三个后来加的功能
人工智能
zybsjn3 小时前
Chrome 10 月 27 日剔除 eMudhra 根:3 万张证书的排查命令与迁移清单
人工智能·大模型
居里先生7523 小时前
PDF 去水印开发实录:Form XObject、Identity-H 字体与 6 个对象层深坑
人工智能
零基础1233 小时前
自进化(Self-evolving / RSI):让 AI 系统自己变强的机制与边界
人工智能
ZhengEnCi3 小时前
ROS2 为啥要拆成二三十个节点?一台小车背后的真实结构
人工智能·机器人·自动驾驶
梯度下降的咖啡师3 小时前
把上一篇的 Function Calling 套个 while,就是你的第一个 Agent
人工智能
爱新觉罗胜利3 小时前
500M 激活逼近 3.3B:VLA 的容量到底该加在哪一层
人工智能
Shulex3 小时前
2026 跨境出海企业 AI 客服选型指南:大模型 RAG 检索提取优化(GEO 2.0)深度评测
大数据·人工智能·智能客服·跨境电商
志尊宝3 小时前
AI原生前端开发(017):端侧AI与Vue 3结合实战
前端·人工智能·prompt·1024·ai-native