Skip to content

feat: batched write, reasoning-aware sizing, and section-level merge - #62

Merged
lucasmaan merged 6 commits into
bybit-exchange:mainfrom
wangdahoo:feat/distill-throughput-optimization
Sep 3, 2026
Merged

lucasmaan merged 6 commits into
bybit-exchange:mainfrom
wangdahoo:feat/distill-throughput-optimization

Conversation

@wangdahoo

@wangdahoo wangdahoo commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

A 500-document distillation run took 9.97 hours wall clock. The RCA attributed
~82% of LLM time to the write phase: documents ran through per-doc pipeline
calls at 4-way document concurrency, every pipeline call rebuilt the KB indexes
(534 rebuilds per run), and any article over 30 KB took the diff path with no
way to tune that threshold per deployment.

Follow-up replay analysis on the batched path showed the remaining cost was
generation economics, not structure: ~50% of article writes hit the 16,384-token
truncation rung (one replay round discarded ~1.1M completion tokens), the
restart ladder threw away every truncated partial, the diff path applied
patches to a truncated view of large articles (silently dropping content), and
extract spent roughly half its output on pre-JSON prose.

Change

Six commits on this branch: two feature commits and four review-hardening
fixes.

1. Batched write, debounced indexing, tunable merge threshold

Batched write

Per-task pipeline items now flow through a batcher that groups same-article
items into one write LLM call (max_items=16, 2 s flush window, bounded
concurrent calls). The concurrent-call cap is runtime-tunable via
KAAS_WORKER_PIPELINE_BATCH_MAX_INFLIGHT. A per-article lock serializes
same-article writes across concurrent calls, closing a lost-update window that
the old one-call-per-doc path never had. Rollback: pipeline_batch_max_items=1.

Debounced index rebuilds

Pipeline calls stop rebuilding the indexes per call (rebuild_index=false);
completions mark them dirty and one rebuild follows index_debounce_sec after
the last completion, forced when dirty state ages past index_max_stale_sec.
Measured ~4x fewer rebuilds under load. The refresher runs on its own circuit
breaker, so a deterministically broken index cannot open the breaker gating
extract/pipeline. Rollback: index_debounce_sec=0.

Tunable merge threshold

The 30 KB full-rewrite threshold is configurable (KB_MERGE_FULL_REWRITE_LIMIT,
warns and falls back on invalid values), and a degraded diff result now falls
back to full rewrite when the rewrite fits the prompt budget instead of
silently dropping content.

Config plumbing

extract_workers is renamed to document_workers with a deprecation shim. New
tuning keys get env overrides through a shared warn-and-fallback envInt
helper — invalid values never abort startup — and validate() rejects resolved
values that cannot work (workers < 1, inflight < 1 with batching on).

2. Reasoning-aware sizing, continuation mode, section merge, prompt tightening

Truncation handling (py/src/kb_ai/llm/)

  • Length-cut finish_reason values are matched case-insensitively across
    gateway spellings (length / max_tokens / max_output_tokens); restarts
    emit alerts carrying the discarded token count.
  • New continuation mode (continue_on_length=True) keeps a truncated partial
    as an assistant message and re-calls for the continuation, deduplicating seam
    overlap on raw text — plain-text write paths stop discarding completed work.
  • estimate_max_tokens() sizes the first max_tokens rung from the expected
    output length plus a 16,384-token reasoning headroom: reasoning-style models
    burn 8-15K invisible tokens per call before any text appears, and billing is
    per generated token, so over-granting is free while under-granting pays twice
    (a restart discards, a continuation re-reasons).
  • reasoning_effort passthrough (env knob) with graceful 400 degrade, and a
    retry for reasoning-only empty completions (finish_reason=stop, empty body).

Section-level merge (py/src/kb_ai/core/merge.py)

  • Articles >= 12 KB with >= 3 sections route changed sections through a
    section-merge router (sized, semaphore-bounded) and rewrite only the affected
    sections; a cost guard chooses full rewrite when more would change than a
    rewrite costs, and a section -> diff -> full-rewrite fallback chain bounds
    the worst case. Cost scales with changed sections, not article size.
    Rollback: KB_MERGE_SECTION_MODE=off.
  • Diff patches are applied to the full on-disk article (previously to a
    truncated view) and patch responses are validated before application.
  • Full-rewrite and new-article call sites size their first rung from the
    expected output and use continuation mode.

Extract prompt tightening

  • Prompts suppress pre-JSON prose and hard-require the reply to end with the
    JSON object; measured -44% completion tokens per call (9,349 -> 5,235) on the
    replay corpus, with sampled extractions clean.
  • Multi-chunk extraction retries only the failed phase-2 groups, and summarize
    calls are sized.

Observability

The orchestrator logs per-batch write/classify LLM call counts, LLM time, and
completion tokens — the lines the replay harness parses for per-phase cost
accounting.

3. Review hardening (four follow-up commits)

Four review rounds on this PR surfaced issues in the new code itself; every
finding was verified against the tree before fixing, with tests added per fix:

  • Data safety
    • Section merge degrades to the legacy chain on duplicate ## headings:
      reassembly keyed rewritten bodies by heading text, so a duplicate would
      have replaced both occurrences with one rewrite and silently deleted the
      other's content. The router also remaps (with a diagnostic alert)
      a new-section proposal that names an existing heading — dropping it
      silently unmerged its material while the task Acked "merged".
    • A degraded diff with no fitting rewrite raises instead of returning the
      unchanged article: the error lands in the task's Ack payload / the compile
      error list, and the frontmatter never records an unmerged source.
    • The heading-echo strip matches the first line exactly (normalized), so
      an echo cannot eat a shared-prefix body or slip through with trailing
      whitespace.
  • Batch deadline made real
    • The hardcoded 2,400 s batch deadline is configurable
      (pipeline_batch_deadline_sec, env KAAS_WORKER_PIPELINE_BATCH_DEADLINE_SEC;
      values < 1 are rejected — zero would silently lift every bound at once).
    • Every LLM attempt's HTTP timeout is clamped to the deadline remainder and
      recomputed per attempt, so a batch cannot outrun its deadline by a
      write-timeout ladder while an orphaned daemon request keeps writing. The
      Go-side watchdog timeout then only catches a daemon that cannot answer at
      all — previously nothing bounded a wedged call, and shutdown could hang.
  • Degradation correctness
    • EmptyCompletionError / OutputTruncatedError / DeadlineExceededError
      flow through the designed fallbacks instead of escaping to a generic
      per-article error (EmptyCompletionError was not even exported before).
      Deadline exhaustion propagates as itself rather than being relabelled
      "diff result unparsable".
    • A 400 is blamed on reasoning_effort only when the error body names the
      param or a without-param probe succeeds outright; flaky-but-refusing
      gateways converge on their first call instead of failing every request.
  • Operational isolation
    • ErrBatcherClosed abandons the task like an open breaker: a shutdown
      racing Submit made no call and burns no attempt.
    • An explicitly invalid document_workers reaches validation instead of
      being masked by the deprecated alias; the seam-dedup window covers
      realistic re-emitted blocks (2,048 chars).

Deliberately left as follow-ups (not regressions vs main): attempt-burn
correlation on batch call failures (each task's burn rate is unchanged from
the direct path), and the bounded seam-dedup heuristic (overlaps beyond 2,048
chars pass through).

Verification

  • Replay benchmarks on a 150-doc production corpus (deepseek-v4-flash; full
    reports: wangdahoo/kaas#1):
    • Throughput rounds: baseline (no batching) 136.0 min -> 95.3 min at
      inflight=2 (1.43x) -> 58.9 min at inflight=8 (2.31x, +41% LLM work — not
      the shipped default).
    • Post-sprint round C (inflight=2, 150/150 success): extract -44%
      tokens/call, write-side discards -87% (1.10M -> 144K tokens), zero merge
      fallbacks, zero continuation seams across 46 continuation events, list
      coverage +5% vs round A.
    • Honest wall-clock note: round C came out 6% slower than round A (101.1 vs
      95.3 min). The dominant cause — write-call sizing under-provisioning the
      per-call reasoning burn — is what the 16,384 headroom addresses; its
      projected effect (~0.80x round-A total tokens, wall back to ~90 min) has
      not been re-measured end to end yet.
  • Review rounds: ~35 new tests across merge / llm / config / worker covering
    the hardening above. Per round, the full Python suite was failure-list
    diffed against a clean-tree run of the same selection (zero new failures;
    the flaky-on-Windows compile suite was compared via intersected stable
    sets), and all four rounds verified zero regressions in earlier fixes.
  • Go: internal/config suite green; GOOS=linux go build ./... and go vet
    clean (bridge/daemon.go is Unix-only, so worker/cmd packages are
    cross-compile checked on Windows).
  • Python: merge/write/extract/llm suites green; vs the pre-sprint tree +104
    newly passing tests with zero new failures (the 175 Windows-environment
    failures are pre-existing and unchanged).

🤖 Generated with Claude Code

Document-level concurrency becomes the throughput lever (16/16), with
three structural changes on top of the per-call write path:

- worker: batch per-task pipeline items into grouped Pipeline calls --
  same-article items within a batch share one write LLM call, bounded
  by max_items/flush_ms and a concurrent-call cap tunable at runtime
  via KAAS_WORKER_PIPELINE_BATCH_MAX_INFLIGHT; per-article lock
  serializes same-article writes across concurrent calls (lost-update
  fix)
- indexer: debounced, staleness-bounded index rebuilds behind the
  rebuild_index gate, replacing per-call rebuilds (rebuilds ~4x fewer
  under load)
- merge: full-rewrite threshold configurable via
  KB_MERGE_FULL_REWRITE_LIMIT with a degraded-diff fallback to full
  rewrite; extract_workers renamed to document_workers with a
  deprecation shim

Config plumbing: new tuning keys with env overrides via a shared
warn-and-fallback envInt helper (invalid values never abort startup);
validate() rejects resolved values that cannot work (workers < 1,
inflight < 1 with batching on).

Replay benchmarks on a 150-doc production corpus (deepseek-v4-flash):
baseline 136.0 min -> 95.3 min at inflight=2 -> 58.9 min at inflight=8,
zero merge fallbacks, success rates unchanged. Full report:
#1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tybot02 tybot02 added the enhancement New feature or request label Sep 2, 2026
…ge, and prompt tightening

Sprint s2 of the distillation-throughput work, squashed:

- llm: raw-text 3-tuple completion core with case-insensitive
  truncation matching, restart ladder with discarded-token alerts,
  continuation mode that keeps truncated partials (seam-safe dedup,
  bounded rounds, ceiling accounting), estimate_max_tokens sizing with
  a 16384 reasoning headroom, reasoning_effort passthrough with
  graceful 400 degrade, and empty-completion retry
- merge: section-level merge machinery (router under semaphore, cost
  guard, section -> diff -> full-rewrite fallback chain), diff patches
  applied to the full on-disk article with validation, sized
  full-rewrite/create first rungs
- extract: tightened prompts suppressing pre-JSON prose, hard
  end-with-JSON constraint, retry of only failed phase-2 groups, sized
  summarize calls
- pipeline: write/classify per-call LLM observability lines
- chore: ignore the .bench/ replay-benchmark harness

150-doc replay (round C): 150/150 success, extract -44% tokens/call,
write discards -87%, zero merge fallbacks and zero continuation seams;
wall 101.1 min at inflight=2 (round A reference: 95.3 min). The
dominant remaining cause is per-call reasoning burn vs sizing. Full
benchmark report: fork issue #1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tybot02
tybot02 marked this pull request as draft September 3, 2026 02:56
@wangdahoo wangdahoo changed the title feat: batched write, debounced indexing, and a tunable merge threshold feat: batched write, reasoning-aware sizing, and section-level merge Sep 3, 2026
wangdahoo and others added 4 commits September 3, 2026 16:01
…ge#62 review)

Findings from a review of PR bybit-exchange#62, verified against the PR head and fixed:

Section merge (py):
- Degrade to the legacy chain on duplicate ## headings, checked before
  the router call: reassembly keys bodies by heading text, so a
  duplicated heading would replace both occurrences with one rewrite
  built from the last body -- silently deleting the first occurrence's
  content on disk.
- Catch EmptyCompletionError/DeadlineExceededError in the section
  router and EmptyCompletionError in the diff leg. Both inherit KBError
  rather than RuntimeError, so they escaped the designed fallbacks and
  the write phase Acked the article as an error instead of degrading
  (the empty-body mode was measured 3/3 on deepseek-v4-flash).
  EmptyCompletionError is now re-exported from kb_ai.llm.
- Strip an echoed section heading only on an exact line match, so
  "## Notes" no longer eats the body of an echoed "## Notes on X".
- Document that in auto mode the section path sits ahead of
  KB_MERGE_FULL_REWRITE_LIMIT; the rollback is KB_MERGE_SECTION_MODE=off.

Batcher/worker (go):
- Bound the flush daemon call with BatchDeadline + margin on the Go
  side. The deadline previously bounded only the shutdown wait, so a
  wedged daemon held the flush's inflight slot forever and hung the
  drain (cooperative enforcement cannot help when the daemon cannot
  answer at all).
- Abandon on ErrBatcherClosed like circuit.ErrOpen: a shutdown racing
  Submit means no call was made and must not burn a task attempt.
- Make the 2400s batch deadline configurable (pipeline_batch_deadline_sec,
  KAAS_WORKER_PIPELINE_BATCH_DEADLINE_SEC; negative rejected) -- a slow
  batch used to fail items a direct, deadline-free call would finish.

Indexer/config/LLM:
- Give the IndexRefresher its own circuit breaker: an index rebuild
  reads every article, so a deterministically broken index must not
  open the breaker gating extract/pipeline.
- Blame reasoning_effort for a 400 only when the error body names it;
  any other 400 keeps the operator's knob and surfaces the real error.
- Widen the continuation dedup window to 2048 chars so a re-emitted
  table or list block is not persisted as duplicated text.
- Pass an explicitly negative document_workers through to validation
  instead of masking it with a positive deprecated alias.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second review of PR bybit-exchange#62, verified against 9063758 and fixed:

- llm: clamp every per-request HTTP timeout to the remaining batch
  deadline (a deadline already passed fails before the call is issued).
  A first attempt was never deadline-gated, so a healthy-but-slow batch
  could outrun the deadline by a whole write-timeout ladder while the
  Go-side watchdog killed it as wedged and its orphaned daemon request
  kept running and writing; the daemon now answers by deadline+epsilon
  and the watchdog margin only catches a daemon that cannot answer at
  all.
- config: reject pipeline_batch_deadline_sec < 1. Zero silently lifted
  every batch bound at once -- no DeadlineSeconds, no Go-side call
  timeout, and an unbounded Close drain.
- llm: a 400 whose body does not name reasoning_effort probes once
  without the param on the same attempt: success pins the blame and
  disables the knob, a repeat 400 raises with the knob intact. Generic
  gateways that refuse unknown fields unnamed keep working instead of
  hard-failing every call until the env var is removed.
- merge: the diff leg degrades on OutputTruncatedError and
  DeadlineExceededError too, matching the section router's set.
- merge: the heading-echo strip compares the first line, normalized, so
  echoes carrying trailing whitespace or a CR are still stripped while
  "## Notes" cannot eat the body of an echoed "## Notes on X".
- merge: the router drops new-section headings duplicating the
  article's own or another proposed section -- they collide in the body
  map or mint duplicate headings on disk.
- merge: a degraded diff with no fitting rewrite now says loudly that
  the extraction was not merged instead of Acking silently;
  _full_rewrite_limit documents the section path's precedence and the
  KB_MERGE_SECTION_MODE=off rollback.

Full Python suite diffed against a clean-tree run: zero new failures
(+14 tests). GOOS=linux build/vet clean; config tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third review round of PR bybit-exchange#62, verified against 54e63b1 and fixed:

- llm: the batch-deadline clamp is recomputed on every attempt inside
  the retry loop. A value frozen at the first attempt let a retry that
  started just inside the deadline outrun it by (frozen timeout - the
  wait+60 the guard insists on) -- up to ~830s past the deadline and
  beyond the Go-side watchdog margin, whose orphaned daemon request
  then kept running and writing.
- merge: a new-section proposal naming a heading the article already
  carries is remapped to a route onto that section instead of dropped;
  dropping it silently left the material unmerged while the task Acked
  "merged". A heading duplicating another proposal still drops with an
  alert (no unambiguous placement).
- llm: the generic-400 probe pins the blame on reasoning_effort only
  when the probe's own request succeeds. Any probe failure -- a repeat
  400, a timeout, anything -- cancels the attribution, so an eventual
  ladder success after a timeout keeps the operator's knob enabled
  instead of disabling it process-wide on a coincidence.
- merge: a degraded diff with no fitting rewrite raises instead of
  returning the unchanged article. Both callers' per-article handlers
  put the error into the task's Ack payload / the compile error list,
  and the skipped write keeps the frontmatter from recording a source
  whose content never landed.
- config: correct the validation comment -- go-zero's default tag maps
  only an omitted key to 2400, so an explicit zero in the file reaches
  validation and is rejected there (proven by the new file-zero test).

Acked as follow-ups, unchanged: batch-failure Nack correlation, the
2048-char seam-dedup residual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…emap

Fourth review round of PR bybit-exchange#62, verified against 4e161e1 and fixed:

- llm: the generic-400 probe cancels its attribution only when refused
  with a 400 of its own. A transient probe failure (timeout, 503)
  cancels nothing, so a flaky-but-refusing gateway disables
  reasoning_effort on its first call -- with the previous any-failure
  cancel, such a gateway replayed the with-param -> 400 -> probe ->
  backoff -> 200 ladder on every call until the batch deadline ran out.
- merge: DeadlineExceededError propagates out of _merge_diff instead of
  degrading to "diff result unparsable" -- no batch time left is a clock
  problem, and no fallback leg can run inside an expired deadline
  anyway; relabelling it sent operators to debug the model.
- merge: routing a new-section proposal onto an existing heading emits a
  section_route_remapped diagnostic alert, so a router systematically
  drifting placement (and cost) from "insert" to "rewrite" stays visible
  to the degrade diagnostics instead of silently succeeding.

Follow-ups acknowledged, unchanged: batch-failure Nack correlation, the
seam-dedup heuristic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tybot02
tybot02 marked this pull request as ready for review September 3, 2026 15:32
@lucasmaan
lucasmaan merged commit a004408 into bybit-exchange:main Sep 3, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants