模型管線中提示詞改變、Schema 未變,工作仍通過驗證
The Prompt Changed, the Schema Didn't, and the Job Still Passed
Morgan Ma 以發票資料抽取工作為例,説明提示詞改動而 Schema 未更新,令格式錯誤的金額通過驗證,夜間總額因而偏低。提示詞要求金額採人類易讀格式後,模型把 1200.00 輸出為 1,200.00;兩者都是字串,解析例外又被轉成 None,最終按零計入。作者提出固定測試資料、記錄提示詞與 Schema 指紋、在隔離環境重播、按指紋比較輸出,並加強格式驗證和守門測試的排查流程。
Version skew is the quietest bug class in model pipelines. Validation passes because the schema never moved. Your prompt did.
That gap is where I lost a week. Here is the walkthrough, plus the harness I now use to catch it.
The symptom: green checks, wrong totals
The extraction job ran nightly. It pulled invoice lines and wrote normalized amounts.
One morning the totals were low. No exceptions. No retries. No dead letters.
The validator was happy. Every record had amount as a string. That was the only rule.
So what did the tests actually cover? Type shape. Nothing else. Not format, not provenance, not drift.
Root cause: one sentence in the prompt
I had edited the prompt the week before. I added a line asking the model to keep amounts human-readable.
So it started returning 1,200.00 instead of 1200.00. Both are strings. Both passed.
Downstream, Decimal("1,200.00") raised. The wrapper caught it and returned None.
None summed to zero. The nightly report still rendered.
Three separate defects lined up: a loose schema, a swallowed exception, and no record of which prompt produced which output.
Fix any one of them and the bug is loud. Fix none, and it is silent for as long as nobody compares.
Step 1 — Freeze the input set first
Before touching a prompt, capture a fixed fixture file. Same inputs, forever.
Twenty to fifty records is enough. Include the ugly ones: multi-currency, credits, blank lines.
mkdir -p fixtures runs
python -m harness.snapshot --source prod-sample --limit 50 \
--out fixtures/invoice-50.jsonl
Never regenerate this file during debugging. It is your ground truth for comparison.
Step 2 — Stamp provenance into every record
This is the part I skipped, and it cost me the most time. Every output needs to say what produced it.
import hashlib, json, pathlib
PROMPT = pathlib.Path("prompts/extract.v3.txt").read_text()
SCHEMA = pathlib.Path("schemas/invoice.json").read_text()
def fp(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:12]
PROVENANCE = {
"prompt_fp": fp(PROMPT),
"schema_fp": fp(SCHEMA),
}
def record(input_id: str, payload: dict) -> dict:
return {
"input_id": input_id,
"meta": PROVENANCE,
"output": payload,
}
Twelve hex characters is plenty. You are detecting change, not defending against attack.
Write these fields to JSONL, not to logs. Logs rotate. Debugging artifacts should not.
Step 3 — Reproduce in a clean environment
Half my confusion was local state. A cached prompt. A stale schemas/ copy. An editable install.
So I reran the whole job in a fresh box with nothing cached.
That is where MonkeyCode's free model access and free server option became relevant to the method. I used a hosted, disposable environment for the clean run, and free model access for the replay passes.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Check the project's current terms yourself before you depend on them. I am not quoting quotas or specs here, because those change.
python -m harness.replay --input fixtures/invoice-50.jsonl \
--out runs/clean.jsonl --no-cache
The rule is simple. The repro must not share a filesystem with your dev shell.
Step 4 — Diff by fingerprint, and refuse mixed runs
Now the useful part. Compare old output to new output, grouped by provenance.
from collections import defaultdict
def load(path):
with open(path) as f:
return [json.loads(line) for line in f]
def diff(baseline_path, candidate_path, allow_skew=False):
base = {(r["input_id"]): r for r in load(baseline_path)}
cand = {(r["input_id"]): r for r in load(candidate_path)}
changed = defaultdict(list)
for key, new in cand.items():
old = base.get(key)
if old is None:
changed["missing_baseline"].append(key)
continue
if not allow_skew and old["meta"] != new["meta"]:
changed["skewed"].append(key)
continue
if old["output"] != new["output"]:
changed["output_changed"].append(key)
return changed
The skewed bucket is the whole point. Without --allow-skew, the tool refuses to compare two runs from different prompts.
That refusal is a feature. It tells you the question you asked was malformed.
Run it:
python -m harness.diff runs/baseline.jsonl runs/clean.jsonl
If skewed is non-empty and you expected a clean comparison, stop. You have two variables, not one.
Step 5 — Constrain the format, not just the type
A schema that says string where you mean decimal is a wish, not a contract.
{
"amount": {
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]{2})?$"
},
"currency": {
"type": "string",
"enum": ["USD", "EUR", "GBP"]
}
}
Now 1,200.00 fails at the boundary, loudly. The prompt can still be friendly. The contract stays strict.
And stop swallowing parse errors. A missing amount should be a dead letter, not a zero.
Step 6 — Add the guard test
One test would have caught this in minutes. It costs four lines.
def test_every_record_has_current_provenance():
rows = load("runs/clean.jsonl")
assert rows, "empty run"
for row in rows:
assert row["meta"]["prompt_fp"] == PROVENANCE["prompt_fp"]
assert row["meta"]["schema_fp"] == PROVENANCE["schema_fp"]
Also assert the fixture count. A silent zero-row run is its own incident.
When a hosted free run is enough
Not every debugging task needs this ceremony. Here is how I decide.
| Task | Hosted free run | Notes |
|---|---|---|
| Repro a crash or parse error | Yes | Prefer it, actually |
| Check output shape and format | Yes | Pair with the skew guard |
| Measure latency or throughput | No | Shared hosting tells you little |
| Rank model quality | No | Needs a labeled set you own |
| Real customer data | No | Do not send it |
Two rules from that table. Never send production records to an environment you do not control. And never trust a timing number from a machine you did not size.
Limitations, and who should skip this
The harness is small on purpose. It has no retries, no rate limiting, and no backoff.
It assumes your outputs are JSON-serializable and order-stable. Flaky ordering will show up as output_changed noise.
Skip this if you have a single prompt, a stable schema, and no downstream consumers. The overhead is not worth it.
Skip it too if you cannot hold a representative fixture set. A skewed diff over bad inputs is just more noise.
If you want to run the clean-environment step without provisioning anything, the project's free model access and free server option are one way in. The harness does not care which provider you use.
The habit worth keeping
Stamp provenance before you change a prompt. Diff before you trust a total.
When a check goes green, ask what it actually asserted. Type shape is rarely the thing you meant.
Run the diff before you run the fix. That order saves a week.
來源:DEV Community · dev.to