大模型如何"记住"你说过的话:多轮对话机制与 Go 实操

大家好,我是极客老墨。

我刚开始做 AI 应用的时候,有个直觉假设:“模型后台应该有个数据库,记住了我的聊天记录。“后来才发现完全不是这么回事。

主流大模型 API 都是"无状态”(Stateless)的。 每一次你发请求过去,它都完全不记得你之前说过什么。之所以它能接住你的话茬,是因为开发者每次都把之前的聊天记录打包发给了它。

这篇就来聊聊,怎么在 Go 代码里实现这种"人工记忆”。

一、多轮对话的本质:以存储换记忆

在 API 调用层面,模型并不负责存储对话历史。实现多轮对话的公式其实很简单:

$$新请求 = 对话历史 + 当前问题$$

每次你提问,你的程序都要从内存(或数据库)里把之前的对话翻出来,拼成一个数组发过去。模型读完这一长串,才能理解你说的"它"是指谁,或者"接着说"是要说什么。

 1sequenceDiagram
 2    participant User as 用户
 3    participant App as 你的 Go 应用
 4    participant LLM as 大模型 (DeepSeek)
 5    
 6    User->>App: 1. "你好,我是老墨"
 7    App->>LLM: POST [User: 你好,我是老墨]
 8    LLM-->>App: "你好,老墨!"
 9    App-->>User: "你好,老墨!"
10    
11    Note over App: 内存存储: [User: 你好, Assistant: 你好老墨]
12    
13    User->>App: 2. "我刚才说我叫什么?"
14    App->>LLM: POST [User: 你好, Assistant: 你好, User: 我刚才说我叫什么?]
15    LLM-->>App: "你刚才说你叫老墨。"
16    App-->>User: "你刚才说你叫老墨。"

二、Messages 数组的三大角色

在标准的 chat/completions 接口中,messages 数组是唯一的上下文载体。它由三种角色组成:

角色 (Role)用途注意事项
system系统指令定义模型身份(如:“你是一个 Go 专家”)。放在数组第一位,且通常不需要重复。
user用户输入真实的人类提问。
assistant模型回复别忘了存这一条! 只有把模型之前的回答也喂回去,它才知道自己刚才答应了你什么。

老墨说: 我早期只记得存 user 的话,不存 assistant 的回复。结果模型就像个单相思的自言自语者,对话逻辑很快就会断掉。

三、Go 实战:实现一个带记忆的命令行助手

在 Go 里,我们用 slice(切片)来管理这个对话历史。核心逻辑是:追加 → 截断(防止超限)→ 请求 → 追加

 1package main
 2
 3import (
 4	"bufio"
 5	"bytes"
 6	"encoding/json"
 7	"fmt"
 8	"net/http"
 9	"os"
10	"strings"
11)
12
13// Message 标准 OpenAI 兼容消息结构
14type Message struct {
15	Role    string `json:"role"`
16	Content string `json:"content"`
17}
18
19type ChatRequest struct {
20	Model    string    `json:"model"`
21	Messages []Message `json:"messages"`
22}
23
24const (
25	apiURL     = "https://api.deepseek.com/chat/completions"
26	maxHistory = 10 // 最多保留最近 10 轮对话(5 组问答)
27)
28
29func main() {
30	// 1. 初始化历史,System Prompt 锁死在第一位
31	history := []Message{
32		{Role: "system", Content: "你是一个专业的 Go 语言助手,回答要简洁。"},
33	}
34
35	scanner := bufio.NewScanner(os.Stdin)
36	fmt.Println(">>> 极客老墨对话助手 (输入 'exit' 退出) <<<")
37
38	for {
39		fmt.Print("\nUser: ")
40		if !scanner.Scan() { break }
41		input := scanner.Text()
42		if strings.ToLower(input) == "exit" { break }
43
44		// 2. 追加用户输入
45		history = append(history, Message{Role: "user", Content: input})
46
47		// 3. 滑动窗口控制:如果太长了,剔除最早的(跳过 system 消息)
48		if len(history) > maxHistory {
49			// 保留 system 消息,截取最近的 N-1 条
50			newHistory := []Message{history[0]}
51			newHistory = append(newHistory, history[len(history)-maxHistory+1:]...)
52			history = newHistory
53		}
54
55		// 4. 发起请求
56		respMsg, err := fetchAI(history)
57		if err != nil {
58			fmt.Printf("Error: %v\n", err)
59			continue
60		}
61
62		// 5. 追加模型输出,实现"记忆"闭环
63		fmt.Printf("\nAI: %s\n", respMsg.Content)
64		history = append(history, respMsg)
65	}
66}
67
68func fetchAI(msgs []Message) (Message, error) {
69	apiKey := os.Getenv("DEEPSEEK_API_KEY") // 从环境变量读取,不要硬编码
70	reqBody := ChatRequest{
71		Model:    "deepseek-v4-flash",
72		Messages: msgs,
73	}
74	// ... 标准 HTTP POST 实现 (参考第 08 篇) ...
75    // 这里省略具体的 http 调用代码以节省篇幅
76	return Message{Role: "assistant", Content: "假装我是 AI 返回的内容"}, nil
77}

四、生产级"记忆"管理:从按条数截断到 Token 级控制

上面的代码用"超过 10 条就删"来控制历史长度。这在 demo 里够用,但放到生产环境会出问题——用户贴一段 2000 行代码进来,虽然只占 1 条消息,Token 数却可能直接刷爆上下文窗口。

更稳的做法是按 Token 数来截断,而不是按条数。

4.1 Token 级滑动窗口

核心思路:用 tiktoken-go 计算每条消息的实际 Token 数,设一个 Token 预算,从最新的消息往前加,塞不下的旧消息直接丢弃。

 1// countMessageTokens 计算单条消息的 token 数
 2func countMessageTokens(msg AdvancedMessage) int {
 3    enc, err := tiktoken.GetEncoding("cl100k_base")
 4    if err != nil {
 5        return len(msg.Content) / 4 // fallback 粗估
 6    }
 7    // 每条消息有 role + content,加上格式 overhead 约 4 tokens
 8    return len(enc.Encode(msg.Content, nil, nil)) + 4
 9}
10
11// trimByTokenBudget 按 token 预算从头删除旧消息(保留 system prompt)
12func trimByTokenBudget(history []AdvancedMessage, budget int) []AdvancedMessage {
13    total := countAllTokens(history)
14    if total <= budget {
15        return history
16    }
17    // history[0] 是 system prompt,从 history[1] 开始删
18    trimmed := []AdvancedMessage{history[0]}
19    remaining := budget - countMessageTokens(history[0])
20    start := len(history) - 1
21    for i := start; i >= 1; i-- {
22        msgTokens := countMessageTokens(history[i])
23        if remaining-msgTokens < 0 {
24            break
25        }
26        remaining -= msgTokens
27        start = i
28    }
29    trimmed = append(trimmed, history[start:]...)
30    return trimmed
31}

DeepSeek V4 虽然支持 1M context,但这不意味着你应该把预算设到 100 万。Token 越多,API 成本线性增长——V4 Flash 输入 $0.14/1M tokens,全塞满一次调用大约 $0.14。如果每个用户每天聊 50 轮,成本会很快堆起来。我一般会把多轮对话的 Token 预算控制在 4000-8000,既够模型理解上下文,又不会让账单失控。

4.2 reasoning_content 在多轮对话中的处理

DeepSeek V4 的 Thinking 模式(通过 thinking: {"type": "enabled"} 开启)会在响应里返回两个字段:reasoning_content(推理过程)和 content(最终回答)。

多轮对话里有一个容易踩的坑:reasoning_content 不能回灌到 messages 数组里。如果你把它当成 assistant 消息的一部分塞回去,API 会直接返回 400 错误。

处理方式很简单——存 assistant 消息时,只保留 contentreasoning_content 单独记日志或丢弃:

 1// storeAssistantMessage 存储回复时过滤 reasoning_content
 2func storeAssistantMessage(history []AdvancedMessage, content, reasoningContent string) []AdvancedMessage {
 3    if reasoningContent != "" {
 4        fmt.Printf("[推理过程] %s...\n", truncate(reasoningContent, 100))
 5    }
 6    return append(history, AdvancedMessage{
 7        Role:    "assistant",
 8        Content: content,
 9        // 注意:故意不设置 ReasoningContent,避免回灌
10    })
11}

如果你做的是 ToC 产品(面向终端用户),reasoning_content 通常不需要展示;如果是 ToB 产品(面向开发者或企业),可以把推理过程放在一个可折叠区域里,方便排查和调试。这一点在上一篇 API 调用里也提过。

4.3 摘要压缩:保留记忆又省 Token

滑动窗口的问题是"一刀切"——旧消息被直接丢弃,模型完全失去早期上下文。用户问"我们最开始聊了什么",模型答不上来。

摘要压缩的思路是:当历史超过阈值时,调一次 AI 把早期对话浓缩成一段摘要,替换掉原始消息。这样既保住了核心记忆,又大幅削减 Token 数。

 1func compressHistory(ctx context.Context, history []AdvancedMessage) ([]AdvancedMessage, error) {
 2    total := countAllTokens(history)
 3    if total <= compressThreshold {
 4        return history, nil
 5    }
 6    // 保留 system prompt + 最近 4 条消息
 7    keepRecent := 4
 8    if len(history) <= keepRecent+1 {
 9        return history, nil
10    }
11
12    oldMessages := history[1 : len(history)-keepRecent]
13    recentMessages := history[len(history)-keepRecent:]
14
15    // 把旧消息格式化成文本,调 AI 做摘要
16    var buf bytes.Buffer
17    for _, msg := range oldMessages {
18        fmt.Fprintf(&buf, "%s: %s\n", msg.Role, msg.Content)
19    }
20
21    summaryPrompt := []AdvancedMessage{
22        {Role: "system", Content: "你是一个对话摘要助手。请用 2-3 句话概括以下对话的核心内容,保留关键事实和结论,去掉寒暄和重复。只输出摘要。"},
23        {Role: "user", Content: buf.String()},
24    }
25    resp, err := callDeepSeekAdvanced(ctx, "deepseek-v4-flash", summaryPrompt)
26    if err != nil {
27        return history, fmt.Errorf("摘要压缩失败: %w", err)
28    }
29
30    // 构建压缩后的历史
31    compressed := []AdvancedMessage{
32        history[0],
33        {Role: "system", Content: fmt.Sprintf("以下是之前对话的摘要:%s", resp.Choices[0].Message.Content)},
34    }
35    compressed = append(compressed, recentMessages...)
36    return compressed, nil
37}

压缩后的 messages 结构大概是这样:

1[
2  {"role": "system", "content": "你是一个 Go 语言助手..."},
3  {"role": "system", "content": "以下是之前对话的摘要:用户询问了 goroutine 和 channel 的区别,讨论了 worker pool 模式..."},
4  {"role": "user", "content": "最近的问题"},
5  {"role": "assistant", "content": "最近的回答"},
6  {"role": "user", "content": "当前问题"}
7]

摘要 prompt 怎么写很关键。我后来摸索的经验是:明确要求"保留关键事实和结论",不然 AI 容易把细节全概括成"讨论了技术问题"这种无用摘要。摘要本身也会消耗一次 API 调用,但用 V4 Flash 做摘要,成本很低——通常几百 Token 就够了。

4.4 Session 隔离:从单人 demo 到多用户服务

命令行 demo 只有一个用户,但 Web 应用需要同时服务多个用户,每个用户有独立的对话历史。这时候需要用 session_id 做隔离。

最简单的方式是用一个带锁的内存 map:

 1type SessionStore struct {
 2    mu       sync.RWMutex
 3    sessions map[string][]AdvancedMessage
 4}
 5
 6func (s *SessionStore) GetOrCreate(sessionID, systemPrompt string) []AdvancedMessage {
 7    s.mu.Lock()
 8    defer s.mu.Unlock()
 9    if history, ok := s.sessions[sessionID]; ok {
10        return history
11    }
12    history := []AdvancedMessage{
13        {Role: "system", Content: systemPrompt},
14    }
15    s.sessions[sessionID] = history
16    return history
17}
18
19func (s *SessionStore) Update(sessionID string, history []AdvancedMessage) {
20    s.mu.Lock()
21    defer s.mu.Unlock()
22    s.sessions[sessionID] = history
23}

进程一重启,内存里的会话就没了。生产环境里,更常见的做法是把会话存进 Redis:

 1// Redis 存储的 key 设计(伪代码)
 2// key:   chat:session:{session_id}
 3// value: JSON 序列化的 []AdvancedMessage
 4// TTL:   24h(或根据产品策略调整)
 5
 6func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) ([]AdvancedMessage, error) {
 7    key := fmt.Sprintf("chat:session:%s", sessionID)
 8    data, err := s.client.Get(ctx, key).Bytes()
 9    if err == redis.Nil {
10        return nil, nil // 会话不存在
11    }
12    var history []AdvancedMessage
13    json.Unmarshal(data, &history)
14    return history, nil
15}
16
17func (s *RedisSessionStore) Save(ctx context.Context, sessionID string, history []AdvancedMessage) error {
18    key := fmt.Sprintf("chat:session:%s", sessionID)
19    data, _ := json.Marshal(history)
20    return s.client.Set(ctx, key, data, 24*time.Hour).Err()
21}

在 Web 应用里,session_id 通常从 HTTP 请求的 cookie 或 header 里获取,然后在 handler 里串起来:

1func chatHandler(store *SessionStore) http.HandlerFunc {
2    return func(w http.ResponseWriter, r *http.Request) {
3        sessionID := r.Header.Get("X-Session-ID")
4        history := store.GetOrCreate(sessionID, "你是一个助手。")
5        // ... 追加用户消息、滑动窗口、压缩、调 API、存回 store
6    }
7}

五、成本与缓存:1M 上下文不是免费午餐

DeepSeek V4 把上下文窗口从 64K 拉到了 1M,但更大的窗口意味着更高的成本。多轮对话场景下,有两件事值得关注。

前缀稳定 = 缓存命中

上一篇我们聊过 DeepSeek 的前缀缓存机制。在多轮对话里,这个机制有天然优势:system prompt 固定在第一位,历史消息按时间顺序追加——这意味着每次请求的前缀都是稳定的,缓存命中率通常很高。

从 API 响应的 usage 字段可以直接看到效果:

1{
2  "usage": {
3    "prompt_tokens": 1200,
4    "completion_tokens": 150,
5    "prompt_cache_hit_tokens": 1000,
6    "prompt_cache_miss_tokens": 200
7  }
8}

上面这个例子里,1200 个 prompt token 中有 1000 个命中了缓存。V4 Flash 的缓存命中价格是 $0.0028/1M——只有未命中价格 $0.14/1M 的 2%。多轮对话天然享受这个红利,前提是你不要在每轮之间打乱消息顺序。

分层管理:近期原文 + 中期摘要 + 远期丢弃

即使有 1M 的窗口,把所有历史全塞进去也不划算。更务实的做法是分三层:

层级策略适用范围
近期(最近 4-6 条)保留原文模型需要精确理解最近的上下文
中期(4-20 条之前)摘要压缩保留核心事实,丢弃细节
远期(20 条之前)直接丢弃对当前对话已无实质影响

把前面的滑动窗口和摘要压缩组合起来,就是这个分层策略的实现。每轮对话的处理流程是:

1追加用户消息 → Token 滑动窗口(控制总量)→ 摘要压缩(保留记忆)→ 调 API → 存回 assistant 回复

老墨总结

多轮对话不是魔法,而是"精细的数组管理"。从简单 demo 到生产服务,复杂度逐步递增,但核心问题始终是那几个:

场景方案复杂度
命令行 demo按条数截断(maxHistory = 10
需要精确控制成本Token 级滑动窗口 + tiktoken-go
不想丢失早期记忆摘要压缩(AI 做摘要替换旧消息)
Web 多用户服务Session 隔离 + Redis 持久化 + TTL
V4 Thinking 模式reasoning_content 不回灌,只存 content低(但容易忘)

我自己的判断是:先从按条数截断开始,跑通闭环后再加 Token 统计和摘要。过早引入复杂机制,反而会让调试变困难。

下一篇聊那个让用户体验翻倍的技术——流式输出(Streaming)

完整示例代码见 Github


文章有帮助?转发给同样在踩坑的朋友。有不同意见?评论区见。


关注公众号:极客老墨

更多 AI 应用开发、工程实践和效率工具分享,欢迎扫码关注。

极客老墨微信公众号二维码

相关阅读