pip, the openai package (any recent 1.x), an API key for the provider that serves both models, the exact model ID strings for DeepSeek V3 and DeepSeek V4.1-Flash-1017, and a CSV of 20 real prompts from your own product.results.csv with latency, token counts and cost per model, a routes.json that sends each task type to the winning model, and a verified routing function.
python3 --version must print 3.10 or newer.pip install --upgrade openai. This is an OpenAI-compatible client — a library that speaks the /chat/completions request format, which is the format both DeepSeek models are called with.DEEPSEEK_API_KEY, never in the source file.https://<your-provider-endpoint>/v1. Replace <your-provider-endpoint> with the host printed in your provider console.MODEL_V3 and MODEL_FLASH, filled with the exact ID strings in Step 1.prompts.csv with the header id,task,prompt, 20 rows taken from real traffic.Example: a valid prompts.csv start.
id,task,prompt
1,extract,"Return JSON with the invoice number and total from: Invoice 4471, total 128.40 EUR, due 2025-03-14"
2,summary,"Summarise in one sentence: the customer says the export button returns a 500 error since this morning"
3,chat_short,"Reply to: can I change the delivery address after the order shipped?"
Why the task column matters: you are not choosing one model for everything. You are collecting per-task evidence so Step 3 can route extract and chat_short differently. For a wider view of how model families differ on assistant-style tasks, see ChatGPT, Claude, Gemini, DeepSeek: AI assistant comparison.
What to do: export the key and base URL, ask the API which models your key can see, and copy the IDs verbatim.
export DEEPSEEK_API_KEY="sk-<your-key>"
export DEEPSEEK_BASE_URL="https://<your-provider-endpoint>/v1"
curl -s "$DEEPSEEK_BASE_URL/models" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" | python3 -m json.tool
Why: DeepSeek V3 and DeepSeek V4.1-Flash-1017 are product names; the API accepts opaque ID strings. A guessed string fails at request time with a 404, and you would waste an hour blaming the network.
How to check: the response is JSON of the shape "data": [ { "id": "..." }, ... ]. Filter just the IDs:
curl -s "$DEEPSEEK_BASE_URL/models" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" | grep -o '"id": *"[^"]*"'
You should see one line containing the V3 family ID and one containing the V4.1-Flash-1017 family ID. Then pin them:
export MODEL_V3="<exact-id-from-the-list>"
export MODEL_FLASH="<exact-id-from-the-list>"
echo "$MODEL_V3 / $MODEL_FLASH"
The echo must print both strings, not the empty placeholder. Finally, record the fields below in a note file — you will need them in Step 2 for the cost calculation.
| Field to compare | DeepSeek V3 | DeepSeek V4.1-Flash-1017 | Where to read it |
|---|---|---|---|
| Exact model ID | `<from /models>` | `<from /models>` | `/models` response |
| Context window (tokens) | check the documentation | check the documentation | model card |
| Price per 1M input tokens | check the documentation | check the documentation | pricing page |
| Price per 1M output tokens | check the documentation | check the documentation | pricing page |
| Rate limit (requests/min) | check the documentation | check the documentation | console limits page |
| Streaming / tool calling support | check the documentation | check the documentation | model card |
Why a table and not a web search: pricing pages get cached and quoted out of date; the console value for your own account is the one that shows up on your invoice.

What to do: create ab_test.py, set the four price variables from Step 1, and run it. The script loops over every prompt twice — once per model — with temperature=0 so repeated runs are comparable.
import csv, os, time
from openai import OpenAI
client = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"],
base_url=os.environ["DEEPSEEK_BASE_URL"])
MODELS = {"v3": os.environ["MODEL_V3"], "flash": os.environ["MODEL_FLASH"]}
PRICE = {"v3": (float(os.environ["PRICE_IN_V3"]), float(os.environ["PRICE_OUT_V3"])),
"flash": (float(os.environ["PRICE_IN_FLASH"]), float(os.environ["PRICE_OUT_FLASH"]))}
with open("prompts.csv", newline="", encoding="utf-8") as f, \
open("results.csv", "w", newline="", encoding="utf-8") as out:
w = csv.writer(out)
w.writerow(["prompt_id", "task", "model", "latency_ms",
"in_tokens", "out_tokens", "cost_usd", "answer"])
for p in csv.DictReader(f):
for key, model_id in MODELS.items():
t0 = time.perf_counter()
try:
r = client.chat.completions.create(
model=model_id,
messages=[{"role": "user", "content": p["prompt"]}],
temperature=0,
)
ms = (time.perf_counter() - t0) * 1000
u = r.usage
pin, pout = PRICE[key]
cost = u.prompt_tokens / 1e6 * pin + u.completion_tokens / 1e6 * pout
ans = (r.choices[0].message.content or "").replace("\n", " ")[:200]
row = [u.prompt_tokens, u.completion_tokens, round(cost, 6), ans]
except Exception as e:
ms = (time.perf_counter() - t0) * 1000
row = ["", "", 0, repr(e)[:200]]
w.writerow([p["id"], p["task"], key, round(ms)] + row)
export PRICE_IN_V3="<usd-per-1m-input>" PRICE_OUT_V3="<usd-per-1m-output>"
export PRICE_IN_FLASH="<usd-per-1m-input>" PRICE_OUT_FLASH="<usd-per-1m-output>"
python3 ab_test.py
Why latency_ms is measured client-side: it includes network round-trip and queueing, which is exactly what your users feel. Provider-side token counters tell you nothing about the wait.
How to check: line count and error count.
wc -l results.csv # 20 prompts x 2 models + 1 header = 41
grep -c "Error code" results.csv # must print 0
head -n 3 results.csv
The first two data rows must share the same prompt_id and differ only in model (v3, then flash). Then aggregate per model — median, not mean, so one slow call does not skew the result:
python3 - <<'PY'
import csv, statistics
from collections import defaultdict
d = defaultdict(list)
for r in csv.DictReader(open("results.csv")):
d[r["model"]].append(r)
for m, rows in d.items():
lat = [int(r["latency_ms"]) for r in rows]
cost = [float(r["cost_usd"]) for r in rows]
print(m, "calls:", len(rows), "p50_ms:", int(statistics.median(lat)),
"total_usd:", round(sum(cost), 4))
PY
Expected output — two lines, one per model:
v3 calls: 20 p50_ms: <number> total_usd: <number>
flash calls: 20 p50_ms: <number> total_usd: <number>
Example: if v3 prints p50_ms: 2400 and flash prints p50_ms: 900 on your prompt set, Flash wins on latency for those 20 prompts — but only for those prompts, and only on that day. For a worked example of how to read such a comparison without over-interpreting it, see GPT-6.1 Sol vs GPT-4o: Model Comparison and Which to Choose.

What to do: add a manual score column, compute the win rate, then encode the decision in routes.json and test the loader.
Create scores.csv by reading the answer column for each row and giving 1–5 points against your own acceptance criterion (for extract: correct JSON and correct numbers, otherwise 1).
prompt_id,model,score_1_5
1,v3,5
1,flash,3
2,v3,4
2,flash,4
Why: latency alone is misleading. A model that answers in 900 ms but returns a wrong invoice total costs more than a 2.4 s call, because a human has to re-check it.
| If your `results.csv` and `scores.csv` show... | Then route |
|---|---|
| One model scores clearly higher on a task | send that task to it |
| Scores within 1 point and p50 latency differs a lot | send the high-volume short task to the faster model |
| Scores within 1 point and latency is close | compare `total_usd` per 1k requests and take the cheaper |
| One model produces most of the `Error code 429` rows | keep the other as `default`, use the throttled one only as fallback |
| A task fails on both models | do not route around it — fix the prompt or the input schema first |
Encode the outcome:
{
"default": "v3",
"routes": {
"extract": "v3",
"summary": "flash",
"chat_short": "flash"
}
}
import json
ROUTES = json.load(open("routes.json", encoding="utf-8"))
def pick_model(task: str) -> str:
return ROUTES["routes"].get(task, ROUTES["default"])
print(pick_model("summary"), pick_model("unknown_task"))
Expected screen output: flash v3. The .get(...) fallback is why an unexpected task name never crashes the request path.
How to check: send five real requests per route through the function in staging and confirm the model ID written to your logs matches the table above.
grep -o '"model": *"[^"]*"' app.log | sort | uniq -c
Success looks like: the counts for summary and chat_short land on the Flash ID, extract lands on the V3 ID, and no Error code lines appear in the tail of the log.
Problem. Every request fails with a model error, but the key works in /models.
Error code: 404 - {'error': {'message': 'Model Not Exist'}} curl -s "$DEEPSEEK_BASE_URL/models" -H "Authorization: Bearer $DEEPSEEK_API_KEY" \
| grep -o '"id": *"[^"]*"'
export MODEL_FLASH="<paste-exactly>"
python3 -c "import os; print(repr(os.environ['MODEL_FLASH']))"
The repr output must show no trailing space inside the quotes. Exact wording of the error text depends on the provider and SDK version — check the documentation for the current message.
Problem. The key works in the browser console but the script returns 401.
Error code: 401 - {'error': {'message': 'Authentication Fails'}}Bearer "sk-...". echo "${DEEPSEEK_API_KEY:0:6}...${#DEEPSEEK_API_KEY}"
export DEEPSEEK_API_KEY=sk-<your-key>
The first command prints the prefix and the length; a length far above the real key length means stray characters.
Problem. The run stops halfway with rate-limit errors, or p50 latency jumps after the first few calls.
Error code: 429 - {'error': {'message': 'Rate limit reached'}} import time
for attempt in range(4):
try:
r = client.chat.completions.create(model=model_id,
messages=[{"role": "user", "content": p["prompt"]}], temperature=0)
break
except Exception as e:
if "429" not in repr(e) or attempt == 3:
raise
time.sleep(2 ** attempt)
Re-run and confirm grep -c "Error code" results.csv prints 0 again.
You now have measured per-task evidence for DeepSeek V3 and DeepSeek V4.1-Flash-1017 on your own prompts, plus a routes.json that sends each task to the model your data supports. Next, wire pick_model() into your request handler, log the resolved model ID on every call, and re-run ab_test.py whenever either model's ID, price or rate limit changes.
Is DeepSeek V4.1-Flash-1017 better than DeepSeek V3?
Not in general — only on your prompts. This is why Step 2 runs both models on the same 20 inputs and Step 3 scores the answers; a "better" label that is not tied to a task, a prompt set and a date is not actionable.
How do I get the exact model ID strings?
Call the /models endpoint with your key, as shown in Step 1, and copy the id values verbatim into MODEL_V3 and MODEL_FLASH. Never retype them from a blog post.
Where do I find the prices for the cost column?
On the provider's pricing page for your account — the same values you put into PRICE_IN_V3, PRICE_OUT_V3, PRICE_IN_FLASH and PRICE_OUT_FLASH. If a value is not published for a field, check the documentation rather than estimating.
Do I have to use both models in production?
No. Many teams end with routes.json pointing almost everything at one model and keeping the second only as a fallback for 429 and timeout errors. The value of the harness is the evidence behind that single choice.