Files
aiops/docs/agent/10-会话管理.md
T
attackerandClaude 92a2247390 feat: 新增 Agent CLI — Web 版 AI 运维 Agent(参考 opsxcli 移植)
后端引擎 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>
2026-06-27 09:55:17 +08:00

389 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(会话)
```go
// 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(消息)
```go
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(创建会话)
```go
// 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)
```go
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(结束会话)
```go
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 接口
```go
// 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)
```go
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)
```go
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`)同一时刻只运行一个任务:
```go
func (ac *agentConn) startTask(...) {
ac.abort() // 取消上一次任务,再启动新的
}
```
### 多用户隔离
- 每个会话绑定 `UserID`(JWT 中间件注入)
- REST 查询自动按 `user_id` 过滤
- 删除会话时校验归属
### 线程安全
- `agentConn.writeMu sync.Mutex` —— 串行化 WebSocket 写入
- `agentConn.cfMu sync.Mutex` —— 保护 `confirms` map
- 引擎 `core.Agent.mu sync.Mutex` —— 保护 token 计数等内部状态
## 持久化策略
### 落库失败不阻断
```go
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 升级版 —— 不需要导出,直接在线回放。
## 扩展指南
### 添加会话标签 / 收藏
1. 在 `AgentCLISession` 增加 `Tags string` / `Starred bool` 字段
2. 增加 REST 接口 `PATCH /sessions/:id`
3. 前端列表增加标签筛选
### 会话统计
```go
// 例:用户本月使用的 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