Local Clef decision model as a LangGraph router vs. a tool-calling agent: accuracy and latency on 2x RTX A5000
Python
1
4 commits
updated Oct 11, 2026
A local decision model as the router of a LangChain/LangGraph workflow, compared with a classic tool-calling agent.
I'm a third-year student, and the agentic business workflows I'm working on were capable but slow. So for the common paths I kept falling back to scripts with keyword rules, which were fast but brittle: the Turkish word for "stock" shows up both in "buy 100 lots of THY" and in "what is a stock?".
When Cloudflare released the Clef decision models and LangChain wrote about using Jev in agent harnesses, I wanted to check, on hardware I actually have, whether a decision model could sit in between: deterministic code for the requests I already know how to handle, an agent for everything else. This repo is what I found. It is a learning project, not a product claim. If something here is wrong or unfair to one of the approaches, please open an issue; corrections are very welcome.
This is an independent project. I'm not affiliated with Cloudflare, LangChain or TypeSafe.
Where this pattern might help (ideas I haven't benchmarked yet):
AutoModeMiddleware from langchain-typesafe worked unchanged against the local server.Agentic workflows are flexible but slow, so many teams fall back to hand-written scripts with keyword rules. Those rules are brittle: "hisse" (stock) appears in both "buy 100 lots of THY" and "what is a stock?". This repo measures a middle path:
Everything runs locally on two consumer/workstation GPUs. Clef speaks the Jev/SystemOne API, so the stock langchain-typesafe client and its middleware work against it unchanged; the only change is setting TYPESAFE_BASE_URL.
Turkish stock-brokerage workflow, 15 requests × 3 runs, same tools, same LLM (Qwen3.5-9B, thinking off):
| clef-routed graph | tool-calling agent | |
|---|---|---|
| Correct (all runs) | 45/45 | 39/45 |
| Tool requests (11), median latency | 0.27 s | 7.20 s |
| Open-ended requests (4), median latency | 5.64 s | 13.14 s |
| Sum of per-request medians (15 requests) | 25.2 s | 124.2 s |
Full per-request table: results/workflow/summary.md. Raw runs with replies: results/workflow/results.json.
A) Tool-calling agent — langchain.agents.create_agent (exported from the compiled graph). The LLM decides on every step:
graph TD
start([start]) --> model
model -.->|tool calls| tools
tools -.-> model
model -.->|final answer| finish([end])
B) Clef-routed workflow — a LangGraph StateGraph. One clef call answers two questions; code does the rest:
graph TD
start([start]) --> classify["classify<br/>clef-flash: intent + ticker<br/>regex: quantity"]
classify -.->|price| price["price<br/>get_price()"]
classify -.->|buy| buy["buy<br/>buy_stock()"]
classify -.->|sell| sell["sell<br/>sell_stock()"]
classify -.->|portfolio| portfolio["portfolio<br/>get_portfolio()"]
classify -.->|missing ticker / quantity| ask["ask_missing"]
classify -.->|other, or p below 0.6| llm["llm_fallback<br/>Qwen3.5-9B"]
price --> finish([end])
buy --> finish
sell --> finish
portfolio --> finish
ask --> finish
llm --> finish
The graphs are generated by the benchmark itself: results/workflow/graph_agent.mmd, results/workflow/graph_clef.mmd.
The single clef call behind classify:
from langchain_typesafe import Choice, TypeSafeClassifier
classifier = TypeSafeClassifier() # TYPESAFE_BASE_URL=http://127.0.0.1:30000
answers = classifier.invoke({
"state": "Türk Hava Yollarından 100 lot al",
"questions": {
"intent": Choice(instructions="What does the user want the brokerage system to do right now?",
criteria={"price": "...", "buy": "...", "sell": "...", "portfolio": "...", "other": "..."}),
"ticker": Choice(instructions="Which stock does the message refer to?",
criteria={"THYAO": "Türk Hava Yolları", "GARAN": "Garanti BBVA", ..., "none": "No specific stock"}),
},
}).choices
# answers["intent"].choice == "buy" (p=0.94), answers["ticker"].choice == "THYAO"
graph LR
subgraph client["Client: Apple M4, 16 GB (macOS)"]
bench["workflow_bench.py<br/>LangChain / LangGraph"]
end
subgraph server["GPU server: Windows 10, 2× RTX A5000"]
api["clef_server.py (FastAPI, 127.0.0.1:30000)"]
gpu0["GPU0: clef-flash<br/>POST /v1/systemone"]
gpu1["GPU1: Qwen3.5-9B<br/>POST /v1/chat/completions"]
api --- gpu0
api --- gpu1
end
bench -->|"SSH tunnel over home Wi-Fi"| api
| GPU server | Intel Core i9-10920X (12 cores / 24 threads), 128 GB RAM, 2× NVIDIA RTX A5000 24 GB (PCIe 3.0 ×16, 230 W), NVIDIA driver 596.86 (WDDM), Windows 10 Pro |
| Server software | Python 3.12.10, PyTorch 2.11.0+cu128, transformers 5.10.2, accelerate 1.15.0, FastAPI/uvicorn |
| Models | clef-flash, BF16, GPU0 (17.8 GiB) · Qwen3.5-9B, BF16, GPU1 (17.5 GiB) · greedy decoding |
| Client | Apple M4, 16 GB, Python 3.12, LangChain 1.4.4, LangGraph 1.2.14, langchain-openai 1.7.0, langchain-typesafe 0.0.1a3 |
| Network | SSH local port forward over home Wi-Fi; all latencies are end to end from the client |
flash-linear-attention and causal-conv1d were not installed (they need a CUDA toolchain on Windows), so both Qwen3.5-based models ran on transformers' pure-PyTorch fallback. That slows both sides; Qwen decoded at ~12–17 tokens/s.
server/clef_server.py exposes:
POST /v1/systemone — Jev/SystemOne-compatible decisions (clef-flash), usable by TypeSafeClassifier, ModelRouterMiddleware, AutoModeMiddleware.POST /v1/chat/completions — OpenAI-compatible chat with tool calling (Qwen3.5-9B). Model names: qwen-fast (thinking off), qwen-think (thinking on; reasoning is force-closed at its token budget so it cannot loop forever).10 short classification cases (support routing, sarcasm, phishing vs. a legitimate GitHub email, a Turkish complaint, news topic, banking intent, toxicity, NLI, log severity), 15 questions in total:
| Model / mode | Correct | Latency per case | Load | Notes |
|---|---|---|---|---|
| clef-flash (1 GPU) | 14/15 | 0.23 s | 20 s | probability per option; mean p(gold) 0.853 |
| clef-omni (30B-A3B MoE, 2 GPUs + CPU offload) | 15/15 | 5.8 s | 67 s | mean p(gold) 0.928; 18 of 48 layers offloaded to CPU |
| Qwen3.5-9B, thinking off, JSON answer | 15/15 | 1.07 s | 14 s | ~14 output tokens |
| Qwen3.5-9B, thinking on, JSON answer | 10/15 | 72.9 s | 14 s | ~1250 tokens of reasoning; looped to the 4096-token cap on 2 cases and never answered |
server/clef_omni_loader.py loads it with device_map="auto", moves the joint-schema head's inputs to the head's device, and reloads lm_head from safetensors when accelerate offloads it to the meta device. Copying offloaded layers on every forward pass explains the 5.8 s.results/classification/omni.log.Results: results/classification/. Note: the summary line in llm.log reports 25/30 for thinking mode; that was a bug in the summary filter (now fixed in server/bench_llm_baseline.py). The per-case lines and llm_results.json give the correct 10/15.
GPU server (Windows, NVIDIA GPUs with ≥ 20 GB each):
pip install torch==2.11.0 torchvision==0.26.0 --index-url https://download.pytorch.org/whl/cu128
pip install -r requirements-server.txt
:: copy server\*.py to %USERPROFILE%\clef, then download the models once:
python -c "from huggingface_hub import snapshot_download as s; s('Cloudflare/clef-flash'); s('Qwen/Qwen3.5-9B')"
Windows OpenSSH Server must be enabled. The server listens on 127.0.0.1 only and is reached through the SSH tunnel.
Client (macOS/Linux):
python3.12 -m venv .venv && .venv/bin/pip install -r requirements-client.txt
export CLEF_HOST=user@your-gpu-box
scripts/start_server.sh # terminal 1: tunnel + server, Ctrl+C stops both
.venv/bin/python client/workflow_bench.py --repeats 3 # terminal 2
The classification benchmark runs on the GPU server itself: python bench_classification.py (clef-flash, then clef-omni) and python bench_llm_baseline.py (Qwen3.5-9B).
MIT for the code in this repository. The models have their own licenses: clef-flash, clef-omni and Qwen3.5-9B are Apache-2.0.
Local Clef decision model as a LangGraph router vs. a tool-calling agent: accuracy and latency on 2x RTX A5000
Python
1
4 commits
updated Oct 11, 2026
A local decision model as the router of a LangChain/LangGraph workflow, compared with a classic tool-calling agent.
I'm a third-year student, and the agentic business workflows I'm working on were capable but slow. So for the common paths I kept falling back to scripts with keyword rules, which were fast but brittle: the Turkish word for "stock" shows up both in "buy 100 lots of THY" and in "what is a stock?".
When Cloudflare released the Clef decision models and LangChain wrote about using Jev in agent harnesses, I wanted to check, on hardware I actually have, whether a decision model could sit in between: deterministic code for the requests I already know how to handle, an agent for everything else. This repo is what I found. It is a learning project, not a product claim. If something here is wrong or unfair to one of the approaches, please open an issue; corrections are very welcome.
This is an independent project. I'm not affiliated with Cloudflare, LangChain or TypeSafe.
Where this pattern might help (ideas I haven't benchmarked yet):
AutoModeMiddleware from langchain-typesafe worked unchanged against the local server.Agentic workflows are flexible but slow, so many teams fall back to hand-written scripts with keyword rules. Those rules are brittle: "hisse" (stock) appears in both "buy 100 lots of THY" and "what is a stock?". This repo measures a middle path:
Everything runs locally on two consumer/workstation GPUs. Clef speaks the Jev/SystemOne API, so the stock langchain-typesafe client and its middleware work against it unchanged; the only change is setting TYPESAFE_BASE_URL.
Turkish stock-brokerage workflow, 15 requests × 3 runs, same tools, same LLM (Qwen3.5-9B, thinking off):
| clef-routed graph | tool-calling agent | |
|---|---|---|
| Correct (all runs) | 45/45 | 39/45 |
| Tool requests (11), median latency | 0.27 s | 7.20 s |
| Open-ended requests (4), median latency | 5.64 s | 13.14 s |
| Sum of per-request medians (15 requests) | 25.2 s | 124.2 s |
Full per-request table: results/workflow/summary.md. Raw runs with replies: results/workflow/results.json.
A) Tool-calling agent — langchain.agents.create_agent (exported from the compiled graph). The LLM decides on every step:
graph TD
start([start]) --> model
model -.->|tool calls| tools
tools -.-> model
model -.->|final answer| finish([end])
B) Clef-routed workflow — a LangGraph StateGraph. One clef call answers two questions; code does the rest:
graph TD
start([start]) --> classify["classify<br/>clef-flash: intent + ticker<br/>regex: quantity"]
classify -.->|price| price["price<br/>get_price()"]
classify -.->|buy| buy["buy<br/>buy_stock()"]
classify -.->|sell| sell["sell<br/>sell_stock()"]
classify -.->|portfolio| portfolio["portfolio<br/>get_portfolio()"]
classify -.->|missing ticker / quantity| ask["ask_missing"]
classify -.->|other, or p below 0.6| llm["llm_fallback<br/>Qwen3.5-9B"]
price --> finish([end])
buy --> finish
sell --> finish
portfolio --> finish
ask --> finish
llm --> finish
The graphs are generated by the benchmark itself: results/workflow/graph_agent.mmd, results/workflow/graph_clef.mmd.
The single clef call behind classify:
from langchain_typesafe import Choice, TypeSafeClassifier
classifier = TypeSafeClassifier() # TYPESAFE_BASE_URL=http://127.0.0.1:30000
answers = classifier.invoke({
"state": "Türk Hava Yollarından 100 lot al",
"questions": {
"intent": Choice(instructions="What does the user want the brokerage system to do right now?",
criteria={"price": "...", "buy": "...", "sell": "...", "portfolio": "...", "other": "..."}),
"ticker": Choice(instructions="Which stock does the message refer to?",
criteria={"THYAO": "Türk Hava Yolları", "GARAN": "Garanti BBVA", ..., "none": "No specific stock"}),
},
}).choices
# answers["intent"].choice == "buy" (p=0.94), answers["ticker"].choice == "THYAO"
graph LR
subgraph client["Client: Apple M4, 16 GB (macOS)"]
bench["workflow_bench.py<br/>LangChain / LangGraph"]
end
subgraph server["GPU server: Windows 10, 2× RTX A5000"]
api["clef_server.py (FastAPI, 127.0.0.1:30000)"]
gpu0["GPU0: clef-flash<br/>POST /v1/systemone"]
gpu1["GPU1: Qwen3.5-9B<br/>POST /v1/chat/completions"]
api --- gpu0
api --- gpu1
end
bench -->|"SSH tunnel over home Wi-Fi"| api
| GPU server | Intel Core i9-10920X (12 cores / 24 threads), 128 GB RAM, 2× NVIDIA RTX A5000 24 GB (PCIe 3.0 ×16, 230 W), NVIDIA driver 596.86 (WDDM), Windows 10 Pro |
| Server software | Python 3.12.10, PyTorch 2.11.0+cu128, transformers 5.10.2, accelerate 1.15.0, FastAPI/uvicorn |
| Models | clef-flash, BF16, GPU0 (17.8 GiB) · Qwen3.5-9B, BF16, GPU1 (17.5 GiB) · greedy decoding |
| Client | Apple M4, 16 GB, Python 3.12, LangChain 1.4.4, LangGraph 1.2.14, langchain-openai 1.7.0, langchain-typesafe 0.0.1a3 |
| Network | SSH local port forward over home Wi-Fi; all latencies are end to end from the client |
flash-linear-attention and causal-conv1d were not installed (they need a CUDA toolchain on Windows), so both Qwen3.5-based models ran on transformers' pure-PyTorch fallback. That slows both sides; Qwen decoded at ~12–17 tokens/s.
server/clef_server.py exposes:
POST /v1/systemone — Jev/SystemOne-compatible decisions (clef-flash), usable by TypeSafeClassifier, ModelRouterMiddleware, AutoModeMiddleware.POST /v1/chat/completions — OpenAI-compatible chat with tool calling (Qwen3.5-9B). Model names: qwen-fast (thinking off), qwen-think (thinking on; reasoning is force-closed at its token budget so it cannot loop forever).10 short classification cases (support routing, sarcasm, phishing vs. a legitimate GitHub email, a Turkish complaint, news topic, banking intent, toxicity, NLI, log severity), 15 questions in total:
| Model / mode | Correct | Latency per case | Load | Notes |
|---|---|---|---|---|
| clef-flash (1 GPU) | 14/15 | 0.23 s | 20 s | probability per option; mean p(gold) 0.853 |
| clef-omni (30B-A3B MoE, 2 GPUs + CPU offload) | 15/15 | 5.8 s | 67 s | mean p(gold) 0.928; 18 of 48 layers offloaded to CPU |
| Qwen3.5-9B, thinking off, JSON answer | 15/15 | 1.07 s | 14 s | ~14 output tokens |
| Qwen3.5-9B, thinking on, JSON answer | 10/15 | 72.9 s | 14 s | ~1250 tokens of reasoning; looped to the 4096-token cap on 2 cases and never answered |
server/clef_omni_loader.py loads it with device_map="auto", moves the joint-schema head's inputs to the head's device, and reloads lm_head from safetensors when accelerate offloads it to the meta device. Copying offloaded layers on every forward pass explains the 5.8 s.results/classification/omni.log.Results: results/classification/. Note: the summary line in llm.log reports 25/30 for thinking mode; that was a bug in the summary filter (now fixed in server/bench_llm_baseline.py). The per-case lines and llm_results.json give the correct 10/15.
GPU server (Windows, NVIDIA GPUs with ≥ 20 GB each):
pip install torch==2.11.0 torchvision==0.26.0 --index-url https://download.pytorch.org/whl/cu128
pip install -r requirements-server.txt
:: copy server\*.py to %USERPROFILE%\clef, then download the models once:
python -c "from huggingface_hub import snapshot_download as s; s('Cloudflare/clef-flash'); s('Qwen/Qwen3.5-9B')"
Windows OpenSSH Server must be enabled. The server listens on 127.0.0.1 only and is reached through the SSH tunnel.
Client (macOS/Linux):
python3.12 -m venv .venv && .venv/bin/pip install -r requirements-client.txt
export CLEF_HOST=user@your-gpu-box
scripts/start_server.sh # terminal 1: tunnel + server, Ctrl+C stops both
.venv/bin/python client/workflow_bench.py --repeats 3 # terminal 2
The classification benchmark runs on the GPU server itself: python bench_classification.py (clef-flash, then clef-omni) and python bench_llm_baseline.py (Qwen3.5-9B).
MIT for the code in this repository. The models have their own licenses: clef-flash, clef-omni and Qwen3.5-9B are Apache-2.0.