Skip to content

Commit 2b94014

Browse files
committed
perf(skill): parallel eval, parallel compile reads, query_wiki cap
Three findings from the performance review on b6607ec: 1. `run_eval` was awaiting ~30 LLM calls sequentially (20 trigger graders + 10 coverage graders). Each prompt is independent — same `desc`/`content` inputs, results accumulated in eval_set order — so `asyncio.gather` is correctness-preserving. Wrap in a `Semaphore(EVAL_CONCURRENCY=8)` to bound simultaneous requests under provider rate limits. Expected wall-clock cut for `openkb skill eval` is roughly 4-15x depending on provider latency, with the floor set by the semaphore. 2. The compile agent had `parallel_tool_calls=False`, forcing every read tool (`list_wiki_dir`, `read_wiki_file`, `get_page_content`) into its own turn. The early phase of compile is naturally a read-fan-out (survey directories, read N summaries, follow `full_text` pointers to source page-ranges). Allowing parallel tool calls lets the model batch independent reads, saving roughly 5-10 outer turns per compile (~20-40s at Opus-class latencies). Writes serialise naturally because each `write_skill_file` depends on accumulated reads. 3. `query_wiki` is a nested `Runner.run(max_turns=50)` inside the outer compile (`max_turns=80`). The docstring's "narrow follow-ups only" was the only enforcement; a pathological run could spawn many nested calls. Added a per-compile counter via closure: after `QUERY_WIKI_MAX_CALLS=3` invocations, the tool returns an error string steering the agent back to direct file reads. Bounds tail latency without breaking the common case (legitimate cross-document sub-questions still get answered). Prompt-caching for `grade_coverage` (the fourth finding in the review) was deferred: the openai-agents SDK takes `instructions` as a plain string with no hook for emitting Anthropic `cache_control` markers. OpenAI's automatic prefix caching already applies because the system prompt is byte-stable across the 10 coverage calls; closing the Anthropic gap is SDK-level work, not application-level. Tests pass (463; unrelated trafilatura env failure in test_url_ingest not touched).
1 parent b6607ec commit 2b94014

2 files changed

Lines changed: 76 additions & 16 deletions

File tree

openkb/skill/creator.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from openkb.schema import get_agents_md
3232

3333
MAX_TURNS = 80 # higher than query (50) because compile can write multiple files
34+
QUERY_WIKI_MAX_CALLS = 3 # bound nested-LLM tail latency; agents should prefer direct reads
3435

3536

3637
def build_skill_create_agent(
@@ -103,6 +104,12 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
103104
return ToolOutputImage(image_url=result["image_url"])
104105
return ToolOutputText(text=result["text"])
105106

107+
# Per-compile counter for query_wiki. Each invocation spawns a nested
108+
# Runner.run(max_turns=50), so an agent that leans on this tool can
109+
# blow up wall-clock and token cost. The docstring already nudges
110+
# toward direct reads; this hard cap is the structural backstop.
111+
query_wiki_calls = 0
112+
106113
@function_tool
107114
async def query_wiki(question: str) -> str:
108115
"""Semantic search over the wiki — narrow follow-ups only.
@@ -112,7 +119,18 @@ async def query_wiki(question: str) -> str:
112119
does the book say about X across multiple chapters?"). For primary
113120
traversal, use list/read/get_page_content instead — they are
114121
cheaper and give you the raw text, not another LLM's summary.
122+
123+
Capped at ``QUERY_WIKI_MAX_CALLS`` invocations per compile.
115124
"""
125+
nonlocal query_wiki_calls
126+
query_wiki_calls += 1
127+
if query_wiki_calls > QUERY_WIKI_MAX_CALLS:
128+
return (
129+
f"query_wiki call cap reached "
130+
f"({QUERY_WIKI_MAX_CALLS} per compile). Use direct file "
131+
f"reads (read_wiki_file / get_page_content) for further "
132+
f"investigation."
133+
)
116134
# Lazy import to avoid a circular dependency at module load time.
117135
from openkb.agent.query import run_query
118136
kb_dir = Path(wiki_root).parent
@@ -141,7 +159,14 @@ def done(summary: str) -> str:
141159
done,
142160
],
143161
model=f"litellm/{model}",
144-
model_settings=ModelSettings(parallel_tool_calls=False),
162+
# Allow the model to issue multiple read tool calls in one turn —
163+
# the compile's early phase is a fan-out (list dir -> read N
164+
# summaries -> read N source page-ranges), and serialising each
165+
# read into its own turn costs roughly 5-10 extra round-trips per
166+
# compile. Writes serialise naturally because each
167+
# `write_skill_file` depends on accumulated reads; the model has
168+
# no reason to issue parallel writes to the same path.
169+
model_settings=ModelSettings(parallel_tool_calls=True),
145170
)
146171

147172

openkb/skill/evaluator.py

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"""
3131
from __future__ import annotations
3232

33+
import asyncio
3334
import json
3435
from dataclasses import dataclass, field
3536
from pathlib import Path
@@ -45,6 +46,11 @@
4546

4647
EVAL_DEFAULT_COUNT = 10 # 10 trigger + 10 no-trigger = 20 prompts
4748
REFERENCES_PREVIEW_BYTES = 4000 # cap reference content fed to the eval LLM
49+
# Bound on concurrent grader LLM calls in run_eval. Without this the
50+
# default count=10 would fire ~30 simultaneous requests, which most
51+
# providers rate-limit. 8 is a conservative starting point — runs ~4x
52+
# the sequential baseline while staying well under typical RPM caps.
53+
EVAL_CONCURRENCY = 8
4854

4955

5056
@dataclass
@@ -366,23 +372,52 @@ async def run_eval(
366372
desc = _read_description(skill_dir)
367373
content = _skill_content_block(skill_dir)
368374
result = EvalResult(prompts=eval_set)
369-
for prompt in eval_set:
370-
graded = await grade_one(desc, prompt.question, model=model)
375+
376+
# Run grading concurrently. Each prompt is independent — graders read
377+
# the same `desc`/`content` strings and produce results that are then
378+
# appended to `result` in eval_set order below, so concurrent
379+
# execution is correctness-preserving. A semaphore caps simultaneous
380+
# LLM calls to avoid hitting provider rate limits.
381+
sem = asyncio.Semaphore(EVAL_CONCURRENCY)
382+
383+
async def _trigger(p: EvalPrompt) -> Literal["trigger", "no-trigger"]:
384+
async with sem:
385+
return await grade_one(desc, p.question, model=model)
386+
387+
async def _coverage(p: EvalPrompt) -> tuple[
388+
Literal["supported", "unsupported", "ambiguous"], str
389+
]:
390+
async with sem:
391+
return await grade_coverage(content, p.question, model=model)
392+
393+
trigger_tasks = [_trigger(p) for p in eval_set]
394+
# Body alignment only meaningful on questions the skill claims to
395+
# handle — for should-not questions the body is correctly empty of
396+
# relevant material.
397+
coverage_prompts = [p for p in eval_set if p.expected == "trigger"]
398+
coverage_tasks = [_coverage(p) for p in coverage_prompts]
399+
400+
trigger_results, coverage_results = await asyncio.gather(
401+
asyncio.gather(*trigger_tasks),
402+
asyncio.gather(*coverage_tasks),
403+
)
404+
405+
# Walk inputs in original order so `result.*` lists are deterministic
406+
# even though the gather() above completed out of order.
407+
for prompt, graded in zip(eval_set, trigger_results):
371408
if graded != prompt.expected:
372409
result.misses.append(EvalMiss(prompt=prompt, graded=graded))
373-
# Body alignment only meaningful on questions the skill claims to
374-
# handle — for should-not questions the body is correctly empty
375-
# of relevant material.
376-
if prompt.expected == "trigger":
377-
verdict, reason = await grade_coverage(content, prompt.question, model=model)
378-
if verdict == "ambiguous":
379-
result.coverage_ambiguous.append(
380-
CoverageMiss(prompt=prompt, reason=reason)
381-
)
382-
elif verdict == "unsupported":
383-
result.coverage_misses.append(
384-
CoverageMiss(prompt=prompt, reason=reason)
385-
)
410+
411+
for prompt, (verdict, reason) in zip(coverage_prompts, coverage_results):
412+
if verdict == "ambiguous":
413+
result.coverage_ambiguous.append(
414+
CoverageMiss(prompt=prompt, reason=reason)
415+
)
416+
elif verdict == "unsupported":
417+
result.coverage_misses.append(
418+
CoverageMiss(prompt=prompt, reason=reason)
419+
)
420+
386421
return result
387422

388423

0 commit comments

Comments
 (0)