add llm_call_log function
This commit is contained in:
parent
2667d1cfec
commit
9a898191b2
5 changed files with 314 additions and 0 deletions
|
|
@ -78,6 +78,16 @@ func NewAgentLoop(
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentLoop {
|
) *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)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
// Register shared tools to all agents
|
// Register shared tools to all agents
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,7 @@ type ToolsConfig struct {
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
|
LLMCallLog LLMCallLogConfig `json:"llm_call_log"`
|
||||||
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
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_"`
|
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 {
|
type SearchCacheConfig struct {
|
||||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||||
|
|
|
||||||
30
pkg/llmlog/types.go
Normal file
30
pkg/llmlog/types.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
184
pkg/logger/llm_logger.go
Normal file
184
pkg/logger/llm_logger.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
82
pkg/providers/logging_provider.go
Normal file
82
pkg/providers/logging_provider.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue