From 9a898191b24319a6c56e40121ec4680a322a66c4 Mon Sep 17 00:00:00 2001 From: t <452214834@qq.com> Date: Sun, 8 Mar 2026 16:50:10 +0800 Subject: [PATCH] add llm_call_log function --- pkg/agent/loop.go | 10 ++ pkg/config/config.go | 8 ++ pkg/llmlog/types.go | 30 +++++ pkg/logger/llm_logger.go | 184 ++++++++++++++++++++++++++++++ pkg/providers/logging_provider.go | 82 +++++++++++++ 5 files changed, 314 insertions(+) create mode 100644 pkg/llmlog/types.go create mode 100644 pkg/logger/llm_logger.go create mode 100644 pkg/providers/logging_provider.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7a70bd00..36b1c0427 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -78,6 +78,16 @@ func NewAgentLoop( msgBus *bus.MessageBus, provider providers.LLMProvider, ) *AgentLoop { + // Initialize LLM call logger + workspace := cfg.WorkspacePath() + logger.InitLLMLogger(cfg.Tools.LLMCallLog, workspace) + + // Wrap provider with logging if enabled + llmLogger := logger.GetLLMLogger() + if llmLogger != nil && llmLogger.IsEnabled() { + provider = providers.NewLoggingProvider(provider, llmLogger, cfg.Agents.Defaults.Provider) + } + registry := NewAgentRegistry(cfg, provider) // Register shared tools to all agents diff --git a/pkg/config/config.go b/pkg/config/config.go index e055b93d1..4ebdb8310 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -661,6 +661,7 @@ type ToolsConfig struct { Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` + LLMCallLog LLMCallLogConfig `json:"llm_call_log"` AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` @@ -677,6 +678,13 @@ type ToolsConfig struct { WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } +// LLMCallLogConfig 配置 LLM 调用日志 +type LLMCallLogConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_LLM_CALL_LOG_ENABLED"` + LogDir string `json:"log_dir" env:"PICOCLAW_TOOLS_LLM_CALL_LOG_DIR"` + MaxFiles int `json:"max_files" env:"PICOCLAW_TOOLS_LLM_CALL_LOG_MAX_FILES"` +} + type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` diff --git a/pkg/llmlog/types.go b/pkg/llmlog/types.go new file mode 100644 index 000000000..2cea5bf17 --- /dev/null +++ b/pkg/llmlog/types.go @@ -0,0 +1,30 @@ +package llmlog + +import ( + "time" +) + +// Message 表示简化的消息结构,用于日志记录 +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// CallRecord 表示一次 LLM 调用记录 +type CallRecord struct { + Timestamp time.Time `json:"timestamp"` + Model string `json:"model"` + Provider string `json:"provider"` + Messages []Message `json:"messages"` + Response string `json:"response"` + Duration time.Duration `json:"duration"` + TokensUsed int `json:"tokens_used,omitempty"` + Error string `json:"error,omitempty"` + IsStreaming bool `json:"is_streaming"` +} + +// Logger 定义 LLM 调用日志记录器接口 +type Logger interface { + IsEnabled() bool + Log(record *CallRecord) error +} \ No newline at end of file diff --git a/pkg/logger/llm_logger.go b/pkg/logger/llm_logger.go new file mode 100644 index 000000000..814a547de --- /dev/null +++ b/pkg/logger/llm_logger.go @@ -0,0 +1,184 @@ +package logger + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/llmlog" +) + +// LLMLogger 记录 LLM 调用日志 +type LLMLogger struct { + config config.LLMCallLogConfig + mu sync.Mutex + logFiles map[string]*os.File // 按日期缓存的文件句柄 + baseDir string +} + +// 确保 LLMLogger 实现 llmlog.Logger 接口 +var _ llmlog.Logger = (*LLMLogger)(nil) + +var ( + globalLogger *LLMLogger + llmOnce sync.Once +) + +// InitLLMLogger 初始化全局 LLM 日志记录器 +func InitLLMLogger(cfg config.LLMCallLogConfig, workspace string) { + llmOnce.Do(func() { + logDir := cfg.LogDir + if logDir == "" { + // 默认路径: workspace/logs/llmcall/ + logDir = filepath.Join(workspace, "logs", "llmcall") + } + + // 展开 ~ 路径 + if strings.HasPrefix(logDir, "~") { + home, _ := os.UserHomeDir() + logDir = filepath.Join(home, logDir[1:]) + } + + globalLogger = &LLMLogger{ + config: cfg, + logFiles: make(map[string]*os.File), + baseDir: logDir, + } + + // 确保目录存在 + os.MkdirAll(logDir, 0755) + + // 启动清理旧日志的 goroutine + if cfg.MaxFiles > 0 { + go globalLogger.cleanupOldLogs() + } + }) +} + +// GetLLMLogger 获取全局 LLM 日志记录器 +func GetLLMLogger() *LLMLogger { + return globalLogger +} + +// IsEnabled 检查日志是否启用 +func (l *LLMLogger) IsEnabled() bool { + return l != nil && l.config.Enabled +} + +// Log 记录一次 LLM 调用 +func (l *LLMLogger) Log(record *llmlog.CallRecord) error { + if !l.IsEnabled() { + return nil + } + + l.mu.Lock() + defer l.mu.Unlock() + + // 获取当天的日志文件 + dateKey := time.Now().Format("2006-01-02") + file, err := l.getLogFile(dateKey) + if err != nil { + return fmt.Errorf("failed to get log file: %w", err) + } + + // 写入 JSON 行 + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshal record: %w", err) + } + + _, err = fmt.Fprintf(file, "%s\n", data) + if err != nil { + return fmt.Errorf("failed to write log: %w", err) + } + + return nil +} + +// getLogFile 获取或创建指定日期的日志文件 +func (l *LLMLogger) getLogFile(dateKey string) (*os.File, error) { + if file, ok := l.logFiles[dateKey]; ok { + return file, nil + } + + // 创建新文件 + filename := fmt.Sprintf("llmcall_%s.jsonl", dateKey) + path := filepath.Join(l.baseDir, filename) + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + l.logFiles[dateKey] = file + return file, nil +} + +// cleanupOldLogs 清理旧日志文件 +func (l *LLMLogger) cleanupOldLogs() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + l.doCleanup() + } +} + +// doCleanup 执行清理 +func (l *LLMLogger) doCleanup() { + l.mu.Lock() + defer l.mu.Unlock() + + // 读取目录中的所有日志文件 + entries, err := os.ReadDir(l.baseDir) + if err != nil { + return + } + + // 收集所有日志文件 + var files []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "llmcall_") && strings.HasSuffix(entry.Name(), ".jsonl") { + files = append(files, entry.Name()) + } + } + + // 如果文件数量超过限制,删除最旧的 + if len(files) > l.config.MaxFiles { + // 按文件名排序(文件名包含日期) + // 删除最旧的文件 + for i := 0; i < len(files)-l.config.MaxFiles; i++ { + path := filepath.Join(l.baseDir, files[i]) + os.Remove(path) + + // 关闭可能打开的文件句柄 + dateKey := strings.TrimPrefix(files[i], "llmcall_") + dateKey = strings.TrimSuffix(dateKey, ".jsonl") + if file, ok := l.logFiles[dateKey]; ok { + file.Close() + delete(l.logFiles, dateKey) + } + } + } +} + +// Close 关闭所有打开的文件句柄 +func (l *LLMLogger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + + var lastErr error + for dateKey, file := range l.logFiles { + if err := file.Close(); err != nil { + lastErr = err + } + delete(l.logFiles, dateKey) + } + + return lastErr +} \ No newline at end of file diff --git a/pkg/providers/logging_provider.go b/pkg/providers/logging_provider.go new file mode 100644 index 000000000..02e8a07d2 --- /dev/null +++ b/pkg/providers/logging_provider.go @@ -0,0 +1,82 @@ +package providers + +import ( + "context" + "time" + + "github.com/sipeed/picoclaw/pkg/llmlog" +) + +// LoggingProvider 包装一个 LLMProvider,记录所有调用日志 +type LoggingProvider struct { + inner LLMProvider + logger llmlog.Logger + provider string // provider 名称,用于日志 +} + +// NewLoggingProvider 创建一个带日志功能的 Provider 包装器 +func NewLoggingProvider(inner LLMProvider, logger llmlog.Logger, providerName string) *LoggingProvider { + return &LoggingProvider{ + inner: inner, + logger: logger, + provider: providerName, + } +} + +// Chat 实现 LLMProvider 接口,记录调用日志 +func (p *LoggingProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + // 如果日志未启用,直接调用内部 provider + if p.logger == nil || !p.logger.IsEnabled() { + return p.inner.Chat(ctx, messages, tools, model, options) + } + + // 记录开始时间 + startTime := time.Now() + + // 调用内部 provider + resp, err := p.inner.Chat(ctx, messages, tools, model, options) + + // 转换消息格式用于日志 + logMessages := make([]llmlog.Message, len(messages)) + for i, m := range messages { + logMessages[i] = llmlog.Message{ + Role: m.Role, + Content: m.Content, + } + } + + // 记录调用 + record := &llmlog.CallRecord{ + Timestamp: startTime, + Model: model, + Provider: p.provider, + Messages: logMessages, + Duration: time.Since(startTime), + IsStreaming: false, + } + + if err != nil { + record.Error = err.Error() + } else { + record.Response = resp.Content + if resp.Usage != nil { + record.TokensUsed = resp.Usage.TotalTokens + } + } + + // 异步写入日志,避免阻塞主流程 + go p.logger.Log(record) + + return resp, err +} + +// GetDefaultModel 返回内部 provider 的默认模型 +func (p *LoggingProvider) GetDefaultModel() string { + return p.inner.GetDefaultModel() +} \ No newline at end of file