第 9 章:多轮对话设计

多轮对话不是"把消息拼起来"那么简单。状态管理、隐私保护、去重召回——每一个都可能让你的 Agent 翻车。 但也别把它想得太复杂——核心就是"会话要连续、数据要安全、召回要精准"。


用户跟你的 Agent 聊了 20 轮。

突然刷新了页面。

回来一看——对话全没了。

这就是 Session 管理没做好的后果。

或者更糟的场景:

用户 A 的对话历史,出现在了用户 B 的会话里。

这就是租户隔离没做好的后果。


9.1 多轮对话的挑战

前两章讲了上下文窗口和记忆系统。这章要把它们落地到真实的对话场景。

一个生产级的对话系统要解决这些问题:

挑战后果解决方向
Session 状态刷新丢对话持久化存储
消息增长历史太长滑动窗口 + 压缩
隐私保护敏感信息泄露PII 脱敏
会话识别找不到之前的对话自动标题
语义去重召回结果重复MMR 重排序
租户隔离数据泄露严格权限检查

一个个来。


9.2 Session 管理

两级缓存架构

HTTP 是无状态的,但对话是有状态的。每次请求都要加载会话,延迟敏感。

解决方案:内存缓存 + Redis 持久化。

两级缓存架构

本地缓存命中是 1ms 级别,Redis 是 5ms 级别。热数据在内存,冷数据落 Redis。

Session 结构

Shannon 的 Session 结构:

type Session struct {
    ID        string
    UserID    string
    TenantID  string                    // 多租户隔离
    CreatedAt time.Time
    UpdatedAt time.Time
    ExpiresAt time.Time                 // TTL 过期
    History   []Message                 // 消息历史
    Context   map[string]interface{}    // 会话变量(标题、偏好等)
    Metadata  map[string]interface{}    // 元数据

    // 成本追踪
    TotalTokensUsed int
    TotalCostUSD    float64
}

type Message struct {
    ID        string
    Role      string     // "user", "assistant", "system"
    Content   string
    Timestamp time.Time
    TokensUsed int
}

几个关键字段:

  • TenantID:多租户隔离,不同租户的会话不能互相访问
  • ExpiresAt:TTL 过期机制,闲置 30 天自动清理
  • TotalTokensUsed:成本追踪,支持预算控制

租户隔离

这个容易忽略但极其重要:

func (m *Manager) GetSession(ctx context.Context, sessionID string) (*Session, error) {
    session := m.loadFromCache(sessionID)

    // 租户隔离检查
    userCtx := authFromContext(ctx)
    if userCtx.TenantID != "" && session.TenantID != userCtx.TenantID {
        // 不泄露 Session 存在信息
        return nil, ErrSessionNotFound  // 注意:不是 ErrUnauthorized
    }

    return session, nil
}

为什么返回 ErrSessionNotFound 而不是 ErrUnauthorized

安全原则:不泄露信息。

如果返回 ErrUnauthorized,攻击者就知道"这个 Session ID 存在,只是我没权限"。他可以慢慢枚举,找到有效的 Session ID,然后尝试其他攻击。

返回 ErrSessionNotFound,攻击者无法区分"不存在"和"没权限"。

防劫持

用户传来的 Session ID 可能是别人的:

func (m *Manager) CreateSessionWithID(ctx context.Context, sessionID, userID string) (*Session, error) {
    // 检查是否已存在
    existing, _ := m.GetSession(ctx, sessionID)
    if existing != nil {
        if existing.UserID != userID {
            // Session 存在但属于别人——可能是劫持尝试
            m.logger.Warn("Attempted session hijacking",
                zap.String("session_id", sessionID),
                zap.String("attacker", userID),
                zap.String("owner", existing.UserID),
            )
            // 生成新 ID,不复用
            return m.CreateSession(ctx, userID, "", nil)
        }
        // 同一用户,返回已有的
        return existing, nil
    }
    // 不存在,正常创建
    return m.createNewSession(sessionID, userID)
}

场景:攻击者猜到了一个 Session ID,想冒充那个用户。系统检测到不匹配,拒绝复用,生成新的 ID。


9.3 消息历史管理

滑动窗口

对话可能有几百轮。不能全存,要设上限:

func (m *Manager) AddMessage(ctx context.Context, sessionID string, msg Message) error {
    session, _ := m.GetSession(ctx, sessionID)
    session.History = append(session.History, msg)

    // 滑动窗口裁剪
    if len(session.History) > m.maxHistory {
        session.History = session.History[len(session.History)-m.maxHistory:]
    }

    return m.UpdateSession(ctx, session)
}

maxHistory 通常设 500。超了就把最老的裁掉。

为什么是 500?

  • 太小(比如 50):历史太短,上下文不够
  • 太大(比如 5000):Redis 存储压力大,每次加载慢

500 是个平衡点。一般对话不会超过 500 轮。

Token 预算填充

即使在 maxHistory 内,也可能超 token 预算。从最近往前填,填满为止:

func (s *Session) GetHistoryWithinBudget(maxTokens int) []Message {
    result := []Message{}
    currentTokens := 0

    // 从最近的消息开始
    for i := len(s.History) - 1; i >= 0; i-- {
        msg := s.History[i]
        msgTokens := estimateTokens(msg.Content)

        if currentTokens + msgTokens > maxTokens {
            break  // 预算用完了
        }

        // 前插保持顺序
        result = append([]Message{msg}, result...)
        currentTokens += msgTokens
    }

    return result
}

这样保证:

  1. 最新的消息优先保留
  2. 不超 token 预算
  3. 消息顺序正确

9.4 PII 脱敏

这是个容易被忽视但极其重要的问题。

问题场景

用户输入: "我的信用卡号是 4532-1234-5678-9012"
    
    
[LLM 压缩成摘要]
    
    
摘要: "用户查询了信用卡 4532-1234-5678-9012 的余额"
    
    
[存入向量库,永久保存]
    
    
[后续对话中可能被召回]
    
    
[泄露给其他用户或日志系统]

信用卡号被永久存储了。这是严重的隐私泄露。

解决方案:存储前脱敏

Shannon 在两个地方做 PII 脱敏:

  1. 压缩摘要时
  2. 存储到向量库时
func redactPII(s string) string {
    if s == "" {
        return s
    }

    // Email
    emailRe := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
    s = emailRe.ReplaceAllString(s, "[REDACTED_EMAIL]")

    // 电话
    phoneRe := regexp.MustCompile(`(?i)(\+?\d[\d\s\-()]{8,}\d)`)
    s = phoneRe.ReplaceAllString(s, "[REDACTED_PHONE]")

    // SSN
    ssnRe := regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`)
    s = ssnRe.ReplaceAllString(s, "[REDACTED_SSN]")

    // 信用卡
    ccRe := regexp.MustCompile(`\b(?:\d{4}[-\s]?){3}\d{4}\b`)
    s = ccRe.ReplaceAllString(s, "[REDACTED_CC]")

    // IP 地址
    ipRe := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
    s = ipRe.ReplaceAllString(s, "[REDACTED_IP]")

    // API Key
    apiKeyRe := regexp.MustCompile(`(?i)(api[_-]?key|token)[\s:=]+[\w-]{20,}`)
    s = apiKeyRe.ReplaceAllString(s, "[REDACTED_API_KEY]")

    // 密码
    secretRe := regexp.MustCompile(`(?i)(password|secret|pwd)[\s:=]+\S{8,}`)
    s = secretRe.ReplaceAllString(s, "[REDACTED_SECRET]")

    return s
}

覆盖的 PII 类型

类型示例替换为
Emailuser@example.com[REDACTED_EMAIL]
电话+1-234-567-8900[REDACTED_PHONE]
信用卡4532-1234-5678-9012[REDACTED_CC]
SSN123-45-6789[REDACTED_SSN]
IP192.168.1.1[REDACTED_IP]
API Keyapi_key=sk-xxx[REDACTED_API_KEY]
密码password=abc123[REDACTED_SECRET]

局限性

正则脱敏不是万能的:

  • 误报192.168.1.1 可能是代码里的常量,不是真正的 IP
  • 漏报:复杂的 PII 格式可能漏掉
  • 上下文丢失:脱敏后可能影响 LLM 理解

生产建议

  1. 正则作为基础防线
  2. 敏感场景用专业的 PII 检测服务(如 AWS Comprehend、Google DLP)
  3. 定期审计存储内容

9.5 会话标题生成

用户回来找之前的对话,看到一堆"Session 1""Session 2",没法识别。

需要自动生成有意义的标题。

LLM 生成

Shannon 用 LLM 生成标题:

func (a *Activities) generateTitleWithLLM(ctx context.Context, query string) (string, error) {
    prompt := fmt.Sprintf(`Generate a chat session title from this user query.

Rules:
- Use the SAME LANGUAGE as the user's query
- For English: 3-5 words, Title Case
- For Chinese/Japanese/Korean: 5-15 characters
- No quotes, no trailing punctuation, no emojis

Query: %s`, query)

    result, err := llmCall(prompt)
    if err != nil {
        return "", err
    }

    // 清理结果
    title := strings.TrimSpace(result)
    title = strings.Trim(title, `"'`)

    return title, nil
}

幂等性

重要:只生成一次,不要重复生成。

func GenerateSessionTitle(ctx context.Context, sessionID, query string) (string, error) {
    // 1. 幂等检查
    sess := getSession(sessionID)
    if title := sess.Context["title"]; title != "" {
        return title, nil  // 已有标题,跳过
    }

    // 2. LLM 生成
    title, err := generateTitleWithLLM(ctx, query)
    if err != nil {
        // 3. 降级:截断原始查询
        title = truncateQuery(query, 40)
    }

    // 4. 长度限制(注意 UTF-8)
    titleRunes := []rune(title)
    if len(titleRunes) > 60 {
        title = string(titleRunes[:57]) + "..."
    }

    // 5. 保存
    updateContext(sessionID, "title", title)
    return title, nil
}

降级策略

LLM 调用可能失败(超时、API 限流)。降级方案是截断原始查询:

func truncateQuery(query string, maxLen int) string {
    // 取第一行
    if idx := strings.Index(query, "\n"); idx > 0 {
        query = query[:idx]
    }

    // 按字符截断(不是字节,避免破坏 UTF-8)
    runes := []rune(query)
    if len(runes) > maxLen {
        // 尝试在单词边界截断
        truncated := string(runes[:maxLen])
        if lastSpace := strings.LastIndex(truncated, " "); lastSpace > maxLen/2 {
            truncated = truncated[:lastSpace]
        }
        return truncated + "..."
    }
    return query
}

为什么用 []rune 而不是直接截断 string

Go 的 string 是 UTF-8 编码的字节序列。一个中文字符占 3 个字节。如果按字节截断,可能截到中文字符中间,产生乱码。

// 错误:按字节截断
s := "你好世界"
s[:3]  // 可能是乱码

// 正确:按 rune 截断
runes := []rune("你好世界")
string(runes[:2])  // "你好"

9.6 语义去重(MMR)

上一章讲了语义检索。但有个问题:返回的结果可能高度重复。

Query: "如何配置 Kubernetes 网络?"

召回结果:
1. [0.95] K8s 网络配置需要先设置 CNI...
2. [0.94] Kubernetes 网络通过 CNI 插件配置...
3. [0.93] 配置 K8s 网络,首先要选择 CNI...
4. [0.92] K8s 的网络依赖 CNI 配置...
5. [0.85] Service Mesh 可以增强网络功能...

前 4 条说的是同一件事,浪费 token。

MMR 算法

MMR(Maximal Marginal Relevance)平衡相关性和多样性:

MMR(d) = lambda * Sim(d, query) - (1-lambda) * max(Sim(d, d_i))

其中:
- Sim(d, query): 文档 d 与查询的相似度
- max(Sim(d, d_i)): 文档 d 与已选文档的最大相似度
- lambda: 权重参数
  - 0.7: 偏向相关性(默认)
  - 0.5: 平衡
  - 0.3: 偏向多样性

实现

func mmrReorder(queryVec []float32, items []SearchResult, topK int, lambda float64) []SearchResult {
    if len(items) <= topK {
        return items
    }

    selected := []int{}
    remaining := make(map[int]bool)
    for i := range items {
        remaining[i] = true
    }

    // 贪心选择
    for len(selected) < topK && len(remaining) > 0 {
        bestIdx := -1
        bestScore := -1e9

        for i := range remaining {
            // 与查询的相关性
            relevance := cosineSim(queryVec, items[i].Vector)

            // 与已选结果的最大相似度(惩罚项)
            maxSim := 0.0
            for _, s := range selected {
                sim := cosineSim(items[i].Vector, items[s].Vector)
                if sim > maxSim {
                    maxSim = sim
                }
            }

            // MMR 公式
            score := lambda*relevance - (1-lambda)*maxSim

            if score > bestScore {
                bestScore = score
                bestIdx = i
            }
        }

        if bestIdx >= 0 {
            selected = append(selected, bestIdx)
            delete(remaining, bestIdx)
        }
    }

    // 重建结果
    result := make([]SearchResult, len(selected))
    for i, idx := range selected {
        result[i] = items[idx]
    }
    return result
}

使用方式

// 需要 5 条,先取 15 条(3 倍)
candidates := vdb.Search(query, 15)
// MMR 重排序
results := mmrReorder(queryVec, candidates, 5, 0.7)

效果

去重后:
1. [0.95] K8s 网络配置需要先设置 CNI...
2. [0.85] Service Mesh 可以增强网络功能...
3. [0.80] NetworkPolicy 用于安全隔离...

更少结果,更高信息密度。


9.7 Circuit Breaker

Redis 挂了怎么办?不能让整个服务都挂。

问题

用户请求   Session  Redis 超时(5秒)→ 用户等待  失败
用户请求   Session  Redis 超时(5秒)→ 用户等待  失败
...

如果 Redis 挂了,每个请求都要等 5 秒超时,用户体验极差。

解决方案:Circuit Breaker

type CircuitBreaker struct {
    failureCount    int
    lastFailure     time.Time
    state           State  // Closed, Open, HalfOpen
    failureThreshold int   // 5
    resetTimeout    time.Duration  // 30s
}

func (cb *CircuitBreaker) Execute(fn func() error) error {
    switch cb.state {
    case Open:
        // 电路断开,直接拒绝
        if time.Since(cb.lastFailure) > cb.resetTimeout {
            cb.state = HalfOpen  // 尝试恢复
        } else {
            return ErrCircuitOpen
        }
    case HalfOpen:
        // 半开状态,尝试一次
        err := fn()
        if err != nil {
            cb.state = Open  // 还是失败,继续断开
            return err
        }
        cb.state = Closed  // 成功,恢复
        cb.failureCount = 0
        return nil
    }

    // Closed 状态,正常执行
    err := fn()
    if err != nil {
        cb.failureCount++
        cb.lastFailure = time.Now()
        if cb.failureCount >= cb.failureThreshold {
            cb.state = Open  // 失败太多,断开
        }
    }
    return err
}

Shannon 的 Redis 客户端就包装了 Circuit Breaker:

client := circuitbreaker.NewRedisWrapper(redisClient, logger)

降级策略

Circuit Breaker 断开后,降级到本地缓存:

func (m *Manager) GetSession(ctx context.Context, sessionID string) (*Session, error) {
    // 先查本地缓存
    if session, ok := m.localCache[sessionID]; ok {
        return session, nil
    }

    // 查 Redis(带 Circuit Breaker)
    session, err := m.client.GetSession(ctx, sessionID)
    if err == ErrCircuitOpen {
        // Redis 断开了,返回空会话(新用户体验)
        m.logger.Warn("Redis circuit open, creating new session")
        return m.createLocalSession(sessionID)
    }

    return session, err
}

9.8 常见的坑

坑 1:Session ID 碰撞

// 递增 ID,容易碰撞(尤其是分布式环境)
sessionID := fmt.Sprintf("session_%d", counter)

// UUID(推荐)
sessionID := uuid.New().String()

坑 2:UTF-8 截断

// 按字节截断,可能破坏中文字符
if len(title) > 50 {
    title = title[:50]  // 可能乱码
}

// 按 rune 截断(正确)
runes := []rune(title)
if len(runes) > 50 {
    title = string(runes[:47]) + "..."
}

坑 3:租户隔离遗漏

// 直接返回(不安全)
return session, nil

// 检查租户隔离
if session.TenantID != userCtx.TenantID {
    return nil, ErrSessionNotFound  // 不是 ErrUnauthorized
}

坑 4:PII 脱敏不全

只处理 Email 不够。要覆盖电话、信用卡、SSN、IP、API Key、密码等。

坑 5:语义召回不去重

直接用 top-k 结果,可能一堆重复的。用 MMR 重排序。

坑 6:Redis 挂了服务全挂

没有 Circuit Breaker,Redis 超时会拖慢所有请求。

坑 7:标题重复生成

每次请求都调 LLM 生成标题,浪费钱。要做幂等检查。


Shannon Lab(10 分钟上手)

本节帮你在 10 分钟内把本章概念对应到 Shannon 源码。

必读(1 个文件)

  • session/manager.go:看 GetSessionCreateSessionWithIDAddMessage 函数,理解租户隔离、防劫持、滑动窗口

选读深挖(2 个,按兴趣挑)


练习

练习 1:设计 Session 结构

你在做一个电商客服 Agent,需要支持:

  • 多轮商品咨询
  • 订单状态查询
  • 投诉处理

设计你的 Session 结构:

  • 需要存哪些字段?
  • 怎么做租户隔离?
  • 怎么处理敏感信息(订单号、地址)?

练习 2:源码阅读

session/manager.go,回答:

  1. cleanupLocalCache 函数用了什么缓存淘汰策略?
  2. 为什么 CreateSessionWithID 要检查 existing.UserID != userID
  3. Circuit Breaker 是在哪里包装的?

练习 3(进阶):实现 PII 检测

扩展 redactPII 函数,添加对以下内容的检测:

  • 中国身份证号(18 位)
  • 银行卡号(16-19 位)
  • 护照号

思考:

  • 怎么避免误报(把正常数字当 PII)?
  • 怎么处理格式变体(带空格、带连字符)?

划重点

核心就一句话:多轮对话要管好状态、控好长度、保好隐私、去好重复

要点:

  1. 两级缓存:LRU 本地缓存 + Redis 持久化
  2. 租户隔离:返回 ErrSessionNotFound 而不是 ErrUnauthorized
  3. PII 脱敏:存储前清洗敏感信息
  4. 标题生成:LLM 生成 + 幂等检查 + 降级截断
  5. MMR 去重:lambda=0.7 平衡相关性和多样性

到这里,Part 3(上下文与记忆)就讲完了。

我们讲了:

  • 第 7 章:上下文窗口管理——单次对话内的"短期记忆"
  • 第 8 章:记忆架构——跨会话的"长期记忆"
  • 第 9 章:多轮对话设计——把它们落地到生产环境

三章串起来,你应该能理解一个生产级 Agent 是怎么"记住东西"的。

下一章进入 Part 4(单 Agent 模式),开始讲 Planning——Agent 如何把复杂任务拆解成可执行的步骤。

从"记住东西"到"做事情",这是 Agent 能力的关键跃迁。


延伸阅读

引用本文 / Cite
Zhang, W. (2026). 第 9 章:多轮对话设计. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/第09章-多轮对话设计
@incollection{zhang2026aiagent_第09章_多轮对话设计,
  author = {Zhang, Wayland},
  title = {第 9 章:多轮对话设计},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/第09章-多轮对话设计}
}