mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-17 05:07:23 +08:00
### Motivation This PR evolves the harness from a pure execution runtime into an **observable, replayable agent evaluation platform**. The current `harness/graph` checkpoint mechanism is insufficient for true event-sourced introspection—we need append-only event logs capturing every tool call, state transition, memory write, and approval decision, enabling deterministic replay, fork/diff, postmortem analysis, and time-travel debugging. ### Key Design Goals 1. **Event-Sourced Execution Model** Replace coarse checkpoints with granular, append-only event logs. Every operation becomes a durable event: tool invocation, state mutation, memory update, human approval. This unlocks deterministic replay, branching execution histories, and regression datasets derived directly from production failures. 2. **First-Class Replay & Evaluation Loop** Replay is not an afterthought—it is a core primitive. A single live run seeds an offline corpus that supports: repeated playback, model substitution, tool result mocking, and strategy comparison. The harness graduates from "executor" to "continuous evaluation platform" where failed production traces convert directly into offline regression suites. 3. **Operational Observability** Beyond raw traces, expose metrics that prove stability over time: - Tool success / failure rates - Approval latency distributions - Retry frequencies - Checkpoint restore reliability - Memory retrieval quality - Cost per completed task - Fork replay pass rates The underlying thesis: the bottleneck for most agent systems is not execution capability, but the inability to **demonstrate continuous, measurable improvement**. ### Type of change - [x] New Feature (non-breaking change which adds functionality)
193 lines
4.6 KiB
Go
193 lines
4.6 KiB
Go
package metrics
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// MetricsAggregator aggregates metrics across multiple execution traces.
|
|
type MetricsAggregator struct {
|
|
mu sync.Mutex
|
|
metrics []*AgentMetrics
|
|
}
|
|
|
|
// NewMetricsAggregator creates a new MetricsAggregator.
|
|
func NewMetricsAggregator() *MetricsAggregator {
|
|
return &MetricsAggregator{}
|
|
}
|
|
|
|
// Add adds a metrics snapshot to the aggregator.
|
|
func (a *MetricsAggregator) Add(m *AgentMetrics) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.metrics = append(a.metrics, m)
|
|
}
|
|
|
|
// AggregatedMetrics contains summary statistics across multiple traces.
|
|
type AggregatedMetrics struct {
|
|
TotalTraces int
|
|
TotalDuration time.Duration
|
|
|
|
// Tool metrics (averages).
|
|
AvgToolCalls float64
|
|
AvgToolSuccessRate float64
|
|
AvgToolRetryRate float64
|
|
P50ToolLatencyMs float64
|
|
P95ToolLatencyMs float64
|
|
P99ToolLatencyMs float64
|
|
|
|
// Checkpoint metrics.
|
|
AvgCheckpointSaves float64
|
|
AvgCheckpointRestores float64
|
|
AvgCheckpointRestoreSuccess float64
|
|
|
|
// Execution metrics.
|
|
AvgSteps float64
|
|
AvgNodesExecuted float64
|
|
AvgRecoveredErrors float64
|
|
AvgInterrupts float64
|
|
|
|
// Cost metrics.
|
|
AvgCostPerTask float64
|
|
AvgForkReplayPassRate float64
|
|
}
|
|
|
|
// Aggregate computes summary statistics across all collected metrics.
|
|
func (a *MetricsAggregator) Aggregate() *AggregatedMetrics {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
result := &AggregatedMetrics{
|
|
TotalTraces: len(a.metrics),
|
|
}
|
|
|
|
if len(a.metrics) == 0 {
|
|
return result
|
|
}
|
|
|
|
var allLatencies []float64
|
|
|
|
for _, m := range a.metrics {
|
|
result.TotalDuration += m.Duration
|
|
result.AvgToolCalls += float64(m.ToolCalls)
|
|
result.AvgToolSuccessRate += m.ToolSuccessRate
|
|
result.AvgToolRetryRate += m.ToolRetryRate
|
|
result.AvgCheckpointSaves += float64(m.CheckpointSaves)
|
|
result.AvgCheckpointRestores += float64(m.CheckpointRestores)
|
|
result.AvgCheckpointRestoreSuccess += m.CheckpointRestoreSuccess
|
|
result.AvgSteps += float64(m.Steps)
|
|
result.AvgNodesExecuted += float64(m.NodesExecuted)
|
|
result.AvgRecoveredErrors += float64(m.RecoveredErrors)
|
|
result.AvgInterrupts += float64(m.InterruptCount)
|
|
result.AvgCostPerTask += m.CostPerTask
|
|
result.AvgForkReplayPassRate += m.ForkReplayPassRate
|
|
|
|
for _, latencies := range m.ToolLatencyMs {
|
|
for _, l := range latencies {
|
|
allLatencies = append(allLatencies, float64(l))
|
|
}
|
|
}
|
|
}
|
|
|
|
n := float64(len(a.metrics))
|
|
result.AvgToolCalls /= n
|
|
result.AvgToolSuccessRate /= n
|
|
result.AvgToolRetryRate /= n
|
|
result.AvgCheckpointSaves /= n
|
|
result.AvgCheckpointRestores /= n
|
|
result.AvgCheckpointRestoreSuccess /= n
|
|
result.AvgSteps /= n
|
|
result.AvgNodesExecuted /= n
|
|
result.AvgRecoveredErrors /= n
|
|
result.AvgInterrupts /= n
|
|
result.AvgCostPerTask /= n
|
|
result.AvgForkReplayPassRate /= n
|
|
|
|
// Compute latency percentiles.
|
|
if len(allLatencies) > 0 {
|
|
sort.Float64s(allLatencies)
|
|
result.P50ToolLatencyMs = percentile(allLatencies, 50)
|
|
result.P95ToolLatencyMs = percentile(allLatencies, 95)
|
|
result.P99ToolLatencyMs = percentile(allLatencies, 99)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Reset clears all collected metrics.
|
|
func (a *MetricsAggregator) Reset() {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.metrics = nil
|
|
}
|
|
|
|
// MetricsWindow tracks metrics over a sliding time window.
|
|
type MetricsWindow struct {
|
|
mu sync.Mutex
|
|
window time.Duration
|
|
entries []windowEntry
|
|
}
|
|
|
|
type windowEntry struct {
|
|
timestamp time.Time
|
|
metrics *AgentMetrics
|
|
}
|
|
|
|
// NewMetricsWindow creates a metrics window with the given duration.
|
|
func NewMetricsWindow(window time.Duration) *MetricsWindow {
|
|
return &MetricsWindow{window: window}
|
|
}
|
|
|
|
// Add adds metrics at the current time.
|
|
func (w *MetricsWindow) Add(m *AgentMetrics) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.entries = append(w.entries, windowEntry{
|
|
timestamp: time.Now(),
|
|
metrics: m,
|
|
})
|
|
w.prune()
|
|
}
|
|
|
|
// Aggregate returns aggregated metrics for the current window.
|
|
func (w *MetricsWindow) Aggregate() *AggregatedMetrics {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.prune()
|
|
|
|
agg := NewMetricsAggregator()
|
|
for _, entry := range w.entries {
|
|
agg.Add(entry.metrics)
|
|
}
|
|
return agg.Aggregate()
|
|
}
|
|
|
|
// prune removes entries outside the window.
|
|
func (w *MetricsWindow) prune() {
|
|
cutoff := time.Now().Add(-w.window)
|
|
keep := make([]windowEntry, 0, len(w.entries))
|
|
for _, e := range w.entries {
|
|
if e.timestamp.After(cutoff) {
|
|
keep = append(keep, e)
|
|
}
|
|
}
|
|
w.entries = keep
|
|
}
|
|
|
|
// percentile computes the p-th percentile from a sorted slice.
|
|
func percentile(sorted []float64, p int) float64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
idx := int(math.Ceil(float64(p)/100.0*float64(len(sorted))) - 1)
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(sorted) {
|
|
idx = len(sorted) - 1
|
|
}
|
|
return sorted[idx]
|
|
}
|