本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 4 课(11)
上一课解决了密钥管理。这一课不重复网络调用,而是把请求和响应的数据模型设计清楚。
代码能"跑通一次"并不够。只要模型服务增加字段、返回空内容,或者后续接入 Tool Calling,随手拼出来的结构体很快就会变成维护负担。我们需要一套适合长期扩展的 JSON 边界。
一、本课学习目标
学完本课,你应该能够:
- 根据 API 文档设计 Go 请求和响应结构。
- 理解 JSON 标签、零值和
omitempty。 - 使用指针区分"字段没有可用值"和"字段是空字符串"。
- 使用
json.Unmarshal完成本地解析实验。 - 理解
json.Unmarshal与json.NewDecoder的适用场景。 - 在 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,
)
}
这段代码演示了两层校验:
- JSON 是否有合法语法,字段类型是否能正确映射。
- 解析成功后,
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 数,不是模型生成的文本。记录用量时要区分"请求成功"和"回答满足业务要求"。
八、本课总结
- 请求和响应应分别建模,避免把主动输入与外部响应混为一体。
json标签显式连接 Go 字段与协议字段。omitempty控制序列化是否省略零值。- 指针可以表达"不发送"和"明确发送
0/false"的区别。 - 响应中的
content可能缺失或为null,解析后仍需业务校验。 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:从大模型到工程落地