如何構建確定性 Mock LLM Provider,讓評測套件無需 API 金鑰或網絡即可運行
Build a Deterministic Mock LLM Provider So Your Eval Suite Runs With No API Keys and No Network
確定性 Mock LLM Provider 以共用介面和提示詞雜湊作種子,讓評測套件無需 API 金鑰或網絡連線,也能取得可重現的回應。它可透過 fixtures 和指令控制輸出,並以 degrade 模式測試評分器能否辨別較差結果。這種模擬只驗證測試流程,不衡量模型回答質素;作者的 evalgate 實作見 github.com/AgentPostmortem/evalgate。
Every LLM project I have worked on eventually hits the same wall. Your eval suite and your unit tests both want to call a model, and the moment they do, CI slows to a crawl, costs money, needs secret keys, and flakes whenever the vendor changes a checkpoint. The output is nondeterministic by design, so a "did the pipeline work" test starts failing for reasons that have nothing to do with your code.
The fix I keep reaching for is a deterministic mock provider. It implements the exact same interface as your real providers, returns the same shape of response, and is completely offline. Tests that exercise your scoring, aggregation, and reporting logic never touch a network. In this tutorial I will build one from scratch. The code is grounded in my project evalgate, which ships exactly this, but the technique transfers to any stack.
Start with an interface both sides share
The whole trick depends on one thing: your real provider and your mock must be interchangeable. So define a narrow interface first and make every adapter conform to it.
export interface ProviderRequest {
model: string;
messages?: { role: string; content: string }[];
prompt?: string;
temperature?: number;
maxTokens?: number;
}
export interface ProviderResponse {
output: string;
latencyMs: number;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
costUsd?: number;
model: string;
raw?: unknown;
}
export interface Provider {
readonly name: string;
complete(request: ProviderRequest): Promise<ProviderResponse>;
embed?(text: string): Promise<number[]>;
}
Note that the response carries latency, token usage, and cost, not just text. If your mock only returns a string, every code path that reads usage or costUsd stays untested. Model the full shape.
Collapse the request to a single prompt
Requests can arrive as a single prompt or as a list of chat messages. The mock only needs one canonical string to reason about, so normalize first.
export function resolvePrompt(request: ProviderRequest): string {
if (request.prompt !== undefined) return request.prompt;
if (request.messages && request.messages.length > 0) {
return request.messages.map((m) => `${m.role}: ${m.content}`).join("\n");
}
return "";
}
Make it deterministic with a seeded hash
The core promise is that the same input always yields the same output. I derive everything from a hash of the prompt and feed that seed into a small pseudo random generator. FNV-1a plus mulberry32 are both a few lines each and have zero dependencies.
const seed = fnv1a(prompt);
const rand = mulberry32(seed);
const latencyMs = Math.round(5 + rand() * 50); // stable "latency" in 5-55ms
Because the seed comes from the prompt, even the fake latency is reproducible. Run the suite a thousand times and you get identical numbers, which is what makes snapshot style assertions possible.
Two ways to control the output: fixtures and directives
A useful mock is not random. It is steerable. I give it two levers.
Fixtures are canned responses matched as case insensitive substrings against the prompt. The first match wins. This lets a specific test pin an exact output without caring how the mock works internally.
for (const [needle, response] of Object.entries(this.fixtures)) {
if (prompt.toLowerCase().includes(needle.toLowerCase())) return response;
}
Directives are instruction verbs embedded in the prompt itself, so an example suite can exercise real scorer behavior without any per test wiring. I scan for the last verb: body line and act on it.
switch (verb) {
case "exactly":
case "echo":
case "json":
return payload;
case "uppercase":
return payload.toUpperCase();
case "lowercase":
return payload.toLowerCase();
case "reverse":
return [...payload].reverse().join("");
}
Now a case whose prompt ends with exactly: {"ok": true} deterministically produces that JSON, and your JSON scorer gets a real input to grade. Anything with no fixture and no directive falls back to a stable pseudo response derived from the hash, so it is still reproducible:
const base = `mock response for prompt hash ${fnv1a(prompt)}`;
The part people forget: a degrade mode
Here is the feature that earns its keep. A green test suite proves your scorers accept good output. It does not prove they reject bad output. If your exact match scorer has a bug that always returns 1.0, every test passes and you never notice.
So I add a degrade flag that deterministically corrupts the output, simulating a dumber model or a regression.
function corrupt(text: string): string {
if (text.length === 0) return "?";
// Break exact match while keeping partial similarity intact.
return text.slice(0, Math.max(1, text.length - 1)) + " [oops]";
}
Dropping the last character and appending a marker is deliberate. It breaks exact match assertions but keeps enough overlap that a similarity scorer still returns a middling score, not zero. That lets you write a test asserting your pipeline scores lower in degrade mode, which is how you prove the scorers actually discriminate.
Wire it through a registry
Finally, make the mock the default so no configuration means no network. A tiny registry maps provider names to lazy factories.
registry.register("mock", () => new MockProvider(mockOptions));
registry.register("openai", (e) => openai(e));
registry.register("anthropic", (e) => anthropic(e));
Your suite references a provider by name. In CI you point it at mock and everything runs offline, for free, in milliseconds. Locally, flip it to a real provider when you actually want to measure model quality.
The one honest caveat
A mock provider tests your harness, not your model. It will tell you that your scoring, aggregation, cost accounting, and reporting all work. It will never tell you whether GPT or Claude actually answers your prompts well, because it is not a language model. Deterministic mocks and real model evals are complementary. Run the mock on every commit to protect your plumbing, and run real model evals on a schedule or before a release to measure quality. Confusing the two is how teams end up with a perfectly green suite and a product that gives bad answers.
If you want to see all of this wired together with scorers, a runner, and example suites, the full implementation lives in github.com/AgentPostmortem/evalgate. Steal the pattern and give your test suite its keys back.
來源:DEV Community · dev.to