Skip to content

Commit e5ba80e

Browse files
feat(evals): Add latency summary feature (#1252)
Co-authored-by: Akhilesh Negi <akhileshnegi.an3@gmail.com>
1 parent 0ab41b3 commit e5ba80e

19 files changed

Lines changed: 967 additions & 24 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Add latency to evaluation_run
2+
3+
Revision ID: 086
4+
Revises: 085
5+
Create Date: 2026-10-14 00:00:00.000000
6+
"""
7+
8+
import sqlalchemy as sa
9+
from alembic import op
10+
from sqlalchemy.dialects import postgresql
11+
12+
revision = "086"
13+
down_revision = "085"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
op.add_column(
20+
"evaluation_run",
21+
sa.Column(
22+
"latency",
23+
postgresql.JSONB(astext_type=sa.Text()),
24+
nullable=True,
25+
comment="Avg OpenAI call latency per stage (fast runs only; NULL for batch)",
26+
),
27+
)
28+
29+
30+
def downgrade():
31+
op.drop_column("evaluation_run", "latency")

‎backend/app/api/docs/evaluation/v2/create_evaluation.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ assistant's configured instructions; applies only when the run resolves a config
1010
prompt), and **Adherence to Knowledge Base** (groundedness of the answer against
1111
the retrieved chunks; applies only to rows that retrieved chunks). Per-row scores +
1212
reasoning are stored natively by Kaapi in the `score_trace_url` trace unit; the
13-
single `judge` cost stage covers the one combined call. v2 runs compute **no cosine
13+
single `judge` cost stage covers the one combined call. The run also records the
14+
per-stage average OpenAI call latency (response, judge) on `latency`. v2 runs compute **no cosine
1415
similarity** and do **not** touch Langfuse.
1516

1617
Judging is system-config only: the judge always uses the configured model
@@ -37,7 +38,7 @@ reaches a terminal state. A single POST fires on **both** completion and failure
3738
carrying an `APIResponse` body — `success: true` for a completed run, `success:
3839
false` with the `error` message for a failed one; either way `data` is a slim run
3940
snapshot (`id`, `run_name`, `dataset_name`, `status`, `run_mode`, `inserted_at`,
40-
`updated_at` — no `score`/`cost`; fetch those from the run's own endpoint). When a
41+
`updated_at` — no `score`/`cost`/`latency`; fetch those from the run's own endpoint). When a
4142
`webhook_secret` credential is configured for the project the request is signed
4243
(`X-Webhook-Signature` / `X-Webhook-Timestamp`). Delivery is best-effort and
4344
at-least-once, so the receiver should be idempotent. The URL is rejected with

‎backend/app/crud/evaluations/fast.py‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
Stage 1 — Responses unit: evaluation_run.batch_job_id
88
Stage 2 — Embeddings unit: evaluation_run.embedding_batch_job_id
9-
Stage 3 — Score + trace + cost (no marker; each step is idempotent)
9+
Stage 3 — Score + trace + cost + latency (no marker; each step is idempotent)
1010
Stage 4 — Persist score unit (summary + per-trace) via the shared save_score
1111
helper, so the cached trace unit (score_trace_url) exists before
1212
the run is advertised as complete and the read path (trace view /
@@ -100,6 +100,7 @@
100100
resolve_config_prompt,
101101
select_judgeable_rows,
102102
)
103+
from app.crud.evaluations.latency import attach_latency, elapsed_ms, start_timer
103104
from app.crud.evaluations.langfuse import (
104105
create_langfuse_dataset_run,
105106
update_traces_with_cosine_scores,
@@ -240,12 +241,14 @@ def failed_result(generated_output: str) -> ResponseResult:
240241
return failed_result("ERROR: missing question in dataset item")
241242

242243
try:
244+
started = start_timer()
243245
result = _execute_llm_call_for_question(
244246
config=config,
245247
question=question,
246248
project_id=project_id,
247249
organization_id=organization_id,
248250
)
251+
latency_ms = elapsed_ms(started)
249252
except Exception as exc:
250253
logger.warning(
251254
f"[_llm_call_for_item] Item failed | item_id={item_id} | error={exc}",
@@ -265,6 +268,8 @@ def failed_result(generated_output: str) -> ResponseResult:
265268
return failed_result(f"ERROR: {result.error}")
266269

267270
guardrail, input_to_llm, output_from_llm = _guardrail_fields(result)
271+
if result.guardrail_outcome is not None:
272+
latency_ms = None
268273

269274
# Tokens are billed even on an output block, so usage is read off every outcome.
270275
return build_response_result(
@@ -285,6 +290,7 @@ def failed_result(generated_output: str) -> ResponseResult:
285290
retrieved_chunks=extract_file_search_chunks(
286291
result.response.provider_raw_response if result.response else None
287292
),
293+
latency_ms=latency_ms,
288294
)
289295

290296

@@ -301,19 +307,23 @@ def _embedding_call_for_pair(
301307
return build_embedding_failure(item_id, "empty output or ground_truth")
302308

303309
try:
310+
started = start_timer()
304311
response = _create_embedding(
305312
openai_client,
306313
model=embedding_model,
307314
output_text=output_text,
308315
ground_truth=ground_truth,
309316
)
317+
latency_ms = elapsed_ms(started)
310318
except openai.OpenAIError as exc:
311319
logger.warning(
312320
f"[_embedding_call_for_pair] Item failed | item_id={item_id} | error={exc}"
313321
)
314322
return build_embedding_failure(item_id, str(exc))
315323

316-
return parse_embedding_pair(item_id=item_id, response=response)
324+
return parse_embedding_pair(
325+
item_id=item_id, response=response, latency_ms=latency_ms
326+
)
317327

318328

319329
def _upload_unit_to_s3(
@@ -644,7 +654,7 @@ def _attach_stage_costs(
644654
response_results: list[dict[str, Any]],
645655
embedding_results: list[dict[str, Any]] | None,
646656
) -> None:
647-
"""Attach the response- and embedding-stage costs (idempotent per stage)."""
657+
"""Attach the response- and embedding-stage costs and latency (idempotent per stage)."""
648658
if response_results:
649659
attach_cost(
650660
session=session,
@@ -675,6 +685,13 @@ def _attach_stage_costs(
675685
embedding_raw_results=embedding_raw,
676686
)
677687

688+
attach_latency(
689+
eval_run=eval_run,
690+
log_prefix=log_prefix,
691+
response_results=response_results or None,
692+
embedding_results=embedding_results or None,
693+
)
694+
678695

679696
def _score_cosine_path(
680697
*,
@@ -745,14 +762,22 @@ def _score_judge_path(
745762

746763
# One combined judge call means all metric tokens land in single cost stage.
747764
if outcome.judge_results and judge_model:
765+
judge_usage_rows: list[dict[str, Any]] = []
766+
for result in outcome.judge_results.values():
767+
judge_usage_rows.append(
768+
{"usage": result.usage, "latency_ms": result.latency_ms}
769+
)
748770
attach_cost(
749771
session=session,
750772
eval_run=eval_run,
751773
log_prefix=log_prefix,
752774
judge_model=judge_model,
753-
judge_results=[
754-
{"usage": result.usage} for result in outcome.judge_results.values()
755-
],
775+
judge_results=judge_usage_rows,
776+
)
777+
attach_latency(
778+
eval_run=eval_run,
779+
log_prefix=log_prefix,
780+
judge_results=judge_usage_rows,
756781
)
757782
return outcome
758783

@@ -819,7 +844,7 @@ def _stage3_score_and_trace(
819844
embedding_results: list[EmbeddingResult] | None,
820845
log_prefix: str,
821846
) -> tuple[EvaluationRun, EvaluationScore, list[dict[str, Any]]]:
822-
"""Stage 3 — cosine (v1) or judge (v2), create traces, attach costs. Idempotent.
847+
"""Stage 3 — cosine (v1) or judge (v2), create traces, attach costs + latency. Idempotent.
823848
824849
Returns the run, the full score unit (summary_scores + per-trace records in the
825850
batch path's shape), and the Langfuse `write_items` (empty for v2). Everything
@@ -898,7 +923,7 @@ def attach_costs() -> None:
898923
else None
899924
)
900925

901-
# Persist cost + unscoreable here; the score unit (summary + traces) is persisted
926+
# Persist cost + latency + unscoreable here; the score unit (summary + traces) is persisted
902927
# by the caller via save_score, before the completed transition, so it lands in
903928
# S3 like the batch path. This write must stay committed: save_score opens a
904929
# second session on the same row.
@@ -907,6 +932,7 @@ def attach_costs() -> None:
907932
eval_run=eval_run,
908933
update=EvaluationRunUpdate(
909934
cost=eval_run.cost,
935+
latency=eval_run.latency,
910936
unscoreable=eval_run.unscoreable,
911937
),
912938
)
@@ -1036,7 +1062,7 @@ def run_fast_evaluation(
10361062
f"Score unit not persisted; run row is gone | eval_run_id={eval_run.id}"
10371063
)
10381064

1039-
# Stage 5 — mark completed. Cost was persisted in Stage 3, score in Stage 4,
1065+
# Stage 5 — mark completed. Cost + latency were persisted in Stage 3, score in Stage 4,
10401066
# so there's never a completed + NULL-score window.
10411067
eval_run = update_evaluation_run(
10421068
session=session,

‎backend/app/crud/evaluations/fast_results.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
Pure builders and aggregation helpers: no OpenAI, DB or storage access. The
44
dicts produced here are the units uploaded to S3, so they must stay
5-
JSON-serializable and match the batch path's shape.
5+
JSON-serializable. They are a superset of the batch path's shape; `latency_ms`
6+
is fast-only.
67
"""
78

89
from collections.abc import Mapping
@@ -39,6 +40,7 @@ class ResponseResult(TypedDict, total=False):
3940
guardrail: str | None
4041
input_to_llm: str | None
4142
output_from_llm: str | None
43+
latency_ms: float | None
4244

4345

4446
class EmbeddingResult(TypedDict, total=False):
@@ -50,6 +52,7 @@ class EmbeddingResult(TypedDict, total=False):
5052
usage: dict[str, int]
5153
failed: bool
5254
error: str
55+
latency_ms: float | None
5356

5457

5558
RESPONSE_USAGE_KEYS: tuple[str, ...] = ("input_tokens", "output_tokens", "total_tokens")
@@ -70,6 +73,7 @@ def build_response_result(
7073
guardrail: str | None = None,
7174
input_to_llm: str | None = None,
7275
output_from_llm: str | None = None,
76+
latency_ms: float | None = None,
7377
) -> ResponseResult:
7478
"""One Stage-1 per-item result, in the batch path's shape."""
7579
return {
@@ -85,6 +89,7 @@ def build_response_result(
8589
"guardrail": guardrail,
8690
"input_to_llm": input_to_llm,
8791
"output_from_llm": output_from_llm,
92+
"latency_ms": latency_ms,
8893
}
8994

9095

@@ -103,6 +108,7 @@ def build_embedding_failure(item_id: str, error: str) -> EmbeddingResult:
103108
"usage": {},
104109
"failed": True,
105110
"error": error,
111+
"latency_ms": None,
106112
}
107113

108114

@@ -111,7 +117,9 @@ def extract_usage(usage_obj: Any, keys: tuple[str, ...]) -> dict[str, int]:
111117
return {key: int(field_value(usage_obj, key, 0) or 0) for key in keys}
112118

113119

114-
def parse_embedding_pair(*, item_id: str, response: Any) -> EmbeddingResult:
120+
def parse_embedding_pair(
121+
*, item_id: str, response: Any, latency_ms: float | None = None
122+
) -> EmbeddingResult:
115123
"""Unpack an embeddings response into one Stage-2 per-pair result.
116124
117125
The request embeds `[output_text, ground_truth]`, so index 0 is the generated
@@ -139,6 +147,7 @@ def parse_embedding_pair(*, item_id: str, response: Any) -> EmbeddingResult:
139147
"ground_truth_embedding": ground_truth_embedding,
140148
"usage": extract_usage(field_value(response, "usage"), EMBEDDING_USAGE_KEYS),
141149
"failed": output_embedding is None or ground_truth_embedding is None,
150+
"latency_ms": latency_ms,
142151
}
143152

144153

‎backend/app/crud/evaluations/judge.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
KNOWLEDGE_BASE_JUDGE_PROMPT,
2626
PROMPT_JUDGE_PROMPT,
2727
)
28+
from app.crud.evaluations.latency import elapsed_ms, start_timer
2829
from app.crud.evaluations.response_parsing import extract_response_text
2930
from app.crud.evaluations.retry import retry_openai_call
3031
from app.crud.evaluations.score import (
@@ -138,6 +139,7 @@ class JudgeResult:
138139

139140
metrics: dict[JudgeMetricEnum, MetricScore]
140141
usage: dict[str, int]
142+
latency_ms: float | None = None
141143

142144

143145
def _required_input_union(metrics: list[JudgeMetricSpec]) -> list[JudgeInputEnum]:
@@ -304,7 +306,9 @@ def judge_row(
304306
}
305307

306308
try:
309+
started = start_timer()
307310
response = _create_judge_response(openai_client, params)
311+
latency_ms = elapsed_ms(started)
308312
except openai.OpenAIError as exc:
309313
status = getattr(exc, "status_code", None)
310314
# 5xx is provider-side (alert-worthy); 4xx/None is caller/Kaapi-side noise.
@@ -327,4 +331,4 @@ def judge_row(
327331
}
328332

329333
metric_scores = _parse_judge_output(extract_response_text(response), applicable)
330-
return JudgeResult(metrics=metric_scores, usage=usage)
334+
return JudgeResult(metrics=metric_scores, usage=usage, latency_ms=latency_ms)

0 commit comments

Comments
 (0)