Skip to content

Commit 567d635

Browse files
Alexey Panfilovclaude
andcommitted
feat: split classify and LLM timings in request_trace
Add llm.WithTimings context to capture classifier duration inside router.pick. After Chat/ChatStream returns, iter_N_classify_ms and iter_N_llm_ms are logged separately — llm_ms is net model time only (total minus classify). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c1ef5b7 commit 567d635

4 files changed

Lines changed: 72 additions & 8 deletions

File tree

internal/agent/agent.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,16 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
164164
sysPrompt += "\n\n" + crossSessionCtx
165165
}
166166

167-
endLLM := tr.begin(fmt.Sprintf("iter%d_llm", i))
168-
resp, err := a.router.Chat(ctx, history, sysPrompt, tools)
169-
endLLM()
167+
iterCtx, timing := llm.WithTimings(ctx)
168+
llmStart := time.Now()
169+
resp, err := a.router.Chat(iterCtx, history, sysPrompt, tools)
170+
llmTotal := time.Since(llmStart)
171+
if timing.ClassifyRan {
172+
tr.addSpan(fmt.Sprintf("iter%d_classify", i), timing.ClassifyDur)
173+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal-timing.ClassifyDur)
174+
} else {
175+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal)
176+
}
170177
if err != nil {
171178
tr.log(a.logger, chatID, "llm_error")
172179
return "", fmt.Errorf("llm: %w", err)
@@ -256,10 +263,17 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
256263
sysPrompt += "\n\n" + crossSessionCtx
257264
}
258265

259-
endLLM := tr.begin(fmt.Sprintf("iter%d_llm", i))
260-
ch, err := a.router.ChatStream(ctx, history, sysPrompt, tools)
266+
iterCtx, timing := llm.WithTimings(ctx)
267+
llmStart := time.Now()
268+
ch, err := a.router.ChatStream(iterCtx, history, sysPrompt, tools)
261269
if err != nil {
262-
endLLM()
270+
llmTotal := time.Since(llmStart)
271+
if timing.ClassifyRan {
272+
tr.addSpan(fmt.Sprintf("iter%d_classify", i), timing.ClassifyDur)
273+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal-timing.ClassifyDur)
274+
} else {
275+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal)
276+
}
263277
tr.log(a.logger, chatID, "llm_error")
264278
return "", fmt.Errorf("llm: %w", err)
265279
}
@@ -270,7 +284,13 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
270284

271285
for chunk := range ch {
272286
if chunk.Err != nil {
273-
endLLM()
287+
llmTotal := time.Since(llmStart)
288+
if timing.ClassifyRan {
289+
tr.addSpan(fmt.Sprintf("iter%d_classify", i), timing.ClassifyDur)
290+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal-timing.ClassifyDur)
291+
} else {
292+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal)
293+
}
274294
tr.log(a.logger, chatID, "stream_error")
275295
return "", fmt.Errorf("llm stream: %w", chunk.Err)
276296
}
@@ -287,7 +307,13 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
287307
}
288308
}
289309
}
290-
endLLM()
310+
llmTotal := time.Since(llmStart)
311+
if timing.ClassifyRan {
312+
tr.addSpan(fmt.Sprintf("iter%d_classify", i), timing.ClassifyDur)
313+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal-timing.ClassifyDur)
314+
} else {
315+
tr.addSpan(fmt.Sprintf("iter%d_llm", i), llmTotal)
316+
}
291317

292318
if len(toolCalls) == 0 {
293319
a.store.AddMessage(chatID, llm.Message{Role: "assistant", Content: accumulated})

internal/agent/trace.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ func (t *requestTrace) begin(name string) func() {
3636
}
3737
}
3838

39+
// addSpan records a pre-measured span (e.g. extracted from context timings).
40+
func (t *requestTrace) addSpan(name string, dur time.Duration) {
41+
t.spans = append(t.spans, traceSpan{name: name, dur: dur})
42+
}
43+
3944
// log emits a single structured log line with all span durations in milliseconds.
4045
func (t *requestTrace) log(logger *slog.Logger, chatID int64, status string) {
4146
args := make([]any, 0, 3+len(t.spans)*2)

internal/llm/router.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,13 @@ func (r *Router) classify(ctx context.Context, text string) int {
537537
prompt = defaultClassifierPrompt
538538
}
539539
msgs := []Message{{Role: "user", Content: classifierText}}
540+
classifyStart := time.Now()
540541
resp, err := provider.Chat(classifierCtx, msgs, prompt, nil)
542+
classifyDur := time.Since(classifyStart)
543+
if t := timingsFrom(ctx); t != nil {
544+
t.ClassifyDur = classifyDur
545+
t.ClassifyRan = true
546+
}
541547
if err != nil {
542548
r.logger.Warn("classifier error, falling back to primary", "classifier", classifierKey, "err", err)
543549
return 2

internal/llm/timing.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package llm
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// RequestTimings collects per-phase durations for a single router.Chat call.
9+
// Attach to a context via WithTimings; the router populates it during the call.
10+
type RequestTimings struct {
11+
ClassifyDur time.Duration
12+
ClassifyRan bool
13+
}
14+
15+
type timingCtxKey struct{}
16+
17+
// WithTimings returns a child context carrying a *RequestTimings that the router
18+
// will populate. The caller reads timings after Chat/ChatStream returns.
19+
func WithTimings(ctx context.Context) (context.Context, *RequestTimings) {
20+
t := &RequestTimings{}
21+
return context.WithValue(ctx, timingCtxKey{}, t), t
22+
}
23+
24+
func timingsFrom(ctx context.Context) *RequestTimings {
25+
t, _ := ctx.Value(timingCtxKey{}).(*RequestTimings)
26+
return t
27+
}

0 commit comments

Comments
 (0)