- Add LLMBatcher for grouping similar LLM requests - Automatic grouping by request type and model - Enqueue requests with optional result channels - Auto-flush on max batch size - Manual flush on demand - Time-based flush (max batch age) - Result delivery via channels - Batch status tracking and error handling - API cost reduction through request consolidation - 29 LLM batching tests, all passing Features: - Enqueue() for adding LLM requests - Flush() for manual batch creation - GetPendingBatch() for next batch - MarkBatchExecuting/Completed/Failed() - GroupByTypeAndModel() - automatic grouping - ResultDelivery() via channels - GetStats() for batching statistics - Token counting and tracking Performance Benefits: - 3 Implementer requests → 1 API call - N requests in M batches saves N-M API calls - Example: 30 requests in 3 batches saves 27 API calls (90% reduction) - Configurable batch size (default 10) - Configurable max age (default 2s) Grouping Strategy: - Requests grouped by (Type, Model) - Implementer + claude-opus → separate batch from Implementer + gpt-4 - Judge requests grouped separately from Implementer - Enables provider-specific optimizations Result Delivery: - Each request gets async result channel - Results delivered to channels on completion - Error results on batch failure - Non-blocking result delivery Statistics: - Total requests tracked - Total batches created - Average requests per batch - API calls saved calculation - Total tokens used - Total execution time Test Coverage: - 29 LLM batching tests (enqueue, flush, grouping, delivery) - Result delivery verification - Token counting tested - Auto-flush and manual flush - Error handling - Multi-type grouping - Concurrent safety (RWMutex) Next: T2.7 (Workflow history pruning)
374 lines
8.6 KiB
Go
374 lines
8.6 KiB
Go
package batching
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LLMRequest represents a single LLM request to be batched
|
|
type LLMRequest struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"` // "implementer", "judge", "planner"
|
|
Model string `json:"model"`
|
|
Prompt string `json:"prompt"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
ResultCh chan *LLMResult `json:"-"`
|
|
}
|
|
|
|
// LLMResult represents the result of a single LLM request
|
|
type LLMResult struct {
|
|
RequestID string `json:"request_id"`
|
|
Response string `json:"response"`
|
|
Error error `json:"error,omitempty"`
|
|
Duration time.Duration `json:"duration"`
|
|
TokenCount int `json:"token_count"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// LLMBatch represents a batch of LLM requests
|
|
type LLMBatch struct {
|
|
ID string
|
|
Requests []*LLMRequest
|
|
Model string
|
|
Type string
|
|
CreatedAt time.Time
|
|
ExecutedAt time.Time
|
|
Status string // "pending", "executing", "completed", "failed"
|
|
Error error
|
|
Results map[string]*LLMResult
|
|
ExecutionTime time.Duration
|
|
}
|
|
|
|
// LLMBatcher batches LLM requests for efficient API usage
|
|
type LLMBatcher struct {
|
|
mu sync.RWMutex
|
|
queue []*LLMRequest
|
|
maxBatchSize int
|
|
maxBatchAge time.Duration
|
|
lastFlushTime time.Time
|
|
executedBatches []*LLMBatch
|
|
pendingBatches []*LLMBatch
|
|
stats *LLMBatchStats
|
|
}
|
|
|
|
// LLMBatchStats tracks LLM batching statistics
|
|
type LLMBatchStats struct {
|
|
TotalRequests int
|
|
TotalBatches int
|
|
AvgRequestsPerBatch float64
|
|
APICallsSaved int // Total API calls saved (individual requests - batches)
|
|
TotalTokens int
|
|
TotalExecutionTime time.Duration
|
|
}
|
|
|
|
// NewLLMBatcher creates a new LLM batcher
|
|
func NewLLMBatcher(maxBatchSize int, maxBatchAge time.Duration) *LLMBatcher {
|
|
if maxBatchSize <= 0 {
|
|
maxBatchSize = 10
|
|
}
|
|
if maxBatchAge <= 0 {
|
|
maxBatchAge = 2 * time.Second
|
|
}
|
|
|
|
return &LLMBatcher{
|
|
queue: make([]*LLMRequest, 0),
|
|
maxBatchSize: maxBatchSize,
|
|
maxBatchAge: maxBatchAge,
|
|
lastFlushTime: time.Now(),
|
|
executedBatches: make([]*LLMBatch, 0),
|
|
pendingBatches: make([]*LLMBatch, 0),
|
|
stats: &LLMBatchStats{
|
|
TotalRequests: 0,
|
|
TotalBatches: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Enqueue adds an LLM request to the queue
|
|
func (lb *LLMBatcher) Enqueue(req *LLMRequest) {
|
|
if req == nil {
|
|
return
|
|
}
|
|
|
|
if req.ID == "" {
|
|
req.ID = fmt.Sprintf("req-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
req.Timestamp = time.Now()
|
|
if req.ResultCh == nil {
|
|
req.ResultCh = make(chan *LLMResult, 1)
|
|
}
|
|
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
lb.queue = append(lb.queue, req)
|
|
lb.stats.TotalRequests++
|
|
|
|
// Auto-flush if batch is full
|
|
if len(lb.queue) >= lb.maxBatchSize {
|
|
lb.flushLocked()
|
|
}
|
|
}
|
|
|
|
// flushLocked creates a batch from queued requests (must be called with lock held)
|
|
func (lb *LLMBatcher) flushLocked() {
|
|
if len(lb.queue) == 0 {
|
|
return
|
|
}
|
|
|
|
// Group by type and model
|
|
groups := make(map[string][]*LLMRequest)
|
|
for _, req := range lb.queue {
|
|
key := fmt.Sprintf("%s:%s", req.Type, req.Model)
|
|
groups[key] = append(groups[key], req)
|
|
}
|
|
|
|
// Create batch for each group
|
|
for key, reqs := range groups {
|
|
batch := &LLMBatch{
|
|
ID: fmt.Sprintf("batch-%d", lb.stats.TotalBatches),
|
|
Requests: reqs,
|
|
Model: reqs[0].Model,
|
|
Type: reqs[0].Type,
|
|
CreatedAt: time.Now(),
|
|
Status: "pending",
|
|
Results: make(map[string]*LLMResult),
|
|
}
|
|
|
|
lb.pendingBatches = append(lb.pendingBatches, batch)
|
|
lb.stats.TotalBatches++
|
|
_ = key // Silence unused variable warning
|
|
}
|
|
|
|
lb.queue = make([]*LLMRequest, 0)
|
|
lb.lastFlushTime = time.Now()
|
|
}
|
|
|
|
// Flush manually flushes the current queue
|
|
func (lb *LLMBatcher) Flush() {
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
lb.flushLocked()
|
|
}
|
|
|
|
// GetPendingBatch returns the next pending batch without removing it
|
|
func (lb *LLMBatcher) GetPendingBatch() *LLMBatch {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
if len(lb.pendingBatches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return lb.pendingBatches[0]
|
|
}
|
|
|
|
// MarkBatchExecuting marks a batch as executing
|
|
func (lb *LLMBatcher) MarkBatchExecuting(batchID string) {
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
for _, batch := range lb.pendingBatches {
|
|
if batch.ID == batchID {
|
|
batch.Status = "executing"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MarkBatchCompleted marks a batch as completed and delivers results
|
|
func (lb *LLMBatcher) MarkBatchCompleted(batchID string, results map[string]*LLMResult) {
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
var idx int
|
|
var found *LLMBatch
|
|
for i, batch := range lb.pendingBatches {
|
|
if batch.ID == batchID {
|
|
idx = i
|
|
found = batch
|
|
break
|
|
}
|
|
}
|
|
|
|
if found != nil {
|
|
found.Status = "completed"
|
|
found.ExecutedAt = time.Now()
|
|
found.ExecutionTime = found.ExecutedAt.Sub(found.CreatedAt)
|
|
found.Results = results
|
|
|
|
// Deliver results to request channels
|
|
for _, req := range found.Requests {
|
|
if result, exists := results[req.ID]; exists {
|
|
select {
|
|
case req.ResultCh <- result:
|
|
default:
|
|
// Channel not ready or closed
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update stats
|
|
lb.stats.TotalTokens += countTokensInBatch(found)
|
|
lb.stats.TotalExecutionTime += found.ExecutionTime
|
|
|
|
// Move to executed batches
|
|
lb.executedBatches = append(lb.executedBatches, found)
|
|
lb.pendingBatches = append(lb.pendingBatches[:idx], lb.pendingBatches[idx+1:]...)
|
|
}
|
|
}
|
|
|
|
// MarkBatchFailed marks a batch as failed
|
|
func (lb *LLMBatcher) MarkBatchFailed(batchID string, err error) {
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
var found *LLMBatch
|
|
for _, batch := range lb.pendingBatches {
|
|
if batch.ID == batchID {
|
|
found = batch
|
|
break
|
|
}
|
|
}
|
|
|
|
if found != nil {
|
|
found.Status = "failed"
|
|
found.Error = err
|
|
found.ExecutedAt = time.Now()
|
|
|
|
// Deliver errors to request channels
|
|
for _, req := range found.Requests {
|
|
result := &LLMResult{
|
|
RequestID: req.ID,
|
|
Error: err,
|
|
}
|
|
|
|
select {
|
|
case req.ResultCh <- result:
|
|
default:
|
|
// Channel not ready or closed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetStats returns batching statistics
|
|
func (lb *LLMBatcher) GetStats() *LLMBatchStats {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
stats := *lb.stats
|
|
if stats.TotalBatches > 0 {
|
|
stats.AvgRequestsPerBatch = float64(stats.TotalRequests) / float64(stats.TotalBatches)
|
|
// API calls saved: total requests - total batches
|
|
stats.APICallsSaved = stats.TotalRequests - stats.TotalBatches
|
|
}
|
|
|
|
return &stats
|
|
}
|
|
|
|
// QueueSize returns current queue size
|
|
func (lb *LLMBatcher) QueueSize() int {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
return len(lb.queue)
|
|
}
|
|
|
|
// PendingBatchCount returns number of pending batches
|
|
func (lb *LLMBatcher) PendingBatchCount() int {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
return len(lb.pendingBatches)
|
|
}
|
|
|
|
// GetBatchByID returns a batch by ID
|
|
func (lb *LLMBatcher) GetBatchByID(batchID string) *LLMBatch {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
for _, batch := range lb.pendingBatches {
|
|
if batch.ID == batchID {
|
|
return batch
|
|
}
|
|
}
|
|
|
|
for _, batch := range lb.executedBatches {
|
|
if batch.ID == batchID {
|
|
return batch
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TimeSinceLastFlush returns time since last flush
|
|
func (lb *LLMBatcher) TimeSinceLastFlush() time.Duration {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
return time.Since(lb.lastFlushTime)
|
|
}
|
|
|
|
// ShouldFlush checks if queue should be flushed based on age
|
|
func (lb *LLMBatcher) ShouldFlush() bool {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
if len(lb.queue) == 0 {
|
|
return false
|
|
}
|
|
|
|
return time.Since(lb.lastFlushTime) >= lb.maxBatchAge
|
|
}
|
|
|
|
// GetExecutedBatches returns all executed batches
|
|
func (lb *LLMBatcher) GetExecutedBatches() []*LLMBatch {
|
|
lb.mu.RLock()
|
|
defer lb.mu.RUnlock()
|
|
|
|
result := make([]*LLMBatch, len(lb.executedBatches))
|
|
copy(result, lb.executedBatches)
|
|
|
|
return result
|
|
}
|
|
|
|
// Clear clears all pending operations
|
|
func (lb *LLMBatcher) Clear() {
|
|
lb.mu.Lock()
|
|
defer lb.mu.Unlock()
|
|
|
|
lb.queue = make([]*LLMRequest, 0)
|
|
lb.pendingBatches = make([]*LLMBatch, 0)
|
|
lb.executedBatches = make([]*LLMBatch, 0)
|
|
}
|
|
|
|
// countTokensInBatch counts total tokens in a batch
|
|
func countTokensInBatch(batch *LLMBatch) int {
|
|
total := 0
|
|
for _, result := range batch.Results {
|
|
total += result.TokenCount
|
|
}
|
|
return total
|
|
}
|
|
|
|
// GetBatchInfo returns human-readable batch information
|
|
func (batch *LLMBatch) GetInfo() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": batch.ID,
|
|
"type": batch.Type,
|
|
"model": batch.Model,
|
|
"status": batch.Status,
|
|
"request_count": len(batch.Requests),
|
|
"created_at": batch.CreatedAt,
|
|
"executed_at": batch.ExecutedAt,
|
|
"duration": batch.ExecutionTime,
|
|
"error": batch.Error,
|
|
}
|
|
}
|