后端引擎 backend/pkg/agentcli/(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳): - llm: OpenAI 兼容客户端,复用 model.LLMConfig - tools: ssh_execute(复用 pkg/remoteexec)+ analyze_output + 风险评估黑名单 - safety: 三级安全模式 + 五级风险 + WebSocket 异步确认 - prompt: 五层 System Prompt - core: ReAct 16 轮循环 + 自适应检查点 + 循环检测 - evolver: 四层记忆 + 进化循环,data/agentcli/ 文件存储 集成层: - controller/agent_cli.go: WebSocket 控制器(事件流推流 + 高危确认 + 会话持久化) - model/agent_cli.go: agent_cli_sessions/messages 表 + AutoMigrate - 路由 /ws/agent-cli + /api/v1/aiops/agent-cli/sessions - seed 权限 aiops:agent-cli / aiops:llm-config 前端: - AgentCLI.tsx: 对话流 + 工具块 + 高危确认 + 四模块 tips 面板 - LLMConfig.tsx: 大模型设置独立页 - api/agentCli.ts + Sidebar/App 菜单路由 文档: - docs/agent/ 11 篇(opsxcli 同名文档适配改写 + 00-README 映射总览) 其他: - CLAUDE.md 补充 agent-cli 模块说明 - 修正 .gitignore 根级二进制规则(前导 /,避免误伤 frontend/src/components/aiops/) Co-Authored-By: Claude <noreply@anthropic.com>
17 KiB
10 - 会话管理
本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(
backend/model/agent_cli.go+controller/agent_cli.go)。 核心改造:opsxcli 用本地 JSONL 文件存储会话(单用户 CLI 场景);aiops 用 GORM 数据库表存储会话与消息,支持多用户并发、按 user_id 隔离、REST 查询与历史回放。
设计理念
- 会话管理:Agent 引擎本身无状态(每次任务独立),所有对话过程持久化到会话层,支持历史回溯、审计、断点排查
- 多用户隔离:Web 场景下多个用户同时使用 Agent CLI,会话必须按 user_id 隔离
- 结构化查询:会话需要列表、筛选、删除等业务操作,必须走 DB 而非文件
为什么改用 DB?opsxcli 是 CLI 工具,单用户、单机使用,JSONL 文件够用且零依赖;aiops 是 Web 平台,多用户并发、需要列表查询和按主机/用户筛选、需要与其他业务表(Server、User)关联 —— DB 是必然选择。
架构总览
┌────────────────────────────────────────────────────┐
│ REST + WebSocket │
│ /ws/agent-cli (WebSocket 推流) │
│ /aiops/agent-cli/* (REST 会话管理) │
├────────────────────────────────────────────────────┤
│ controller/agent_cli.go │
│ AgentCLIHandler │
│ ├─ Run (WebSocket) │
│ ├─ ListSessions / GetSession / DeleteSession │
│ └─ agentConn (单连接运行态) │
├────────────────────────────────────────────────────┤
│ model/agent_cli.go (GORM) │
│ AgentCLISession (agent_cli_sessions 表) │
│ AgentCLIMessage (agent_cli_messages 表) │
├────────────────────────────────────────────────────┤
│ MySQL / SQLite (aiops 既有 DB) │
└────────────────────────────────────────────────────┘
核心数据模型
AgentCLISession(会话)
// backend/model/agent_cli.go
type AgentCLISession struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index" json:"user_id"` // 发起用户(隔离)
ServerID uint `gorm:"index" json:"server_id"` // 目标主机
ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照
ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照
Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断)
Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商
Model string `gorm:"size:100" json:"model"` // 模型名
Rounds int `json:"rounds"` // ReAct 轮次
TotalTokens int `json:"total_tokens"` // token 消耗
Duration int64 `json:"duration"` // 耗时(毫秒)
Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AgentCLISession) TableName() string { return "agent_cli_sessions" }
设计要点:
UserID索引:多用户隔离的核心,所有查询都按 user_id 过滤ServerID+ServerName+ServerHost:关联主机的同时保存快照(主机 later 被删/改名时历史仍可读)Status四态:running(进行中)/done(完成)/aborted(用户中断)/error(出错或达最大轮次)Title:query 截断到 60 字符,作为会话列表的显示标题
AgentCLIMessage(消息)
type AgentCLIMessage struct {
ID uint `gorm:"primarykey" json:"id"`
SessionID uint `gorm:"index;not null" json:"session_id"`
Round int `json:"round"` // ReAct 轮次
Type string `gorm:"size:32" json:"type"` // thought/tool_call/tool_result/done/error/session
Role string `gorm:"size:32" json:"role"` // user/assistant/tool
Content string `gorm:"type:text" json:"content"` // 思考文本/最终答案
ToolName string `gorm:"size:64" json:"tool_name"` // 工具名
ToolArgs string `gorm:"type:text" json:"tool_args"` // 工具参数 JSON
ToolOutput string `gorm:"type:text" json:"tool_output"` // 工具输出
Risk string `gorm:"size:32" json:"risk"` // 风险等级
Success bool `json:"success"`
Duration string `gorm:"size:32" json:"duration"` // 工具耗时
Tokens int `json:"tokens"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (AgentCLIMessage) TableName() string { return "agent_cli_messages" }
消息类型映射引擎事件:
| Event.Type | DB Type | DB Role | Content | ToolName | ToolOutput |
|---|---|---|---|---|---|
| 用户输入 | (无 Type) | user | query | — | — |
thought |
thought | assistant | AI 思考文本 | — | — |
tool_call |
tool_call | tool | (空) | 工具名 | (空,Args 在 ToolArgs) |
tool_result |
tool_result | tool | 输出 | 工具名 | 输出(冗余便于查询) |
done |
done | assistant | 最终答案 | — | — |
error |
error | assistant | 错误信息 | — | — |
progress |
— | — | — | — | — |
会话生命周期
WebSocket start {query}
│
▼
startTask
├─ 创建 AgentCLISession (status=running)
├─ 存用户消息 (role=user, content=query)
├─ 构建 Engine、注入 Callback
└─ go engine.Run(ctx, query, serverInfo)
│
│ 每个事件触发 onEvent → 落库 agent_cli_messages
│ ├─ thought → persistMsg(type=thought, role=assistant)
│ ├─ tool_call → persistMsg(type=tool_call, role=tool, args, risk)
│ ├─ tool_result → persistMsg(type=tool_result, role=tool, output, success)
│ ├─ done → persistMsg(type=done, role=assistant, answer, tokens)
│ └─ error → persistMsg(type=error, role=assistant, message)
│
▼
finish (更新 session.status / rounds / duration / error)
├─ ctx.Err() != nil → status=aborted
├─ err != nil → status=error
└─ 否则 → status=done
controller 实现
startTask(创建会话)
// backend/controller/agent_cli.go
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) {
ac.abort() // 取消上一次任务
// 1. 创建 DB 会话
title := query
if r := []rune(title); len(r) > 60 {
title = string(r[:60]) + "..."
}
sess := &model.AgentCLISession{
UserID: ac.userID, ServerID: ac.server.ID,
ServerName: ac.server.Hostname, ServerHost: ac.server.IP,
Title: title, Status: "running",
Provider: llmCfg.Provider, Model: llmCfg.Model,
}
ac.db.Create(sess)
ac.session = sess
// 2. 存用户消息
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
// 3. 构建 Engine
engine, _ := agentcli.NewEngine(...)
engine.SetCallback(ac.onEvent)
engine.SetConfirmFn(ac.confirmFn)
// 4. 启动 ReAct 循环(goroutine)
go func() {
defer engine.Close()
_, err := engine.Run(ctx, query, serverInfo)
// ... finish
}()
}
onEvent(事件落库 + 推 WS)
func (ac *agentConn) onEvent(e core.Event) {
ev := wsEvent{...} // 序列化
if ac.session != nil {
ev.SessionID = ac.session.ID
}
ac.write(ev) // 推 WS
// 持久化(progress 不入库)
if ac.session == nil { return }
sid := ac.session.ID
switch e.Type {
case core.EventThought:
ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, ...)
case core.EventToolCall:
argsJSON, _ := json.Marshal(e.ToolArgs)
ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, ...)
case core.EventToolResult:
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
case core.EventDone:
ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, ..., true, "", e.Tokens)
case core.EventError:
ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, ...)
}
}
finish(结束会话)
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
// 推 WS 结束事件
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds})
// 更新 DB
updates := map[string]interface{}{
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
}
if errMsg != "" {
updates["error"] = errMsg
}
ac.db.Model(sess).Updates(updates)
}
REST 接口
// backend/router/routes.go
agentCLIGroup := authorized.Group("/aiops/agent-cli")
{
agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) // 列表(按 user_id 过滤)
agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) // 详情(含全部消息)
agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) // 删除(校验归属)
}
前端 frontend/src/api/agentCli.ts 调用这些接口实现「会话历史」页面:
- 列表展示用户的所有会话(标题、主机、状态、耗时、token)
- 点击某会话查看完整 ReAct 推理过程(thought / tool_call / tool_result 时间线)
- 删除会话(连带消息)
WebSocket 协议
后端 → 前端(wsEvent)
type wsEvent struct {
Type string `json:"type"` // session/thought/tool_call/tool_result/progress/done/error/confirm
Round int `json:"round,omitempty"`
SessionID uint `json:"session_id,omitempty"`
Thought string `json:"thought,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
Risk string `json:"risk,omitempty"`
Output string `json:"output,omitempty"`
Success bool `json:"success,omitempty"`
Duration string `json:"duration,omitempty"`
Tokens int `json:"tokens,omitempty"`
Answer string `json:"answer,omitempty"`
Message string `json:"message,omitempty"`
ConfirmID string `json:"confirm_id,omitempty"` // confirm 事件专属
Command string `json:"command,omitempty"` // confirm 事件专属
}
前端 → 后端(wsControl)
type wsControl struct {
Type string `json:"type"` // start/confirm/deny/abort
Query string `json:"query,omitempty"` // start 时任务描述
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
}
并发与隔离
单连接串行
每个 WebSocket 连接(agentConn)同一时刻只运行一个任务:
func (ac *agentConn) startTask(...) {
ac.abort() // 取消上一次任务,再启动新的
}
多用户隔离
- 每个会话绑定
UserID(JWT 中间件注入) - REST 查询自动按
user_id过滤 - 删除会话时校验归属
线程安全
agentConn.writeMu sync.Mutex—— 串行化 WebSocket 写入agentConn.cfMu sync.Mutex—— 保护confirmsmap- 引擎
core.Agent.mu sync.Mutex—— 保护 token 计数等内部状态
持久化策略
落库失败不阻断
func (ac *agentConn) persistMsg(...) {
if err := ac.db.Create(msg).Error; err != nil {
_ = err // 持久化失败不阻断主流程
}
}
DB 写入失败时,WS 推送仍正常,用户体验不受影响(只是历史回放缺这条)。
progress 不入库
progress 事件每个轮次都发,且只含 round 和 token,价值低、频率高,不入库(避免 DB 压力)。
历史回放
前端通过 GET /sessions/:id 拿到完整消息列表,按时间顺序渲染:
[会话 #42] 检查 nginx 启动失败的原因
├─ 用户:检查 nginx 启动失败的原因
├─ Round 1 [thought]:我先查看 nginx 服务状态...
├─ Round 1 [tool_call] ssh_execute "systemctl status nginx" (高风险)
├─ Round 1 [tool_result] failed (1.2s) [exit_code=3]
├─ Round 2 [thought]:服务 failed,查看日志...
├─ Round 2 [tool_call] ssh_execute "journalctl -u nginx -n 500" (安全)
├─ Round 2 [tool_result] 500 行 (0.8s)
├─ Round 3 [thought]:日志太长,先分析...
├─ Round 3 [tool_call] analyze_output(output, focus="error")
├─ Round 3 [tool_result] 8 行疑似错误 (0.05s)
├─ Round 4 [thought]:端口被占,确认一下...
├─ Round 4 [tool_call] ssh_execute "ss -tlnp | grep :80" (安全)
├─ Round 4 [tool_result] (0.3s)
└─ [done] 根因:80 端口被 apache 占用。建议:systemctl stop httpd && systemctl start nginx
这是 opsxcli「会话导出为 Markdown」的 Web 升级版 —— 不需要导出,直接在线回放。
扩展指南
添加会话标签 / 收藏
- 在
AgentCLISession增加Tags string/Starred bool字段 - 增加 REST 接口
PATCH /sessions/:id - 前端列表增加标签筛选
会话统计
// 例:用户本月使用的 token 总数
db.Model(&AgentCLIMessage{}).
Where("session_id IN (?) AND type = ?",
db.Model(&AgentCLISession{}).Select("id").Where("user_id = ?", uid),
"done").
Select("COALESCE(SUM(tokens), 0)").Scan(&totalTokens)
导出会话为 Markdown
参考 opsxcli 的 ExportMarkdown,在 controller 增加一个 REST 接口,遍历消息组装 Markdown 文本返回。
常见陷阱
| 陷阱 | 解决方案 |
|---|---|
| 多用户看到别人的会话 | 所有查询按 user_id 过滤,删除时校验归属 |
| WebSocket 断开导致会话悬挂 | ctx.Err() 触发 finish(status=aborted);前端重连后可查看历史 |
| 消息表无限增长 | 定期清理老会话(运维任务);或按 created_at 分区 |
| 落库失败丢失历史 | 不阻断主流程,但应在日志记录;progress 不入库减少压力 |
| 长任务超过 WS 超时 | ReAct 循环每步检查 ctx.Err(),前端断开 → ctx cancel → finish(aborted) |
| 并发写 WS 错乱 | writeMu sync.Mutex 串行化写入 |
| ConfirmFn 无人响应 | 5 分钟超时自动拒绝 |
lastRound 不准 |
从 DB 查 MAX(round) 而非内存计数(断线重连场景) |
与 opsxcli 的差异总结
| 维度 | opsxcli | aiops |
|---|---|---|
| 存储后端 | JSONL 文件(~/.opsxcli/agent/sessions/) |
GORM DB(agent_cli_sessions / agent_cli_messages 表) |
| 多用户 | 不支持(单用户 CLI) | 原生支持(user_id 索引隔离) |
| 会话结构 | {session-id}/meta.json + messages.jsonl |
单表 sessions + 单表 messages(一对多) |
| 查询能力 | 只能遍历文件 | SQL 任意查询、JOIN、聚合 |
| 导出 | ExportMarkdown / ExportJSON / ExportToFile | 在线回放(前端时间线) |
| 消息字段 | role/content/tool_calls | type/role/content/tool_name/tool_args/tool_output/risk/success/duration/tokens |
| 接口 | Manager 接口(10 方法) | REST(3 方法)+ WebSocket 推流 |
存储载体变了,但「会话 = 元数据 + 消息流」的核心模型保留。DB 方案在多用户 Web 场景下是必然选择,也使会话与其他业务表(Server、User、LLMConfig)的关联变得自然。
作者:Logan.Li