Last used: 2026-03-24
Memory references: 7
Status: Active
llm-eval-router
Set up a production-quality shadow evaluation pipeline that automatically
promotes local Ollama models when they statistically prove they match cloud
model quality — reducing inference costs with evidence, not hope.
The core idea
Run every task through your best local model (shadow) in parallel with your
cloud baseline (ground truth). A lightweight judge ensemble scores the local
output. After 200+ runs, if the local model hits 0.95 mean score, promote it
to handle that task type in production. Demote it automatically if quality drops.
When to use
- - You're paying for Claude/GPT API calls on tasks that don't need that quality
- You have Ollama running locally with capable models (qwen2.5, phi4, mistral, etc.)
- You want evidence-based cost reduction, not blind routing
- You have defined task types: summarize, classify, extract, format, analyze, RAG
When NOT to use
- - Tasks that require real-time web knowledge (use cloud)
- Tasks with strict latency requirements < 2 seconds (local models on CPU are slow)
- Tasks with high safety stakes (always use cloud with safety filters)
- You don't have Ollama or a Mac/Linux machine with enough RAM (8GB+ per model)
Prerequisites
- - Ollama installed and running (ollama.com)
- At least one capable model:
ollama pull qwen2.5 or INLINECODE1 - Python 3.10+
- API keys: Anthropic (ground truth) + OpenAI (judge) — Gemini optional (tiebreaker)
- Langfuse for observability (self-hosted or cloud) — optional but strongly recommended
Network & Privacy
This skill makes outbound API calls to:
- - Anthropic API — to generate ground truth baseline responses (every accumulation cycle)
- OpenAI API — for judge scoring (sampled at 15% of runs)
- Google Gemini API — tiebreaker judge only (when primary judges disagree by ≥0.20)
What stays local:
- - All Ollama model inference runs entirely on your device
- Scored run data is stored on disk in INLINECODE2
- No telemetry, analytics, or data collection of any kind
- No data is sent anywhere other than the explicit API calls above
Langfuse (optional) can be self-hosted or cloud. If self-hosted, all observability data stays on your network.
Core concepts
6-Dimension Evaluation
Every response is scored on:
| Dimension | Default weight | Analyze weight | What it measures |
|---|
| Structural | 25% | 10% | Format compliance, required keys present |
| Semantic |
25% |
40% | Meaning equivalence to ground truth |
| Factual | 20% | 25% | No hallucinated facts/numbers/entities |
| Completion | 15% | 18% | Task fully addressed |
| Tool use | 10% | 4% | Correct tool/format selection |
| Latency | 5% | 3% | Within acceptable bounds |
Important: Use per-task weight overrides. The default 25/25 split treats structural
accuracy equally with semantic similarity — which works for extract/classify/format tasks
(where exact format matters) but is wrong for open-ended analysis. difflib.SequenceMatcher
on two prose analyses of the same question scores ~0.29 even when they're semantically
identical. With structural weight at 25%, this alone caps analyze scores at ~0.59.
CODEBLOCK0
Also: For analyze tasks, constrain output structure via system_prompt so GT and
candidates produce comparably-formatted responses (Finding/Recommendation/Confidence/Reasoning).
This reduces Layer 2 drift and improves difflib scores even at reduced weight.
Judge ensemble
- - Primary judges (15% sampling rate): Claude Sonnet + gpt-4o-mini score independently
- Tiebreaker (only when |scoreA - scoreB| ≥ 0.20): Gemini 2.5-flash
- Unsampled runs (85%): Layer 1+2 validators only (deterministic, free)
- Promotion gates always trigger full judge evaluation regardless of sampling rate
Layer 1+2 validators (free, deterministic)
- - Layer 1: JSON validity, required key presence, forbidden pattern check
- Layer 2: Drift detection — novel entities/numbers/URLs not in ground truth
These run on every response at zero cost. Judges only run when L1+L2 pass and
the sampling rate triggers.
Promotion / Demotion
- - Promote: 200+ runs, rolling mean ≥ 0.95 for a model/task pair
- Demote: rolling 7-day pass rate < 0.92
- Control floor: one model (phi4, granite4, or similar) serves as the measured floor —
any model scoring below it should be flagged, not promoted
Implementation steps
Step 1 — Define your task types
Create config/task_types.yaml:
CODEBLOCK1
Step 2 — Set up the router
The router assigns each task to a model using a round-robin strategy during
burn-in (building n), then switches to confidence-weighted routing after promotion.
CODEBLOCK2
Step 3 — Ground truth comparison
For each task, run it through BOTH the local model (candidate) and the cloud
baseline (ground truth). Never use the ground truth response in production —
it's only for evaluation.
CODEBLOCK3
Step 4 — Confidence tracker
Track scores per model/task pair on disk (so restarts don't lose data):
CODEBLOCK4
Step 5 — Accumulator loop
Run this on a cron (every 10-20 minutes via launchd/systemd):
CODEBLOCK5
Step 6 — Routing policy
CODEBLOCK6
Step 7 — API
Expose a simple HTTP API (FastAPI):
CODEBLOCK7
Key lessons learned (from 900+ production runs)
What worked:
- - phi4 as control floor: a measured floor model prevents "promoted because everyone
else is also bad" errors. If the floor model beats a candidate, flag it — don't promote.
- - Thinking token stripping: CoT models (deepseek-r1, qwen2.5-coder with reasoning)
must have
<think>...</think> blocks stripped before evaluation. Otherwise Layer 2
drift detection flags the reasoning chain as hallucinated content.
- -
None ≠ 0.0 for unsampled runs: a run where no judge scored is not a failing run.
Store
None, exclude from mean. Mixing None with 0.0 poisons the mean.
- -
require_json: False for plain-text tasks: classify and extract tasks that return
formatted text (not JSON objects) will fail Layer 1 if you require JSON. Separate
the "is the format correct" check from "is it valid JSON."
- - Per-task weight overrides: do not use one weight profile for all task types.
Structural accuracy (difflib) is wrong for prose analysis — use semantic similarity as
the primary signal for open-ended tasks. This lifted analyze mean from 0.44–0.59 to 0.70.
- - Structured output prompts for analyze tasks: add a
system_prompt that specifies
an exact output format (Finding/Recommendation/Confidence/Reasoning). Both GT and
candidates follow the same template, improving structural alignment and reducing drift
penalty. Without this, Layer 2 drift fires on differently-phrased but correct analyses.
- - MCP server for agentic access: expose CP as MCP tools (
run_task, get_status,
get_champions,
get_promotion_timeline,
get_cost_heatmap). Lets an LLM agent
query evaluation state without bespoke integration work.
What didn't work:
- - Large models (>9GB): gpt-oss:20b and similar required 39+ second inference —
the latency dimension alone tanks the composite score. Practical ceiling is ~9GB models
on 24GB unified memory to avoid GPU memory swapping.
- - 100% judge sampling: runs through the full Claude+GPT+Gemini panel on every evaluation
costs more in judge API fees than you save by routing locally. Sample at 15%.
- - Chroma 1.5.1 with Python 3.14: Pydantic V1 BaseSettings incompatibility. Use
qdrant or numpy cosine store instead.
- - One-size-fits-all weight profiles: defining global weights at system init and never
overriding per task type led to all analyze evals silently failing for 112+ runs.
Lesson: evaluate your evaluator's scores by task type early — if a whole task type
caps at a suspicious ceiling (e.g. 0.59), the metric is wrong, not the models.
Expected timeline
With a 20-minute accumulator cadence and 9 candidates × 7 task types:
- - First 50 runs per model: ~5 hours
- First promotions (200 runs): ~1-2 days per model/task pair
- Stable routing layer: 1-2 weeks
Cost estimate
Per accumulation cycle (one task, one model):
- - Ground truth: ~$0.002 (Claude Sonnet, ~500 input + 200 output tokens)
- Judge sample (15%): ~$0.003 (Sonnet + GPT-4o-mini)
- Local model: $0 (Ollama, on-device)
At 6 runs/hour × 24 hours: ~$0.70/day during burn-in.
After first promotions: drops to ~$0.10/day (90%+ of task volume local).
技能名称: llm-eval-router
详细描述:
最后使用: 2026-03-24
记忆引用: 7
状态: 活跃
llm-eval-router
搭建一个生产级影子评估管道,当本地 Ollama 模型在统计上证明其质量与云模型匹配时,自动将其提升——用证据而非希望来降低推理成本。
核心理念
将每个任务同时通过你的最佳本地模型(影子)和云基线(真实答案)运行。一个轻量级裁判集成对本地输出进行评分。经过 200+ 次运行后,如果本地模型平均得分达到 0.95,则将其提升以在生产环境中处理该任务类型。如果质量下降,则自动降级。
使用场景
- - 你正在为不需要那种质量的任务支付 Claude/GPT API 调用费用
- 你本地运行着 Ollama,并拥有能力足够的模型(qwen2.5、phi4、mistral 等)
- 你想要基于证据的成本降低,而非盲目路由
- 你已定义任务类型:总结、分类、提取、格式化、分析、RAG
不适用场景
- - 需要实时网络知识的任务(使用云)
- 延迟要求严格低于 2 秒的任务(CPU 上的本地模型速度慢)
- 安全风险高的任务(始终使用带有安全过滤器的云)
- 你没有安装 Ollama 或没有足够 RAM(每个模型 8GB+)的 Mac/Linux 机器
前置条件
- - Ollama 已安装并运行(ollama.com)
- 至少一个能力足够的模型:ollama pull qwen2.5 或 ollama pull phi4
- Python 3.10+
- API 密钥:Anthropic(真实答案)+ OpenAI(裁判)—— Gemini 可选(决胜裁判)
- Langfuse 用于可观测性(自托管或云)—— 可选但强烈推荐
网络与隐私
此技能会向外发出 API 调用至:
- - Anthropic API —— 用于生成真实答案基线响应(每个累积周期)
- OpenAI API —— 用于裁判评分(采样率为 15% 的运行)
- Google Gemini API —— 仅作为决胜裁判(当主裁判分歧 ≥ 0.20 时)
本地保留的内容:
- - 所有 Ollama 模型推理完全在你的设备上运行
- 评分后的运行数据存储在磁盘上的 data/scores/*.json 中
- 无任何遥测、分析或数据收集
- 除上述明确 API 调用外,不会向任何地方发送数据
Langfuse(可选)可以自托管或使用云服务。如果自托管,所有可观测性数据都保留在你的网络中。
核心概念
6 维度评估
每个响应在以下维度上评分:
| 维度 | 默认权重 | 分析权重 | 衡量内容 |
|---|
| 结构 | 25% | 10% | 格式合规性,存在必需键 |
| 语义 |
25% |
40% | 与真实答案的意义等价性 |
| 事实 | 20% | 25% | 无幻觉事实/数字/实体 |
| 完成度 | 15% | 18% | 任务被完全解决 |
| 工具使用 | 10% | 4% | 正确的工具/格式选择 |
| 延迟 | 5% | 3% | 在可接受范围内 |
重要提示: 使用按任务设置的权重覆盖。默认的 25/25 分配将结构准确性与语义相似性等同对待——这适用于提取/分类/格式化任务(精确格式很重要),但对于开放式分析则是错误的。对同一问题的两段散文分析使用 difflib.SequenceMatcher 评分约为 0.29,即使它们在语义上完全相同。当结构权重为 25% 时,仅此一项就将分析得分上限限制在约 0.59。
python
src/evaluator.py — 按任务设置的权重配置
TASK
WEIGHTOVERRIDES = {
analyze: {
structural_accuracy: 0.10, # difflib 对散文无意义
semantic_similarity: 0.40, # 嵌入向量余弦相似度捕捉语义
factual_drift: 0.25,
task_completion: 0.18,
tool_correctness: 0.04,
latency_score: 0.03,
},
code_transform: {
structural_accuracy: 0.15,
semantic_similarity: 0.35,
factual_drift: 0.20,
task_completion: 0.20,
tool_correctness: 0.07,
latency_score: 0.03,
},
}
另外: 对于分析任务,通过 system_prompt 约束输出结构,使真实答案和候选模型生成格式可比较的响应(发现/建议/置信度/推理)。这减少了第二层漂移,并即使在降低权重的情况下也能改善 difflib 分数。
裁判集成
- - 主裁判(15% 采样率):Claude Sonnet + gpt-4o-mini 独立评分
- 决胜裁判(仅当 |分数A - 分数B| ≥ 0.20 时):Gemini 2.5-flash
- 未采样运行(85%):仅第一层+第二层验证器(确定性,免费)
- 提升门控始终触发完整裁判评估,无论采样率如何
第一层+第二层验证器(免费,确定性)
- - 第一层:JSON 有效性、必需键存在性、禁止模式检查
- 第二层:漂移检测——真实答案中不存在的新实体/数字/URL
这些在每个响应上以零成本运行。仅当第一层+第二层通过且采样率触发时,裁判才运行。
提升 / 降级
- - 提升:200+ 次运行,某个模型/任务对的滚动平均值 ≥ 0.95
- 降级:滚动 7 天通过率 < 0.92
- 控制基线:一个模型(phi4、granite4 或类似)作为测量的基线——任何得分低于它的模型都应被标记,不得提升
实施步骤
步骤 1 — 定义你的任务类型
创建 config/task_types.yaml:
yaml
tasks:
- id: summarize
description: 用 N 句话总结文档
require_json: false
judge_dimensions: [semantic, factual, completion]
- id: classify
description: 将文本分类到 N 个类别之一
require_json: true # 响应必须是有效 JSON
judge_dimensions: [structural, semantic, completion]
- id: extract
description: 从非结构化文本中提取结构化数据
require_json: true
judge_dimensions: [structural, factual, completion]
- id: format
description: 根据模板重新格式化内容
require_json: false
judge_dimensions: [structural, semantic, completion]
步骤 2 — 设置路由器
路由器在磨合期(构建 n)使用轮询策略将每个任务分配给一个模型,然后在提升后切换到基于置信度的路由。
python
src/router.py — 简化版本
class Router:
def
init(self, candidates: list[str], control_floor: str):
self.candidates = candidates
self.control
floor = controlfloor
self.
rrcounters = defaultdict(int)
def route(self, tasktype: str, confidencetracker: ConfidenceTracker) -> str:
返回此任务类型的最佳模型。
promoted = confidencetracker.getpromoted(task_type)
if promoted:
return promoted # 直接使用已提升的模型
# 磨合期使用轮询以实现公平暴露
idx = self.rrcounters[task_type] % len(self.candidates)
self.rrcounters[task_type] += 1
return self.candidates[idx]
步骤 3 — 真实答案比较
对于每个任务,同时通过本地模型(候选)和云基线(真实答案)运行。切勿在生产中使用真实答案响应——它仅用于评估。
python
async def evaluatepair(prompt: str, localresponse: str, gt_response: str,
task_type: str) -> float:
# 第一层:确定性
l1score = validators.layer1(localresponse, task_type)
if l1_score == 0.0:
return 0.0 # 硬性失败——安全或格式违规
# 第二层:启发式漂移
l2score = validators.layer2(localresponse, gt_response)
# 采样裁判(15%)
if random.random() < JUDGESAMPLERATE:
sonnetscore = await judgesonnet(prompt, local_response,