大模型如何"记住"你说过的话:多轮对话机制与 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 消息时,只保留 content,reasoning_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 应用开发、工程实践和效率工具分享,欢迎扫码关注。
