Files
Test b2cebe1ba7 feat(T2.6): implement LLM request batching
- 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)
2026-08-23 17:23:35 -07:00

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,
}
}