Files
xk-ai-agent/internal/service/runlog.go
2026-08-14 21:50:48 +08:00

366 lines
13 KiB
Go
Raw Permalink 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.
package service
// ========================================================================
// RunLog —— Agent 运行轨迹记录器(内存环形缓冲)
// ========================================================================
// 记录最近 N 次 Enhance 运行的完整轨迹每一步干了什么、时间节点、token
// 供 /api/v1/agent/runs 系列接口与 /agent/view 可视化面板查询。
//
// 为什么用内存环形缓冲而不是落库:
// 1. 历史明细 PHP 端已经写入 xk_ai_generation_step权威审计数据
// Go 端的定位是「实时观测最近运行」,重启清空可接受
// 2. 内存缓冲能记录到"PHP 侧看不到"的运行守卫拦截、resolveClient 失败
// 这类在返回 PHP 之前就终止的请求
// 3. 零依赖DB 抖动时面板依然可用(观测工具不能依赖被观测对象)
//
// 并发安全:所有读写都经过 sync.RWMutexAdd 是 O(1) 覆盖写
// ========================================================================
import (
"encoding/json"
"strings"
"sync"
"time"
)
// AgentRunStatus 运行状态枚举(与面板徽标颜色对应)
const (
RunStatusOK = 1 // 成功
RunStatusFail = 2 // 失败LLM 调用失败 / 配置解析失败等)
RunStatusBlocked = 3 // 医疗守卫拦截
)
// AgentRunRecord 一次 Agent 运行的完整轨迹
//
// Steps 直接复用 EnhanceStep与返回 PHP 的结构一致),
// 每个 step 自带 started_at / finished_at / duration_ms就是面板时间线的数据源
type AgentRunRecord struct {
ID int64 `json:"id"` // 递增 ID进程内唯一重启重置
Scene string `json:"scene"` // 场景medical_record / prescription
Provider string `json:"provider"` // 实际使用的供应商
Model string `json:"model"` // 实际使用的模型名
CfgSource string `json:"cfg_source"` // 配置来源active / yaml_default 等
Status int `json:"status"` // 1成功 2失败 3守卫拦截
Error string `json:"error,omitempty"` // 失败原因(成功时为空)
TotalMs int `json:"total_ms"` // 总耗时毫秒
StartedAt int64 `json:"started_at"` // 开始时间unix 秒)
FinishedAt int64 `json:"finished_at"` // 结束时间unix 秒)
PromptTokens int `json:"prompt_tokens"` // 输入 token 合计
CompletionTokens int `json:"completion_tokens"` // 输出 token 合计
TotalTokens int `json:"total_tokens"` // 总 token 合计
Steps []EnhanceStep `json:"steps"` // 完整步骤明细(时间线数据源)
// RequestSnapshot 请求入参快照(截断后的 JSON 字符串)
// 用途:管理前端的「失败一键重放」——把入参回填进 Enhance 调试台重跑。
// 只在详情接口返回列表摘要不含内容截断控制内存200 条 × ~2KB ≈ 400KB 上限)
RequestSnapshot string `json:"request_snapshot,omitempty"`
}
// AgentRunSummary 列表视图用的轻量摘要(不含 step 的 detail 全文,节省传输)
type AgentRunSummary struct {
ID int64 `json:"id"`
Scene string `json:"scene"`
Provider string `json:"provider"`
Model string `json:"model"`
CfgSource string `json:"cfg_source"`
Status int `json:"status"`
Error string `json:"error,omitempty"`
TotalMs int `json:"total_ms"`
StartedAt int64 `json:"started_at"`
TotalTokens int `json:"total_tokens"`
StepBriefs []RunStepBrief `json:"step_briefs"` // 每步的类型/状态/耗时(列表里画小圆点用)
}
// RunStepBrief 步骤摘要(列表视图用)
type RunStepBrief struct {
StepType string `json:"step_type"`
Status int `json:"status"`
DurationMs int `json:"duration_ms"`
}
// runLogBuffer 环形缓冲本体
type runLogBuffer struct {
mu sync.RWMutex
items []AgentRunRecord // 定长环形数组
size int // 容量
count int // 已写入条数(<= size
head int // 下一个写入位置
nextID int64 // 自增 ID
}
// 全局单例:保留最近 200 次运行
var runLog = &runLogBuffer{
items: make([]AgentRunRecord, 200),
size: 200,
}
// Add 追加一条运行记录环形覆盖写O(1)
func (b *runLogBuffer) Add(rec AgentRunRecord) int64 {
b.mu.Lock()
defer b.mu.Unlock()
b.nextID++
rec.ID = b.nextID
b.items[b.head] = rec
b.head = (b.head + 1) % b.size
if b.count < b.size {
b.count++
}
return rec.ID
}
// List 按时间倒序返回运行摘要(最新在前)
//
// 过滤参数:
// - limit : 返回条数上限(<=0 时取 50
// - scene : 非空时只返回该场景
// - status : >0 时只返回该状态
func (b *runLogBuffer) List(limit int, scene string, status int) []AgentRunSummary {
if limit <= 0 {
limit = 50
}
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]AgentRunSummary, 0, min(limit, b.count))
// 从最新写入的位置往回遍历head-1 是最新一条)
for i := 0; i < b.count && len(out) < limit; i++ {
idx := (b.head - 1 - i + b.size*2) % b.size
rec := b.items[idx]
if scene != "" && rec.Scene != scene {
continue
}
if status > 0 && rec.Status != status {
continue
}
briefs := make([]RunStepBrief, 0, len(rec.Steps))
for _, st := range rec.Steps {
briefs = append(briefs, RunStepBrief{
StepType: st.StepType,
Status: st.Status,
DurationMs: st.DurationMs,
})
}
out = append(out, AgentRunSummary{
ID: rec.ID,
Scene: rec.Scene,
Provider: rec.Provider,
Model: rec.Model,
CfgSource: rec.CfgSource,
Status: rec.Status,
Error: rec.Error,
TotalMs: rec.TotalMs,
StartedAt: rec.StartedAt,
TotalTokens: rec.TotalTokens,
StepBriefs: briefs,
})
}
return out
}
// Get 按 ID 取完整记录(含全部 step detail
func (b *runLogBuffer) Get(id int64) (*AgentRunRecord, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
for i := 0; i < b.count; i++ {
idx := (b.head - 1 - i + b.size*2) % b.size
if b.items[idx].ID == id {
// 返回拷贝,避免调用方拿到内部切片引用后被后续覆盖写污染
rec := b.items[idx]
steps := make([]EnhanceStep, len(rec.Steps))
copy(steps, rec.Steps)
rec.Steps = steps
return &rec, true
}
}
return nil, false
}
// RunLogStatsResult 统计聚合结果面板「统计概览」Tab 数据源)
type RunLogStatsResult struct {
Total int `json:"total"` // 缓冲内总运行数
Success int `json:"success"` // 成功数
Failed int `json:"failed"` // 失败数
Blocked int `json:"blocked"` // 守卫拦截数
AvgMs int `json:"avg_ms"` // 平均耗时(毫秒,只算成功的)
TotalTokens int `json:"total_tokens"` // token 消耗合计
SceneCounts map[string]int `json:"scene_counts"` // 按场景分布
LastError string `json:"last_error"` // 最近一次失败的原因
LastErrorAt int64 `json:"last_error_at"` // 最近一次失败的时间
BufferSize int `json:"buffer_size"` // 缓冲容量(面板显示"最近 N 条"
}
// Stats 聚合统计O(n)n<=200 可忽略)
func (b *runLogBuffer) Stats() RunLogStatsResult {
b.mu.RLock()
defer b.mu.RUnlock()
res := RunLogStatsResult{
SceneCounts: map[string]int{},
BufferSize: b.size,
}
sumMs := 0
okCount := 0
for i := 0; i < b.count; i++ {
idx := (b.head - 1 - i + b.size*2) % b.size
rec := b.items[idx]
res.Total++
res.TotalTokens += rec.TotalTokens
res.SceneCounts[rec.Scene]++
switch rec.Status {
case RunStatusOK:
res.Success++
sumMs += rec.TotalMs
okCount++
case RunStatusBlocked:
res.Blocked++
default:
res.Failed++
// 只记录最新的一条失败(遍历是从新到旧,第一条命中即最新)
if res.LastError == "" {
res.LastError = rec.Error
res.LastErrorAt = rec.StartedAt
}
}
}
if okCount > 0 {
res.AvgMs = sumMs / okCount
}
return res
}
// ------------------------------------------------------------------
// 包级导出函数(供 router 层调用,隐藏 buffer 实现细节)
// ------------------------------------------------------------------
// RunLogList 查询运行摘要列表
func RunLogList(limit int, scene string, status int) []AgentRunSummary {
return runLog.List(limit, scene, status)
}
// RunLogGet 查询单条完整记录
func RunLogGet(id int64) (*AgentRunRecord, bool) {
return runLog.Get(id)
}
// RunLogStats 查询聚合统计
func RunLogStats() RunLogStatsResult {
return runLog.Stats()
}
// recordAgentRun 把一次 Enhance 运行写入环形缓冲
//
// 调用方EnhancerService.Enhance包装层成功/失败/拦截统一走这里)
//
// 状态推断规则:
// - err == nil → 成功
// - steps 里有 medical_guard 步骤 → 守卫拦截(在 err != nil 的前提下)
// - 其他 → 失败
func recordAgentRun(req *EnhanceRequest, resp *EnhanceResponse, err error, startedAt time.Time, cfgSource string) {
rec := AgentRunRecord{
Scene: req.Scene,
CfgSource: cfgSource,
StartedAt: startedAt.Unix(),
FinishedAt: time.Now().Unix(),
TotalMs: int(time.Since(startedAt).Milliseconds()),
RequestSnapshot: buildRequestSnapshot(req),
}
if resp != nil {
rec.Provider = resp.Provider
rec.Model = resp.Model
rec.Steps = resp.Steps
if resp.TotalMs > 0 {
rec.TotalMs = resp.TotalMs
}
// token 汇总 + 从步骤里兜底 provider/model守卫拦截时 resp.Provider 为空)
for _, st := range resp.Steps {
rec.PromptTokens += st.PromptTokens
rec.CompletionTokens += st.CompletionTokens
rec.TotalTokens += st.TotalTokens
if rec.Provider == "" && st.Provider != "" {
rec.Provider = st.Provider
}
if rec.Model == "" && st.Model != "" {
rec.Model = st.Model
}
}
}
if err == nil {
rec.Status = RunStatusOK
} else {
rec.Error = err.Error()
rec.Status = RunStatusFail
// 守卫拦截的错误信息以"医疗守卫拦截"开头enhancer.go 里约定的前缀)
if strings.HasPrefix(rec.Error, "医疗守卫拦截") {
rec.Status = RunStatusBlocked
}
}
runLog.Add(rec)
}
// snapshotMessage 快照里的单条消息(只留角色 + 截断后的内容)
type snapshotMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// requestSnapshot 快照结构(重放时前端按这个结构回填调试台表单)
type requestSnapshot struct {
Scene string `json:"scene"`
Context string `json:"context,omitempty"`
KBEnabled bool `json:"kb_enabled"`
TopK int `json:"top_k,omitempty"`
Provider string `json:"provider,omitempty"`
Messages []snapshotMessage `json:"messages"`
Truncated bool `json:"truncated,omitempty"` // 有内容被截断时置 true前端提示"非完整入参"
}
// buildRequestSnapshot 构造请求入参快照
//
// 为什么不直接 json.Marshal(req) 再截断字符串:
// 粗暴截断会产生非法 JSON重放时前端没法解析回填。
// 这里逐字段限长rune 级,中文安全)后再序列化,保证输出永远是合法 JSON
// - context 最多 800 字
// - 每条消息 content 最多 600 字,最多保留前 8 条
func buildRequestSnapshot(req *EnhanceRequest) string {
if req == nil {
return ""
}
const (
maxContextRunes = 800
maxMsgRunes = 600
maxMsgCount = 8
)
snap := requestSnapshot{
Scene: req.Scene,
KBEnabled: req.KBEnabled,
TopK: req.TopK,
Provider: req.Provider,
}
snap.Context, snap.Truncated = truncateSnapRunes(req.Context, maxContextRunes, snap.Truncated)
for i, m := range req.Messages {
if i >= maxMsgCount {
snap.Truncated = true
break
}
content, truncated := truncateSnapRunes(m.Content, maxMsgRunes, snap.Truncated)
snap.Truncated = truncated
snap.Messages = append(snap.Messages, snapshotMessage{Role: m.Role, Content: content})
}
b, err := json.Marshal(snap)
if err != nil {
return ""
}
return string(b)
}
// truncateSnapRunes 按 rune 截断字符串,并传递"是否发生过截断"标记
func truncateSnapRunes(s string, max int, alreadyTruncated bool) (string, bool) {
r := []rune(s)
if len(r) <= max {
return s, alreadyTruncated
}
return string(r[:max]), true
}