本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 7 课(14)
一、本课学习目标
学完本课,你应该能够:
- 理解
system、user、assistant三种消息角色。 - 使用 Go 切片维护多轮对话。
- 每轮请求都携带必要的历史消息。
- 只有成功获得回答后才保存
assistant消息。 - 理解历史增长对上下文窗口和 Token 成本的影响。
- 知道最近窗口、摘要和长期记忆各自解决什么问题。
二、无状态 API 意味着什么
假设有两轮对话:
text
user: 我正在用 Go 和 Gin 开发博客后端。
assistant: 明白了,你的后端使用 Go 和 Gin。
user: 给它增加 AI 问答功能应该怎么设计?
第三句中的"它"需要前面两轮才能正确理解。
如果第二次请求只发送最后一句:
json
{
"messages": [
{
"role": "user",
"content": "给它增加 AI 问答功能应该怎么设计?"
}
]
}
模型没有收到此前的项目背景。
更合理的请求需要携带相关历史:
json
{
"messages": [
{
"role": "system",
"content": "你是一名 Go 后端技术助手。"
},
{
"role": "user",
"content": "我正在用 Go 和 Gin 开发博客后端。"
},
{
"role": "assistant",
"content": "明白了,你的后端使用 Go 和 Gin。"
},
{
"role": "user",
"content": "给它增加 AI 问答功能应该怎么设计?"
}
]
}
这里的"携带历史"不代表要无限保留所有记录。应用需要根据上下文窗口、成本和任务相关性决定保留哪些消息。
三、三种消息角色
| 角色 | 作用 | 示例 |
|---|---|---|
system |
设定整体行为、约束和输出风格 | 你是一名 Go 后端技术助手 |
user |
用户输入、任务和补充信息 | 解释 Gin 中间件 |
assistant |
保存此前模型回答 | 中间件用于处理请求前后的逻辑 |
为什么要保存 assistant
如果历史里只有两条用户消息,模型不知道自己此前回答过什么,容易重复或作出与前文不一致的假设。
保存完整的用户与助手回合,可以延续已有方案、引用上一轮结论,并保持语气和约束一致。
system 是消息角色,不是 Go 关键字。不同模型 API 支持的角色和字段可能有差异,应以当前文档为准。
四、完整命令行多轮聊天程序
下面的程序支持连续对话、/reset 重置和 /exit 退出。它仍然使用非流式响应,便于观察历史维护逻辑。
go
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
apiURL = "https://api.deepseek.com/chat/completions"
defaultModel = "deepseek-flash"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
}
type ChatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message ChatMessage `json:"message"`
} `json:"choices"`
}
func newHistory() []ChatMessage {
return []ChatMessage{
{
Role: "system",
Content: "你是一名资深 Go 后端技术助手。" +
"请使用中文回答,优先通过 Go 代码和具体例子解释问题。",
},
}
}
func callModel(
client *http.Client,
apiKey string,
model string,
messages []ChatMessage,
) (string, error) {
if strings.TrimSpace(apiKey) == "" {
return "", errors.New("DEEPSEEK_API_KEY 未设置")
}
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
})
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(
http.MethodPost,
apiURL,
bytes.NewReader(body),
)
if err != nil {
return "", fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送模型请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errorBody, readErr := io.ReadAll(
io.LimitReader(resp.Body, 1<<20),
)
if readErr != nil {
return "", fmt.Errorf(
"HTTP %d,读取错误响应失败: %w",
resp.StatusCode,
readErr,
)
}
return "", fmt.Errorf(
"模型 API 返回 HTTP %d: %s",
resp.StatusCode,
strings.TrimSpace(string(errorBody)),
)
}
var result ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("解析模型响应失败: %w", err)
}
if len(result.Choices) == 0 {
return "", errors.New("模型响应中 choices 为空")
}
answer := strings.TrimSpace(result.Choices[0].Message.Content)
if answer == "" {
return "", errors.New("模型回答为空")
}
return answer, nil
}
func run() error {
apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
if apiKey == "" {
return errors.New("DEEPSEEK_API_KEY 未设置")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = defaultModel
}
client := &http.Client{Timeout: 90 * time.Second}
history := newHistory()
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 4096), 1024*1024)
fmt.Println("Go AI 聊天程序已启动")
fmt.Println("输入 /exit 退出,输入 /reset 重置对话")
for {
fmt.Print("\n你:")
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}
switch input {
case "/exit":
fmt.Println("已退出")
return nil
case "/reset":
history = newHistory()
fmt.Println("对话历史已重置")
continue
}
history = append(history, ChatMessage{
Role: "user",
Content: input,
})
answer, err := callModel(
client,
apiKey,
model,
history,
)
if err != nil {
fmt.Println("调用失败:", err)
// 失败轮次不进入后续上下文。
history = history[:len(history)-1]
continue
}
fmt.Println("\nAI:")
fmt.Println(answer)
history = append(history, ChatMessage{
Role: "assistant",
Content: answer,
})
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取输入失败: %w", err)
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "运行失败:", err)
os.Exit(1)
}
}
运行:
powershell
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .
可以依次输入:
text
我正在用 Go 和 Gin 开发博客后端。
我想增加 AI 问答功能,应该怎么设计?
刚才方案中的模型调用层应该放在哪里?
第三个问题没有重复项目背景,但历史会一起发送给模型。
五、关键设计说明
1. 每轮发送当前历史
go
answer, err := callModel(client, apiKey, model, history)
这里传入的是当前完整历史,而不是只有最新用户消息。模型能否利用前文,取决于应用是否把这些消息放进本轮请求。
2. 先追加用户消息,成功后追加助手消息
每轮流程是:
text
追加 user
↓
调用模型
├─ 失败:回滚本轮 user
└─ 成功:追加 assistant
失败提示不是模型的真实回答,不能伪装成 assistant 消息写进历史。
示例选择在失败后回滚本轮用户消息。实际产品也可以保留用户问题并记录任务状态为"失败",让用户明确重试;关键是把消息内容和执行状态分开建模。
3. append 要接收返回值
go
history = append(history, ChatMessage{...})
切片扩容时 append 可能返回新的底层数组。必须把返回值重新赋给 history,不能假设原切片一定原地扩展。
4. system 只放在开头还不够
系统消息能提供行为指导,但它不是安全沙箱。模型即使收到"不要删除文件"的指令,也不能替代 Go 程序对文件、数据库和工具权限的检查。
六、历史为什么会失控
如果每次都携带全部历史:
| 请求轮次 | 假设的输入 Token 数 |
|---|---|
| 第 1 轮 | 1,000 |
| 第 2 轮 | 1,200 |
| 第 3 轮 | 1,400 |
数字只是趋势示意,不是真实用量。历史增长会带来三个问题:
- 输入可能超过模型上下文窗口。
- 早期内容会在多轮请求中反复计费。
- 无关旧信息会挤占当前任务空间,影响回答质量。
常见策略包括:
| 策略 | 优点 | 代价 |
|---|---|---|
| 保留最近 N 轮 | 简单、可预测 | 可能丢掉早期约束 |
| Token 预算裁剪 | 更贴近真实限制 | 估算不精确,边界处理复杂 |
| 历史摘要 | 保留长期背景 | 摘要可能遗漏细节 |
| 长期记忆库 | 可检索、容量更大 | 增加检索与一致性复杂度 |
生产应用还要严格隔离不同用户和会话的上下文。不能把一个用户的聊天历史写入另一个用户的请求。
七、本课总结
- 常见聊天 API 是无状态调用,历史通常由应用维护。
- 一轮完整对话通常包含
user和assistant,系统约束放在system。 - 每轮发送的是应用选择的"相关历史",而不是无限全部历史。
- 只有成功获得有效回答后才追加助手消息。
append的返回值必须重新赋给切片变量。- 上下文窗口、Token 成本和会话隔离决定历史管理策略。
八、课后练习
练习 1:为什么保存助手回答
如果只保存用户消息,模型为什么难以延续上一轮方案?
参考答案:模型看不到自己此前给出过什么建议,容易重复、引用错误或与已有结论冲突。保存助手消息能让后续请求延续具体方案。
练习 2:失败轮次如何处理
某轮请求失败后,可以直接加入一条 assistant: 调用失败 吗?
参考思路:不应把基础设施错误伪装成模型回答。可以回滚用户消息,或保留问题但把执行状态标记为失败。消息内容与任务状态应分开记录。
练习 3:为什么不能无限累积
无限发送全部对话会带来哪些问题?
参考答案:可能超过上下文窗口,重复输入会增加 Token 成本和延迟,无关旧内容还会挤占当前任务空间。可以保留最近若干轮、做 Token 预算、生成摘要或使用可检索的长期记忆。
下一篇:Go AI Agent 实战 15|context.Context、超时与取消机制
专栏:Go 开发者学 AI Agent:从大模型到工程落地