第 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
}
这样保证:
- 最新的消息优先保留
- 不超 token 预算
- 消息顺序正确
9.4 PII 脱敏
这是个容易被忽视但极其重要的问题。
问题场景
用户输入: "我的信用卡号是 4532-1234-5678-9012"
│
▼
[LLM 压缩成摘要]
│
▼
摘要: "用户查询了信用卡 4532-1234-5678-9012 的余额"
│
▼
[存入向量库,永久保存]
│
▼
[后续对话中可能被召回]
│
▼
[泄露给其他用户或日志系统]
信用卡号被永久存储了。这是严重的隐私泄露。
解决方案:存储前脱敏
Shannon 在两个地方做 PII 脱敏:
- 压缩摘要时
- 存储到向量库时
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 类型
| 类型 | 示例 | 替换为 |
|---|---|---|
user@example.com | [REDACTED_EMAIL] | |
| 电话 | +1-234-567-8900 | [REDACTED_PHONE] |
| 信用卡 | 4532-1234-5678-9012 | [REDACTED_CC] |
| SSN | 123-45-6789 | [REDACTED_SSN] |
| IP | 192.168.1.1 | [REDACTED_IP] |
| API Key | api_key=sk-xxx | [REDACTED_API_KEY] |
| 密码 | password=abc123 | [REDACTED_SECRET] |
局限性
正则脱敏不是万能的:
- 误报:
192.168.1.1可能是代码里的常量,不是真正的 IP - 漏报:复杂的 PII 格式可能漏掉
- 上下文丢失:脱敏后可能影响 LLM 理解
生产建议:
- 正则作为基础防线
- 敏感场景用专业的 PII 检测服务(如 AWS Comprehend、Google DLP)
- 定期审计存储内容
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:看GetSession、CreateSessionWithID、AddMessage函数,理解租户隔离、防劫持、滑动窗口
选读深挖(2 个,按兴趣挑)
activities/context_compress.go:看redactPII函数,理解 PII 脱敏的具体实现activities/session_title.go:看GenerateSessionTitle函数,理解标题生成的幂等性和降级策略
练习
练习 1:设计 Session 结构
你在做一个电商客服 Agent,需要支持:
- 多轮商品咨询
- 订单状态查询
- 投诉处理
设计你的 Session 结构:
- 需要存哪些字段?
- 怎么做租户隔离?
- 怎么处理敏感信息(订单号、地址)?
练习 2:源码阅读
读 session/manager.go,回答:
cleanupLocalCache函数用了什么缓存淘汰策略?- 为什么
CreateSessionWithID要检查existing.UserID != userID? - Circuit Breaker 是在哪里包装的?
练习 3(进阶):实现 PII 检测
扩展 redactPII 函数,添加对以下内容的检测:
- 中国身份证号(18 位)
- 银行卡号(16-19 位)
- 护照号
思考:
- 怎么避免误报(把正常数字当 PII)?
- 怎么处理格式变体(带空格、带连字符)?
划重点
核心就一句话:多轮对话要管好状态、控好长度、保好隐私、去好重复。
要点:
- 两级缓存:LRU 本地缓存 + Redis 持久化
- 租户隔离:返回
ErrSessionNotFound而不是ErrUnauthorized - PII 脱敏:存储前清洗敏感信息
- 标题生成:LLM 生成 + 幂等检查 + 降级截断
- MMR 去重:lambda=0.7 平衡相关性和多样性
到这里,Part 3(上下文与记忆)就讲完了。
我们讲了:
- 第 7 章:上下文窗口管理——单次对话内的"短期记忆"
- 第 8 章:记忆架构——跨会话的"长期记忆"
- 第 9 章:多轮对话设计——把它们落地到生产环境
三章串起来,你应该能理解一个生产级 Agent 是怎么"记住东西"的。
下一章进入 Part 4(单 Agent 模式),开始讲 Planning——Agent 如何把复杂任务拆解成可执行的步骤。
从"记住东西"到"做事情",这是 Agent 能力的关键跃迁。
延伸阅读
- Redis Data Types - Redis 数据结构
- Circuit Breaker Pattern - Martin Fowler 的经典文章
- MMR 论文 - MMR 原论文
- GDPR PII Categories - GDPR 对个人数据的定义
- Shannon Session Management - Shannon Session 管理实现