Go AI Agent 实战 14|多轮对话与上下文历史管理

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

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

上一篇:大模型流式输出 Streaming 的实现

一、本课学习目标

学完本课,你应该能够:

  1. 理解 system、user、assistant 三种消息角色。
  2. 使用 Go 切片维护多轮对话。
  3. 每轮请求都携带必要的历史消息。
  4. 只有成功获得回答后才保存 assistant 消息。
  5. 理解历史增长对上下文窗口和 Token 成本的影响。
  6. 知道最近窗口、摘要和长期记忆各自解决什么问题。

二、无状态 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

数字只是趋势示意,不是真实用量。历史增长会带来三个问题:

  1. 输入可能超过模型上下文窗口。
  2. 早期内容会在多轮请求中反复计费。
  3. 无关旧信息会挤占当前任务空间,影响回答质量。

常见策略包括:

策略 优点 代价
保留最近 N 轮 简单、可预测 可能丢掉早期约束
Token 预算裁剪 更贴近真实限制 估算不精确,边界处理复杂
历史摘要 保留长期背景 摘要可能遗漏细节
长期记忆库 可检索、容量更大 增加检索与一致性复杂度

生产应用还要严格隔离不同用户和会话的上下文。不能把一个用户的聊天历史写入另一个用户的请求。

七、本课总结

  1. 常见聊天 API 是无状态调用,历史通常由应用维护。
  2. 一轮完整对话通常包含 user 和 assistant,系统约束放在 system。
  3. 每轮发送的是应用选择的"相关历史",而不是无限全部历史。
  4. 只有成功获得有效回答后才追加助手消息。
  5. append 的返回值必须重新赋给切片变量。
  6. 上下文窗口、Token 成本和会话隔离决定历史管理策略。

八、课后练习

练习 1:为什么保存助手回答

如果只保存用户消息,模型为什么难以延续上一轮方案?

参考答案:模型看不到自己此前给出过什么建议,容易重复、引用错误或与已有结论冲突。保存助手消息能让后续请求延续具体方案。

练习 2:失败轮次如何处理

某轮请求失败后,可以直接加入一条 assistant: 调用失败 吗?

参考思路:不应把基础设施错误伪装成模型回答。可以回滚用户消息,或保留问题但把执行状态标记为失败。消息内容与任务状态应分开记录。

练习 3:为什么不能无限累积

无限发送全部对话会带来哪些问题?

参考答案:可能超过上下文窗口,重复输入会增加 Token 成本和延迟,无关旧内容还会挤占当前任务空间。可以保留最近若干轮、做 Token 预算、生成摘要或使用可检索的长期记忆。


下一篇:Go AI Agent 实战 15|context.Context、超时与取消机制

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

相关推荐
ss2731 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 25 PostgreSQL checkpoint与脏页处理机制
1024程序员节
jiangliushi61 小时前
GPT-6.1 Sol 免费用:git status 里中文文件名变成一串数字乱?条命令改好
git·codex·1024程序员节
肖永威1 小时前
AI大模型不是答案机:研发场景下的多模型迭代工作流
人工智能·1024程序员节
INGNIGHT1 小时前
377.组合总和 Ⅳ(背包)
c++·算法·1024程序员节
microrain1 小时前
设备报文怎么变,SagooIoT 只约定三个函数名
javascript·物联网·golang·sagooiot
海盗12341 小时前
微软技术日报 2026-10-24:CA 私钥是 4,Windows 封了中间证书未动父根
人工智能·机器人·aigc·1024程序员节
奶酪博士1 小时前
【1024快乐】用HTML写一个本地版心愿墙页面
html·1024程序员节
“AI国潮设计-小江”1 小时前
AIGC实战:Python+SDXL批量生成英歌舞IP立牌,解锁猪猪蛋糕“反差萌”流量密码(附完整API源码)
开发语言·人工智能·python·prompt·aigc
合尘猫1 小时前
AI客服国标落地一个月:从工程角度看,真正要改的是三处数据结构
数据库·人工智能·架构