酒店摄像头检测
98.35M · 2026-03-22
晚上和一个老同学相互交流学习到很晚,讨论持续到凌晨,关于在AI Agent应用实际开发过程中开发者都会面临的一个问题或者说是疑问:模型是无状态的也没有缓存“记忆”机制,那会话的上下文是怎么实现跟踪的呢?
带着这个疑问和思考,以下我将从设计原理到实践进行解构,帮理理解Agent开发中个最核心的问题之一。
其实很多开发者对大模型的会话ID唯一性标识充满了疑问和不解,要想从事Agent应用开发,首先要有个意识:
token 通常会被释放(即从显存/内存中清除),但具体取决于你是否需要保留对话上下文。时机大模型使用过程中,人类不管是chat还是Agent方式交互,都是有状态性的对话跟踪,这个里面实现的原理,其实非常简单生硬,就是在模型上层实现会话的跟踪,接下来会进行详细结构。
在大语言模型(LLM)的应用开发中,我们面临着一个核心的矛盾:模型推理本质是无状态的(Stateless),而人类对话本质是有状态的(Stateful)。
HTTP 协议作为 LLM 服务的主要载体,遵循请求 - 响应模式,服务器默认不保留任何客户端的上一次请求信息。然而,一个多轮对话应用(Chatbot)必须“记住”用户之前说了什么。解决这一矛盾的关键枢纽,就是 会话 ID(Session ID / Conversation ID)。
本文将深入剖析主流部署工具(vLLM, Ollama, OpenAI, DeepSeek)的会话管理机制,揭示无状态模型实现上下文跟踪的技术原理,并最终使用 Golang 实现一套高并发、支持异步处理的专有会话 ID 管理方案。
不同的模型服务提供商和部署工具,对“会话”的理解和实现层级各不相同。理解这些差异是设计通用会话管理层的前提。
部署工具的好处是已经帮开发者实现了一套会话ID的生成跟踪机制,一般开发者只要获取会话ID进行跟踪和开发即可,原理本质上都一样,只是放在哪一层的策略问题。
根据各家模型的介绍和结合开源模型源码可以大概知晓其会话ID的基本实现方式:
OpenAI 提供了两种主要模式,其会话管理逻辑截然不同:
/v1/chat/completions):
messages 数组(包含历史对话)。会话跟踪完全由客户端或中间件负责。/v1/threads):
thread_id。thread_id 和新消息。/api/chat 接口接受 messages 列表。虽然 Ollama 进程在内存中可能缓存部分 KV Cache 以加速同一连接的连续请求,但从 API 契约来看,它依赖客户端传递历史上下文。request_id 或 conversation_id 用于日志追踪,但上下文记忆仍需客户端维护。graph TD
subgraph Client ["客户端 / 中间件"]
C1[维护 Session ID]
C2[存储 History 上下文]
end
subgraph Provider ["模型服务层"]
O1[OpenAI Chat: 无状态]
O2[OpenAI Assistants: 有状态 Thread]
V1[vLLM/Ollama: 无状态推理]
end
C1 -->|携带 Session ID + History| O1
C1 -->|携带 Session ID + History| V1
C1 -->|仅携带 Thread ID| O2
style O1 fill:#f9f,stroke:#333
style V1 fill:#f9f,stroke:#333
style O2 fill:#bbf,stroke:#333
既然模型本身“健忘”,我们需要在应用层构建“海马体”。
LLM 的“记忆”实际上是输入 Token 的一部分。
历史消息 + 新消息 拼接成完整的 List 发送给模型。新消息 + 模型回复 追加到本地存储的历史记录中。会话 ID 是对话的“主键”。
sequenceDiagram
participant User as 用户
participant App as 应用服务 (Go)
participant Store as 存储 (Redis/DB)
participant LLM as 模型服务
User->>App: 发送消息 (无 ID 或带旧 ID)
alt 新会话
App->>App: 生成新 Session ID (UUID)
else 旧会话
App->>Store: 根据 ID 加载历史上下文
end
App->>Store: 保存新 Session ID
App->>App: 拼接历史 + 新消息
App->>LLM: 请求补全 (携带完整 Context)
LLM-->>App: 返回回复
App->>Store: 更新历史 (追加回复)
App-->>User: 返回回复 + Session ID
LLM 推理耗时较长(秒级到分钟级),在 Web 服务中通常采用异步任务模式。此时,会话 ID 不仅是“对话标识”,更是“任务关联标识”。
Request ID,用于追踪单次任务。graph TD
Req["HTTP 请求"] --> Gen["生成 Session ID & Task ID"]
Gen --> Queue["推入任务队列"]
Gen --> Ack["立即返回 202 Accepted + Session ID"]
Worker["异步 Worker"] --> Queue
Worker --> Lock["获取会话锁"]
Lock --> Load["加载上下文"]
Load --> Infer["调用 LLM"]
Infer --> Save["保存新上下文"]
Save --> Unlock["释放会话锁"]
Save --> Notify["推送结果 (SSE/Webhook)"]
style Gen fill:#ff9,stroke:#333
style Lock fill:#f96,stroke:#333
从事开发工作这些年来,我一直有个习惯:如果不理解一个东西,那就亲自动手DIY一下尝试去实现它,在解决问题的过程中就能很快理解而且记忆深刻。只知其然而不知其所以然的结果就是很快淡忘
以下将使用 Golang 实现一个线程安全、支持上下文滑动窗口、并具备异步处理能力的会话管理中间件。
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),
}
}
这里实现了“有则复用,无则新建”的逻辑,并加入了滑动窗口清理。
// 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
}
在异步场景下,必须确保同一个 Session ID 在同一时间只有一个请求在修改上下文,否则会出现“丢失消息”或“上下文错乱”。
// 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)
}
package main
import (
"fmt"
"your_project/session" // 假设上面的代码在 session 包
)
func main() {
manager := session.NewManager()
// 第一次请求 (创建会话)
sid, ch := manager.ProcessAsync("user_123", "", "你好,介绍一下你自己")
fmt.Printf("新会话 ID: %sn", sid)
// 模拟等待异步结果
resp := <-ch
fmt.Printf("模型回复: %sn", resp)
// 第二次请求 (复用会话)
// 传入上一次的 sid,实现多轮对话
sid2, ch2 := manager.ProcessAsync("user_123", sid, "那你能写代码吗?")
if sid != sid2 {
fmt.Println("Error: Session ID mismatch")
}
resp2 := <-ch2
fmt.Printf("模型回复: %sn", resp2)
// 验证上下文是否保留
// (实际可通过 manager.GetSession(sid).Messages 查看)
}
uuid 库),防止用户遍历 ID 窃取他人对话。User ID 或 API Key 绑定,查询时需校验归属权。在 Go 实现中,我们使用了简单的条数截断。在生产环境中,建议引入 Token 计数器(如 tiktoken-go,是一个基于Go语言实现的高效BPE(Byte Pair Encoding)分词工具,专门为OpenAI的模型设计。这个项目源自于原生的tiktoken,并为Go开发者提供了方便的接口和性能出色的分词服务):
System Prompt 永远不被移除。上述 Go 代码使用内存存储(map),适用于单节点。若部署在 Kubernetes 等多节点环境:
Session 数据存入 Redis。sync.Mutex,确保不同 Pod 间对同一 Session ID 的互斥访问。除了推送(SSE),还应提供轮询接口:
GET /api/session/{session_id}/status
返回:{ "status": "processing", "progress": 0.5 } 或 { "status": "completed", "response": "..." }。
会话 ID 是连接无状态模型与有状态人类意图的桥梁。无论是使用 OpenAI 的托管服务,还是自建 vLLM 集群,理解会话管理的底层原理都是开发者必须要掌握的基本常识。
通过一个DIY demo的简单实现展示了如何通过唯一标识生成、线程安全的上下文维护以及异步任务关联,构建一个健壮的 LLM 应用后端。在实际生产环境场景中,请根据业务规模,将内存存储升级为 Redis 集群,并引入更精细的 Token 管理策略,以平衡成本与体验。
最后希望所有的开发者,能成功地转入AI应用开发思维,成为一名合格的AI应用开发工程师,其实有传统开发的经验的工程师,更具备快速成为AI应用开发工程师的资格。身边有很多同学朋友都说赶紧力不从心,其实大多数的焦虑源于对新事物的不理解,真正理解了就会祛魅,培养和享受AI时代的开发乐趣。
说得有点儿啰嗦,如有误欢迎批评指正交换意见,谢谢。
附:原文地址: www.wdft.com/20d2dfe9.ht…