feat(p4-1): llm-trait — kb-llm 크레이트 + MockLanguageModel #21
Reference in New Issue
Block a user
Delete Branch "feat/p4-1-llm-trait"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
변경 요약
P4-1 llm-trait 작업입니다. 새 크레이트
kb-llm을 추가해서 P4 후속 작업 (p4-2 Ollama, 향후 llama.cpp / candle 등) 이 의존할 안정적인 trait 표면을 마련하고, downstream 테스트 (특히 p4-3 RAG 파이프라인)에서 사용할 결정적 mock을 feature flag로 노출합니다. P3-1 (kb-embed) 패턴을 그대로 따릅니다.무엇을 했는가
트레이트 재노출
kb_core::{LanguageModel, GenerateRequest, TokenChunk, FinishReason, TokenUsage, ModelRef}을 그대로pub use. 이 크레이트 자체에는 새로운 타입을 추가하지 않습니다 — kb-core가 재구성되더라도 downstream 호출 사이트가kb_llm::LanguageModel통해 안정 import.MockLanguageModel (
#[cfg(feature = "mock")], default OFF)p4-3 RAG 파이프라인 테스트가 Ollama 의존성 없이 trait 객체를 받을 수 있게 만든 결정적 mock입니다. 핵심 동작:
canned_response을 Unicode scalar value 단위 (char)로 stream해서TokenChunk::Token(c.to_string())발행. grapheme cluster 단위 아님 — Hangul jamo, emoji ZWJ sequence, combining mark은 여러 chunk로 split됩니다. trait-shape 검증용으로는 충분하고 실제 adapter는 combine 가능. 모듈 doc-comment에 명시.req.stop처리: 비어있지 않은 stop string마다 byte position을 찾고Iterator::min으로 가장 이른 위치 채택 (동률은req.stop선언 순서가 이김).str::find는 UTF-8 char boundary를 반환하므로 직접 byte-slice가 안전.finish_reason은Stop으로 override (OpenAI / Ollama real-world contract와 일치). 아니면canned_finish을 verbatim 통과.canned_response이 비어있어도TokenChunk::Done { finish_reason, usage }은 항상 발행됨. 반환 iterator는Vec<TokenChunk>::into_iter().map(Ok)을 box한 형태라 triviallySend.헬퍼
assert_finish_chunk(chunks)— chunk 배열의 마지막이Done임을 assert. proptest나 trait contract 검증 시 useful. P3-1의assert_vector_shape와 같은 시리즈.테스트
dyn_dispatch_via_box_works(ZeroLanguageModel을 in-test 정의해서 trait 표면이 mock 없이도 사용 가능함을 증명) +mock_feature_off_compiles.--features mock라인 (9건): streams_then_done, honors_stop_strings, honors_first_stop_match, dyn_dispatch_via_box, concat_equals_canned, model_ref_has_no_dimensions, finish_reason_passes_through_when_no_stop_match, 그리고 100-case proptest. proptest는 random Unicode 문자열에 대해 (a) 마지막 chunk가 Done, (b) Token chunk 개수 ==canned_response.chars().count(), (c) Token concat == canned (stop으로 truncated된 경우 truncated body)을 검증.cargo clippy --workspace --all-targets [--features kb-llm/mock] -- -D warnings두 feature 조합 모두 clean.Symbol gating 검증
cargo build --release -p kb-llm(default):nm target/release/libkb_llm.rlib | grep MockLanguageModel→ 0건.cargo build --release -p kb-llm --features mock: trait impl symbols 3건 (generate_stream,model_ref,apply_stop).spec Risks/notes의 "릴리스 빌드 (no
--features mock)는 MockLanguageModel 심볼을 포함해서는 안 된다"가 심볼 레벨에서 보장됩니다.의존성
Allowed deps 명세에는
kb-core,kb-config,serde,thiserror,tracing이 있는데 — 본 PR에서는 kb-core + anyhow만 직접 의존으로 선언했습니다. 리뷰의 MUST-FIX 사항이었습니다: 나머지 4개는 spec에 "Allowed"로 적혀있지만 이 skeleton 크레이트에서 실제로 imports되지 않아서, 종속성 그래프를 슬림하게 유지하기 위해 제거. p4-2/p4-3에서 필요해지면 그때 own dep site에 다시 추가.anyhow는 trait 반환 타입 (anyhow::Result)이 강제하므로 forced.Forbidden deps (
reqwest,ureq,tokio,whisper-rs,kb-source-fs,kb-parse-md,kb-normalize,kb-chunk,kb-store-*,kb-embed*,kb-search,kb-rag,kb-tui,kb-desktop) 어느 것도cargo tree -p kb-llm에 없습니다.변경 파일
crates/kb-llm/Cargo.toml(신규)crates/kb-llm/src/lib.rs(신규 — 재노출 + helper)crates/kb-llm/src/mock.rs(신규)crates/kb-llm/tests/mock.rs(신규)crates/kb-llm/tests/reexports.rs(신규)Cargo.toml(workspace member 등록)Cargo.lockOut of scope
kb-llm-localOllama = p4-2).usage.prompt_tokensbest-effort).design §7.1, §7.2, §0 Q5, §3.8 / report §11.2 참고.
Establishes the kb-llm trait crate so concrete LLM adapters (p4-2 Ollama, future llama.cpp / candle) target a stable surface. Pure re- export of kb_core::{LanguageModel, GenerateRequest, TokenChunk, FinishReason, TokenUsage, ModelRef} plus a feature-gated deterministic mock for downstream RAG tests (p4-3) that need an LLM trait object without an Ollama dependency. MockLanguageModel (cfg(feature = "mock"), default OFF): - Holds canned_response + canned_finish + canned_usage + (model_id, provider, context_tokens). Pure in-memory; no I/O. - generate_stream() honors GenerateRequest.stop: scans every non-empty stop string against the canned response, takes the earliest byte position (Iterator::min returns the first equal element on ties so declaration order in req.stop wins), truncates with a direct byte- slice (str::find returns a UTF-8 char boundary by contract). - When a stop matches, finish_reason is overridden to Stop (matches OpenAI / Ollama real-world behaviour); otherwise the caller's canned_finish passes through verbatim. - Emits one TokenChunk::Token per Unicode scalar value (char), NOT per grapheme cluster — Hangul jamo, emoji ZWJ sequences, combining marks split. Acceptable for trait-shape testing; real adapters MAY combine. Documented in module docs. - Always terminates with TokenChunk::Done { finish_reason, usage } even if the canned response is empty. The returned iterator is a boxed Vec<TokenChunk>::into_iter().map(Ok), trivially Send. - Real adapters MAY return Err from generate_stream itself (e.g. connection refused) before any chunk is yielded; the mock never does. Documented for the trait re-exporter consumer audience. Helpers: - assert_finish_chunk(chunks) — asserts the last chunk is a Done. Useful for proptests asserting trait contract over random inputs. Tests: - cargo test -p kb-llm (no features): 2 reexport / dyn-dispatch tests. - cargo test -p kb-llm --features mock: 9 tests including 100-case proptest over random Unicode strings asserting Done terminator, char-count == streamed Token chunks, concat == canned (truncated by stop), plus explicit cases for stop-string truncation, first-stop- match precedence, model_ref dimensions=None invariant, finish reason pass-through. - All 271 workspace tests pass; clippy clean for both default and mock-on feature configurations. Symbol gating verified: - cargo build --release -p kb-llm (default): nm shows zero MockLanguageModel symbols. - cargo build --release -p kb-llm --features mock: three trait-impl symbols present. Spec invariant "release builds MUST NOT include MockLanguageModel" enforced at the symbol level. Allowed deps respected: only kb-core (path) and anyhow (workspace, forced by trait return type). Dropped kb-config / serde / thiserror / tracing from the spec's allowed list — they are listed as Allowed but nothing in this skeleton crate references them, and dropping them keeps the dependency graph slim for downstream consumers. p4-2/p4-3 will add what they need at their own dep sites. Forbidden deps (reqwest, ureq, tokio, whisper-rs, kb-source-fs, kb-parse-md, kb-normalize, kb-chunk, kb-store-*, kb-embed*, kb-search, kb-rag, kb-tui, kb-desktop) all absent from cargo tree -p kb-llm. Out of scope: real adapter (p4-2 Ollama), token counting against the real tokenizer, server-side cancellation / abort signals (P+). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>P4-1 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
spec compliance + code quality 양쪽 리뷰 결과 BLOCKER 0건. spec compliance는 PASS with NIT, code quality는 1 MUST-FIX (unused deps) + 8 NIT. MUST-FIX + 4개 NIT (byte-boundary 코멘트, min vs min_by_key doc 텍스트, grapheme cluster 경계 명문화, real adapter error 가능성 doc note)을 PR에 반영했습니다.
핵심 포인트:
#[cfg(feature = \"mock\")]+ default OFF, 심볼 레벨에서nm검증 (default 0건, --features mock 3건).Iterator::min의 stdlib stable-ordering 보장 + UTF-8 char boundarystr::findcontract를 활용해 명시적 tie-break / boundary check 없이도 정확.cargo metadata이 실 사용 의존성만 반영.워크스페이스 default 271 passed / 24 ignored / 0 failed. clippy clean (default + --features mock).
inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. 다음은 P4-2 ollama-adapter.
@@ -0,0 +11,4 @@kb-core = { path = "../kb-core" }anyhow = { workspace = true }[features]spec Allowed deps 목록에서 실제로 imports되지 않는
kb-config,serde,thiserror,tracing을 제거. spec compliance를 좁게 해석하기보다cargo metadata이 정확하게 "실 사용 deps"를 반영하도록 — clippyunused-crate-dependencies가 잡았을 cruft를 PR 단계에서 정리. p4-2/p4-3에서 필요해지면 그때 own dep site에서 추가.@@ -0,0 +19,4 @@//! [`TokenChunk::Token`]`(c.to_string())`. This makes streaming UTF-8 safe//! by construction (no character is split across chunks). Emits one//! `TokenChunk` per Unicode scalar value (`char`), not per grapheme//! cluster — Hangul jamo, emoji ZWJ sequences, and combining marks splitmodule doc에 "Unicode scalar value (
char)" vs "grapheme cluster" 차이를 명시 — 미래 누군가가 emoji ZWJ sequence가 분할되는 걸 보고 "버그다"라며 grapheme 단위로 "고치는" 회귀를 차단합니다. trait-shape 검증이 mock의 책임이고 grapheme combine은 real adapter의 책임이라는 경계를 명문화.@@ -0,0 +57,4 @@// Earliest byte position wins. Ties break by first occurrence in// `stop` (Iterator::min returns the first equal element, and we// iterate `stop` in its declared order). Empty stop strings are// ignored — they would otherwise match at position 0 and silentlystop string truncation 로직:
Iterator::min이 동률에서 첫 element를 반환한다는 stdlib 보장에 의존해서req.stop선언 순서를 자연스럽게 tie-breaker로 활용.min_by_key을 안 쓴 이유 + UTF-8 char boundary 안전성 (str::find반환값 직접 slice)을 코멘트로 박아두어 미래 reader가 "왜 더 명시적인 코드가 아닌가"의 답을 즉시 얻습니다.@@ -0,0 +97,4 @@// sidesteps lifetime juggling around `&self.canned_response` inside// a `'static` iterator and trivially gives `Send` (Vec<TokenChunk>// is Send because TokenChunk is Send).let mut chunks: Vec<TokenChunk> = truncatedstop이 매치되면
finish_reason을Stop으로 override, 아니면canned_finishverbatim 통과. spec line 73의 "truncate before emitting"이 finish_reason까지 명시 안 했지만 OpenAI / Ollama real-world contract와 일치하는 자연스러운 선택입니다.finish_reason_passes_through_when_no_stop_match테스트로 두 경로 모두 pin.@@ -0,0 +197,4 @@.count();prop_assert_eq!(token_count, canned.chars().count());// Concatenation == canned (byte-equal).100-case proptest이 random Unicode 문자열로 stop 처리 + char-by-char streaming + Done 종결 invariant을 한 번에 검증.
assert_finish_chunkhelper로 contract 위반을 즉시 명시적으로 fail. 향후 streaming logic을 손댈 때 모두 fail해야 정상이라는 의미라 회귀 안전망이 단단해졌습니다.@@ -0,0 +29,4 @@fn generate_stream(&self,_req: GenerateRequest,) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {테스트 안에서
ZeroLanguageModel을 직접 정의해서 trait 표면 자체만으로 dyn dispatch가 동작함을 보인 게 의도가 좋습니다 — re-export 크레이트의 진짜 책임 ("upstream trait를 호환되게 노출하기")을 mock에 의존하지 않고 검증. mock feature가 꺼진 상태에서도 의미 있는 테스트가 남는 형태.