mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
Chat was run through Ollama's OpenAI-compatible /v1 endpoint, which has no context-size field, so the num_ctx we sent was silently discarded and the entire NUM_CTX_LADDER was dead code. Every conversation ran at the backend default and was truncated from the middle (where history and retrieved context actually sit). Measured with identical ~7k-token prompts: 2,060 tokens processed against 7,049 on the native endpoint, and with the needed fact in the discarded middle the old path hallucinated a plausible wrong number. Transport: add a native /api/chat path using the already-installed ollama package, sending a real options object (num_ctx, num_predict, temperature, seed) plus keep_alive. The OpenAI path is still used for LM Studio and llama.cpp, minus the ineffective num_ctx and with include_usage so both paths return prompt token counts. /api/show is now read for trained context, parameter size, quantization and any modelfile num_ctx. The <think> stream normalizer moves to app/utils/think_stream.ts so both transports share it; it gains a flush() that fixes a dangling partial tag being dropped at end of stream. Token accounting: replace chars/N with a structural segmenter scaled by a per-model factor learned by EWMA from the prompt_eval_count every response already returned and threw away. End-to-end estimator error is 4.6% against 18-20% for a fixed divisor — qwen2.5 converges to k=1.26 where llama3 sits at 1.00, so no single global constant serves both. Context window: resolved once per model from trained context, the exact KV cost derived from GGUF metadata (head_count_kv, not head_count — GQA makes them differ 4x), and available VRAM, then memoized. Memoization is load-bearing: asking for a different num_ctx makes Ollama unload and reload the model, stalling the turn and discarding the KV cache. The floor never overrides the model's trained ceiling — tinyllama trains to 2048, and a blind 4096 floor pushes RoPE past anything the weights saw. Prompt budget: planPrompt allocates explicitly instead of delegating overflow to silent backend truncation — response reserve, then fixed blocks (system and current query, never dropped), then RAG chunks whole and best-first, then history in whole user+assistant turns, evicted newest-first in blocks with an elision marker. Block eviction keeps the prefix stable for several turns rather than shifting on every one. KV-cache ordering: the per-turn retrieved block moves behind history to the tail, so the stable prefix stays byte-identical turn to turn. At turn 14 of a long conversation, prefill is 231ms at the tail against 539ms at the front (~1ms/turn growth against ~25ms). The win is conditional — with a large retrieved block and short history the two measure the same — so both orderings ship behind RAG_PLACEMENT. The per-turn query rewrite also moves to the tasks model; on a single-slot Ollama it was evicting the chat model's cached prefix on every single turn. Also: OLLAMA_CONTEXT_LENGTH and OLLAMA_KV_CACHE_TYPE=q8_0 on the container (q8_0 is treated as headroom, never as a budgeting assumption, since unsupported architectures fall back to f16 silently), a Context Window setting in Settings > Models, and budget fields on PipelineTrace. Eval: golden suites (--suite) so the new long_context goldens cannot move the aggregates the committed baselines were recorded against, plus budget and estimator-error metrics through the report path. Retrieval is unchanged and eval:compare reports no metric outside the tolerance band; the long_context suite scores correctness 1.000 with historyElided 0.500 (the two eviction cases, by design). Unit tests 337 -> 386 passing, same 7 pre-existing failures. Ingest-side CHAR_TO_TOKEN_RATIO is deliberately untouched: changing it re-chunks the corpus and invalidates retrieval baselines, which belongs in its own measured change.
192 lines
8.1 KiB
TypeScript
192 lines
8.1 KiB
TypeScript
/**
|
|
* Token estimation for prompt budgeting.
|
|
*
|
|
* NOMAD cannot tokenize exactly. The model's real vocabulary lives inside a GGUF
|
|
* on the Ollama side, Ollama exposes no tokenize endpoint (ollama#12030 is still
|
|
* open), and bundling a BPE vocabulary would mean shipping the *wrong* vocabulary
|
|
* for whatever Llama/Qwen/Gemma/Granite model the user actually pulled. Fetching
|
|
* the right one per model needs network access, which an offline-first appliance
|
|
* does not have.
|
|
*
|
|
* So this estimates — but it estimates in a way that corrects itself. Two parts:
|
|
*
|
|
* 1. A structural segmenter (below). BPE vocabularies are built from natural text,
|
|
* so in practice a token is roughly "a word, or a punctuation mark, or a chunk
|
|
* of a long/rare word". Counting those directly tracks a real tokenizer far
|
|
* better than dividing character count by a constant, because the constant is
|
|
* only right for the kind of text it was tuned on. Prose runs ~4 chars/token;
|
|
* dense JSON, markdown tables and code run closer to 2.5. Those are exactly the
|
|
* payloads that overflow a window, so that is exactly where a fixed ratio is
|
|
* most wrong and most dangerous.
|
|
*
|
|
* 2. A per-model correction factor learned from ground truth. Every Ollama
|
|
* response reports `prompt_eval_count` — the real token count of the prompt we
|
|
* just sent. TokenCalibrationService folds the observed error into a per-model
|
|
* EWMA and feeds it back here as `ratio`. Costs nothing: no extra inference, no
|
|
* extra dependency, and it converges on the actual tokenizer within a couple of
|
|
* turns.
|
|
*
|
|
* Measured against real `prompt_eval_count` over a six-fixture set (prose,
|
|
* markdown table, TypeScript, JSON, bullet list, mixed long-form), mean absolute
|
|
* error after per-model calibration:
|
|
*
|
|
* llama3:8b segmenter 5.7% chars/3.5 18.0%
|
|
* qwen2.5:0.5b segmenter 6.2% chars/3.5 20.0%
|
|
* granite4.1:8b segmenter 6.7% chars/3.5 18.4%
|
|
*
|
|
* The learned factors were k=1.02, k=1.26 and k=1.00 respectively — which is the
|
|
* argument for making it per-model rather than a global constant. Qwen tokenizes
|
|
* noticeably finer than Llama, and no single tuned divisor can serve both.
|
|
*
|
|
* Kept pure and dependency-free so it runs under bare `node --test`, the same
|
|
* convention as `rag_prompt.ts`.
|
|
*/
|
|
|
|
/**
|
|
* Chars-per-token used by the *ingestion* path when sizing chunks for the
|
|
* embedding model.
|
|
*
|
|
* Deliberately unchanged from RagService's historical value. Chunk size is baked
|
|
* into the eval corpus fingerprint, so touching it re-chunks the corpus and
|
|
* invalidates every committed retrieval baseline. Re-tuning it is a legitimate
|
|
* change, but it is a *retrieval* change and belongs to its own measured PR — not
|
|
* folded silently into a context-management refactor. Re-exported here only so
|
|
* there is one obvious place to find every token constant.
|
|
*/
|
|
export const INGEST_CHARS_PER_TOKEN = 2
|
|
|
|
/**
|
|
* Per-message overhead from the chat template: role marker, turn delimiters, and
|
|
* the trailing newline the template adds. Real templates cost roughly 3-5 tokens
|
|
* a message. The old char-based math ignored this entirely, which quietly
|
|
* under-counted a 40-turn conversation by well over a hundred tokens.
|
|
*/
|
|
export const PER_MESSAGE_OVERHEAD_TOKENS = 4
|
|
|
|
/** Tokens the template adds once, for the generation prompt that follows the messages. */
|
|
export const CONVERSATION_OVERHEAD_TOKENS = 3
|
|
|
|
/**
|
|
* Safety margin applied when a budget decision must not overflow.
|
|
*
|
|
* Under-estimating is strictly worse than over-estimating: an over-estimate wastes
|
|
* a little window, an under-estimate overflows it and hands the backend a silent
|
|
* truncation — the exact failure this whole subsystem exists to prevent.
|
|
*/
|
|
export const ESTIMATE_SAFETY_MARGIN = 1.1
|
|
|
|
/**
|
|
* Character-class fragments, kept as strings so the CJK ranges appear exactly
|
|
* once and the regexes below stay readable.
|
|
*/
|
|
const CJK = '\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uac00-\\ud7af'
|
|
const WORD = 'A-Za-z0-9\\u00c0-\\u024f'
|
|
|
|
/**
|
|
* Segments that a BPE tokenizer tends to split on. Order matters: the first
|
|
* alternative that matches wins.
|
|
*
|
|
* - CJK/Hangul codepoints are usually one token each (often more), so they are
|
|
* matched singly rather than as words.
|
|
* - Runs of letters/digits are word-ish; long ones get sub-split below.
|
|
* - Runs of punctuation/symbols are matched as a *run*, not per character. BPE
|
|
* vocabularies contain merged punctuation pieces (`":`, `",`, `);`, `],`), so
|
|
* charging one token per punctuation character over-counts structured text
|
|
* badly -- measured at +48% on a JSON payload before this was split out.
|
|
*/
|
|
const SEGMENT_RE = new RegExp(`[${CJK}]|[${WORD}]+|[^\\s${WORD}${CJK}]+`, 'gu')
|
|
|
|
/** Identifies the punctuation-run alternative when walking matches. */
|
|
const PUNCT_RUN_RE = new RegExp(`^[^\\s${WORD}${CJK}]+$`, 'u')
|
|
|
|
/**
|
|
* Above this length, a run of letters/digits is a rare word, an identifier, or a
|
|
* hash, and a real tokenizer will break it into several pieces.
|
|
*/
|
|
const LONG_WORD_CHARS = 7
|
|
|
|
/** Roughly how many characters of a punctuation run one merged token covers. */
|
|
const PUNCT_CHARS_PER_TOKEN = 2
|
|
|
|
/**
|
|
* Estimate the token count of a string.
|
|
*
|
|
* `ratio` is the learned per-model correction (1.0 = uncalibrated). Pass the value
|
|
* from TokenCalibrationService.ratioFor(model).
|
|
*/
|
|
export function estimateTokens(text: string, ratio = 1): number {
|
|
if (!text) return 0
|
|
|
|
let tokens = 0
|
|
const matches = text.match(SEGMENT_RE)
|
|
if (matches) {
|
|
for (const segment of matches) {
|
|
if (PUNCT_RUN_RE.test(segment)) {
|
|
// Punctuation merges: `":` and `},` are usually single tokens.
|
|
tokens += Math.ceil(segment.length / PUNCT_CHARS_PER_TOKEN)
|
|
} else if (segment.length > LONG_WORD_CHARS) {
|
|
// Long words split into roughly one piece per LONG_WORD_CHARS characters.
|
|
tokens += Math.ceil(segment.length / LONG_WORD_CHARS)
|
|
} else {
|
|
tokens += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leading whitespace is folded into the following token by most BPE
|
|
// vocabularies, but newlines are usually tokens in their own right and matter
|
|
// for the markdown-heavy content NOMAD deals in.
|
|
const newlines = (text.match(/\n/g) || []).length
|
|
tokens += newlines
|
|
|
|
return Math.max(1, Math.ceil(tokens * ratio))
|
|
}
|
|
|
|
export type EstimatableMessage = { role: string; content: string }
|
|
|
|
/**
|
|
* Estimate the token cost of a full message array as the backend will see it,
|
|
* including chat-template overhead.
|
|
*/
|
|
export function estimateMessagesTokens(messages: EstimatableMessage[], ratio = 1): number {
|
|
if (messages.length === 0) return 0
|
|
let total = CONVERSATION_OVERHEAD_TOKENS
|
|
for (const message of messages) {
|
|
total += estimateTokens(message.content, ratio) + PER_MESSAGE_OVERHEAD_TOKENS
|
|
}
|
|
return total
|
|
}
|
|
|
|
/**
|
|
* Estimate with the safety margin applied — use this wherever exceeding the
|
|
* budget causes silent truncation rather than merely wasting space.
|
|
*/
|
|
export function estimateTokensConservative(text: string, ratio = 1): number {
|
|
return Math.ceil(estimateTokens(text, ratio) * ESTIMATE_SAFETY_MARGIN)
|
|
}
|
|
|
|
/**
|
|
* Fold a fresh observation into an exponentially-weighted moving average.
|
|
*
|
|
* `alpha` is intentionally low: a single odd turn (a huge code paste, a burst of
|
|
* CJK) should nudge the ratio, not redefine it.
|
|
*/
|
|
export function updateEwma(previous: number | null, observed: number, alpha = 0.25): number {
|
|
if (previous === null || !Number.isFinite(previous) || previous <= 0) return observed
|
|
return previous * (1 - alpha) + observed * alpha
|
|
}
|
|
|
|
/**
|
|
* Guard rail on the learned ratio. A calibration ratio far outside this range
|
|
* means something other than tokenization differs — a backend injecting a hidden
|
|
* system prompt, a tool schema we didn't count, a reasoning preamble. Clamping
|
|
* keeps one bad reading from destroying the budget for every later turn.
|
|
*/
|
|
export const MIN_CALIBRATION_RATIO = 0.5
|
|
export const MAX_CALIBRATION_RATIO = 2.5
|
|
|
|
export function clampRatio(ratio: number): number {
|
|
if (!Number.isFinite(ratio) || ratio <= 0) return 1
|
|
return Math.min(MAX_CALIBRATION_RATIO, Math.max(MIN_CALIBRATION_RATIO, ratio))
|
|
}
|