晚上和一个老同学相互交流学习到很晚,讨论持续到凌晨,关于在AI Agent应用实际开发过程中开发者都会面临的一个问题或者说是疑问:模型是无状态的也没有缓存"记忆"机制,那会话的上下文是怎么实现跟踪的呢?
带着这个疑问和思考,以下我将从设计原理到实践进行解构,帮理理解Agent开发中个最核心的问题之一。
其实很多开发者对大模型的会话ID唯一性标识充满了疑问和不解,要想从事Agent应用开发,首先要有个意识:
- 1.大模型是无状态性的;
- 2.大模型并不具有存储和记忆(具体表现为内部token处理完输出结果终止后释放,类似流水线)。注意这里指的是在一次推理(从输入 Prompt 到生成完整输出)完成后,本次处理过的
token通常会被释放(即从显存/内存中清除),但具体取决于你是否需要保留对话上下文。
时机大模型使用过程中,人类不管是chat还是Agent方式交互,都是有状态性的对话跟踪,这个里面实现的原理,其实非常简单生硬,就是在模型上层实现会话的跟踪,接下来会进行详细结构。
首先是LLM无状态协议下的"记忆"挑战
在大语言模型(LLM)的应用开发中,我们面临着一个核心的矛盾:模型推理本质是无状态的(Stateless),而人类对话本质是有状态的(Stateful)。
HTTP 协议作为 LLM 服务的主要载体,遵循请求 - 响应模式,服务器默认不保留任何客户端的上一次请求信息。然而,一个多轮对话应用(Chatbot)必须"记住"用户之前说了什么。解决这一矛盾的关键枢纽,就是 会话 ID(Session ID / Conversation ID)。
本文将深入剖析主流部署工具(vLLM, Ollama, OpenAI, DeepSeek)的会话管理机制,揭示无状态模型实现上下文跟踪的技术原理,并最终使用 Golang 实现一套高并发、支持异步处理的专有会话 ID 管理方案。
第一部分:主流的部署工具的会话 ID 策略分析(参考相关源码和模型介绍)
不同的模型服务提供商和部署工具,对"会话"的理解和实现层级各不相同。理解这些差异是设计通用会话管理层的前提。
部署工具的好处是已经帮开发者实现了一套会话ID的生成跟踪机制,一般开发者只要获取会话ID进行跟踪和开发即可,原理本质上都一样,只是放在哪一层的策略问题。
根据各家模型的介绍和结合开源模型源码可以大概知晓其会话ID的基本实现方式:
1.1 OpenAI API (Chat Completion vs Assistants)
OpenAI 提供了两种主要模式,其会话管理逻辑截然不同:
- Chat Completion API (
/v1/chat/completions) :- 机制: 完全无状态。
- 会话 ID : API 本身不生成会话 ID。
- 原理 : 客户端必须在每次请求中携带完整的
messages数组(包含历史对话)。会话跟踪完全由客户端或中间件负责。
- Assistants API (
/v1/threads) :- 机制: 服务端有状态。
- 会话 ID : 服务端生成
thread_id。 - 原理 : 消息存储在服务端,客户端只需发送
thread_id和新消息。
1.2 vLLM (推理引擎)
- 机制: vLLM 是一个高性能推理引擎,专注于 Token 生成速度。
- 会话 ID : 原生不支持。
- 原理 : vLLM 暴露的 API 通常兼容 OpenAI 标准。它期望接收完整的 Prompt 或 History。会话管理通常由包裹 vLLM 的 Serving Layer(如 FastAPI, LangChain Serve)实现。
1.3 Ollama (本地部署)
- 机制: 本地运行,支持流式输出。
- 会话 ID : 客户端管理。
- 原理 : Ollama 的
/api/chat接口接受messages列表。虽然 Ollama 进程在内存中可能缓存部分 KV Cache 以加速同一连接的连续请求,但从 API 契约来看,它依赖客户端传递历史上下文。
1.4 DeepSeek / 云厂商模型
- 机制: 类似 OpenAI Chat Completion。
- 会话 ID : 部分云厂商在响应头或 Body 中返回
request_id或conversation_id用于日志追踪,但上下文记忆仍需客户端维护。
1.5 架构对比图
第二部分:无状态模型实现会话跟踪的技术原理
既然模型本身"健忘",我们需要在应用层构建"海马体"。
2.1 核心原理:上下文窗口(Context Window)
LLM 的"记忆"实际上是输入 Token 的一部分。
- 请求时 :客户端将
历史消息 + 新消息拼接成完整的 List 发送给模型。 - 响应时:模型基于整个 List 生成回复。
- 存储时 :客户端将
新消息 + 模型回复追加到本地存储的历史记录中。
2.2 会话 ID 的生成与生命周期
会话 ID 是对话的"主键"。
- 生成方式 :
- UUID v4: 随机性强,适合分布式生成,无碰撞风险。
- Snowflake: 有序 ID,适合数据库索引优化。
- Hash: 基于用户 ID+ 时间戳生成,可重现但需防碰撞。
- 存储介质 :
- Redis: 适合高频读写,设置 TTL 自动过期。
- Database: 适合持久化归档。
- In-Memory: 适合单节点临时测试(生产环境不推荐)。
2.3 会话跟踪流程图
第三部分:异步处理中的会话 ID 逻辑
LLM 推理耗时较长(秒级到分钟级),在 Web 服务中通常采用异步任务模式。此时,会话 ID 不仅是"对话标识",更是"任务关联标识"。
3.1 异步挑战
- 请求断开: HTTP 请求可能在模型返回前超时。
- 状态同步: 用户如何知道哪个回复属于哪个会话?
- 并发冲突: 同一会话在短时间内收到两条消息,如何保证上下文顺序?
3.2 解决方案:会话锁与任务队列
- Correlation ID : 在 Session ID 基础上,为每次请求生成唯一的
Request ID,用于追踪单次任务。 - 会话锁 (Session Lock): 防止同一 Session ID 的并发写入导致上下文错乱。
- 回调机制: 通过 WebSocket 或 SSE (Server-Sent Events) 将结果推回客户端,携带原始 Session ID。
3.3 异步处理逻辑图
第四部分:Golang 专有会话 ID 解决方案实战(仅作原理性理解)
从事开发工作这些年来,我一直有个习惯:如果不理解一个东西,那就亲自动手DIY一下尝试去实现它,在解决问题的过程中就能很快理解而且记忆深刻。只知其然而不知其所以然的结果就是很快淡忘
以下将使用 Golang 实现一个线程安全、支持上下文滑动窗口、并具备异步处理能力的会话管理中间件。
4.1 设计目标
- 唯一性 : 基于 UUID 生成会话 ID(注意:实际ID的生成方案有很多,主流雪花算法等,只要确保原子性即可)。
- 线程安全: 支持高并发读写。
- 上下文管理: 自动维护消息历史,支持最大 Token 数截断。
- 异步友好: 提供锁机制防止并发污染。
4.2 核心数据结构
go
package session
import (
"sync"
"time"
"github.com/google/uuid"
)
// Message 代表单条对话消息
type Message struct {
Role string `json:"role"` // system, user, assistant
Content string `json:"content"` // 内容
Time int64 `json:"time"` // 时间戳
}
// Session 代表一个完整的会话对象
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Messages []Message `json:"messages"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MaxHistory int `json:"max_history"` // 最大保留消息条数 (滑动窗口)
mu sync.Mutex // 会话级锁,防止并发修改上下文
}
// Manager 会话管理器 (单例)
type Manager struct {
sessions map[string]*Session
mu sync.RWMutex // 管理器级锁,保护 map
}
// NewManager 初始化管理器
func NewManager() *Manager {
return &Manager{
sessions: make(map[string]*Session),
}
}
4.3 会话 ID 生成与获取逻辑
这里实现了"有则复用,无则新建"的逻辑,并加入了滑动窗口清理。
go
// GetOrCreateSession 获取或创建会话
func (m *Manager) GetOrCreateSession(userID, sessionID string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
if sessionID != "" {
if s, exists := m.sessions[sessionID]; exists {
return s
}
}
// 创建新会话
newID := uuid.New().String()
s := &Session{
ID: newID,
UserID: userID,
Messages: make([]Message, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
MaxHistory: 20, // 默认保留最近 20 条
}
m.sessions[newID] = s
return s
}
// AddMessage 添加消息并维护上下文 (线程安全)
func (s *Session) AddMessage(role, content string) {
s.mu.Lock()
defer s.mu.Unlock()
msg := Message{
Role: role,
Content: content,
Time: time.Now().Unix(),
}
s.Messages = append(s.Messages, msg)
s.UpdatedAt = time.Now()
// 滑动窗口:如果超过最大限制,移除最早的消息
// 注意:实际生产中应基于 Token 数计算,此处简化为条数
if len(s.Messages) > s.MaxHistory {
s.Messages = s.Messages[len(s.Messages)-s.MaxHistory:]
}
}
// GetContext 获取用于发送给 LLM 的上下文
func (s *Session) GetContext() []Message {
s.mu.Lock()
defer s.mu.Unlock()
// 返回副本,防止外部修改
ctx := make([]Message, len(s.Messages))
copy(ctx, s.Messages)
return ctx
}
4.4 异步处理与锁机制实现
在异步场景下,必须确保同一个 Session ID 在同一时间只有一个请求在修改上下文,否则会出现"丢失消息"或"上下文错乱"。
go
// AsyncTask 模拟异步任务结构
type AsyncTask struct {
SessionID string
UserMsg string
ResultChan chan string
}
// ProcessAsync 模拟异步处理入口
func (m *Manager) ProcessAsync(userID, sessionID, msg string) (string, chan string) {
session := m.GetOrCreateSession(userID, sessionID)
resultChan := make(chan string, 1)
task := AsyncTask{
SessionID: session.ID,
UserMsg: msg,
ResultChan: resultChan,
}
// 将任务放入全局队列 (此处简化为直接启动 goroutine)
go m.handleTask(task)
return session.ID, resultChan
}
// handleTask 核心异步逻辑
func (m *Manager) handleTask(task AsyncTask) {
// 1. 获取会话 (此时会话已存在)
m.mu.RLock()
session, exists := m.sessions[task.SessionID]
m.mu.RUnlock()
if !exists {
task.ResultChan <- "Error: Session not found"
close(task.ResultChan)
return
}
// 2. 锁定会话 (关键步骤:防止并发写)
// 注意:Session.AddMessage 内部已有锁,但我们需要保证
// "读取上下文 -> 调用 LLM -> 写入回复" 的原子性逻辑在业务层可控
// 这里简化演示,实际建议在外部加锁或使用乐观锁
// 用户消息先入库
session.AddMessage("user", task.UserMsg)
// 3. 模拟 LLM 调用 (耗时操作)
// 在实际代码中,这里会调用 vLLM/Ollama/OpenAI
// 需要传入 session.GetContext()
mockLLMResponse := "这是模型基于上下文的回复 (Async)"
// 4. 模型回复入库
session.AddMessage("assistant", mockLLMResponse)
// 5. 通知结果
task.ResultChan <- mockLLMResponse
close(task.ResultChan)
}
4.5 完整调用示例 (Main)
go
package main
import (
"fmt"
"your_project/session" // 假设上面的代码在 session 包
)
func main() {
manager := session.NewManager()
// 第一次请求 (创建会话)
sid, ch := manager.ProcessAsync("user_123", "", "你好,介绍一下你自己")
fmt.Printf("新会话 ID: %s\n", sid)
// 模拟等待异步结果
resp := <-ch
fmt.Printf("模型回复: %s\n", resp)
// 第二次请求 (复用会话)
// 传入上一次的 sid,实现多轮对话
sid2, ch2 := manager.ProcessAsync("user_123", sid, "那你能写代码吗?")
if sid != sid2 {
fmt.Println("Error: Session ID mismatch")
}
resp2 := <-ch2
fmt.Printf("模型回复: %s\n", resp2)
// 验证上下文是否保留
// (实际可通过 manager.GetSession(sid).Messages 查看)
}
第五部分:关键技术点总结与最佳实践
5.1 会话 ID 的安全性
- 不可预测性 : 必须使用加密安全的随机数生成器(如
uuid库),防止用户遍历 ID 窃取他人对话。 - 权限绑定 : 会话 ID 应与
User ID或API Key绑定,查询时需校验归属权。
5.2 上下文截断策略(防止上下文超出模型输入的上限,导致prompt被截断或丢弃等造成token达不到输入预期)
在 Go 实现中,我们使用了简单的条数截断。在生产环境中,建议引入 Token 计数器 (如 tiktoken-go,是一个基于Go语言实现的高效BPE(Byte Pair Encoding)分词工具,专门为OpenAI的模型设计。这个项目源自于原生的tiktoken,并为Go开发者提供了方便的接口和性能出色的分词服务):
- 计算当前上下文总 Token 数。
- 若超过模型限制(如 4096),从最早的消息开始移除,直到满足限制。
- 保留
System Prompt永远不被移除。
5.3 分布式环境下的会话管理
上述 Go 代码使用内存存储(map),适用于单节点。若部署在 Kubernetes 等多节点环境:
- 存储层 : 必须将
Session数据存入 Redis。 - 锁机制 : 使用 Redis Distributed Lock (Redlock) 替代
sync.Mutex,确保不同 Pod 间对同一 Session ID 的互斥访问。 - ID 生成: 确保 UUID 生成算法在所有节点一致。
5.4 异步状态查询接口
除了推送(SSE),还应提供轮询接口: GET /api/session/{session_id}/status 返回:{ "status": "processing", "progress": 0.5 } 或 { "status": "completed", "response": "..." }。
心得感悟
会话 ID 是连接无状态模型与有状态人类意图的桥梁。无论是使用 OpenAI 的托管服务,还是自建 vLLM 集群,理解会话管理的底层原理都是开发者必须要掌握的基本常识。
通过一个DIY demo的简单实现展示了如何通过唯一标识生成 、线程安全的上下文维护 以及异步任务关联 ,构建一个健壮的 LLM 应用后端。在实际生产环境场景中,请根据业务规模,将内存存储升级为 Redis 集群,并引入更精细的 Token 管理策略,以平衡成本与体验。
核心公式 :
有效对话 = 唯一会话 ID + 持久化上下文 + 并发控制
最后希望所有的开发者,能成功地转入AI应用开发思维,成为一名合格的AI应用开发工程师,其实有传统开发的经验的工程师,更具备快速成为AI应用开发工程师的资格。身边有很多同学朋友都说赶紧力不从心,其实大多数的焦虑源于对新事物的不理解,真正理解了就会祛魅,培养和享受AI时代的开发乐趣。
说得有点儿啰嗦,如有误欢迎批评指正交换意见,谢谢。🤝
附:原文地址: www.wdft.com/20d2dfe9.ht...
交流联系方式: github.com/ljq
微信:labsec 邮箱 Email: ljqlab@163.com