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.
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
import { inject } from '@adonisjs/core'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import os from 'node:os'
|
|
import KVStore from '#models/kv_store'
|
|
import { OllamaService } from '#services/ollama_service'
|
|
import { SystemService } from '#services/system_service'
|
|
import {
|
|
UNKNOWN_BACKEND_CONTEXT,
|
|
computeKvBytesPerToken,
|
|
estimateKvBytesPerToken,
|
|
parseParameterBillions,
|
|
parseUserContextCap,
|
|
resolveContextWindow,
|
|
type ContextWindowDecision,
|
|
} from '../utils/context_window.js'
|
|
|
|
/**
|
|
* Decides how large a context window each chat model gets, and then holds that
|
|
* answer still.
|
|
*
|
|
* The "holds it still" part is the whole point. Ollama unloads and reloads a
|
|
* model whenever a request asks for a different `num_ctx` than the loaded
|
|
* instance — so a window that grew with the conversation would stall a turn and
|
|
* throw away the KV cache exactly when the conversation got interesting. The
|
|
* decision is therefore made once per model and memoized for the process
|
|
* lifetime; it is a property of (model, hardware), not of the current prompt.
|
|
*
|
|
* Inputs, in decreasing order of authority:
|
|
* - the user's cap (`ai.contextWindow`), which can only lower the result
|
|
* - the model's trained context length, from /api/show `model_info`
|
|
* - what the hardware can actually afford, from the exact per-token KV cost in
|
|
* the GGUF metadata against detected VRAM (or a share of system RAM)
|
|
*/
|
|
@inject()
|
|
export class ContextWindowService {
|
|
private decisions: Map<string, Promise<ContextWindowDecision>> = new Map()
|
|
private availableBytes: Promise<number | undefined> | null = null
|
|
|
|
constructor(
|
|
private ollamaService: OllamaService,
|
|
private systemService: SystemService
|
|
) {}
|
|
|
|
/**
|
|
* The context window to request for `model`. Memoized — repeat calls within a
|
|
* process return the identical number, which is what keeps the model loaded.
|
|
*/
|
|
async windowFor(model: string): Promise<number> {
|
|
return (await this.decisionFor(model)).contextWindow
|
|
}
|
|
|
|
async decisionFor(model: string): Promise<ContextWindowDecision> {
|
|
const cached = this.decisions.get(model)
|
|
if (cached) return cached
|
|
|
|
const pending = this._resolve(model).catch((error) => {
|
|
logger.warn(
|
|
`[ContextWindow] Falling back to ${UNKNOWN_BACKEND_CONTEXT} for ${model}: ${error instanceof Error ? error.message : error}`
|
|
)
|
|
return { contextWindow: UNKNOWN_BACKEND_CONTEXT, limitedBy: 'default' as const }
|
|
})
|
|
this.decisions.set(model, pending)
|
|
return pending
|
|
}
|
|
|
|
private async _resolve(model: string): Promise<ContextWindowDecision> {
|
|
const [info, userCapRaw] = await Promise.all([
|
|
this.ollamaService.getModelInfo(model),
|
|
KVStore.getValue('ai.contextWindow'),
|
|
])
|
|
const userCap = parseUserContextCap(userCapRaw)
|
|
|
|
// No /api/show means a non-Ollama backend, which sets its context at server
|
|
// start and ignores anything we send. Budget against a conservative value so
|
|
// the prompt planner still bounds itself rather than trusting the backend to
|
|
// truncate gracefully — it won't; it will truncate silently.
|
|
if (!info.contextLength && !info.rawModelInfo) {
|
|
const decision: ContextWindowDecision = {
|
|
contextWindow: userCap ?? UNKNOWN_BACKEND_CONTEXT,
|
|
limitedBy: userCap ? 'user' : 'default',
|
|
}
|
|
logger.info(
|
|
`[ContextWindow] ${model}: no model metadata available, budgeting at ${decision.contextWindow}`
|
|
)
|
|
return decision
|
|
}
|
|
|
|
const kvBytesPerToken =
|
|
computeKvBytesPerToken(info.rawModelInfo) ??
|
|
estimateKvBytesPerToken(parseParameterBillions(info.parameterSize, model))
|
|
|
|
const decision = resolveContextWindow({
|
|
modelMaxCtx: info.contextLength,
|
|
modelfileNumCtx: info.modelfileNumCtx,
|
|
kvBytesPerToken,
|
|
availableBytes: await this._availableBytes(),
|
|
modelBytes: await this._modelBytes(model),
|
|
userCap,
|
|
})
|
|
|
|
logger.info(
|
|
`[ContextWindow] ${model}: ${decision.contextWindow} tokens (limited by ${decision.limitedBy}; ` +
|
|
`${(kvBytesPerToken / 1024).toFixed(0)} KiB/token KV` +
|
|
`${decision.affordableTokens !== undefined ? `, ~${decision.affordableTokens} affordable` : ''})`
|
|
)
|
|
return decision
|
|
}
|
|
|
|
/**
|
|
* Memory available for inference, in bytes.
|
|
*
|
|
* Prefers the VRAM figure Ollama itself reports at startup — it is the number
|
|
* that actually governs whether a window fits, and it already accounts for
|
|
* whichever device Ollama chose. Falls back to a conservative share of system
|
|
* RAM for CPU-only hosts, which is the low end of the hardware NOMAD targets
|
|
* and precisely where getting this wrong hurts most.
|
|
*/
|
|
private async _availableBytes(): Promise<number | undefined> {
|
|
if (!this.availableBytes) {
|
|
this.availableBytes = (async () => {
|
|
try {
|
|
const compute = await this.systemService.getOllamaInferenceComputeFromLogs()
|
|
if (compute && compute.vramMiB > 0) {
|
|
return compute.vramMiB * 1024 * 1024
|
|
}
|
|
} catch (error) {
|
|
logger.debug(
|
|
`[ContextWindow] GPU probe failed, assuming CPU inference: ${error instanceof Error ? error.message : error}`
|
|
)
|
|
}
|
|
// CPU inference shares host RAM with MySQL, Redis, Qdrant and every other
|
|
// container on the box, so claim only a modest slice of it.
|
|
return os.totalmem() * CPU_RAM_SHARE
|
|
})()
|
|
}
|
|
return this.availableBytes
|
|
}
|
|
|
|
private async _modelBytes(model: string): Promise<number | undefined> {
|
|
try {
|
|
const models = await this.ollamaService.getModels(true)
|
|
return models.find((m) => m.name === model)?.size || undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Drop memoized decisions — call when the backend or the user's cap changes. */
|
|
invalidate(): void {
|
|
this.decisions.clear()
|
|
this.availableBytes = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Share of system RAM assumed available for CPU inference. The rest of the NOMAD
|
|
* stack (MySQL, Redis, Qdrant, Kiwix, the admin app itself) lives in the same
|
|
* pool, and a machine that starts swapping is worse than one with a small window.
|
|
*/
|
|
const CPU_RAM_SHARE = 0.5
|